Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Ourios

The Ourios mark: log lines streaming into a sail

οὔριος — the fair wind that fills a ship’s sail. The mark is the thesis as a glyph: the sail, and the same shape mirrored as log lines — the data is the wind.

Ourios is a log storage and query backend built on Apache Parquet, a Drain-derived online template miner, and Apache DataFusion. The thesis: Parquet + template mining + DataFusion collapses the inverted index, the compression layer, the storage tier, and the query engine into one stack of off-the-shelf parts plus thin glue. Log lines collapse to (template_id, params) at ingest — a logical 50–200× reduction whose payoff is query pruning (a selective query reads a handful of row groups instead of scanning the corpus), not on-disk bytes that beat a byte codec. Our job is the glue, plus the honest handling of the places where template mining can go wrong.

The repository README is the short front door; this book is the design record and the depth behind it.

Project status

Pre-release, under active RFC-driven development. The full path — OTLP in; WAL, miner, Parquet on object storage; logs-DSL / DataFusion queries out — is implemented and tested behind RFC acceptance gates, and the performance thesis is measured (including against Grafana Loki) in Benchmarks. Signed pre-release binaries, container images, and a Helm chart exist, but interfaces and the on-disk schema can still move: treat everything as pre-1.0.

How this book is organised

  • Getting started — run the single binary, configure it, deploy it with Docker or Helm, and lock it down before a listener leaves localhost.
  • Architecture — the load-bearing reading: OTLP’s log data model vs. the miner’s view of it, hazards (where projects in this space die, and how we won’t), verification (how an RFC criterion becomes a red-gate test becomes a green one), and the glossary.
  • Benchmarks — the thesis gates, stated so they could falsify the project, and every measurement against them with run IDs and caveats; plus the roadmap.
  • RFCs — every subsystem is specified before it is built. Each RFC pins Given / When / Then acceptance scenarios and climbs a maturity ladder (drafted → specified → red → green → validated → accepted); the frontmatter status tells you how much to trust it.
  • Talks — lecture-length explanations of the ideas behind the RFCs, for when you want the background rather than the specification.

Contributions, RFC discussion, and push-back on the invariants are all welcome — CONTRIBUTING.md and CLAUDE.md in the repository root carry the governing conventions, including the project’s intentionally fully AI-assisted development process under human maintainer review.

Quickstart — single binary

The fastest path from zero to querying logs: one ourios-server process on your machine, local-disk storage, no auth. This is the development/evaluation posture — see Kubernetes (Helm) for the production topology and Authentication before exposing any listener beyond localhost.

1. Get the binary

Download a signed release archive (Linux; Apple-silicon and Intel macOS builds come from cargo build today):

curl -LO https://github.com/jensholdgaard/ourios/releases/latest/download/ourios-server-x86_64-unknown-linux-gnu.tar.xz
tar -xf ourios-server-x86_64-unknown-linux-gnu.tar.xz

Releases from v0.1.1 on attach offline provenance bundles (*.intoto.jsonl) alongside their assets, verifiable without any network round-trip:

gh attestation verify ourios-server-x86_64-unknown-linux-gnu.tar.xz \
  --repo jensholdgaard/ourios \
  --bundle ourios-server-x86_64-unknown-linux-gnu.intoto.jsonl

Or build from source with cargo build --release -p ourios-server.

2. Run it

The binary is one server with three roles — receiver (OTLP ingest), querier (the logs-DSL API), and the background compactor (on by default). Enable the two network roles and point everything at a scratch directory:

mkdir -p /tmp/ourios/data /tmp/ourios/wal

OURIOS_BUCKET_ROOT=/tmp/ourios/data \
OURIOS_WAL_ROOT=/tmp/ourios/wal \
OURIOS_RECEIVER_ENABLED=1 \
OURIOS_RECEIVER_GRPC_ADDR=127.0.0.1:4317 \
OURIOS_RECEIVER_HTTP_ADDR=127.0.0.1:4318 \
OURIOS_QUERIER_ENABLED=1 \
OURIOS_QUERIER_HTTP_ADDR=127.0.0.1:4319 \
./ourios-server

Startup prints the bound addresses and warns once that auth is in open mode:

receiver gRPC listening on 127.0.0.1:4317
receiver HTTP listening on 127.0.0.1:4318
querier HTTP listening on 127.0.0.1:4319

The ports are the OTLP defaults (4317 gRPC, 4318 HTTP) plus 4319 for the query API; the explicit 127.0.0.1 binds keep this quickstart on localhost (the server defaults to 0.0.0.0 for container use). Prefer a config file over env vars? See Configuration--config ourios.yaml makes the file the sole source.

3. Send logs

Ourios speaks OTLP and nothing else — any OpenTelemetry SDK or Collector can ship to it unmodified. Every export names its tenant out of band — the X-Ourios-Tenant header (HTTP) or x-ourios-tenant metadata (gRPC); resource attributes such as service.name describe the producer and never choose the tenant (RFC 0046).

With a Collector, point the OTLP exporter at it and set the tenant once:

exporters:
  otlp:
    # host:port is version-proof for the gRPC exporter; recent
    # Collectors also accept scheme'd forms.
    endpoint: localhost:4317
    headers:
      x-ourios-tenant: checkout
    tls:
      insecure: true

An SDK sets the same header through the standard variable: OTEL_EXPORTER_OTLP_HEADERS=x-ourios-tenant=checkout.

Or hand-deliver one OTLP/JSON record for a first smoke test:

curl -s http://localhost:4318/v1/logs \
  -H 'X-Ourios-Tenant: checkout' \
  -H 'Content-Type: application/json' \
  -d '{
    "resourceLogs": [{
      "resource": { "attributes": [
        { "key": "service.name", "value": { "stringValue": "checkout" } }
      ]},
      "scopeLogs": [{ "logRecords": [{
        "timeUnixNano": "1751971200000000000",
        "severityNumber": 9,
        "body": { "stringValue": "user 42 logged in" }
      }]}]
    }]
  }'

An empty {} response is the OTLP success shape. The batch is fsynced to the write-ahead log before that acknowledgement — kill the process mid-ingest and acknowledged data survives.

4. Query

POST /v1/query takes the logs DSL as plain text, with the tenant in a header:

curl -s http://localhost:4319/v1/query \
  -H 'X-Ourios-Tenant: checkout' \
  -H 'Content-Type: text/plain' \
  -d 'severity >= info | limit 10'

The response carries the total match count, the returned rows (bodies reconstructed from their mined templates), and scan statistics that show the Parquet pruning at work:

{
  "rows": 1,
  "stats": { "row_groups_scanned": 1, "row_groups_pruned": 0, "bytes_read": 4096 },
  "records": [ {
    "time_unix_nano": 1751971200000000000,
    "severity_number": 9,
    "body": { "kind": "rendered", "line": "user 42 logged in", "reconstruction": "faithful" },
    "...": "..."
  } ]
}

The DSL’s full grammar — field predicates, regex, time ranges, aggregation pipelines like service == "api" and severity >= error | count by template_id — is specified in RFC 0002.

Where to next

  • Docker — the same server from the published image.
  • Kubernetes (Helm) — the production topology on S3-compatible object storage.
  • Authentication — static bearer tokens and OIDC; do this before any listener leaves localhost.
  • The MCP surface (agents querying Ourios over the Model Context Protocol) rides the querier at /mcp — enable with OURIOS_QUERIER_MCP_ENABLED=1 (RFC 0027).
  • Observe your coding agent — point Claude Code or Copilot CLI at Ourios and query its own cost and tool use back through the MCP surface, all on your machine.

Configuration

Two mutually exclusive sources (RFC 0020 / RFC 0004):

  • A YAML file via --config <path> — the file is then the sole source; the environment participates only through ${env:NAME} substitution inside it (with ${env:NAME:-default} defaults, $$ escaping — the OTel Collector data model).
  • OURIOS_* environment variables when no --config is given — the container/dev posture.

Parsing is strict: a malformed value is a startup error in either mode, and in config-file mode an unknown YAML key is rejected too (unrecognised OURIOS_*-lookalike env vars are simply not read — there is no unknown-key concept in the environment).

A complete file example

storage:
  # local (a filesystem directory as the store — dev/single-node) or
  # s3 (object storage as the source of truth — production; RFC 0019).
  backend: s3
  s3:
    bucket: ourios-logs
    region: eu-central-1
    # Any S3-compatible provider: AWS, MinIO, R2, Ceph/RGW, …
    endpoint: https://s3.eu-central-1.amazonaws.com
    # Secret hygiene is enforced: credentials MUST be ${env:…}
    # references — inline literals fail startup.
    access_key_id: ${env:OURIOS_S3_ACCESS_KEY_ID}
    secret_access_key: ${env:OURIOS_S3_SECRET_ACCESS_KEY}
  # RFC 0022: per-key promoted attribute columns (service.name is
  # always promoted). Each key costs bytes on every row — opt in
  # deliberately.
  promoted_attributes:
    resource: [k8s.namespace.name]
    log: [http.request.method, http.route]

receiver:
  enabled: true
  grpc_addr: 0.0.0.0:4317
  http_addr: 0.0.0.0:4318
  # The WAL stays on local disk by design, S3 or not (RFC 0019).
  wal_root: /var/lib/ourios/wal
  # RFC 0035: concurrent Parquet-encode workers (default: all cores).
  encode_workers: 4

querier:
  enabled: true
  http_addr: 0.0.0.0:4319
  default_window_secs: 3600
  mcp:
    enabled: false

auth:
  # See the Authentication guide. Omit the whole section for open
  # mode (development only — the server warns once at startup).
  tokens:
    - name: edge-collector
      token: ${env:OURIOS_EDGE_TOKEN}
      tenants: [checkout, payments]
  oidc:
    issuer: https://dex.example.com
    audience: ourios-collector
    tenant_claim: groups              # optional once openfga binds the tenants
    name_claim: name
    # RFC 0047: agent principals + group claims for the graph resolver
    agent_claim: ourios_principal_type=agent
    groups_claim: groups
  openfga:                            # RFC 0047 — the graph binds tenants
    api_url: http://openfga.auth.svc:8080
    store_id: 01M07RYMXRDW4ND5M7XQV04W8R
    authorization_model_id: 01M07RZE9RHPVPTYCV22RX0TDA   # pinned; omit = latest
    api_token: ${env:OURIOS_OPENFGA_TOKEN}                 # ${env:…} only
    session_ttl_secs: 60
    consistency: minimize_latency     # or higher_consistency
    request_timeout_secs: 5
    server_list_objects_deadline_ms: 3000
    visibility:                       # RFC 0047 §3.4 — layer 2 inside a tenant
      objects:
        - type: conversation
          column: attr.gen_ai.conversation.id
      # Optional (RFC 0048 §3.2). Which promoted columns carry the
      # principals in a conversation. An omitted list takes the semconv
      # default shown here and is exempt from the promoted-column check;
      # every EXPLICITLY listed entry must be a promoted column (startup
      # error otherwise). self_principal_column, when set, must be a
      # promoted column AND one of user_columns.
      # identities:
      #   user_columns: [attr.user.hash, attr.enduser.pseudo.id]
      #   agent_columns: [attr.gen_ai.agent.id]
      self_principal_column: attr.user.hash
      # Optional. REPLACES the default set (body + the GenAI content
      # attributes) — list every column to mask; must not be empty.
      # content_columns: [body, attr.gen_ai.input.messages, attr.gen_ai.output.messages]
      max_objects: 10000
      list_timeout_ms: 2000           # must be < server_list_objects_deadline_ms

Environment variables (no --config)

VariableMeaning
OURIOS_STORAGE_BACKENDlocal (default) or s3
OURIOS_BUCKET_ROOTlocal-backend store root
OURIOS_S3_BUCKET / OURIOS_S3_REGION / OURIOS_S3_ENDPOINT / OURIOS_S3_PREFIXS3 addressing
OURIOS_S3_ACCESS_KEY_ID / OURIOS_S3_SECRET_ACCESS_KEY / OURIOS_S3_SESSION_TOKENS3 credentials
OURIOS_RECEIVER_ENABLED / OURIOS_RECEIVER_GRPC_ADDR / OURIOS_RECEIVER_HTTP_ADDRreceiver role
OURIOS_WAL_ROOTWAL directory (receiver)
OURIOS_RECEIVER_ENCODE_WORKERSconcurrent encode pool size (RFC 0035; default: all cores)
OURIOS_QUERIER_ENABLED / OURIOS_QUERIER_HTTP_ADDR / OURIOS_QUERIER_DEFAULT_WINDOW_SECSquerier role
OURIOS_QUERIER_MCP_ENABLEDthe /mcp agent surface (RFC 0027)
OURIOS_COMPACTION_ENABLED / OURIOS_COMPACTION_INTERVAL_SECSbackground compactor

Auth configuration is file-only — there are deliberately no OURIOS_AUTH_* variables; token values reach the file through ${env:…} references.

There is no tenant-derivation configuration: the tenant is named out of band on every export (RFC 0046) — the X-Ourios-Tenant header over OTLP/HTTP, x-ourios-tenant metadata over OTLP/gRPC — exactly as the querier’s X-Ourios-Tenant. It is required in open mode too (no default tenant) and, with auth on, must be one of the credential’s tenants. A Collector sets it once, e.g. exporters.otlp.headers.x-ourios-tenant: acme (or per request via the headers_setter extension). Resource attributes such as service.name describe the producer and never choose the tenant.

Docker

The release pipeline publishes a multi-arch image (amd64 + arm64) to GHCR, cosign-signed keyless:

docker pull ghcr.io/jensholdgaard/ourios:0.1.1

Verify the signature before trusting it — the identity is pinned to the exact release tag, so substitute both occurrences of the version when verifying another release (SECURITY.md is the authoritative verification reference):

cosign verify \
  --certificate-identity 'https://github.com/jensholdgaard/ourios/.github/workflows/image.yml@refs/tags/v0.1.1' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  ghcr.io/jensholdgaard/ourios:0.1.1

Image variants

Every release publishes three signed multi-arch images from the same source:

  • default (:<version>) — glibc binary on distroless/cc.
  • -static (:<version>-static) — static musl binary on distroless/static: no libc, libgcc, or libssl in the image, so the OS-package vulnerability surface scanners report is ~empty. Pick this one for the strictest supply-chain posture with the operational niceties (CA bundle, tzdata, nonroot passwd entry) kept.
  • -scratch (:<version>-scratch) — the same musl binary on bare scratch, plus only the CA bundle TLS needs. Nothing else in the filesystem: the absolute minimum attack surface, but also zero operational conveniences — no tzdata, no passwd entry, and CA-bundle updates arrive only with Ourios releases rather than base-image bumps.

All three run identically (same flags, ports, and config surface below).

Run

Same binary, same configuration surface as the quickstart — env vars, or a mounted config file:

docker run --rm \
  -p 4317:4317 -p 4318:4318 -p 4319:4319 \
  -v ourios-data:/var/lib/ourios \
  -e OURIOS_BUCKET_ROOT=/var/lib/ourios/data \
  -e OURIOS_WAL_ROOT=/var/lib/ourios/wal \
  -e OURIOS_RECEIVER_ENABLED=1 \
  -e OURIOS_QUERIER_ENABLED=1 \
  ghcr.io/jensholdgaard/ourios:0.1.1

With a config file instead (the production posture — auth lives in the file):

docker run --rm \
  -p 4317:4317 -p 4318:4318 -p 4319:4319 \
  -v ourios-data:/var/lib/ourios \
  -v "$PWD/ourios.yaml:/etc/ourios/ourios.yaml:ro" \
  -e OURIOS_EDGE_TOKEN \
  -e OURIOS_S3_ACCESS_KEY_ID -e OURIOS_S3_SECRET_ACCESS_KEY \
  ghcr.io/jensholdgaard/ourios:0.1.1 \
  --config /etc/ourios/ourios.yaml

Secrets stay out of the file via ${env:…} references — pass through every variable your file references (the example forwards the auth token and, for an S3-backend file like the Configuration example, the store credentials; a local-backend file needs neither OURIOS_S3_* variable). The server shuts down gracefully on SIGTERM — docker stop flushes the ingest pipeline before exit.

Local note: any OCI runtime works — with containerd, nerdctl run/nerdctl compose take the same arguments.

Kubernetes (Helm)

The chart at deploy/helm/ourios deploys the production topology: three workloads, one binary, backed by S3-compatible object storage (AWS S3, MinIO, R2, Ceph/RGW, … — RFC 0019):

  • receiver — a StatefulSet with a per-replica WAL PVC (the WAL is local by design; only data + audit go to S3);
  • querier — a stateless Deployment, scales independently;
  • compactor — a singleton Deployment.

Install

kubectl create secret generic ourios-s3 \
  --from-literal=OURIOS_S3_ACCESS_KEY_ID=… \
  --from-literal=OURIOS_S3_SECRET_ACCESS_KEY=…

helm install ourios deploy/helm/ourios \
  --set storage.backend=s3 \
  --set storage.s3.bucket=ourios-logs \
  --set storage.s3.region=eu-central-1 \
  --set storage.s3.endpoint=https://s3.eu-central-1.amazonaws.com \
  --set storage.s3.existingSecret=ourios-s3

(On AWS EKS, IRSA replaces the secret — leave existingSecret empty and annotate the service account with the role ARN; the two modes are mutually exclusive.)

The chart renders an RFC 0020 config file into a ConfigMap; credentials reach it as ${env:…} references resolved from the secret — never inline.

The chart’s README is the authoritative reference: full values.yaml documentation, the topology diagram, local-development (MinIO) recipes, and sizing notes. This page stays a pointer so the two never drift.

Sending and querying

In-cluster, point Collectors at the receiver Service (ourios-receiver:4317) and query the querier Service on 4319 — fronted by whatever ingress/TLS termination your cluster standardises on. Configure authentication before exposing either beyond the cluster boundary.

Authentication

Three postures, one enforcement path (RFC 0026 + RFC 0029), plus an optional authorization graph behind them (RFC 0047). Whatever authenticates a request, the result is the same (name, read tenants, write tenants) binding: ingest batches must fall entirely inside the binding’s write set (whole-batch 403 otherwise, before the WAL), queries and MCP tool calls enforce the read set, and the name labels the audit trail and metrics. Static tokens and OIDC claims bind both sets identically; the graph binds them separately. Rejections are deliberately undifferentiated — one 401 shape, no probing oracle.

Naming the tenant (every posture)

Authentication decides which tenants a caller may touch; the request itself must still name the one it means (RFC 0046: tenancy is out-of-band — never derived from the OTLP payload). Every ingest export carries the tenant selector — the x-ourios-tenant gRPC metadata key, or the X-Ourios-Tenant HTTP header (HTTP header names are case-insensitive) — naming the tenant the whole export lands in; an export without it is rejected (gRPC INVALID_ARGUMENT / HTTP 400) before any WAL work. Queries name their tenant with the same HTTP header; MCP tool calls instead pass it as the tenant tool argument — the /mcp route does not read the header. With a Collector, set the selector on the exporter:

exporters:
  otlp:
    endpoint: ourios.example.com:4317
    headers:
      x-ourios-tenant: checkout

One export = one tenant: a pipeline feeding several tenants runs one exporter per tenant (Collector routing connectors compose cleanly with this). The named tenant must fall inside the caller’s write set, or the whole batch is rejected (gRPC PERMISSION_DENIED / HTTP 403).

Open mode (development only)

No auth section at all. Every request passes unbound; the server warns once at startup. Never expose an open-mode listener beyond localhost or a trusted network segment.

Static bearer tokens

The Collector-friendly baseline — static credentials in the config file, values injected via ${env:…} (inline literals fail startup):

auth:
  tokens:
    - name: edge-collector
      token: ${env:OURIOS_EDGE_TOKEN}
      tenants: [checkout, payments]   # or ["*"] for all tenants

Senders attach Authorization: Bearer <token>; with a Collector:

extensions:
  bearertokenauth:
    token: ${env:OURIOS_EDGE_TOKEN}
exporters:
  otlp:
    endpoint: ourios.example.com:4317   # TLS by default; gRPC host:port
    auth:
      authenticator: bearertokenauth

Comparison is constant-time; token values never appear in logs, errors, metrics, or audit events — only the name does.

OIDC (JWTs from an identity provider)

Adds standards-based machine identity in front of the same enforcement — any conforming issuer works; Dex (CNCF) is the recommended lightweight deployment and the one the acceptance suite runs against:

auth:
  oidc:
    issuer: https://dex.example.com
    audience: ourios-collector        # your client id
    tenant_claim: groups              # a string-list claim → the tenant set
    name_claim: name                  # the audit/metric label

Verification is local: the issuer is contacted once at startup (discovery + JWKS — an unreachable issuer fails startup, by design) and again only when an unseen key id appears (rotation). Signatures verify against the asymmetric allow-list only — RS256/384/512, PS256/384/512, ES256/384; alg: none and HMAC never verify.

Machine senders use the OAuth2 client-credentials flow — with a Collector this is zero custom code:

extensions:
  oauth2client:
    client_id: ourios-collector
    client_secret: ${env:DEX_CLIENT_SECRET}
    token_url: https://dex.example.com/token
    scopes: [openid, profile, groups]
exporters:
  otlp:
    endpoint: ourios.example.com:4317   # TLS by default; gRPC host:port
    auth:
      authenticator: oauth2client

Both halves coexist in one config — a static-token Collector and JWT-bearing senders authenticate side by side, each confined to its own tenant binding.

OpenFGA (relationship graph binds the tenants)

RFC 0047 adds OpenFGA as an authorization layer behind either authenticator — OpenFGA never authenticates. Once a bearer is known (static token or verified JWT), the principal is mapped (service_account:<token name>, user:<sub>, or agent:<sub> when the token carries the configured agent_claim) and the graph answers which tenants it may query (can_query) and write (can_write), using the in-tree model deploy/openfga/model.fga:

auth:
  tokens:
    - name: collector-cluster1
      token: ${env:OURIOS_COLLECTOR_TOKEN}
      tenants: ["*"]                  # the graph decides; a list here only narrows
  oidc:
    issuer: https://dex.example.com
    audience: ourios
    groups_claim: groups              # → contextual team#member tuples
    agent_claim: ourios_principal_type=agent
    # tenant_claim is optional here — the graph binds the tenants
  openfga:
    api_url: http://openfga.auth.svc:8080
    store_id: 01M07RYMXRDW4ND5M7XQV04W8R
    authorization_model_id: 01M07RZE9RHPVPTYCV22RX0TDA   # pinned; omit = latest
    api_token: ${env:OURIOS_OPENFGA_TOKEN}                 # ${env:…} only
    session_ttl_secs: 60              # revocation latency = binding cache TTL
    consistency: minimize_latency     # higher_consistency bypasses OpenFGA's cache

Grants are administrative tuples written through OpenFGA’s own API or CLI, never by Ourios: tenant:acme#reader@user:alice, tenant:acme#writer@service_account:collector-cluster1, tenant:acme#owner@team:platform#member. A token’s group claim rides along as request-scoped team:<group>#member@<principal> tuples (never persisted; at most 100), so team membership needs no sync pipeline. A credential’s own tenant list — a static token’s tenants, an OIDC tenant_claim — can only narrow what the graph grants, never widen it; a principal the graph grants nothing is unbound (401).

Visibility inside a tenant (layer 2)

With the graph configured, every query also runs the RFC 0047 §3.4 two-step for the principal in the tenant it queries — query rewrite at plan time, never per-record checks:

  1. Check(principal, can_read_content, tenant) allowed → the tenant predicate only (today’s plan).
  2. else Check(can_read_metadata, tenant) allowed → every row, with the configured content_columns returned as null (body as {"kind":"masked"}); a query that filters or aggregates on one of them is 403 column_forbidden, naming the column.
  3. else the principal is scoped (bound through scoped_reader): its readable conversations are enumerated through the streamed ListObjects — filtered to this tenant, at most max_objects tenant ids, within list_timeout_ms — and become attr.gen_ai.conversation.id IN (…), OR’d with the self fast path (self_principal_column == <subject>, user: principals only). Past the bound: 403 visibility_bound (“ask for tenant-wide read”); a cut-off stream: 503 visibility_incomplete — never a partial predicate. Template-level queries (drift, list_templates, template_drift) need tenant-wide content read (403 visibility_scoped).
auth:
  openfga:
    # …
    server_list_objects_deadline_ms: 3000   # OPENFGA_LIST_OBJECTS_DEADLINE
    visibility:
      objects:
        - type: conversation
          column: attr.gen_ai.conversation.id   # a promoted column
      self_principal_column: attr.user.hash     # optional; user: principals only
      # content_columns: [...]                  # optional; REPLACES the default set
      max_objects: 10000                        # tenant ids only
      list_timeout_ms: 2000                     # MUST be < the server deadline

content_columns defaults to the GenAI content attributes plus body; an explicit list replaces that set (list every column to mask) and may not be empty — masking is never silently disabled. objects unset means scoped principals see nothing (their bound is not enumerable). Tenant-scoped graph objects are tenant:<T> and conversation:<enc(T)>/<id> where enc percent-encodes / and % in the tenant, so tenants containing / never alias; a tenant that cannot be an object id (:, #, whitespace) has no graph objects and every question about it fails closed. The two Checks cache with the session TTL; the enumeration runs per query. The branch a query took is recorded on ourios.query.visibility (ourios.query.visibility.branch) and the request span.

Feeding the graph from the data

With a conversation object bound, Ourios writes the data-derived tuples itself (RFC 0047 §3.3) — nobody hand-writes conversation grants: for every stored row the compaction sweep rewrites, and for every batch the receiver flushes, the emitter derives conversation:T/<id>#parent@tenant:T, #participant@user:<user.hash | enduser.pseudo.id> (plus tenant:T#scoped_reader@user:<…>), #actor@agent:<gen_ai.agent.id> (plus its binding tuple) and the per-tenant tool:T/<name>#parent@tenant:T objects, and writes them in idempotent ≤ 100-tuple batches (ourios.graph.tuples). Operators write only the administrative tuples (tenant#reader/writer/owner/ metadata_reader, team#member, tool#caller, conversation#delegate).

Erasing a conversation (RFC 0047 §3.6 / RFC 0048 §3.3): the front door is the CLI, run with the daemon’s config —

ourios-server --config ourios.yaml graph erase --tenant acme --conversation c-7
ourios-server --config ourios.yaml graph erasures            # pending + phase

erase writes the durable request marker (erasure/tenant_id=<enc>/conversation=<enc>, create-if-absent, so a repeat never resets an in-flight erasure) and the next sweep rewrites every partition of the tenant with the conversation’s rows dropped, then deletes its graph tuples, then writes a conversation_erased audit event, logs one ourios.compaction.erasure.completed event and removes the marker. Rows first, tuples after — a dangling tuple is harmless, a dangling row is a leak.

Backfilling history (RFC 0048 §3.4): data stored before the graph was configured is fed to it with

ourios-server --config ourios.yaml graph backfill --tenant acme [--from 2026-08-01T00:00:00Z]

— reads every partition of the tenant (--from keeps partitions whose UTC hour starts at or after it), derives and writes the same tuples the sweep would, in idempotent ≤ 100-tuple batches, and never rewrites Parquet. Resumable: run it again after an interruption. Backfill and erasure exclude each other through the store: backfill refuses while erasures are pending, and the sweep defers a tenant’s erasures while a backfill lock exists (graph backfill --unlock --tenant acme clears a crashed run’s lock; graph erasures lists both marker kinds).

Both verbs are instrumented like the daemon: each run is one OpenTelemetry CLI callee span (process.executable.name, process.pid, process.exit.code, and error.type on failure), the backfill’s per-partition progress events reach stderr, and everything exports over OTLP when the standard variables point somewhere — OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4317. Silence it the standard way: OTEL_SDK_DISABLED=true for everything, or per signal with OTEL_TRACES_EXPORTER=none, OTEL_METRICS_EXPORTER=none and OTEL_LOGS_EXPORTER=none.

The binding is cached per credential for session_ttl_secs and is fail-closed: an unreachable or slow OpenFGA answers 503 on the query and MCP surfaces and UNAVAILABLE/503 on ingest, and ourios.auth.resolutions counts the failure as error.type = upstream_unavailable. Static tokens and OIDC keep working without an openfga section; a deployment that wants coarse tenants only never touches it.

TLS

The listeners speak plaintext today; terminate TLS in front (ingress, service mesh, or an L4 proxy) — bearer tokens over plaintext are not auth. Native listener TLS is tracked on the auth epic.

Query DSL by example

Sixteen sample queries, each stated first in plain English and then in the logs DSL (RFC 0002), followed by a migration sketch from Grafana Loki’s LogQL and Amazon CloudWatch Logs Insights. This page is also the RFC 0002 §9 readability sheet: if a query’s meaning is not obvious from the English line above it, that is a defect in the DSL — file an issue.

Every query is a single line: a predicate (what rows match), optionally followed by pipe stages (window, aggregate, sort, limit, project, render). A bare true predicate means “no filter”. Queries without a range(...) stage get the server’s default look-back window.

The samples

  1. Every error or worse in the last hour:

    severity >= error | range(-1h, now)
    
  2. Everything the checkout service logged in the last 15 minutes:

    service == "checkout" | range(-15m, now)
    
  3. Errors from checkout whose body mentions a timeout:

    service == "checkout" and severity >= error and contains(body, "timeout")
    
  4. How many log lines each service produced today, busiest first:

    true | range(-24h, now) | count by service | sort count desc
    
  5. Error count per template, to find the noisiest failure shape:

    severity >= error | count by template_id | sort count desc | limit 20
    
  6. The ten most recent lines of one template, rendered back to the original bytes (reconstructed from the template, or the retained body verbatim for lossy rows):

    template_id == 42 | sort ts desc | limit 10 | render
    
  7. Lines matching a regular expression (matched against the body; add ^/$ yourself if you want anchoring):

    matches(body, "user [0-9]+ locked out")
    
  8. Everything with a given trace id, across all services:

    trace_id == "4bf92f3577b34da6a3ce929d0e0e4736"
    
  9. Warnings and errors, excluding a known-noisy service:

    severity >= warn and service != "vacuum-daemon"
    
  10. Lines carrying a specific attribute value (any OTLP attribute is addressable, promoted or not):

    attr.decision == "deny" | range(-6h, now)
    
  11. Kubernetes-style resource lookup — one pod’s logs:

    resource["k8s.pod.name"] == "checkout-7d4b9f-x2m8p" | range(-30m, now)
    
  12. Total LLM spend by model over the last day (typed numeric attribute, RFC 0042):

    true | range(-24h, now) | sum(attr.cost_usd) by attr.model
    
  13. Login-failure count per five-minute bucket — a rate over time:

    template_id == 42 | range(-3h, now) | count by bucket(5m)
    
  14. The same failures broken down by the template’s first parameter (e.g. which user), pinned to one template:

    template_id == 42 | range(-3h, now) | count by param(0)
    
  15. Structured OTel events by name (RFC 0043/0044):

    event_name == "gen_ai.client.inference.operation.details" | limit 50
    
  16. Low-confidence parses that kept their original body — the miner’s own honesty check:

    lossy == true and confidence < 0.5 | project ts, service, body
    

Migrating from LogQL (Grafana Loki)

The structural difference: Loki selects streams by label matchers in {braces}, then pipes line filters; Ourios has no stream/label split — everything (service, resource attributes, log attributes, severity, template) is one predicate namespace, and aggregation is a pipe stage instead of a wrapping function.

You write in LogQLYou write in the Ourios DSL
{service_name="checkout"}service == "checkout"
{service_name="checkout"} |= "timeout"service == "checkout" and contains(body, "timeout")
{service_name="checkout"} |~ "user [0-9]+"service == "checkout" and matches(body, "user [0-9]+")
{service_name="checkout"} != "healthz"service == "checkout" and not contains(body, "healthz")
{env="prod"} | json | decision="deny"attr.decision == "deny" (no extraction step — attributes are already columns)
sum by (service_name) (count_over_time({env="prod"}[24h]))true | range(-24h, now) | count by service
count_over_time({service_name="checkout"} |= "timeout" [5m]) (rate panel)service == "checkout" and contains(body, "timeout") | count by bucket(5m)
{...} | logfmt | level="error"severity >= error (severity is first-class and numeric; no line-format parsing)

Two things have no LogQL counterpart and come free here: template_id (query the shape of a line, pre-clustered at ingest — no regex needed to group “user N logged in” lines) and param(n) (group by a template’s wildcard value without an extraction stage).

Not carried over from LogQL: unwrap-style duration/bytes conversions and metric-query arithmetic (/, + between range vectors) — the DSL returns counts and typed-attribute aggregates, and arithmetic between result sets is the caller’s job today.

Migrating from CloudWatch Logs Insights

Insights is closest in spirit — a pipe language over discovered fields — so most queries transliterate stage by stage:

You write in Logs InsightsYou write in the Ourios DSL
fields @timestamp, @message | limit 20true | project ts, body | limit 20
filter @message like /timeout/contains(body, "timeout")
filter @message like /user \d+/matches(body, "user [0-9]+")
filter level = "ERROR"severity >= error
stats count(*) by bin(5m)true | count by bucket(5m)
stats count(*) by servicetrue | count by service
stats sum(cost_usd) by modeltrue | sum(attr.cost_usd) by attr.model
sort @timestamp desc | limit 10true | sort ts desc | limit 10
parse @message "user * logged in from *" as user, ip | stats count(*) by usertemplate_id == 42 | count by param(0) (the miner already parsed it)

The parse-then-stats idiom is the one to notice: what Insights does with an ad-hoc glob at query time, Ourios did once at ingest — the template is a stored column, so the grouping needs no pattern and costs no scan-time extraction.

Not carried over from Insights: cross-log-group joins and dedup — out of scope for v1 of the DSL.

Observe your coding agent

Coding agents (Claude Code, GitHub Copilot CLI, …) emit OpenTelemetry already. Point that telemetry at a local Ourios and you close a loop: the agent’s own API cost, token usage, and tool decisions land as OTLP logs, and the agent can query them back — through Ourios’s MCP surface — about itself.

The whole loop runs on your machine. Agent telemetry is sensitive (prompts, tool output, source, and whatever flows through them), so the value here is that none of it leaves the host — no SaaS, no phone-home. That is the point, not a footnote.

Three steps: run Ourios, point the agent at it, ask the agent about itself.

1. Run Ourios

Aggregating by attribute (count by attr.model, sum(attr.cost_usd)) needs those attributes promoted to columns, which is a config-file setting (RFC 0022). Write an ourios.yaml:

storage:
  backend: local
  local:
    bucket_root: /var/lib/ourios/store
  # Claude Code emits these as flat log-attribute keys (not the OTel
  # GenAI semantic-convention dotted names); promote the ones you want to
  # group or sum by.
  promoted_attributes:
    log: [model, cost_usd, tool_name, decision]
receiver:
  enabled: true
  grpc_addr: "0.0.0.0:4317"
  http_addr: "0.0.0.0:4318"
  wal_root: /var/lib/ourios/wal
querier:
  enabled: true
  http_addr: "0.0.0.0:4319"
  mcp:
    enabled: true

Run it — mapping the ports to loopback only (127.0.0.1:), because this config has no auth section and so runs open (RFC 0026); a bare -p 4318:4318 would expose an unauthenticated receiver to your LAN:

docker run --rm \
  -p 127.0.0.1:4317:4317 -p 127.0.0.1:4318:4318 -p 127.0.0.1:4319:4319 \
  -v ourios-data:/var/lib/ourios \
  -v "$PWD/ourios.yaml:/etc/ourios/ourios.yaml:ro" \
  ghcr.io/jensholdgaard/ourios:0.1.1 \
  --config /etc/ourios/ourios.yaml

See Docker for image variants and signature verification. docker stop flushes the ingest pipeline on SIGTERM.

2. Point the agent at it

Telemetry is read at process startup, so set these, then start a new agent session. The enable flag alone ships nothing — you also need the exporter block.

# where + how (any OTLP-log source)
export OTEL_LOGS_EXPORTER=otlp
export OTEL_METRICS_EXPORTER=none          # Ourios is logs-only
export OTEL_TRACES_EXPORTER=none
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
export OTEL_SERVICE_NAME=my-agent
export OTEL_EXPORTER_OTLP_HEADERS=x-ourios-tenant=my-agent   # → the Ourios tenant

# then the per-tool enable flag:
export CLAUDE_CODE_ENABLE_TELEMETRY=1      # Claude Code
# export COPILOT_OTEL_ENABLED=true         # Copilot CLI

The tenant is named out of band by the exporter header (OTEL_EXPORTER_OTLP_HEADERS=x-ourios-tenant=…, RFC 0046) — never by service.name, which stays a plain resource attribute you can filter on. The tenants that exist show up as directories under bucket_root/data/ — e.g. /var/lib/ourios/store/data/tenant_id=my-agent/.

Prompt and tool bodies are not captured by default. Turn them on only on data you’re willing to retain — this is the sensitive part:

export OTEL_LOG_USER_PROMPTS=1 OTEL_LOG_TOOL_DETAILS=1   # Claude Code, opt-in

3. Add the MCP surface, and ask

Connect the same agent to Ourios’s read-only MCP:

claude mcp add --transport http ourios http://127.0.0.1:4319/mcp

Now ask the agent about itself in plain language — it reads the ourios://query-schema resource and composes the query:

What has my-agent cost so far, by model? Use the ourios tools.

Queries are scoped by tenant — the x-ourios-tenant value the exporter header set (my-agent above), not a single run — so this reports every session that shipped under that tenant, not just the current one (see the last note below).

Or query the DSL directly. First find your templates — the list_templates MCP tool (or just ask the agent) lists each template_id with its rendered text; note the id of the claude_code.api_request template. Then:

# your tool-use distribution
curl -s http://127.0.0.1:4319/v1/query \
  -H 'X-Ourios-Tenant: my-agent' -H 'Content-Type: text/plain' \
  -d 'true | count by attr.tool_name'

# total spend per model — substitute the api_request template id for <ID>
curl -s http://127.0.0.1:4319/v1/query \
  -H 'X-Ourios-Tenant: my-agent' -H 'Content-Type: text/plain' \
  -d 'template_id == <ID> | sum(attr.cost_usd) by attr.model'

Things that will trip you up

  • Query by template_id or an attribute, not severity. These events carry severity_number 0 (unset), so severity >= trace silently excludes them. Use template_id == N (find N with list_templates) or a promoted attribute, or the match-all true.
  • Fresh records sit in the WAL for up to ~5 minutes before they flush to Parquet, and the querier reads Parquet only — so a query right after a burst of activity can come back empty. Give it a few minutes, or docker stop (which flushes) and restart.
  • Promotion is write-side. It applies to telemetry captured after the server starts with the config above; earlier data has no attr.model column to group on.
  • CLAUDE_CODE_ENABLE_TELEMETRY=1 alone exports nothing — the OTEL_LOGS_EXPORTER/endpoint block in step 2 is what ships the logs.
  • Queries are tenant-wide, not per-session. The tenant is the exporter-header value, so every session that shipped under it aggregates together (service.name is producer metadata you can filter on, not the tenant). To scope to one run, promote session.id (add it to promoted_attributes.log) and filter on it — e.g. attr.session.id == "…" | sum(attr.cost_usd) by attr.model.

Where to next

  • Authentication — before any listener leaves loopback, put a token or OIDC in front (the config above is open).
  • RFC 0027 — the MCP surface; RFC 0032 — the query-schema resource the agent reads to compose queries.
  • RFC 0002 — the full query DSL, including the count/sum/min/max/avg aggregations.

OTLP log format — what crosses the wire vs. what Ourios consumes today

Status: investigation finding. Drafted 2026-05-13 to answer “is our template miner targeting the actual OTLP shape, or a made-up one?” Conclusion: the latter. This doc surfaces the gap and lists the RFC patches it implies; it does not change code.

The Ourios glossary commits the project’s ingest contract to OTLP over gRPC and HTTP — “we do not invent our own format” (docs/glossary.md, entry OTLP). The template-miner RFC (docs/rfcs/0001-template-miner.md) does not carry through on that commitment: §6.1’s record schema has eight fields, none of which exist on the OTLP wire, and the ingest signature today is MinerCluster::ingest(tenant_id, raw: &str) — a flat text line, not a structured LogRecord. This document closes the loop.

The first audience for this finding is the maintainer; the second is the RFC 0001 amendment PR and the future RFC 0003 (OTLP receiver) it implies.


1. What OTLP actually carries

The wire-level definition lives in opentelemetry-proto/opentelemetry/proto/logs/v1/logs.proto and the spec at opentelemetry.io/docs/specs/otel/logs/data-model. The relevant message hierarchy is:

LogsData
└── ResourceLogs[]
    ├── resource: Resource           ← Resource.attributes carries service.name, host.*, etc.
    ├── schema_url: string
    └── scope_logs: ScopeLogs[]
        ├── scope: InstrumentationScope   ← name, version, attributes
        ├── schema_url: string
        └── log_records: LogRecord[]

A single LogRecord carries:

FieldTypeNotes
time_unix_nanofixed64Event time at the source; 0 = unknown
observed_time_unix_nanofixed64When the collector saw it; required once observed
severity_numberenumNormalised TRACE..FATAL with sub-levels (1–24)
severity_textstringSource’s original level string
bodyAnyValueThe log content. Not necessarily a string.
attributesKeyValue[]Per-occurrence structured context
dropped_attributes_countuint32Truncation indicator
flagsfixed32Lower 8 bits = W3C trace flags
trace_idbytes (16)Trace correlation
span_idbytes (8)Span correlation
event_namestringIdentifier for structured-event records

Plus, inherited from the parent containers: the Resource attributes (the unit of “where did this come from” — typically service.name, host.name, k8s.pod.uid, etc.) and the InstrumentationScope name/version (which library/module emitted this record).

AnyValue is a oneof of: string_value, bool_value, int_value, double_value, array_value (recursive), kvlist_value (recursive map of strings → AnyValue), and bytes_value. The spec is explicit about the structured case:

Body MUST support AnyValue to preserve the semantics of structured logs emitted by the applications.

So a real OTLP emitter is at liberty to send a LogRecord whose body is, for example, {"msg": "user logged in", "user_id": 42, "from_ip": "10.0.0.1"} as a kvlist_value — with the parameters already structured out, not embedded in a free-text string.


2. What Ourios consumes today

MinerCluster::ingest(tenant_id: &TenantId, raw: &str) -> u64 (in crates/ourios-miner/src/cluster.rs). The pipeline:

  1. tokenize(raw) splits on Unicode whitespace (crates/ourios-miner/src/tokenize.rs).
  2. mask(tokens) runs UUID / IPv4 / NUM rules over the resulting &str slice (crates/ourios-miner/src/mask.rs).
  3. descend + leaf lookup attaches to or creates a template (crates/ourios-miner/src/tree.rs).

The Parquet record promised by RFC 0001 §6.1 carries:

tenant_id, template_id, template_version, params,
separators, body?, confidence, lossy_flag

That’s the entire data model. Zero fields from the OTLP wire are reflected in the record.

That was the RFC 0001 record. The schema RFC 0017/0018 arrived at does store the OTLP fields (severity, trace context, attributes, scope), and template_id sits beside them as a sibling column — which is the thing to be clear about: it is an Ourios-derived, tenant-local u64, not an OTLP field, and not portable between stores (RFC 0010 drift and RFC 0007 aliases exist because of exactly that). A template’s portable identity is its string — log.record.template in the emerging OpenTelemetry convention (RFC 0050).


3. The gaps

3.1 Severity is missing from the record (and from the template key)

severity_number is one of the most common operator query filters: “show me all ERROR-or-worse from service.name = api in the last hour.” Today the miner has no severity field.

Worse: the template key doesn’t include severity. A line emitted at INFO and the same line emitted at ERROR would currently collapse to one template_id. That’s a §3.1-class problem (“no silent template merges”) in disguise — two semantically distinct events sharing one id.

3.2 Timestamps are missing from the record

time_unix_nano and observed_time_unix_nano carry the data that the B1 thesis gate (“predicate-pushdown query latency on time/template/tenant filters”) explicitly measures. Without a time column we cannot run B1 at all.

Today there is no time field on the record. The Parquet writer PR (Phase 2 in docs/roadmap.md) cannot land without RFC 0001 §6.1 amending to add at least time_unix_nano.

3.3 Resource and scope are missing

Resource.attributes is OTLP’s “who sent this” partition key — in real deployments, service.name is the natural partition for template trees (it’s effectively the per-service template namespace). Today our tenant_id is operator-supplied and has no declared mapping from OTLP fields. We need to decide: tenant_id := resource.attributes["service.name"]? Or some configured mapping rule? RFC 0003 (OTLP receiver) is the place for this; RFC 0001 just needs to make resource_attributes a record column so the decision can land.

InstrumentationScope.name distinguishes the same body text emitted from different code paths in the same service. Likely also belongs in the template key — myapp.login and myapp.checkout emitting "request received" are different events.

3.4 Attributes carry the structured params we try to mine

In a structured-logging world, the values our mask() rules try to extract from text (NUMs, IPs, UUIDs) are typically already typed and separated by the SDK as Attributes. A modern emitter sends:

  • body = "user logged in"
  • attributes = {"user.id": 42, "client.address": "10.0.0.1"}

Not:

  • body = "user 42 logged in from 10.0.0.1"
  • attributes = {}

Our miner gets the second form and does work to reconstruct roughly what the first form already had. Worse, given the first form, we currently mine "user logged in" as a flat fixed template and lose the typed attribute values entirely — they’d never reach the Parquet record. The operator query “show me all logins from client.address = 10.0.0.1” returns nothing.

The implication for the miner is significant: the params slot on the record cannot be only “things mask() extracted from the body string.” It must also carry the OTLP attributes of the record — either as a sibling column (operator-queryable) or folded into the existing params shape (more complex).

3.5 Body is not always a string

AnyValue body. Today ingest(raw: &str) cannot accept a structured body at all. Three plausible paths:

  1. Render-to-string at the receiver. Convert structured Body to a canonical JSON-ish string before handing to the miner. Loses the structure but preserves the existing miner shape. Risk: §3.3 (“bit-identical body reconstruction”) requires the rendered form to round-trip; canonicalising arbitrary AnyValue trees is non-trivial.
  2. Treat structured Body as not-mineable. Store it verbatim in the body? column with lossy_flag = false (it’s an explicit structured value, not a lossy reconstruction); the miner emits a template_id of “structured body” and the query path knows to read body? directly. Simpler, gives up templating for those records.
  3. Mine inner string fields. If body is a kvlist_value with a "msg" field, mine msg as the line. Pragmatic but ad-hoc; the field name is convention not spec.

Path (2) is the cleanest minimum; path (1) is the eventual ambition; path (3) is a configurable convenience layer. All three need an explicit spec decision.

3.6 Trace correlation is missing

trace_id, span_id, flags are how operators correlate logs to spans in the same trace. Real operators use this constantly. Today: no fields, no support. Add as record columns.

3.7 The ingest signature itself is wrong

ingest(tenant_id: &TenantId, raw: &str) cannot accept any of the above. The eventual signature is roughly:

#![allow(unused)]
fn main() {
fn ingest(&mut self, record: &OtlpLogRecord) -> u64
}

…where OtlpLogRecord is a struct that mirrors the OTLP wire shape (or borrows directly from a tonic-decoded protobuf message). This is a breaking change to the cluster’s public surface and is rightly the territory of RFC 0001’s amendment.


4. Implications

4.1 RFC 0001 §6.1 needs amendment

The minimum schema additions to make the record OTLP-faithful:

AddTypeRationale
time_unix_nanou64B1 gate; required column
observed_time_unix_nanoOption<u64>OTLP has both
severity_numberu8Operator queries; template key
severity_textOption<String>Source’s original level
attributesKeyValue[]The structured params we currently miss
resource_attributesKeyValue[]service.name etc.
scope_nameOption<String>Template-key candidate
scope_versionOption<String>Diagnostic / drift detection
trace_idOption<[u8; 16]>Trace correlation
span_idOption<[u8; 8]>Trace correlation
flagsu32W3C trace flags
event_nameOption<String>Structured-event records

Plus an explicit decision on:

  • Template key. Is the leaf identified by (masked_body_tokens) alone, or by some tuple of (severity_number, scope_name, masked_body_tokens)?
  • body representation. AnyValue → what does the miner see? (Per §3.5 above.)
  • tenant_id derivation. What OTLP field(s) define it?

4.2 RFC 0001 §6.2 (algorithm) needs a tokenize/mask amendment

tokenize + mask are designed for text. Once Body is AnyValue, the front of the pipeline branches: structured Body skips the tokenize/mask path entirely (or uses path (3) above on a configured field). The algorithm spec needs to acknowledge this fork.

4.3 RFC 0003 (OTLP receiver) becomes a prerequisite, not a follow-up

Today’s roadmap.md §5 lists the OTLP receiver as “first post-MVP shipping PR series.” That sequencing assumes the receiver is just the wire-decode-and-forward layer for an already-OTel-aligned record schema. With the gaps in §3 above, the receiver and the schema co-evolve: you cannot define the record without knowing what the receiver hands you, and you cannot define the receiver without knowing what the record expects. RFC 0003 should be drafted alongside the RFC 0001 amendment, not after it.

4.4 The Phase-3 corpus + bench need an OTLP-shaped corpus

The corpus runner (ourios-bench, Phase 3) cannot validly exercise the C2 thesis gate (template-count convergence) on flat-text input if the production input is OTLP. The corpus input must itself be OTLP-shaped — either a pre-recorded batch of LogsData protobuf, or a generator that emits realistic LogRecords including the structured-Body and attributes-bearing variants.

4.5 The current cluster’s behaviour is not fully wrong, just narrow

Plain-text traditional logs (Syslog, Log4j, slog with default text formatter) produce LogRecords with string Body and near-empty Attributes. The current miner handles those records correctly modulo the missing timestamp / severity / resource columns. So the current code is not throw-away; it’s the text arm of a fork that the OTLP-aware ingest will need.


5. Recommendation

Three follow-ups, in order:

  1. Patch RFC 0001 §6.1 + §6.2 (a meta:-shaped change to the record schema and the algorithm spec). Land the new columns, the template-key decision, and the AnyValue handling fork. Do this first because the rest of the work depends on it.

  2. Draft RFC 0003 — OTLP receiver. Cover (a) the wire-decode layer (tonic for gRPC, axum/hyper for HTTP/protobuf, against the official opentelemetry-proto crate); (b) the OtlpLogRecord → MinerCluster mapping; (c) the tenant_id derivation rule; (d) the WAL-before-ack sequencing under the new structured shape (§3.4); (e) build-vs-depend evaluation (tonic + hand-roll vs. embedding the rotel Rust collector vs. running the OTel Collector out-of-process and forwarding).

  3. Patch the miner crates to consume the new record shape and route Body through the AnyValue fork. Update the roadmap to reflect that OTel-native ingest is no longer strictly post-MVP for the C2 gate’s validity.

The user-visible effect: the eventual benchmarks measure what an actual OTel deployment would experience, not a flat-text caricature of it. The thesis claim of “Parquet + template mining

  • DataFusion is the right stack for OTel logs“ becomes testable in the form an operator would actually evaluate it.

6. References

Last updated: 2026-05-13.

Hazards

Referenced from CLAUDE.md §4 (“Before any change to the hot path, re-read docs/hazards.md”) and §10 (“When in doubt: 1. Read docs/hazards.md”). This document is the load-bearing reading for any hot-path reviewer. Each hazard names a specific failure mode, the mitigation we have committed to, the detection signal, and the rule for when a deviation is a tuning question vs. an architectural one.

How to use this document

  • Before opening a PR that touches any subsystem named in a hazard section: re-read that section. The PR description must explicitly say which hazard it touches and how the change preserves the mitigation.
  • In review: if a hazard is touched and not addressed in the PR description, that is a block, not a nit.
  • In production: the named detection signals are the alerts that cannot be silenced without an RFC. They exist precisely so the failure mode is visible before it corrupts data.

Hazards map onto invariants in CLAUDE.md §3. Hazards describe what goes wrong; invariants describe what we promised. They are two faces of the same constraint.


H1 — Template miner correctness

Failure mode. The miner merges semantically-distinct templates because they share token structure. The canonical horror: user logged in <*> and user logged out <*> differ in one token; below a permissive threshold they merge into user logged <*> <*>. A query for the login event silently returns logout rows. The operator never knows.

Mitigation.

  • Default similarity threshold ≥ 0.7 (strict).
  • Lowering the threshold below 0.7 requires an RFC, not a config change.
  • Three-zone confidence model: clean match (≥ threshold) / lossy match (floor ≤ x < threshold, retain body — reconstruction still succeeds, so lossy_flag is not set) / parse failure (< floor, retain body, increment counter). lossy_flag is reserved for the H7 case (genuine tokenizer / preprocessing failure where reconstruct(record) != ingested_bytes is possible); it is not a low-confidence signal. See docs/rfcs/0001-template-miner.md §6.6 for the precise definition.
  • Every template-widening event is audited: the audit record names the old template, the new template, tenant, timestamp, and reason.

Detection. All metrics carry tenant_id; some carry service.

  • merges_total counter: spike on stable input → service-version change or threshold drift.
  • body_retention_ratio gauge: rising → input shifted or threshold is too tight.
  • confidence_p01 histogram tail: collapsing → many matches are barely passing; threshold should be revisited.
  • parse_failures_total: nonzero is genuine failure, not lossy.

Escalation. A spike on one tenant is a tuning question (masking rules, per-tenant threshold). A spike across many tenants on a stable corpus is a policy question — RFC.

See also. CLAUDE.md §3.1; docs/rfcs/0001-template-miner.md §§6.3–6.4; docs/benchmarks.md C2 (template count convergence), C3 (merge rate).


H2 — Parameter cardinality blowup

Failure mode. A params slot captures something it should not — an entire stack trace, a base64 payload, a request body, a megabyte JSON blob. Parquet’s dictionary encoding for that column collapses (every value distinct). File sizes explode. Query latency on that column degrades by orders of magnitude. The backend’s compression claim evaporates for that workload.

Mitigation.

  • Per-parameter byte limit, default 256 B, ceiling 1 KiB — raising the ceiling requires an RFC.
  • Overflow spills the original value into the body column; the params slot is replaced by a truncation marker (length + hash, no original payload).
  • Counter increments on overflow.

Detection.

  • params_overflow_ratio per service: alert when > 1 % of lines for any one service hit overflow.
  • Parquet column-size variance: a column whose dictionary efficiency drops sharply between compactions usually means a new overflow pattern.

Escalation. Service-specific spike → masking rule that pre-redacts the offending field. Broad spike → revisit the limit (still ≤ 1 KiB). Anyone proposing > 1 KiB → RFC.

Structured bodies (RFC 0037, proposed mitigation — not yet implemented). The per-parameter byte limit above guards the string path only. A structured (non-string) body — a GenAI event’s gen_ai.input.messages array, any AnyValue kvlist/array — is retained whole as canonical JSON in the body column and is not capped: truncating it would violate the bit-identical-reconstruction invariant (CLAUDE.md §3.3). Crucially, this hazard’s failure mode — dictionary-encoding collapse — does not apply to body: the Parquet writer disables the dictionary on the body column by design (crates/ourios-parquet/src/writer.rs §3.6 — bodies are unbounded and high-entropy), so a large structured body has no dictionary to collapse. The residual risk is raw storage size, not dictionary collapse, so RFC 0037 proposes to guard it by observation, not truncation: a structured_body_bytes metric (dimensioned by service) plus a per-service alert to flag oversized emitters, with RFC 0036’s write-side row-group/file sizing bounding the on-disk footprint. The fix for an oversized structured body is at the emitter (redaction/truncation before export), not a store-side cap.

See also. CLAUDE.md §3.2, §3.3; RFC 0001 §6.5; RFC 0037; benchmarks C4.


H3 — WAL durability vs. latency

Failure mode. The ingester acknowledges an OTLP batch before the write is durably persisted. The ingester then crashes (process kill, host failure, container reschedule). The producer believes the data was accepted; we have lost data we promised to keep.

Mitigation.

  • An ack is emitted only after fsync (or equivalent durability primitive) on the WAL.
  • Batched fsync with an explicit operator-tunable knob: default flush every 100 ms or when the current segment fills, whichever first.
  • Crash-recovery test is part of CI: SIGKILL the ingester mid-batch, restart, assert no acknowledged data is missing. Test runs on every PR; failure blocks merge.
  • Replication, when added, is in addition to the WAL, not a replacement.

Detection.

  • ingest_ack_latency_p99: rising trend usually means fsync is the bottleneck.
  • wal_unflushed_bytes: bytes acked but not yet on durable storage — must always be bounded.
  • CI crash-recovery test: any failure is critical, regardless of flake history.

Escalation. Fsync latency rising → tune batch size or move to faster storage. Ack-without-fsync ever observed in code review → P0 bug, hotfix path.

See also. CLAUDE.md §3.4; RFC 0008 (WAL design); benchmark D2 (compaction keeps up).


H4 — The small-file problem

Failure mode. WAL segments get rotated and flushed to Parquet too eagerly. The result is thousands of small files per tenant per day. Object-storage LIST calls dominate query planning time. Cold cache hits are murderous. Operators see “query took 12 s on 4 GB of logs” and lose faith in the backend.

Mitigation.

  • Target row-group size 128 MB – 1 GB for ingest-side Parquet files. Compacted files deliberately rotate row groups at a smaller threshold (RFC 0036 §3.3, initially 32 MiB) — the pruning-granularity knob: within one compacted file a smaller row group buys tighter per-group min/max statistics (so a windowed query scans the groups that hold its answer, not the whole hour) at the cost of a few more footer entries. File economics are governed by the file band below, which compaction leaves untouched.
  • Target file size 256 MB – 2 GB post-compaction.
  • Background compaction job per tenant; cadence is a tunable.
  • Compaction is required to keep the WAL backlog bounded under sustained ingest (D2).

Detection.

  • File-size histogram per tenant: fewer than 5 % of files below 128 MiB at steady state.
  • File count vs. data volume: file count must grow sub-linearly with bytes ingested.

Escalation. Skewed file-size distribution on a single tenant → compaction tuning. Sustained small-file emission across the cluster → ingest-scaling block, RFC.

See also. CLAUDE.md §4 hazard 4; benchmarks D3.


H5 — Template schema evolution across deploys

Failure mode. A service ships a new version. Log format changes — a new field, a renamed token, reordered words. The template tree built from last month’s logs no longer matches the new format cleanly. Queries against template_id = X start returning incomplete results because some rows are now stored under template_id = X'. The operator sees a 30 % drop in event volume and misdiagnoses it as an outage.

Mitigation.

  • Templates are versioned: a template’s internal representation can change; the logical identity persists across versions.
  • Explicit alias mechanism: template_id.resolves_to(X) in the DSL resolves a query across all aliases of X.
  • Drift detection is a first-class query — operators can ask “what templates drifted in the last 24 h?” and get a list.
  • A new template_version emits an audit event, just like a merge.

Detection.

  • Spike in distinct template count immediately after a deploy → expected; investigate only if it persists past the deploy window.
  • Diff between template_id = X and template_id.resolves_to(X) result counts → measures alias coverage.
  • Audit event volume: drift events should correlate with deploy cadence, not appear randomly.

Escalation. Alias graph becomes a tangle (templates with > N aliases or cycles) → revisit alias semantics, RFC. Drift correlated with deploys → expected; not an alert.

See also. CLAUDE.md §3.5; RFC 0001 §6.7.


H6 — Query DSL vs. DataFusion SQL surface

Failure mode. A user-facing query surface accidentally exposes DataFusion specifics — a SQL keyword leaks into an error message, a planner hint becomes documented, a join type that doesn’t make sense in a logs context becomes reachable. We then cannot upgrade DataFusion or change the planner without breaking saved user queries and dashboards. The DSL has become a contract we never intended to sign.

Mitigation.

  • The DSL is a separately specified layer (docs/rfcs/0002).
  • All DSL constructs compile to DataFusion LogicalPlan, never to SQL strings. SQL never appears in any user-visible output.
  • No SQL escape hatch by default. If one is added later, it ships under a separate RFC, sandboxed, opt-in, and tenant-gated.
  • DSL evolution is a written semver contract with users; major versions ship with a deprecation window.

Detection.

  • PR review: any test or error message containing the substring “DataFusion” or referring to a DataFusion type by name in a user-facing surface is a block.
  • Any code path that constructs SQL strings from user input is a block.
  • User report: “this query worked yesterday after the upgrade” triggers a regression review.

Escalation. Leak found in user-facing surface → block + hotfix. Recurring temptation in implementation → tighten the API boundary, move shared helpers behind a non-exported module.

See also. CLAUDE.md §4 hazard 6; RFC 0002.


H7 — Bit-identical body reconstruction

Failure mode. An operator opens the UI and asks “show me what was actually logged.” We render the row from template + params and produce a string that drops a space, a quote, a separator, or a trailing newline. The operator chases a bug that doesn’t exist — or, worse, fails to chase a bug that does, because the rendered line looked normal.

Mitigation.

  • The miner either captures inter-token whitespace and separators or it sets lossy_flag = true on the row. There is no third option.
  • Reconstruction is a property test against the testdata corpus: for every non-lossy row, reconstruct(record) == ingested_bytes exactly. Property failure blocks merge.
  • The reader honours lossy_flag. The UI surfaces lossy rows with an explicit warning (“this row’s body cannot be exactly reconstructed”) rather than rendering them.
  • Tenants may opt into default-on body retention at a storage cost.

Detection.

  • Reconstruction property test (CI): zero failures, ever, on the committed corpus.
  • body_retention_ratio gauge: a sudden rise indicates input distribution change OR a regression in whitespace capture.
  • User complaint of “the rendered log does not match what we sent” → reproduce, add to corpus, fix.

Escalation. Ever fails on a real-world corpus → block + hotfix. Whitespace-capture state machine becomes a complexity sink → simplify by retaining more bodies; the storage cost is real but acceptable, lying to the user is not.

See also. CLAUDE.md §3.3; RFC 0001 §6.6; benchmarks C1.


H8 — Replication-induced dedup under clock drift

Forward-looking hazard. Ourios does not currently replicate at ingest, so this hazard is dormant. It is recorded here so that any future RFC proposing replication starts with the failure mode already understood.

Failure mode. A multi-ingester replication design quietly introduces a storage multiplier if dedup is keyed on filename and a time window. Even sub-second clock drift between replicas causes the dedup pass to miss duplicates: each replica writes “the same” record under a slightly different filename or window, the dedup miss is invisible to any single replica’s logs, and the user pays for redundancy they thought they had bought once. A widely-deployed log backend was found in 2026 to be carrying a ~2.3× storage multiplier from exactly this failure mode, motivating a full re-architecture of its durability layer.

Mitigation.

  • Currently: not a concern. Ourios does not replicate at ingest. Per CLAUDE.md §3.4, durability is per-ingester fsync on the WAL; per §3.6, object storage is the long-term truth. Replication, if introduced later, is “in addition to the WAL, not instead of it.”
  • When replication is proposed: dedup MUST be content-keyed, never time-windowed. The producer (or the OTLP layer) supplies an idempotency key — a content hash combined with a producer identifier and a sequence number. Ingesters treat the key as opaque. Clock drift becomes irrelevant to dedup correctness.
  • Time-window dedup is rejected by default, regardless of how cheap or convenient it appears. An RFC proposing it must read this hazard and address it explicitly.

Detection.

  • bytes_stored / bytes_received per tenant: must hover near 1 on a single-replica deployment. A multiplier > 1 + ε on a single-replica deployment indicates double-writing somewhere upstream.
  • On a future replicated deployment: this ratio should remain near 1 after dedup; a sudden rise indicates clocks drifted and the dedup pass is missing duplicates.
  • A “dedup hit rate” metric on the dedup pass — a sudden drop signals that something is masking what should be visible duplicates.

Escalation. Replication proposed → RFC must explicitly address this hazard. Time-window dedup proposed → block, redirect to a content-keyed approach. Storage multiplier > 1.05 on a single- replica deployment → P0 investigation, something is double-writing.

See also. CLAUDE.md §3.4 (WAL durability), §3.6 (object storage as truth). Cautionary tale: Grafana Loki’s 2026 re-architecture replacing replicate-at-ingest with Kafka-as-durability (InfoQ news, April 2026).


Adding a new hazard

A new hazard belongs in this document if all of the following hold:

  • It is a failure mode that silently corrupts data, lies to the user, or destroys the project’s value proposition.
  • It is not obvious from reading the code (otherwise it is a bug, not a hazard).
  • It has at least one named mitigation in the codebase or a committed RFC.

A new hazard is added via a meta: RFC, the same path as changes to CLAUDE.md §3 invariants.

Verification

Status: active. This document is the process spec. The proposed amendments to docs/rfcs/README.md and CLAUDE.md at the bottom of this file are tracked separately and applied in their own PR once the structure here is settled.

What this doc is for

The Ourios docs already define invariants (CLAUDE.md §3), hazards (docs/hazards.md), per-RFC testing strategies (RFC §6 — see §2.5), thesis-gates (docs/benchmarks.md), and the project’s testing discipline (CLAUDE.md §6.2). What is missing is the described process that connects them: how a contributor (human or agent) takes a §3 invariant or an H-x hazard, turns it into reviewable acceptance criteria, turns those into red tests, drives them green, and gates an RFC’s transition to accepted.

This doc fills that gap. It is human-readable process, not test code, not tooling, not a coverage policy.


1. The flow

Six links, four gates between them. The diagram below names them; the text after walks the chain in the order a contributor encounters it.

Invariant (§3)        Hazard (H-x)        RFC (§§1–4)
       \                 |                   /
        \________________|__________________/
                         ↓
                Acceptance criteria
              (RFC §5 — normative,
               structured prose)
                         ↓
                   Red tests
                  (compile, fail)
                         ↓
                  Green tests
              (unit + property + corpus)
                         ↓
                  Validated
        (corpus + thesis-gates pass on
         representative inputs)

The chain has two entry points (Invariant, Hazard) that converge on the third (RFC). An RFC enumerates the invariants and hazards it touches in its §1 Summary; reviewers verify the enumeration is exhaustive at the Drafted → Specified gate.

Invariant → RFC. A CLAUDE.md §3 invariant is a project-level promise. Until an RFC operationalises it, the invariant is a known debt. §4 Entry points describes the three doors into this chain.

Hazard → RFC. Each hazards.md H-x item names the RFCs and crates responsible in its Mitigation and See also fields. The hazard does not move; the RFC inherits the obligation to defend it.

RFC → Acceptance criteria. Acceptance criteria live in RFC §5 (see §2) and translate the invariants and hazards the RFC touches into testable scenarios. The Specified gate ratifies the list.

Acceptance criteria → Red tests. A red test is a compiling stub that fails — typically with todo!() or unimplemented!() — and references the scenario id in a doc comment. Red tests are not required at the Specified gate; they are the artefact of crossing the Red gate, immediately before implementation begins. Forcing stubs to compile at Specified would push authors into premature specificity about types and signatures. Red stubs are tagged #[ignore] so the outer CI loop stays green while the inner loop (the implementor running cargo test -- --ignored locally) sees the todo!()s fire as a TODO list — see §3 for the two-loop spec.

Red tests → Green tests. Implementation lands; each stub becomes a real test that passes; unit, property, and corpus tests cover the scenario as CLAUDE.md §6.2 dictates. The Green gate confirms every §5 acceptance criterion has a matching passing test.

Green tests → Validated. The thesis-gates in benchmarks.md §7 that the RFC’s pillars touch must pass on representative corpora. Once they do, the RFC’s status: flips to validated. Maintainer sign-off then flips it to accepted.

2. Acceptance criteria

The contract here is single-typed: every invariant or hazard the RFC touches resolves to one or more scenarios, each with an id, a leading clause grammar, and a greppable counterpart in test code.

2.1 Format

Structured prose using bold leading clauses. Each scenario carries a short numeric id (see §2.2) and follows the Given / When / Then / And pattern:

Scenario H1.1 — Semantically distinct templates do not silently merge

  • Given a corpus containing user logged in <*> and user logged out <*>
  • When similarity threshold is 0.7 (default)
  • Then the two remain distinct template_ids
  • And any widening produces an audit event recording both old and new templates

The format is the markdown the project already uses, not Gherkin. We do not adopt .feature files, cucumber-rs, or any other BDD tooling: the test code is Rust (CLAUDE.md §6.2), and the scenario lives in the RFC where reviewers are already reading. A second source of truth — a .feature file checked separately — would drift, and the tooling does not pay for itself at our scale.

2.2 Scenario ids

Three id grammars, chosen to make the source of the obligation visible at a glance:

  • H<n>.<m> — hazard-rooted; H1.1 is the first scenario defending hazard H1.
  • §3.<n>.<m> — invariant-rooted; §3.4.2 is the second scenario defending CLAUDE.md §3.4 (WAL-before-ack).
  • RFC<NNNN>.<m> — RFC-internal; reserved for scenarios that defend an RFC’s own design decisions, not a numbered invariant or hazard. Example: RFC0001.3 for a Drain3-extension behaviour that is not load-bearing for any §3 invariant but is part of the RFC’s contract.

Numbers within an id family are assigned in the order scenarios are written and never renumbered. A retired scenario keeps its number; new scenarios append. This gives git log -S "H1.1" a stable target across the lifetime of the project.

2.3 Greppability

The id is referenced from the test code in a doc comment, exactly:

#![allow(unused)]
fn main() {
/// Scenario H1.1 — Semantically distinct templates do not silently merge.
/// See `docs/rfcs/0001-template-miner.md` §5.
#[test]
fn login_and_logout_do_not_merge_at_default_threshold() { /* … */ }
}

grep -R "H1.1" . then yields the scenario in the RFC, the test in the crate, and any cross-references in the docs — bidirectional in one command. If a scenario is renamed, both ends move in the same commit.

2.4 Normative vs. exhaustive

Acceptance criteria are the normative tests an RFC promises will exist. The implementation will write many more — regression tests, edge cases, performance smokes — and those are not catalogued in the RFC. Reviewers ratify the normative set: every invariant and hazard the RFC touches has at least one scenario, and the scenarios as written are testable in principle.

The opposite mistake — listing every test the implementation will ever write — turns the RFC into a test plan and freezes the implementation. We do not do that.

2.5 Location in the RFC

Acceptance criteria are a new RFC §5, immediately before Testing strategy. The placement is deliberate: criteria are the spec the testing strategy operationalises, so reviewers reading the RFC top to bottom encounter the what before the how. The proposed amendment to docs/rfcs/README.md at the bottom of this file captures the renumbering: existing §5 Testing strategy shifts to §6, Open questions to §7, References to §8.

3. The RFC maturity model

Five stages, four gates. Each stage is a value of the RFC’s status: frontmatter field, so an RFC’s current maturity is visible without reading the body:

StageWhat existsGate to next
DraftedRFC §§1–4 and §§7–8 filled; §§5–6 may be stubbedPeer review of design
Specified§5 acceptance criteria written, scenarios numberedReview: do the criteria cover every invariant and hazard the RFC touches? Are they testable in principle?
RedTest stubs compile, are tagged #[ignore], and fail with todo!() (or equivalent) when runImplementation begins
GreenAll §5 criteria pass; unit + property + corpus tests greenValidation against representative inputs
ValidatedThesis-gates in benchmarks.md §7 pass on representative corporaMaintainer signs off; status flips to accepted

accepted is a distinct terminal status — it represents maintainer sign-off after Validated is reached. rejected and superseded are the other terminals, all three reachable from anywhere in the maturity ladder. A Drafted or Specified RFC may be rejected on review without ever reaching Red; an Accepted RFC may be superseded by a later one without re-traversing the chain.

The table is the spec; the paragraphs below explain what artefacts exist at each stage and what a reviewer is ratifying.

Drafted. The RFC has §§1–4 (Summary, Motivation, Proposed design, Alternatives considered) plus §§7–8 (Open questions, References) filled enough that two engineers reading it would produce roughly the same implementation. Acceptance criteria (§5) and Testing strategy (§6) may be empty or stubbed. The PR is open with status: drafted; review focuses on whether the design is correct in principle. The gate to Specified is a peer reviewer saying “yes, this design is what we want — now write down the contract.”

Specified. §5 Acceptance criteria is filled. Every invariant in CLAUDE.md §3 and every hazard in hazards.md that the RFC touches has at least one numbered scenario. §6 Testing strategy references those scenarios and names the technique (proptest, corpus, criterion) for each. The reviewer asks one question: could a competent implementor turn each criterion into a test as written? If the answer is no — for any criterion — the RFC has a gap and goes back to Drafted.

The Specified gate is the most valuable. It is the only gate where the cost of being wrong is bounded by review time rather than implementation time. We do not require test stubs to compile here; forcing stubs would push authors into premature decisions about function signatures, traits, and module structure, which is the Red gate’s job, not this one.

Red. Test stubs exist, are tagged #[ignore], and fail when run. Each stub carries a doc comment naming its scenario id (§2.3). Stubs may be todo!(), unimplemented!(), assert!(false) — anything that compiles and fails. Implementation may begin.

The Red signal lives at two granularities, deliberately:

  • Inner loop (local dev cycle). The implementor working on a stub runs cargo test <name> -- --ignored and watches the todo!() panic. Each panic is one TODO item; as the body fills in, the #[ignore] comes off and the test joins the default run.
  • Outer loop (CI). Default cargo test skips ignored tests, so the Red-stage PR lands cleanly through branch protection rather than fighting it. CI’s signal that the Red gate is satisfied is structural: stubs compile, every §5 scenario has an #[ignore]’d test with a matching id, and cargo test -- --include-ignored exits non-zero on each. (The greppability contract in §2.3 makes the per-scenario coverage check mechanical — grep -R "H1.1" returning both the RFC line and the test stub line is the assertion.)

The two-loop split is what lets us treat the Red status as a landable, mergeable state rather than a half-broken branch. A Red-stage main is healthy: outer loop green, inner loop fully populated with the work that needs doing.

The gate is mechanical: every scenario in §5 has at least one stub with a matching id, the stub is tagged #[ignore], and cargo test -- --include-ignored exits non-zero on each.

Green. Implementation lands. Every stub becomes a real test; unit, property, and corpus tests cover their scenarios as CLAUDE.md §6.2 dictates. cargo test --all-features passes. The reviewer confirms each §5 criterion now resolves to a passing test (the greppability contract makes this mechanical). No performance claim is made yet.

Validated. Every thesis-gate in benchmarks.md §7 that the RFC’s pillars touch passes on representative corpora. Maintainer inspects the corpus and the delta against target, signs off, and flips status: to accepted. The RFC is now binding; subsequent changes go through the regression handling in §3.1.

3.1 Regression handling after Validated

A failing test on a previously-Validated RFC is, by default, the test doing its job. The RFC does not reopen. Standard PR workflow: fix the regression, ship the patch, the test stays green.

The RFC reopens only when a single criterion fails repeatedly on the same code path — concretely, when the same scenario id fails on three independent commits within a 30-day rolling window, or when two distinct regressions touch the same criterion within the same window. The threshold is that the criterion has stopped being a defence and has become a moving target; that is a signal the RFC’s commitment is under-defended or under-specified, and the design (not just the implementation) needs revisiting.

This threshold is informal at the Specified gate; it sharpens once real signals exist. The point of writing it down now is that contributors do not race to reopen RFCs on every CI flake, nor pretend a repeated structural failure is just bad luck.

Thesis-gate failures during Validated follow benchmarks.md §7’s existing escalation rule (one fail on one corpus → tuning RFC; two or more → pillar RFC, pause), not this section.

3.2 Outer loop vs. inner loop

The maturity model is the outer loop. Each stage names a checkpoint that an external reviewer can verify: at Specified the scenarios are written, at Red the stubs compile and fail, at Green the same stubs pass. Nothing in the outer loop says how a developer fills the Red → Green transition.

The recommended inner loop is classic Beck-style TDD: write one failing test, make it pass with minimal code, refactor, triangulate by writing the next test that forces generalisation, repeat. It is not mandatory — a developer who prefers to stub all scenarios up front and implement against them is welcome to. The outer loop only requires that every §5 scenario has a stub by the Red gate and a passing test by the Green gate.

Two consequences worth being explicit about:

  1. More tests than scenarios. The inner loop typically writes many tests per scenario — one per concrete example, then regression tests as bugs surface. Acceptance criteria (§2.4) are the normative set the RFC is held to; the inner loop fills out the rest.
  2. No refactor stage in the model. Refactoring is part of the inner loop, not a maturity stage. A Green or Validated RFC may be refactored without re-traversing the chain, as long as every §5 criterion stays green.

The split is the BDD/ATDD outer-shell convention adapted to a project already committed to Rust, proptest, and criterion: the scenarios are written in the BDD-flavoured prose of §2.1 because they live in RFCs and humans read them; the tests are written in the TDD-flavoured loop developers already know.

4. Entry points

The same machinery, three doors:

  • Invariant entry — an item in CLAUDE.md §3. The criteria live in the RFC that operationalises that invariant; if no RFC yet exists for the relevant subsystem, the invariant is a known debt and the next RFC for that subsystem must address it.
  • Hazard entry — an item in hazards.md. Each hazard’s Mitigation section names the RFCs and crates responsible; their acceptance criteria must reference the hazard id.
  • RFC entry — a new RFC under docs/rfcs/. The RFC enumerates the invariants and hazards it touches in its §1 Summary; criteria in §5 must cover each.

5. Relationship to benchmarks.md

Correctness gates live here. Thesis-gates live in benchmarks.md §7. An RFC reaches Validated only when both:

  • Every §5 acceptance criterion has a passing test, and
  • Every thesis-gate in benchmarks.md §7 that the RFC’s pillars touch passes on representative corpora.

Single sentence; intentional non-duplication. benchmarks.md stays the performance owner.

6. Worked example

A concrete trace of the chain in §1, against an artefact that already exists. RFC 0001 Template miner is currently status: draft (becoming drafted once the amendment to docs/rfcs/README.md lands). Its operationalisation of CLAUDE.md §3.1 No silent template merges and hazards.md H1 Template miner correctness is the first place this process gets to bite on real material.

6.1 Invariant → RFC

CLAUDE.md §3.1 promises:

A template merge that crosses semantic boundaries (e.g. merging “user logged in” with “user logged out” because they share token structure) corrupts the backend.

hazards.md H1 names the canonical horror — user logged in <*> and user logged out <*> differing in one token, merging under a permissive threshold to user logged <*> <*>, a query for the login event silently returning logout rows.

RFC 0001 §6.4 Merge policy is the section that defends the invariant. As of the Drafted gate it commits to “When two templates become candidates for merge”, an audit event schema, and the rule “Default: strict. Never silent. No exceptions.”

6.2 RFC → Acceptance criteria

The Specified gate adds a new §5 to RFC 0001:

Scenario H1.1 — Semantically distinct templates do not silently merge

  • Given a corpus containing user logged in <*> and user logged out <*>
  • When similarity threshold is 0.7 (default)
  • Then the two remain distinct template_ids
  • And any widening produces an audit event recording both old and new templates

Scenario H1.2 — Lossy-zone match retains body

  • Given a line whose best match has confidence in the lossy zone (floor ≤ x < threshold)
  • When the line is ingested
  • Then the body column contains the original line bytes
  • And the row carries lossy_flag = false (the flag is reserved for tokenizer / preprocessing failure per docs/rfcs/0001-template-miner.md §6.6 — the lossy zone retains the body but reconstruction still succeeds)

Scenario H1.3 — Every widening emits an audit event

  • Given any sequence of inputs that triggers a template widening
  • When the widening completes
  • Then an audit event exists naming the old template, the new template, the tenant id, the timestamp, and the event_type

Three scenarios cover §3.1’s three rules: do not merge across semantics, retain bodies on low confidence, audit every widening. Reviewers ratify that this is exhaustive against CLAUDE.md §3.1 and H1; they do not catalogue every edge-case test the implementation will write.

6.3 Acceptance criteria → Red tests

The Red gate adds three stubs to crates/ourios-miner/tests/:

#![allow(unused)]
fn main() {
/// Scenario H1.1 — Semantically distinct templates do not silently merge.
/// See `docs/rfcs/0001-template-miner.md` §5.
#[test]
#[ignore = "RFC 0001 Red gate — implementation pending"]
fn h1_1_login_and_logout_remain_distinct_at_default_threshold() {
    todo!("RFC 0001 §6.4");
}

/// Scenario H1.2 — Lossy-zone match retains body.
/// See `docs/rfcs/0001-template-miner.md` §5.
#[test]
#[ignore = "RFC 0001 Red gate — implementation pending"]
fn h1_2_lossy_zone_match_retains_body() {
    todo!("RFC 0001 §6.6");
}

/// Scenario H1.3 — Every widening emits an audit event.
/// See `docs/rfcs/0001-template-miner.md` §5.
#[test]
#[ignore = "RFC 0001 Red gate — implementation pending"]
fn h1_3_every_widening_emits_an_audit_event() {
    todo!("RFC 0001 §6.4");
}
}

Default cargo test skips the ignored stubs and passes (outer loop / CI green); cargo test -- --ignored exits non-zero with all three failing (inner loop / Red signal). The gate is satisfied; implementation may begin.

6.4 Red → Green

Implementation lands across ourios-miner (and supporting types in ourios-core). The three stubs become real tests: H1.1 ingests the two-template corpus, asserts two distinct template_ids, and queries the audit log for absence of widening events. H1.2 ingests a line whose token similarity falls in the lossy zone and asserts that the row’s body carries the original bytes and lossy_flag is false (the flag is reserved for the H7 reconstruction-failure case; see RFC 0001 §6.6). H1.3 ingests a sequence that provokes a widening and asserts the audit event’s structure.

cargo test --all-features passes. Reviewers confirm each H1.x id now resolves to a passing test via grep. No benchmark claim is made.

6.5 Green → Validated

benchmarks.md C2 Template count convergence is the thesis-gate that H1 most directly touches: if the miner is silently merging across semantics, template count grows wrong. The benchmark harness runs C2 on the LogPAI corpora and any self-collected corpus available, plots template count vs. lines ingested, and asserts the convergence target.

Once C2 passes — and any other thesis-gate the RFC’s pillars touch — the maintainer signs off. RFC 0001’s status: flips to accepted. The miner’s contract is now binding.

6.6 The failure mode that re-opens the RFC

A hypothetical: six months in, three independent PRs land that each add a workaround to keep H1.1 green — a special-case for common verb pairs, then for HTTP method tokens, then for log-level tokens. Each workaround is small, each test stays green. By the fourth PR, a reviewer notices: the criterion has stopped being a defence and has become a moving target. Per §3.1, the RFC reopens. The right answer is not a fifth workaround; it is to revisit RFC 0001 §6.4 — the merge policy itself is under-specified for the workloads we are seeing.

This is what the threshold in §3.1 is for. It is not a CI-flake counter; it is a signal that the design’s defence has eroded and needs to be redrawn before more code is written on top of it.

7. What this doc is not

  • Not test-tooling guidance — proptest, criterion, etc. live in CLAUDE.md §6.2.
  • Not a coverage policy — Ourios is a correctness project; line coverage is the wrong metric.
  • Not an agent-instruction file — agents follow it because it is written down, not because it speaks to them.

8. Resolved decisions

Three questions raised during the outline review, decided before expansion so the rationale is preserved:

  • Maturity stages appear in RFC frontmatter as the status: field. Reviewers and tooling see an RFC’s current stage without reading the body. See §3.
  • Single regressions do not reopen a Validated RFC. A failing test on an existing criterion is the test doing its job; standard PR workflow applies. Repeated regression on the same criterion (rough threshold: same scenario id failing on three independent commits, or two distinct regressions touching the same criterion, both measured in a 30-day rolling window) signals the criterion has stopped being a defence and reopens the RFC. See §3.1.
  • Thesis-gate failures during Validated follow benchmarks.md §7, not this doc. One thesis-gate failing on one corpus → tuning RFC; two or more → pillar RFC and an implementation pause.

Proposed amendment — docs/rfcs/README.md

Two changes. Shown as the new text:

In Required frontmatter

Update the status field’s valid values from the current four-state list to the five-stage maturity model plus terminals:

status: drafted | specified | red | green | validated | accepted | rejected | superseded

The maturity stages (drafted through validated) are gates an RFC moves through; accepted is the terminal post-maintainer-signoff binding state; rejected and superseded are the off-ramps. See docs/verification.md §3.

In Required sections

Insert a new item between the current §4 Alternatives considered and §5 Testing strategy, renumbering subsequent items:

  1. Acceptance criteria — normative scenarios, one per invariant or hazard the RFC touches. Format: structured prose with Given / When / Then / And leading clauses; each scenario carries an id of the form H1.1, §3.4.2, or RFC<NNNN>.<m>, referenced from the test code so the mapping is greppable. See docs/verification.md §2.

Testing strategy shifts to §6, Open questions to §7, and References to §8.

In Lifecycle

Replace the current four-status list with the five-stage maturity model:

  1. Drafted — PR opened with status drafted. Sections §§1–4 and §§7–8 are filled. Discussion happens in PR review.
  2. Specified — §5 acceptance criteria are written, every invariant and hazard the RFC touches has at least one scenario, and review has confirmed the criteria are testable in principle.
  3. Red — test stubs exist and fail. Implementation may begin.
  4. Green — all acceptance criteria pass; unit + property + corpus tests green.
  5. Validated — thesis-gates in docs/benchmarks.md §7 pass on representative corpora. Maintainer flips status to accepted.

A regression detected after Validated either reopens the RFC (if a criterion is invalidated) or spawns a tuning RFC per benchmarks.md §7 (if a thesis-gate degrades). See docs/verification.md §3.

The earlier superseded and rejected entries remain unchanged.

Existing RFC frontmatter

RFC 0001 and RFC 0002 currently carry status: draft. The amendment PR renames both to status: drafted so the maturity model applies uniformly. No content change to the RFCs themselves at that step.


Proposed amendment — CLAUDE.md

A single new subsection under §5 Development workflow, following §5.5 One-word mode:

5.6 Verification process

The path from invariant or hazard to passing test is described in docs/verification.md. Acceptance criteria live in RFC §5; docs/rfcs/README.md defines the maturity stages an RFC moves through. The shortest version of the rule: if a criterion cannot be turned into a test, the RFC has a gap.

No change to §6.2 Testing discipline; verification.md links to it. The §6.2 content (proptest, corpus tests, crash recovery, criterion) is the catalogue of techniques; verification.md is the process that decides which technique is required where.


Applying the amendments

The body of this document is the verification process spec. The two proposed amendments above are pending application:

  • docs/rfcs/README.mdstatus: value list, new §5 Acceptance criteria in Required sections with renumbering, lifecycle rewrite, draftdrafted rename in RFC 0001 and 0002.
  • CLAUDE.md — new §5.6 Verification process.

Both should land in a single PR. RFC 0001 then gets a §5 Acceptance criteria applied as the first concrete use of the process — the worked example in §6 of this document is the target shape, and applying it will probably surface specificity gaps in RFC 0001’s existing design. That surfacing is the point.

Add this document to docs/SUMMARY.md under the Architecture header in the same PR that applies the amendments.

Glossary

Vocabulary used in the Ourios docs. Entries marked (Ourios) carry a project-specific meaning that may differ from the industry-default. Cross-references in italics point to other entries here.


Audit event. A structured record emitted by the miner every time a template is widened (parameters generalised), merged with another template, or versioned. Audit events are themselves stored as logs and are queryable. They are the trail by which an operator can answer “did this template silently change yesterday?” See hazards.md H1.

Bit-identical reconstruction. The property that, for any ingested log line, either Ourios can reproduce the exact original byte sequence from what it stored, or the row carries lossy_flag = true. Never an in-between. Tested as a property test against the corpus. See CLAUDE.md §3.3, hazards.md H7.

Body. The free-form text content of a log record. In OTel terms, the body field of a LogRecord. In Ourios storage, the body is either reconstructible from template + params (most rows) or retained verbatim in a dedicated column (lossy rows, parse failures, or tenants who opted in to always-retain).

Compaction. Background process that merges many small Parquet files into fewer large ones, targeting row-group sizes of 128 MB to 1 GB and file sizes of 256 MB to 2 GB. Driven by the small-file hazard (H4).

Confidence. A scalar in [0, 1] assigned by the miner to each matched row, measuring how well the row matched its assigned template. The three-zone model partitions confidence into clean match (≥ threshold), lossy match (floor ≤ x < threshold), and parse failure (< floor). (Ourios) — extends Drain, which is binary-classifying.

Corpus. A collection of anonymised log lines used as test input. Lives under testdata/corpus/. Public LogPAI corpora form the floor; self-collected corpora per deployment archetype are added over time. Reconstruction, template-count convergence, and merge rate are all measured against the corpus on every miner change.

DataFusion. The Apache project providing the query engine Ourios uses. Ingests logical plans, optimises them, executes against Parquet. Ourios extends DataFusion with two custom logical nodes (render, template_id.resolves_to) but otherwise treats it as a black box. DataFusion specifics never leak into the user-facing DSL (H6).

Drain. The 2017 paper (He, Zhu, Zheng, Lyu — ICWS 2017) that introduces a fixed-depth tree algorithm for online log parsing. The basis of the miner. See docs/rfcs/0001-template-miner.md and docs/talks/0001-template-miner.md.

Drain3. The IBM-maintained fork of Drain that adds persistent state, masking, variable-length wildcards, and dynamic thresholds. Some of its extensions are adopted in Ourios; some are explicitly not. RFC 0001 §4 lists the per-extension verdict.

Drift. The phenomenon where a service’s log format changes between deploys, producing new templates that are aliases of older ones. (Ourios) — drift is detected as a first-class query, not an after-the-fact discovery. See H5 and RFC 0001 §6.7.

DSL. The user-facing query language for Ourios logs (RFC 0002). Compiles to DataFusion logical plans; does not expose SQL. Two candidate predicate sublanguages (OTTL-borrowed vs. distanced) and three top-level surfaces (SQL-clause, LogQL-pipe, Insights-verb) are under design.

Floor. The lower bound of confidence below which the miner declares a parse failure. Default ~0.3. Below the floor, the row is stored body-only and parse_failures_total increments. (Ourios) — not present in the Drain paper.

Fsync. The POSIX call that forces buffered writes to durable storage. The WAL fsyncs before acknowledging an OTLP batch. See H3, CLAUDE.md §3.4.

Hazard. A named failure mode that, if not actively mitigated, silently corrupts data or destroys the project’s value proposition. The eight current hazards are catalogued in docs/hazards.md. New hazards are added via a meta: RFC.

Ingester. The Ourios role that receives OTLP over gRPC/HTTP, mines templates, writes to the WAL, and (eventually) flushes to Parquet via the compactor. One half of the ingester/querier binary split.

Length group. The first-level partition in the Drain parse tree: one branch per distinct token count. Drain assumes lines of different length are probably from different call sites and uses length as a cheap initial filter.

Log group. Drain’s term for a template together with the rows that have matched it. A leaf in the parse tree contains a list of log groups.

Lossy. A row whose lossy_flag is set, indicating that reconstruction from template + params may not be byte-identical. Always paired with the original body being retained on that row. See H7.

LogPAI. The benchmark-corpus project for log parsing (github.com/logpai/logparser). Ourios uses LogPAI corpora (HDFS, BGL, Spark, Apache, OpenSSH, Windows) as the public-corpus floor for benchmarks.

Masking. Pre-tokenisation regex rules that replace volatile sub-strings (IPs, UUIDs, numbers) with placeholders before the miner walks the tree. A Drain3 extension. Whether and where Ourios applies masking is a design choice in RFC 0001 §4.

Merge. When the miner widens an existing template to absorb a new line — e.g. replacing a literal token with a wildcard. Every merge fires an audit event. Strict thresholds make merges rare; audit makes them visible. See H1.

Miner. Short for template miner — the Ourios subsystem that runs Drain online over ingested log lines and emits (template_id, params, confidence, lossy_flag) for each row. Lives in the ourios-miner crate. Designed in RFC 0001.

template_id. The miner’s key for a template: a u64 that is Ourios-derived and tenant-local, not an OTLP field and not portable. Two stores — or one store after an RFC 0023 eviction or a re-mint — can give the same log shape different ids, which is why drift is a first-class query (RFC 0010) and aliases exist (RFC 0007). It shares the Parquet record’s flat namespace with genuine OTLP fields (severity_number, trace_id, body), so the name can read like a wire field; it is not one. The portable identity of a template is its string, which the OpenTelemetry ecosystem is converging on as log.record.template (collector-contrib’s drainprocessor, tracking semantic-conventions #1283 / #2064) — see RFC 0050 for how Ourios accepts and reconciles a template that was derived upstream.

OTLP. OpenTelemetry Protocol, the wire format for telemetry data. The Ourios ingest contract: incoming logs are OTLP over gRPC or HTTP. We do not invent our own format.

OTTL. OpenTelemetry Transformation Language, the OTel Collector’s text-based DSL for filtering and mutating telemetry in processor pipelines. Ourios deliberates between borrowing OTTL’s predicate sublanguage and distancing from it (RFC 0002).

Parquet. The Apache columnar file format Ourios uses for on-disk storage. Per-column compression, predicate pushdown via min/max statistics, bloom filters, page indexes. The on-disk truth of the system; local disk is cache and WAL only. See CLAUDE.md §§2.1, 3.6.

Params. The variable parts of a log line that the miner extracts when matching a template. Bounded per-parameter to 256 B by default; overflow spills to the body column. See H2.

Parse failure. A row whose match confidence falls below the floor. Stored body-only; parse_failures_total counter increments.

Predicate pushdown. A query-engine optimisation where filter predicates are applied as early as possible — at the storage layer rather than after a full scan. Parquet’s min/max page statistics make this nearly free for time-range and equality filters. The mechanism by which predicate queries beat zstdcat | grep (B1).

Property test. A test that asserts an invariant over many randomly-generated inputs (typically via proptest). In Ourios: reconstruction is always a property test; the parser round-trips; the miner’s tree operations preserve invariants. See CLAUDE.md §6.2.

Querier. The Ourios role that accepts queries (over the DSL), plans them through DataFusion, scans Parquet, and returns results. Other half of the ingester/querier split.

Reconstruction. The act of producing the original body of a log line from the stored template + params (and, where retained, the captured whitespace state). Subject to the bit-identical guarantee. See H7.

Row group. Parquet’s unit of compression and predicate-pushdown locality — a horizontal partition of rows within a file. Target size 128 MB to 1 GB. Smaller row groups mean faster row-group skip but worse compression and more metadata overhead.

Semconv registry (Ourios). The weaver registry defining every Ourios telemetry name (ourios.* metrics and attributes), from which the ourios-semconv constants (Rust and TypeScript) are generated. Since 2026-08-25 it lives in the shared jensholdgaard/ourios-semconv repo; this repo consumes it at the ref pinned in semconv/REGISTRY_REF (regenerate via just semconv-generate), and each dashboard-plugin repo carries its own pin. RFCs written before the extraction reference the previous in-repo paths, semconv/registry/ and templates/registry/ — those references are historical, not stale.

Similarity. The Drain match score between an incoming line and a log group’s template: the fraction of token positions where the line matches the template (wildcards count as matches). The single most important knob in the system. See RFC 0001 §3.

SUMMARY.md. mdBook’s table-of-contents file (docs/SUMMARY.md) that defines book navigation. Drafts (no link target) appear as greyed-out entries.

Template. The structural pattern of a class of log lines, with variable parts replaced by wildcards. E.g. ERROR db connection failed for user <*>. The miner extracts templates online from raw logs. (Ourios) — every template is scoped per tenant; the same string in two tenants is two templates.

Template id. The identifier of a template within a tenant. Either a hash of the canonical template string or a per-tenant monotonic integer (open question, RFC 0001 §6.1).

Template tree. The Drain parse tree, scoped per tenant. Its shape is root → length group → token-prefix nodes (depth d) → leaf log groups. (Ourios) — Drain assumes one tree; we keep one per tenant (CLAUDE.md §3.7).

Template version. A monotonic integer that bumps when a template’s representation changes (e.g. token order, new wildcard). The logical identity of the template persists across versions via the alias mechanism. See drift, RFC 0001 §6.7.

Tenant. An isolation boundary: a customer, a project, an environment. Every code path that touches data takes a tenant_id; every Parquet file is partitioned by tenant; every template tree is scoped per tenant. Multi-tenancy is not bolted on (CLAUDE.md §3.7).

Thesis-gate. A benchmark goal whose failure on representative corpora invalidates an architectural pillar — meaning the response is an RFC to revisit CLAUDE.md §2, not a tuning sprint. The five thesis-gates are catalogued in docs/benchmarks.md §7.

Threshold (st). The Drain similarity cutoff above which a line is assigned to an existing log group rather than opening a new one. Ourios default ≥ 0.7; values below 0.7 require an RFC (H1, CLAUDE.md §3.1).

Token-prefix node. Drain’s intermediate tree level: branches on the value of the line’s first N tokens (depth d, paper default 3–4). Below it, at the leaf, is a list of log groups.

Truncation marker. The placeholder that replaces an oversized params slot when the per-parameter byte limit is exceeded. The original value spills to the body column. See H2.

WAL. Write-ahead log. The Ourios ingester writes every acknowledged batch to the WAL, fsyncs, and only then acknowledges to the OTLP client. WAL segments are eventually flushed to Parquet by the compactor. The crash-recovery test SIGKILLs the ingester mid-batch and asserts no acknowledged data is lost. See H3, CLAUDE.md §3.4.

Benchmarks

Referenced from CLAUDE.md §6.2 (“regressions block merges”) and from docs/rfcs/0001-template-miner.md §8. Flat-file, living document, parallel to docs/hazards.md. Updated with measured results as they come in.

This document is an honesty contract with ourselves. The thesis (CLAUDE.md §2) claims that Parquet + Drain-derived template mining + DataFusion beats the naive alternative of byte-level compression over flat text. That claim is falsifiable. This file lists the measurements that would falsify it.

The thresholds were pinned before any number was measured; if we miss them on representative corpora, the thesis is wrong and a pillar changes. As of 2026-06-14 the four gating thesis-gates B1, B2, C1, C2 all pass on the §1 hardware baseline (§9.4/§9.6). A1 fails but no longer gates — RFC 0011 (accepted) reclassified the compression-vs-zstd ratio as a recorded diagnostic (its failure is structural; see §2 / the §7 table).

0. How to read this document

Every goal below carries two labels.

  • Scopethesis-gate, tuning-goal, or diagnostic.
    • A thesis-gate failing on representative corpora means a pillar (CLAUDE.md §2) is wrong. The response is an RFC, not a sprint.
    • A tuning-goal failing means the design is sound but the implementation needs work. The response is a PR.
    • A diagnostic is measured and recorded but gates nothing — it characterises a property or guards against regression. A1 was reclassified here by RFC 0011 (accepted); see §2.
  • Barmust-win, should-win, stretch, or informational.
    • must-win — shipping without it is shipping a broken claim.
    • should-win — expected on representative corpora; explained when missed.
    • stretch — aspirational; missing is not a bug.
    • informational — a diagnostic’s bar: the number is recorded for insight, never blocks.

A goal with scope thesis-gate and bar must-win is load-bearing for the whole project. Four of those below are gating — B1, B2, C1, C2, each marked [THESIS]. A1 keeps the [THESIS] tag (a thesis-relevant measurement) but RFC 0011 (accepted) set its scope to diagnostic: it is recorded, not gating (see its section below and the §7 table).

1. Corpora and methodology

Before any goal is meaningful, the corpora and methodology must be pinned — otherwise we will argue about numbers instead of about architecture.

  • Public: LogPAI corpora (HDFS, BGL, Spark, Apache, OpenSSH, Windows) — the same corpora the Drain paper reports on. Lets us reproduce published claims as a sanity floor.
    • LogHub HDFS_v1 is the first of these wired in, as a bench-time-fetched corpus for the query gates: .github/workflows/query-bench.yml downloads HDFS_v1.zip from the official Zenodo record (record 8196385, DOI 10.5281/zenodo.8196385, md5-pinned in the workflow), uses the extracted HDFS.log (~1.47 GiB plain text — above §8’s ≥ 1 GiB canonical minimum) in-job, and discards it with the runner. It is never redistributed: not committed (the testdata/corpus/README.md anonymisation gate — LogHub data is explicitly not sanitised), not attached to a release, not uploaded as an artifact; only aggregate numbers leave the job. LogHub’s license notice, included here as it requires: “The datasets are freely available for research or academic work. For any usage or distribution of the datasets, please refer to the loghub repository URL (https://github.com/logpai/loghub) and cite the loghub paper: Jieming Zhu, Shilin He, Pinjia He, Jinyang Liu, Michael R. Lyu. Loghub: A Large Collection of System Log Datasets for AI-driven Log Analytics. In IEEE International Symposium on Software Reliability Engineering (ISSRE), 2023. The above license notice shall be included in all copies.”
  • Self-collected (deferred): at least one anonymised corpus per target deployment archetype. Proposed set:
    1. Structured Java/Spring service (well-templated, low entropy).
    2. Go service under Kubernetes (heterogeneous, mid entropy).
    3. Heterogeneous k8s aggregate across many services (high entropy, mixed formats).
  • Hardware baseline: a commodity 8 vCPU / 32 GiB RAM host with gp3-class SSD. All must-win numbers are quoted against this baseline; scaling to larger hardware is a separate question. The realised baseline (the baseline-8vcpu-32gib hardware tag, first used for the §9.4 authoritative run) is a dedicated host with 8 dedicated vCPU, 32 GiB RAM, and a local NVMe-class SSD — at or above the spec on every axis, so numbers quoted against the tag satisfy this baseline. It is identified only by the tag.
  • Reference system: zstdcat <file.zst> | grep <pattern>. The “naive alternative” the thesis beats or does not beat. Everything is quoted relative to this, not in absolute terms.

Goals quoted below assume this setup. When a goal is measured on a different setup, the measurement is annotated.

2. Compression goals (Category A)

The core claim that template mining does useful work before byte codecs run.

A1 [THESIS] — End-to-end compression ratio vs. zstd-alone

Demoted to a diagnostic (RFC 0011, accepted). A1 is refuted on every corpus class — including the maximally-templated one — for structural reasons, so it no longer gates any RFC’s validated. It is still measured and recorded (§7 table / §9 series) as the columnar queryability premium and a codec-regression guard. The scope, bar, target, and falsifier below are retained as the diagnostic’s reference line — now informational, not gating.

  • Scope: diagnostic (RFC 0011; originally thesis-gate).
  • Bar: informational (RFC 0011; originally must-win).
  • Metric: bytes(raw_corpus) / bytes(ourios_parquet_directory) compared to bytes(raw_corpus) / bytes(zstd_compressed_corpus).
  • Target: Ourios ratio ≥ the zstd-alone ratio, on every corpus in §1. Best-case corpora (well-templated services) should show ≥ 10×.
  • Falsifier: if any representative corpus yields ≤ 2× improvement over zstd-alone, the template-mining pillar is not pulling its weight on that class of logs. Open an RFC.
  • Why recorded (diagnostic, not a bar): CLAUDE.md §2 pillar #2 describes a logical 50–200× reduction (lines → (template_id, params)) whose payoff is query pruning (B1/B2), not on-disk bytes vs a byte codec. A1 tracks the on-disk ratio as the columnar queryability premium + a codec-regression guard; RFC 0011 (accepted) demoted it from a gate to this diagnostic.

A2 — Bytes per line, amortised

  • Scope: tuning-goal.
  • Bar: should-win.
  • Metric: total Parquet bytes for tenant / line count for tenant.
  • Target:
    • Structured service logs: ≤ 30 B/line.
    • Heterogeneous k8s: ≤ 100 B/line.
    • Stretch: ≤ 15 B/line on high-repetition corpora.
  • Why: makes A1 legible to operators, who think in bytes-per-line, not ratios.

3. Query performance goals (Category B)

Why not zstdcat | grep? Because the query layer is supposed to exploit structure the tree extracted.

B1 [THESIS] — Predicate-pushdown queries

  • Scope: thesis-gate.
  • Bar: must-win.
  • Query shape: count events WHERE tenant=X AND ts BETWEEN t1 AND t2 AND level='ERROR'.
  • Reference: zstdcat files_in_range.zst | grep ERROR | wc -l on the same corpus, same time window.
  • Target: Ourios ≥ 10× faster at 1 GiB corpus, widening to ≥ 100× at 100 GiB.
  • Falsifier: if Ourios is not materially faster than the zstdcat pipeline on predicate queries, DataFusion + Parquet statistics are not delivering on the “skip row groups via footer reads” pillar (CLAUDE.md §2.1). Open an RFC.
  • Instruments: B1 is proven structurally (deterministically) by ourios-querier’s rfc0007_1_* tests. The criterion bench crates/ourios-bench/benches/b1.rs adds the wall-clock ratio: a b1/synthetic group (controlled pruning instrument vs. an in-process zstdcat | grep reference) and a b1/real-corpus group (set OURIOS_B1_CORPUS_DIRS to a comma-separated list of corpus dirs; skipped when unset). The real arm runs OTLP corpora only (corpus/otel-demo-v*, which carry real per-record severity): B1’s predicate filters on severity, and the RFC 0006 §3.3 plain-text loader assigns every line a fixed severity (9 / INFO), so a severity predicate over a plain-text corpus has no selectivity and such dirs are skipped with a note. CI runs land via .github/workflows/query-bench.yml on ci-runner — indicative only. The authoritative numbers are the baseline-8vcpu-32gib run of 2026-06-12 (§9.4): PASS at 34.2× / 25.4× on the two ~1 GB OTel-Demo corpora, with exact row-count agreement against the reference pipeline. Open quality improvement (non-blocking): the measured error bands are ultra-thin (11 / 28 rows), which flatters pruning — a denser error band is the remaining methodological wish.

B2 [THESIS] — Template-exact queries

  • Scope: thesis-gate.
  • Bar: must-win.
  • Query shape: SELECT * WHERE template_id = X AND ts BETWEEN ….
  • Target: latency proportional to result cardinality, not to corpus size, above a corpus size of ~10 GiB. Concretely: median latency ≤ 200 ms for a query returning 10 000 rows, regardless of whether the corpus is 10 GiB or 10 TiB.
  • Falsifier: if template-exact queries scan proportionally to corpus size, template mining is buying compression but not query locality — the inverted-index collapse thesis (CLAUDE.md §2) is wrong in practice. Open an RFC.
  • Instruments: B2 is proven structurally (deterministically) by ourios-querier’s rfc0007_2_* test — for a fixed result the scanned row groups + bytes stay flat as the corpus grows. The criterion bench crates/ourios-bench/benches/b2.rs adds the wall-clock view: a b2/synthetic group (result held constant, corpus scaled 1×/10×/50×) and a b2/real-corpus group over real corpora (set OURIOS_B2_CORPUS_DIRS to a comma-separated list of corpus dirs; skipped when unset, since the corpora aren’t committed). Both loader formats feed it: the OTLP/JSON corpus/otel-demo-v* releases and the bench-time-fetched plain- text LogHub HDFS_v1 (§1). Run with cargo bench -p ourios-bench --bench b2. CI runs land via .github/workflows/query-bench.yml on ci-runner — indicative only. The authoritative numbers are the baseline-8vcpu-32gib run of 2026-06-12 (§9.4): PASS — the windowed template-exact scan stays at 1 row group with a flat ~4.2–5.9 ms latency band across every corpus, including the first reading from a second corpus family (LogHub HDFS_v1, 11.2 M rows: 1/14 row groups, 5.92 ms), while the full-span variant grows with corpus size. The formal target speaks above ~10 GiB, which remains a future scale extension; the flat shape holding at 11.2 M rows across two corpus families is the operative evidence.

B3 — Substring queries (the hard case)

  • Scope: tuning-goal.
  • Bar: must-match; stretch: beat.
  • Query shape: SELECT * WHERE body LIKE '%<substring>%' or equivalent.
  • Target: not slower than the reference system. Stretch: faster on well-templated corpora by searching the template text rather than every line.
  • Why this is only tuning-goal, not thesis-gate: substring search is the case where the tree does not help directly. We are allowed to match the reference system here; losing against it is a bug but not a pillar failure.

4. Miner correctness goals (Category C)

Correctness is not a performance goal, but it belongs here because these are the properties the benchmark harness actually measures on every run.

C1 [THESIS] — Bit-identical reconstruction rate

  • Scope: thesis-gate.
  • Bar: must-win.
  • Metric: of all non-lossy-flagged rows, fraction whose reconstruct(template, params) equals the ingested bytes exactly.
  • Target: 100.000%.
  • Falsifier: a single row that reconstructs wrong without a lossy flag is a violation of CLAUDE.md §3.3 and a blocker, not a benchmark regression. Accompanied by: the lossy-flagged fraction should be ≤ 5% on structured corpora, ≤ 20% on heterogeneous ones, as a quality signal (not a gate).
  • Why this is a thesis-gate: if we cannot promise reconstruction, the honesty contract (lecture §6) collapses.

C2 [THESIS] — Template count convergence

  • Scope: thesis-gate.
  • Bar: must-win.
  • Metric: template count as a function of lines ingested, on a corpus from a single stable service.
  • Grain (amended for #444, 2026-07-10): because the metric is defined per stable service, the gate is evaluated per service.name on a multi-service corpus, not on the whole corpus. A corpus passes iff every service with ≥ 1 M lines converges; a single-service (or plain-text <unknown>) corpus is gated on that one service’s exact-millionth-line ratio, reproducing the pre-amendment verdict for historical converged corpora. The whole-corpus ratio is retained as a diagnostic. See RFC 0006 §3.4.3.
  • Target: template count grows sub-linearly and plateaus within of its steady-state value by 1 M lines. Steady-state value is corpus-specific but is on the order of 10²–10⁴ templates for a normal service.
  • Falsifier: if template count grows linearly with corpus size, Drain has failed to abstract — we are storing one template per line, which means the tree is providing compression only accidentally. That is the inverse of the thesis. Open an RFC.

C3 — Merge rate

  • Scope: tuning-goal.
  • Bar: should-win.
  • Metric: merges_total / lines_ingested.
  • Target: ≤ 1 merge per 10⁵ lines on stable corpora, with every merge carrying an audit event. Spikes above this rate are investigated; they usually indicate a new service version.
  • Why only tuning-goal: merge rate depends on corpus stability more than on algorithm quality. The auditing is the invariant (§3.1); the rate is a signal.

C4 — Parameter overflow rate

  • Scope: tuning-goal.
  • Bar: must-win.
  • Metric: fraction of rows where any params slot hit the 256 B limit.
  • Target: ≤ 1% on representative corpora, per CLAUDE.md §3.2.
  • Falsifier (tuning sense): if >1% on a common archetype, either the limit is too tight for that workload or a masking rule is missing. The response is tuning, not an RFC.

5. Ingest goals (Category D)

The hot path must keep up with real deployments; otherwise none of the above matters.

D1 — OTLP → WAL throughput

Recast per-node (RFC 0034; enacted 2026-07-21). D1’s original metric was lines/second/core — an axis the architecture deliberately serializes twice (sequential per-tenant mining — the CLAUDE.md §3.7-scoped trees assign ids first-seen, which must match WAL-order replay, RFC 0001 §3.5.3; and the single durable WAL stream, §3.4) and one that contradicted D1’s own per-node falsifier (§9.19–§9.21). The must-win below is per-node on the §1 baseline class; the old per-core target and the per-tenant single-stream ceiling are retained as recorded diagnostics — informational, gating nothing (the RFC 0011 A1 pattern). Asserting run: §9.23 (PASS).

  • Scope: tuning-goal.

  • Bar: must-win.

  • Metric: lines/second sustained per node on baseline-8vcpu-32gib, multi-tenant load (soak --tenants N with N = cores) through one shared WAL/commit stream, with WAL fsync batched at 100 ms (the CLAUDE.md §3.4 default).

  • Target: the asserting run offers exactly 100 000 lines/s per node and must achieve ≥ 99% of offered (the single acceptance rule — pacing loss up to 1% is within the bar), with p99 ingest-ack latency ≤ 200 ms over that same run. Achieved ≈ offered is also the below-saturation proof: a saturated pipeline cannot keep pace with the paced load, and queue-bound latencies at over-offered load are a different regime that does not count (§9.20’s reading).

  • Diagnostics (informational, still recorded — RFC 0034):

    • the original per-core target (≥ 100 000 lines/s/core) is retained as the diagnostic’s reference line; §9.23 records 12,490 lines/s/core at the asserting run.
    • the per-tenant single-stream ceiling — the most one service can push into one tenant (≈ 86k lines/s under the §9.20 probe configuration; §9.20/§9.21) — guards the mining path against regression.

    Neither gates any RFC’s validated.

  • Falsifier (tuning sense): below this we cannot ingest a meaningful share of production traffic per node, which makes the operational story uninteresting.

D2 — WAL → Parquet compaction keeps up

  • Scope: tuning-goal.
  • Bar: must-win.
  • Metric: WAL backlog (bytes, segments) as a function of time under sustained ingest at D1’s rate.
  • Target: bounded; backlog returns to zero during any one-hour window of sustained load.
  • Falsifier (tuning sense): a growing backlog under steady-state load means compaction is the bottleneck — a correctness-adjacent bug because it lets the WAL grow unboundedly.

D3 — Small-file count under sustained load

  • Scope: tuning-goal.
  • Bar: should-win.
  • Metric: number of Parquet files per tenant per day after background compaction has settled.
  • Target: file sizes cluster in the 256 MiB–2 GiB band per CLAUDE.md §4 / hazard 4. Fewer than 5% of files below 128 MiB at steady state.
  • Why: the small-file problem is a named hazard, not a nice-to-have.

6. Honesty goals (Category E)

Not performance. Not falsifiable by a benchmark in the usual sense. Listed here because the benchmark harness asserts them on every run.

E1 — Zero silent merges

  • Scope: correctness invariant (not a benchmark).
  • Metric: in the corpus-test suite, for every row whose template_id changed over its lifetime in the tree, an audit event exists with matching timestamp and tenant.
  • Target: 100%. This is a proptest, not a measurement.

E2 — Zero cross-tenant leakage

  • Scope: correctness invariant (not a benchmark).
  • Metric: no template mined under tenant A ever appears in tenant B’s tree or in a row for tenant B.
  • Target: 100%. Asserted via corpus tests that interleave lines from two synthetic tenants and verify complete isolation.

7. The thesis-gate summary

The five [THESIS]-tagged goals, consolidated:

#GoalFailing means
A1Compression ≥ 3× over zstd-alone — diagnostic, not gating (RFC 0011)Recorded for the columnar queryability premium + codec-regression guard; does not block any RFC’s validated. Refuted on every corpus class incl. max-templated HDFS_v1 (§9.5) for structural reasons — template mining’s compression is logical/query-pruning, captured by B1/B2
B1Predicate queries ≥ 10× faster than zstdcat | grepParquet statistics pillar not delivering
B2Template-exact queries scale with result size, not corpus sizeInverted-index-collapse thesis is wrong in practice
C1100% bit-identical reconstruction on non-lossy rowsHonesty contract with user violated
C2Template count plateaus sub-linearlyDrain has failed to abstract

Policy: if one thesis-gate fails on one representative corpus, that is a corpus-specific tuning RFC. If two or more thesis-gates fail on any representative corpus, that is a pillar-level RFC — we pause implementation and revisit CLAUDE.md §2 before continuing.

This escalation rule is the point of the whole document. The worst failure mode for a greenfield project is shipping something whose central claim quietly fails on real data and then papering over it with more implementation. These goals exist so we cannot do that to ourselves without noticing.

8. What is deliberately out of scope

  • SIEM-style full-text search latency — explicitly out of scope (CLAUDE.md §1).
  • Cross-tenant aggregation queries — tenancy is isolation-first (CLAUDE.md §3.7). Aggregations that cross tenants are an RFC topic, not a benchmark.
  • LLM-based parser comparisons — interesting, deferred. Listed in RFC 0001 §7 as an alternative. Benchmarking it would be a separate RFC.
  • Cold-start query latency — below a corpus size of ~1 GiB the overhead of Parquet metadata dominates, and the thesis is uninteresting. Benchmarks start at 1 GiB.

9. Status

First measurements landed 2026-06-01 (the writer-side gates A1 / C1 / C2 — see §9.1). They are diagnostic, not canonical: they ran on a GitHub-hosted runner (ci-runner), not the §1 hardware baseline (baseline-8vcpu-32gib), against an OTel-Demo corpus that is shape-representative (real multi-service template + envelope diversity) but not size-representative — every corpus is well below §8’s ≥ 1 GiB canonical minimum, so this run is intentionally diagnostic, not a thesis verdict. The query-side gates now have instruments — B1 and B2 are proven structurally in ourios-querier, and both have criterion latency benches with real-corpus arms (§B1/§B2 “Instruments”; OTel-Demo for B1, OTel-Demo + the bench-time-fetched LogHub HDFS_v1 for B2, run on ci-runner via .github/workflows/query-bench.yml as indicative numbers). 2026-06-11 extended the writer-side scale series to ~1 GB (§9.2) and landed the first B1/B2 query readings (§9.3) — recorded here as indicative ci-runner entries per the maintainer’s 2026-06-12 authorization. 2026-06-12 landed the authoritative baseline run (§9.4): every gate measured on the §1 hardware (baseline-8vcpu-32gib), recorded per the maintainer’s 2026-06-12 authorization. B1, B2, C1, and C2 pass authoritatively; on that basis RFC 0007 flipped to validated (its gates, per docs/verification.md §3, are the querier-pillar ones — B1/B2). A1 fails authoritatively and carries a hardware-sensitivity caveat (§9.4). (A1 was subsequently reclassified a recorded diagnostic, not a gate — RFC 0011, accepted 2026-06-14. The A1 readings throughout §9 are diagnostic; A1 gates nothing, and the “open gate” / “must-win” framing in the dated entries below is superseded.)

Reviewers: a PR that materially affects the hot path must either (a) cite the benchmark result and its delta against the relevant goal, or (b) explain why the hot-path effect is bounded below measurability. “I did not run the benchmarks” is a PR rejection, per CLAUDE.md §6.6.

No ourios-bench --update-benchmarks-md run has populated this region yet. It is the bench-managed results area — automated runs replace everything between these markers with one table per (git-sha, hardware). The hand-written §9.1 below is the curated diagnostic narrative and lives outside the region so automated runs never touch it. (This empty region is pre-placed so the first --update-benchmarks-md run replaces it in place rather than appending a second results section at end-of-file.)

9.1 Results — 2026-06-01 (diagnostic, ci-runner)

Corpus. corpus/otel-demo-v{1..4} — OTel Demo 2.2.0 logs captured via the collector fileexporter (workflow .github/workflows/capture-otel-demo-corpus.yml), business-service logs only (collector self-telemetry + load-generator filtered out), OTLP/JSON. Sizes 30 / 136 / 272 / 547 MiB — all below §8’s ≥ 1 GiB canonical benchmark minimum (this run is deliberately sub-minimum, to chart the trend, hence diagnostic). Hardware. ci-runner (hosted, ~4 vCPU) — not the §1 baseline, so deltas are indicative, not authoritative.

A1 — compression (target: ourios ≥ 3.0× zstd-19).

Scale series (ourios at the production ZSTD-3 default):

corpussizeourioszstd-19A1 delta
v130 MiB15.5×33.3×0.465
v2136 MiB21.5×32.3×0.666
v3272 MiB23.4×32.3×0.725
v4547 MiB24.6×32.4×0.758

Codec sweep (v4 = 547 MiB, ourios ZSTD level varied):

ourios ZSTDouriosA1 delta
3 (prod default)24.6×0.758
926.2×0.808
1526.4×0.816
1926.9×0.829

A1 verdict: FAIL (target 3.0×; best observed 0.829). Both levers are bounded. Scale lifts the delta but plateaus ~0.78 (ourios asymptotes ~25×; zstd-19 is flat ~32× — the logs are locally repetitive, so zstd compresses them well at any size, not via a whole-corpus window). Raising ourios’s codec to ZSTD-19 adds only ~+0.07 and saturates by level 9. Even at equal codec strength, ourios stays ~17% larger than monolithic zstd-19: a structural cost of columnar Parquet (per-column/per-chunk framing, page indexes, row-group metadata, bloom filters) versus zstd-19 over one concatenated stream. That same chunking is what enables row-group skipping — so the ~17% space premium is the price of queryability, not an optimisation target. On pure compression of this corpus, ourios ≈ 0.83× zstd-19; the thesis rests on query performance (B1/B2), not on beating a byte codec.

C1 — reconstruction (target: 100% bit-identical or flagged lossy). PASS at every size: 1.0 reconstruct rate, ~1.1% of records flagged lossy (structured/kvlist bodies) and retained verbatim per CLAUDE.md §3.3.

C2 — template-count convergence (target: sub-linear). PASS (supportive). Templates grew 282 → 429 → 722 → 1322 while records grew 38k → 183k → 366k → 735k — sub-linear throughout. The formal gate abstains below 1 M lines (§3.4.3), but the curve shape is the strongest evidence yet for the template-mining premise.

Escalation (§7). One gate (A1) fails, on a size-non-representative corpus (all < §8’s 1 GiB minimum) and non-baseline hardware — so this is “corpus-specific,” not the two-gate pillar-level pause. C1 + C2 support the thesis. The production ZSTD-3 default is retained: the codec gain is small, saturates by level 9, and the residual gap is structural, so a higher default isn’t worth the ingest-CPU.

9.2 Results — 2026-06-11 (diagnostic, ci-runner) — A1 / C1 / C2 at ~1 GB

Corpus. corpus/otel-demo-v5 (1,042,274,219 B) and corpus/otel-demo-v6 (1,034,615,505 B) — same capture pipeline as §9.1, extending the scale series to ~1 GB (both within 4% of, but still just under, §8’s ≥ 1 GiB binary minimum). v6 was captured with the OTel Demo failure flags enabled (adFailure cartFailure productCatalogFailure), so it carries a real error band; v5 is an unflagged capture. Hardware. ci-runner — indicative, not the §1 baseline. Runs. bench.yml 27370641352 (v5), 27373716667 (v6).

A1 — compression (target: ourios ≥ 3.0× zstd-19).

corpussizerunourioszstd-19A1 delta
v51,042,274,219 B2737064135226.3×31.7×0.828
v61,034,615,505 B2737371666726.0×31.5×0.824

A1 verdict: FAIL (target 3.0×). The scale series now reads 0.465 (v1, 30 MiB) → 0.666 (v2) → 0.725 (v3) → 0.758 (v4) → 0.828 (v5) / 0.824 (v6): the delta is size-driven and still rising, but decelerating — the crossover is not reached at ~1 GB, consistent with §9.1’s structural reading (ourios asymptotes ~26×; zstd-19 stays flat ~32×). v5 ≈ v6 shows the failure-flag error band does not perturb A1. This is the first A1 miss at (essentially) canonical size, so §9.1’s “size-non-representative” mitigation no longer applies; it remains a single-gate fail (no §7 two-gate pause), the §9.1 structural explanation stands, and the thesis-deciding counterpart — B1/B2 — now passes indicatively (§9.3). Whether the §7 corpus-specific tuning-RFC response triggers is a maintainer decision, sensibly taken once an authoritative baseline-8vcpu-32gib run confirms the number. (Resolved 2026-06-12: the §9.4 baseline run confirms — and slightly worsens — the deltas; the decision is now live with the maintainer.)

C1 — reconstruction (target: 100% bit-identical or flagged lossy). PASS on both: 1.000000 — v5 reconstructs 1,213,004 / 1,213,004 non-lossy rows exactly (lossy ratio 0.0114); v6 1,208,323 / 1,208,323 (lossy 0.0112).

C2 — template-count convergence (target: ratio ≥ 0.5 at 1 M lines). PASS on both — and for the first time on ≥ 1 M-line corpora, so the formal gate applies rather than §9.1’s abstention: v5 convergence ratio 0.756 (end count 1605, sample cadence 1336); v6 ratio 0.760 (end count 1606, cadence 1329).

9.3 Results — 2026-06-11 (indicative, ci-runner) — first B1 / B2 query readings

Corpus. corpus/otel-demo-v{4,5,6} (the §9.1 / §9.2 captures). The LogHub HDFS_v1 B2 arm did not run (fetch_hdfs off — memory-bound on the hosted runner), so only one corpus family has fed the query gates. Hardware. ci-runner — indicative, not the §1 baseline. Runs. query-bench.yml 27379085890 (B1 + the B2 structural metrics, after the effective-timestamp stack #178/#179) and 27357104694 (the prior run; its windowed / full-span latencies are quoted where noted). Recording. B1/B2 entries land in §9 per the maintainer’s 2026-06-12 authorization. RFC 0006 never reserved §9 (its §1 anticipated B1/B2 landing “in a follow-up extension PR once the querier is live” — RFC 0007); the workflow itself never writes §9 — every entry here is curated by hand.

B1 — predicate pushdown vs zstdcat | grep (target: ≥ 10× at 1 GiB). Query: severity ERROR, full corpus span. Run 27379085890:

corpusrowsRGs scannedourios bytesreference bytes (zstd)ouriosreferencespeedup
v5113/6326,1021,403,0256.14 ms245.5 ms40.0×
v6285/6764,0821,455,9128.50 ms258.5 ms30.4×

Row counts agree exactly with the reference pipeline on both corpora. v4 is skipped: the unflagged 100-user capture genuinely contains zero error-band rows, so the predicate selects nothing.

B1 verdict: PASS (indicative) — both corpora clear the ≥ 10× bar at 3–4× margin, on the first real-corpus reading. Caveats, stated plainly: ci-runner, not the §1 baseline; the error bands are ultra-thin (11 / 28 rows — extreme selectivity is the friendliest case for pruning); both corpora sit just under the §8 1 GiB minimum. An authoritative baseline-8vcpu-32gib rerun (ideally with a denser error band) is required before this counts as the canonical B1 number.

B2 — template-exact latency ∝ result, not corpus. Windowed 1-hour template-exact query, result roughly constant as the corpus grows. Structural metrics (run 27379085890): scanned row groups stay flat at 1 — v4 1/5, v5 1/6 (17,632 rows, 1.86 MB), v6 1/6 (11,750 rows, 1.59 MB). Wall-clock (prior run 27357104694): windowed latencies sit in a flat ~3.4–4.1 ms band (v4 3.59 / v5 4.13 / v6 3.40 ms) while the full-span variant grows with corpus size (7.3 / 10.6 / 10.6 ms) — exactly the result-bound-vs-corpus-bound split the gate asks for.

B2 verdict: PASS (supportive, indicative) — the flat shape is confirmed on real corpora at ~1 GB; the formal target speaks above ~10 GiB, which remains unmeasured, and the second corpus family (HDFS_v1) hasn’t fed the arm yet.

RFC 0007 validated assessment. These are the measurements the RFC 0007 green → validated gate needs, but not yet in the form the ladder requires (§1 quotes must-win numbers against baseline-8vcpu-32gib): see the status note in docs/rfcs/0007-querier.md. The RFC stays green with a validated-pending note — authoritative baseline rerun required; denser error band and a second corpus family supporting. (Resolved 2026-06-12: the §9.4 authoritative run delivered the baseline rerun and the second corpus family (HDFS_v1); RFC 0007 is validated. The denser error band remains an open quality improvement.)

9.4 Results — 2026-06-12 (authoritative, baseline-8vcpu-32gib)

Corpus. corpus/otel-demo-v{1..6} (the §9.1 / §9.2 captures; 30 MiB → ~1 GB) for A1 / C1 / C2 and B1/B2’s OTel-Demo arms, plus — for the first time — the bench-time-fetched LogHub HDFS_v1 (§1; ~1.47 GiB plain text, 11,175,629 rows ingested across 5 files) feeding the B2 arm as the second corpus family. Hardware. baseline-8vcpu-32gib — the §1 baseline (8 dedicated vCPU, 32 GiB RAM, local NVMe-class SSD). These are the authoritative numbers the §1 methodology quotes must-win gates against; the §9.1–§9.3 ci-runner entries remain indicative history. Runs. Dedicated baseline host (no CI run id): one ourios-bench run per corpus (A1/C1/C2) plus one query-bench run (B1 + B2), executed 2026-06-11/12; raw logs retained by the maintainer. Recorded per the maintainer’s 2026-06-12 authorization.

A1 — compression (target: ourios ≥ 3.0× zstd-19).

corpussizeourioszstd-19A1 delta
v130 MiB14.6×33.3×0.439
v2136 MiB19.9×32.3×0.615
v3272 MiB21.4×32.3×0.665
v4547 MiB22.5×32.4×0.693
v5994 MiB23.8×31.7×0.751
v6987 MiB23.6×31.5×0.749

A1 verdict: FAIL (authoritative) (target 3.0×; best observed 0.751). The delta is monotonic with corpus size and the crossover is unobserved, consistent with §9.1’s structural reading. One finding must be recorded honestly: the authoritative deltas sit below the ci-runner series (0.465 → 0.828) at every size — the ourios side compressed less effectively on this hardware (e.g. v5: 23.8× vs CI’s 26.3×) while zstd-19 stayed essentially stable (31.7× on both) — i.e. the ourios writer’s output is environment-sensitive (suspected row-group sizing / threading effects on the resulting encodings). That is now an open A1 investigation item alongside the structural gap itself. A1 gates the compression pillar (RFC 0006’s remit); the §7 escalation response is with the maintainer.

C1 — reconstruction (target: 100% bit-identical or flagged lossy). PASS (authoritative) on every corpus: 1.000000 throughout — v5 reconstructs 1,213,004 / 1,213,004 non-lossy rows exactly (lossy ratio 0.0114), v6 1,208,323 / 1,208,323 (lossy 0.0112); v1–v4 likewise 1.000000 (lossy 0.0097–0.0112). The formal ≥ 1 M-line gate passes on the baseline.

C2 — template-count convergence (target: ratio ≥ 0.5 at 1 M lines). PASS (authoritative) on both ≥ 1 M-line corpora: v5 ratio 0.756 (end template count 1605, sample cadence 1336), v6 ratio 0.760 (end count 1606, cadence 1329). v1–v4 abstain (< 1 M lines), as in §9.1.

B1 — predicate pushdown vs zstdcat | grep (target: ≥ 10× at 1 GiB). Query: severity ERROR, full corpus span. v4 is skipped (zero error-band rows, as in §9.3).

corpusrowsRGs scannedourios bytesreference bytes (zstd)ouriosreferencespeedup
v5113/6326,1021,403,0255.86 ms200.27 ms34.2×
v6285/6764,0821,455,9128.03 ms203.87 ms25.4×

Row counts agree exactly with the reference pipeline on both corpora (11 and 28).

B1 verdict: PASS (authoritative) — both corpora clear the ≥ 10× bar at 2.5–3.4× margin on the §1 baseline. Remaining caveat, non-blocking: the error bands are still ultra-thin (11 / 28 rows — the friendliest case for pruning); a denser error band stays an open quality improvement.

B2 — template-exact latency ∝ result, not corpus.

Full-span template-exact (result grows with the corpus, so latency may too):

corpusrows returnedRGs scannedbyteslatency
v489,3825/55,514,0336.84 ms
v5168,4876/66,785,7149.57 ms
v6168,3136/66,801,2559.69 ms
hdfs-v11,723,23214/1416,523,42130.19 ms

Windowed 1-hour template-exact (the gate’s shape: result roughly constant as the corpus grows):

corpuscorpus rowsrows returnedRGs scannedbyteslatency
v4735,37712,8541/51,674,7184.39 ms
v51,367,53217,6321/61,857,9995.07 ms
v61,360,04011,7501/61,592,2794.19 ms
hdfs-v111,175,62928,2071/141,737,8525.92 ms

The HDFS_v1 row is the first reading from the second corpus family (plain-text, the template-diversity case): the corpus is 8–15× the OTel-Demo row counts, yet the windowed scan still touches 1 row group (13 pruned) and stays inside the same flat latency band, while the full-span variant grows with the corpus (6.84 → 30.19 ms) — exactly the result-bound-vs-corpus-bound split the gate asks for.

B2 verdict: PASS (authoritative) — windowed ~10–28 k-row results answer in 4.2–5.9 ms (gate: ≤ 200 ms for ~10 k rows), flat from 735 k to 11.2 M rows across two corpus families. The formal target’s ≥ 10 GiB regime remains a future scale extension; the measured shape is the operative evidence.

RFC 0007 green → validated (resolved). The docs/verification.md §3 ladder reads: “Every thesis-gate in benchmarks.md §7 that the RFC’s pillars touch passes on representative corpora.” RFC 0007’s pillar is the query engine (pillar #3); its gates are B1 and B2, both now passing authoritatively on the §1 baseline over ~1 GB+ corpora including a second family. A1 does not gate RFC 0007 — it belongs to the template-mining/compression pillar, measured under RFC 0006. RFC 0007 is therefore flipped to validated (see its status note); accepted awaits maintainer sign-off per the ladder.

9.5 Results — 2026-06-13 (diagnostic, local unknown hardware) — A1 / C1 / C2 on HDFS_v1

Corpus. LogHub HDFS_v1 (Zenodo record 8196385, md5 76a24b4d…) — 11,175,629 lines, 1,577,982,906 raw bytes; fetched at bench time, never redistributed (query-bench.yml). The maximally-templated log corpus (a handful of templates over 11.2 M lines) — the single best case for the template-mining compression premise. Run via ourios-bench --gates a1,c1,c2 --parquet-zstd-level 19 --allow-unknown-hardware. Local hardware → diagnostic, not authoritative; A1’s verdict is corpus-structural and hardware-independent (compressed bytes are deterministic), C1/C2 are ratios, so the findings hold regardless of the runner.

gateresultverdict
A1ourios 8.300× vs zstd-19 16.000× → delta 0.516× (raw 1.578 GB → ourios 189.98 MB, zstd-19 98.21 MB)FAIL — now diagnostic (RFC 0011)
C11.000000 — 11,175,578 / 11,175,578 non-lossy rows bit-identical; lossy ratio 4.6e-06 (51 rows)PASS
C2end template count 40 at 11.2 M lines (33 at 1 M); ratio 0.825 — sub-linear, formal gate applies (≥ 1 M, §3.4.3)PASS

A1 — the decisive finding (→ RFC 0011). A1 had only ever been measured on OTel-Demo (best 0.829×, §9.1/§9.4). HDFS_v1 is the corpus that should most reward template mining, yet A1 fails harder (0.516×): the more templated the corpus, the more completely monolithic zstd-19 captures its redundancy in one window (16×), while template mining’s extracted params (block IDs, timestamps, IPs) are high-cardinality columns that don’t compress as well and the columnar layout adds framing. The best case for template mining is the best case for the byte codec. So ≥ 3× over zstd cannot hold on any realistic log corpus — A1 is demoted to diagnostic and template mining’s compression value is recognised as logical/query-pruning (B1/B2), not on-disk bytes. See RFC 0011.

C1 + C2 — the miner pillar’s real gates, PASS on a representative corpus. At 11.2 M lines C1 is bit-identical (1.0) with a 4.6e-06 lossy ratio, and C2 plateaus at 40 templates with the formal gate applying (not abstaining, unlike the §9.1 sub-1 M runs). Under RFC 0011 these are RFC 0001’s validated thesis gates — both pass here. The authoritative baseline-8vcpu-32gib representative rerun (for the actual RFC 0001 validated flip) followed on 2026-06-14 (§9.6); as expected of deterministic verdicts, the numbers are identical.

9.6 Results — 2026-06-14 (authoritative, baseline-8vcpu-32gib) — C1 / C2 on HDFS_v1

Corpus. LogHub HDFS_v1 (Zenodo record 8196385, md5 76a24b4d…) — 11,175,629 lines, 1,577,982,906 raw bytes; fetched at bench time on the baseline host, md5-verified, never redistributed. Hardware. baseline-8vcpu-32gib — the §1 baseline (8 dedicated vCPU, 32 GiB RAM, local SSD), provisioned for this run and torn down immediately after. These are the authoritative C1 / C2 numbers for RFC 0001’s validated gates. Run. Dedicated baseline host (no CI run id): one ourios-bench --gates c1,c2 --hardware-kind baseline-8vcpu-32gib run at git 9a57ace; results JSON retained by the maintainer (2026-06-14T00-36-23.225Z-9a57ace.json). A1 was deliberately not run — it is diagnostic, not gating (RFC 0011); the §9.5 diagnostic A1 reading stands.

gateresultverdict
C11.000000 — 11,175,578 / 11,175,578 non-lossy rows reconstruct bit-identically; lossy ratio 4.6e-06 (51 rows)PASS
C2end template count 40 at 11.2 M lines (33 at 1 M); ratio 0.825 — sub-linear, formal gate applies (≥ 1 M, §3.4.3)PASS

Authoritative confirmation. The verdicts match §9.5’s local diagnostic run bit-for-bit — expected, since C1 (reconstruction fidelity) and C2 (template-count convergence) are deterministic functions of (corpus, miner) with no wall-clock or hardware-sensitive component (contrast A1’s writer-environment sensitivity, §9.4). The value of this run is the authoritative hardware_kind stamp on the two gates that, under RFC 0011, define RFC 0001’s validated: both PASS on a representative ≥ 1 M-line corpus on §1 baseline hardware.

9.7 Results — 2026-06-15 (authoritative, baseline-8vcpu-32gib) — D2 / D3 / B2-post (RFC 0009 compaction)

Hardware. baseline-8vcpu-32gib — the §1 baseline (8 dedicated vCPU, 32 GiB RAM, local SSD), provisioned for this run and torn down immediately after. These are the authoritative D2 / D3 / B2-post numbers for RFC 0009’s validated measure (RFC0009.7). Run. Dedicated baseline host (no CI run id): the ourios-bench compaction bench at git 4d52288. Two invocations — the band-scale one-shot (OURIOS_COMPACTION_BASELINE=1, FILES=32, ROWS=4800, BODY_BYTES=4096) for D2/D3, then the b2-post-compaction criterion group. Synthetic (no corpus): D2/D3 drive one partition of 32 small files (~485 MiB) through compact_partition; B2-post queries 32-files-vs-1-file with the result set held constant.

measureresultverdict
D2 compaction throughput32 files (485.2 MiB) → 1 in 2.91 s = 166.8 MiB/s; 153,600 rows conservedkeeps up — single-partition / single-threaded, ≫ any per-partition seal rate, so the backlog drains
D3 small-file size bandoutput 456.7 MiBIN the 256 MiB–2 GiB band; 0% of live files < 128 MiB (target < 5%)PASS
B2-post query latencytemplate query: uncompacted 12.78 ms (32 row groups, 33.5 MiB read, 32 files) → compacted 2.10 ms (1 row group, 1.05 MiB, 1 file) = 6.1×PASS

Reading. D3 is the headline: a band-scale compaction lands its output squarely in the H4 256 MiB–2 GiB target with zero sub-128 MiB files — the small-file problem, eliminated. D2 shows consolidation runs at ~167 MiB/s on one partition/thread, far above any plausible per-partition seal rate, so a backlog drains (the “keeps up” property). B2-post quantifies the query payoff that motivated RFC 0009 (the PR #92 B2 finding that per-file footer/metadata reads dominate): collapsing 32 files → 1 cuts the footer reads ~6× on this query. The structural reductions (32 → 1 files / row groups, rows conserved) are hardware-independent and also pinned in ourios-parquet’s rfc0009_1_* / compaction_conserves_every_row tests; these wall-clock figures are the baseline-hardware stamp for RFC 0009’s validated. The full sustained-ingest soak (D2’s “backlog returns to zero in a one-hour window at D1’s rate”) and D1 itself remained unrun until §9.19 (2026-07-20: D2 soak PASS; D1’s per-core bar is the open reading) — the throughput here is the RFC0009.7 D2 measure, not that soak.

9.8 Results — 2026-06-18 (authoritative, baseline-8vcpu-32gib) — ingest write-path + recovery (criterion) and real-corpus A1 / C1 / C2 + B1 / B2

Hardware. baseline-8vcpu-32gib — the §1 baseline (8 dedicated vCPU, 32 GiB RAM, local SSD), provisioned for this run and torn down immediately after. Two such hosts (one per invocation set), both at git d3f2cae. Run. (a) the self-contained ourios-bench criterion benches ingest_write_path (RFC 0014) and recovery (RFC0008.3) — synthetic, no corpus — at full criterion settings; (b) the ourios-bench binary --gates a1,c1,c2 against two real corpora, plus the b1/b2 criterion benches (--warm-up-time 1 --measurement-time 3, matching query-bench.yml) over those corpora. Corpora: LogHub HDFS_v1 (Zenodo record 8196385, md5 76a24b4d… — 11,175,629 lines / 1,577,982,906 raw bytes (1.47 GiB) of real Hadoop production logs, above §8’s ≥ 1 GiB canonical minimum) and the frozen OTel-Demo v1 (corpus/otel-demo-v1, 38,782 lines / 31.5 MiB). HDFS is fetched in-job and never redistributed (§1).

(a) Ingest write path + recovery — supportive wall-clock (criterion).

benchmedianthroughput
wal_append/batch — OTLP→WAL append + fsync (the WAL-before-ack unit)372 µs10.5 MiB/s
sink_write/1000 — WAL→Parquet emit + flush (RFC 0014)2.64 ms379 K rec/s
sink_write/1000012.24 ms817 K rec/s
recovery/{1,4,16} — WAL replay over N segments (RFC0008.3)169 µs → 507 µs → 1.87 ms~O(N), no amplification

Single-threaded micro-benches on synthetic records — supportive wall-clock (the structural sides are pinned by ourios-ingester’s RFC 0014 / ourios-wal’s RFC0008.3 tests), not gates. Dedicated hardware ran ~20–30% faster with much lower variance than the indicative ci-runner figures.

(b) Thesis gates A1 / C1 / C2 on real corpora.

corpusA1 (ourios vs zstd-19 → delta)C1 reconstructionC2 convergence
HDFS_v1 (11.18 M lines, 1.47 GiB)6.21× vs 16.0× → 0.386 — FAIL (diagnostic)1.000000 (11,175,578 / 11,175,578 non-lossy rows; lossy ratio 4.6e-06, 51 rows) — PASSratio 0.825, 40 templates — PASS
OTel-Demo v1 (38.8 K lines)14.6× vs 33.3× → 0.438 — FAIL (diagnostic)1.000000 (lossy ratio 0.0097) — PASSABSTAIN (< 1 M lines), 282 templates

C1 reconstructs every non-lossy row bit-for-bit across 11 M real production lines — the §3.3 invariant holds on real data at scale. C2 converges on HDFS (40 templates over 11 M lines; ratio 0.825 ≥ the threshold) — the template-mining thesis on a real corpus. A1 fails as expected: it is a recorded diagnostic, not a gate (RFC 0011) — template mining’s value is query pruning (B1/B2), not on-disk bytes beating a whole-stream codec.

(c) Query gates B1 / B2 on real corpora.

benchresulttimingpruning
b1/synthetic2000 rowsourios 2.93 ms vs zstd-grep ref 118 µspruned 1/2 row groups, read 7.8 KB
b2/synthetic/{2k,20k,100k}result held constant2.13 / 4.67 / 11.32 mssub-linear in corpus size
b2/real-corpus/HDFS (template 1, ubiquitous)1.72 M rows30.8 ms14/14 row groups (no prune — template is everywhere)
b2/real-corpus/HDFS windowed 1 h28,207 rows6.1 ms13/14 row groups pruned by the time window (~5× faster)

The windowed HDFS arm is the headline: a time-bounded query on the real 11 M-line corpus prunes 13 of 14 row groups via Parquet min/max statistics — the predicate-pushdown thesis (pillar #1) on real production data, ~5× faster than the unwindowed scan. (B1’s real-corpus arm skipped: OTel-Demo v1 has no error-band severity_text rows for the selectivity probe.) B1/B2’s structural pruning is the gate (pinned in ourios-querier); these are the baseline-hardware wall-clock stamp.

Not committed by the bench tooling — this is the curated narrative; the managed BENCH-RESULTS region above is for --update-benchmarks-md runs. The b1/b2 criterion timings use the reduced --warm-up-time 1 --measurement-time 3 (matching query-bench.yml); the structural pruning/template numbers are exact and criterion-setting-independent.

9.9 Results — 2026-07-03 (indicative, ci-runner) — B1 / B2 post-RFC 0022 (promoted attribute columns)

Purpose. The RFC 0022 §5 RFC0022.5 note: the promoted-attribute write path (per-key resource.<k> / attr.<k> columns + the two-arm predicate compile) must leave B1/B2 unchanged. This is the indicative re-run after RFC 0022 went green (#345–#348); the pruning counters are pinned structurally in crates/ourios-querier/tests/rfc0022_attr_columns.rs, this entry is the wall-clock stamp. Corpus. corpus/otel-demo-v4 (107,332 records → 735,377 mined rows / 5 files) and corpus/otel-demo-v5 (163,929 records, ~1.04 GB raw → 1,367,532 mined rows / 6 files). The LogHub HDFS_v1 arm did not run (fetch_hdfs off — memory-bound on the hosted runner). Hardware. ci-runner — indicative, not the §1 baseline. Run. query-bench.yml 28686650566 at git 6e3301b (the RFC 0022 green merge).

B1 — predicate pushdown vs zstdcat | grep (target: ≥ 10× at 1 GiB). Query: severity ERROR, full corpus span.

corpusrowsRGs scannedourios bytesreference bytes (zstd)ouriosreferencespeedup
v5113/6324,7731,403,0258.10 ms282.06 ms34.8×

Row count agrees exactly with the reference pipeline. v4 is skipped as in §9.3 (its capture has no error-band rows).

B1 verdict: PASS (indicative), no regression — 34.8× against §9.3’s 40.0× on the same corpus, comfortably inside hosted-runner noise and 3.5× above the bar. Same caveats as §9.3: ultra-thin error band, corpus just under the §8 minimum, not the §1 baseline.

B2 — template-exact latency ∝ result, not corpus.

benchresulttimingpruning
b2/real-corpus/corpus/v4 (template 45)89,382 rows8.71 ms5/5 row groups (full span)
b2/real-corpus/corpus/v5 (template 8)168,487 rows12.37 ms6/6 row groups (full span)
b2/real-corpus/corpus-window-1h/v412,854 rows5.46 ms1/5 — 4 row groups pruned by the time window
b2/real-corpus/corpus-window-1h/v517,632 rows6.71 ms1/6 — 5 row groups pruned by the time window
b2/synthetic/{2k,20k,100k}result held constant2.17 / 4.77 / 13.61 mssub-linear in corpus size

B2 verdict: PASS (supportive, indicative), no regression — the windowed latencies sit in the same flat few-ms band as §9.3/§9.8 while the full-span variants grow with corpus size, and everything is orders of magnitude under the 200 ms bar. The formal target speaks above ~10 GiB, which remains unmeasured on this runner class.

Assessment. The promoted-column machinery (extra column chunks per row group on the write side; the two-arm OR compile on the read side) shows no measurable drag on either gate. The RFC 0022 green → validated step still requires the authoritative baseline-8vcpu-32gib rerun per the standing bench policy (maintainer opt-in); this entry is its indicative precursor, curated by hand as in §9.3 — the workflow never writes §9.

9.10 Results — 2026-07-04 (authoritative attempt, baseline-8vcpu-32gib) — B1/B2 at 16 GiB: run blocked, miner finding

Purpose. The first run in the §8 10–100 GiB band: B2’s formal target speaks above ~10 GiB and had never been measured there. Corpus. LogHub HDFS_v2 (bench-time fetch, never redistributed): 31 files, 17,240,888,465 bytes ≈ 16.1 GiB raw, ~71 M lines of Hadoop daemon logs — the first corpus in our set whose shape (stack traces, multi-format node logs) differs qualitatively from HDFS_v1’s block events. Hardware. baseline-8vcpu-32gib, provisioned for the run and torn down after. Outcome: the run did not complete — it produced a product finding instead. The B2 store build was OOM-killed at 31.5 GiB RSS: the miner mints templates without bound on this corpus shape (template ids ≥ 56,199 by the 1.8 GiB subset mark, busiest template covering 0.67 % of 8.37 M rows; memory ~linear at ≈2× corpus bytes). Two bench-side pathologies were found and fixed en route — the eager corpus load (#350, now streaming: 1.3 GiB flat over hours) and a quadratic harness snapshot capture (#351, ~400× store-build speedup; gdb stacks exonerate the miner’s CPU path). RFC 0023 (bounded template memory) is the response; its RFC0023.7 criterion is this exact run completing.

What did land before the kill (recorded as diagnostic):

benchresulttimingpruning
b2/synthetic/{2k,20k,100k}result held constant2.72 / 6.33 / 19.7 mssub-linear in corpus size
b2/real-corpus (1.1 GiB subset)windowed 1 h → 1 row6.31 ms5/6 row groups pruned
b2/real-corpus (1.8 GiB subset)template 56199 → 55,751 rows full-span; windowed 1 hwindowed 6.31 ms10/11 row groups pruned by the window

B2’s shape — flat windowed latency, window-driven pruning — holds wherever memory allows; the fragmentation itself (56 k templates, busiest at 0.67 %) also means pillar #2’s logical reduction fails on this corpus shape, which is the same finding from the pruning side. B1 did not reach its arms (stopped before the reference build once the OOM trajectory was clear). No gate verdict is claimed from this entry; the §8-band verdict waits on RFC 0023 + the rerun.

9.11 Results — 2026-07-04 (authoritative, baseline-8vcpu-32gib) — B1 / B2 at 16 GiB + RFC0023.7

Purpose. The §8 10–100 GiB band’s first completed measurement (the §9.10 attempt OOM’d), doubling as RFC0023.7 (bounded mining must complete this exact corpus under 8 GiB peak RSS) and the first B1/B2 readings at ≥ 10 GiB — where B2’s formal target speaks. Corpus. LogHub HDFS_v2 (bench-time fetch): 31 files, 17,240,888,465 bytes ≈ 16.1 GiB, 71,116,785 mined rows → 21 files / 80 row groups. B2 ran under the §3.3 Fixed severity baseline; B1 under the opt-in OURIOS_CORPUS_SEVERITY=log4j extraction (#350), stated per the methodology rule. Hardware. baseline-8vcpu-32gib, provisioned for the run, torn down after. Git 19e0886 (RFC 0023 bounds + telemetry merged).

RFC0023.7 — bounded mining at scale: PASS. Peak RSS 1.73 GiB across both benches’ store builds (5 s sampler), vs the §9.10 OOM at 31.5 GiB on identical input — an 18× reduction, under the 8 GiB bar with 4.6× headroom. Both benches completed (B2 phase 35 min; B1 including its zstd-19 reference build ~2.8 h).

B1 — predicate pushdown vs zstdcat | grep (target: ≥ 10× at 1 GiB, widening to ≥ 100× at 100 GiB). Query: severity ERROR, full 16 GiB span.

corpusrowsRGs scannedourios bytesreference bytes (zstd)ouriosreferencespeedup
HDFS_v224,03054/8019,284,044548,344,798116.76 ms13.545 s116×

Row count agrees exactly with the reference pipeline.

B1 verdict: PASS (authoritative) — the ≥ 100× mark projected for 100 GiB is crossed at 16 GiB. With §9.8’s ~35–40× at ~1 GiB, the measured trajectory confirms the widening the target predicted: the reference’s cost grows with corpus bytes while Ourios’s grows with the matching row groups.

B2 — template-exact latency ∝ result, not corpus (formal target: ≥ 10 GiB, ≤ 200 ms for 10 k rows).

benchresulttimingpruning
b2/real-corpus windowed 1 h78 rows5.60 ms79/80 row groups pruned by the time window (21 partitions)
b2/real-corpus full span56,234,257 rows124.70 ms80/80 scanned (count over the dominant class)
b2/synthetic/{2k,20k,100k}result held constant1.92 / 3.70 / 10.3 mssub-linear in corpus size

B2 verdict: PASS (authoritative, first ≥ 10 GiB reading) — the windowed query answers in the same few-ms band as the ~1 GiB corpora (§9.3/§9.8/§9.9): latency tracks the result, not the 71 M-row corpus.

The fragmentation datum (§9.10’s open question, quantified). The “busiest template” is id 0 — NO_TEMPLATE: under the default 20 k ceiling, ~79 % of HDFS_v2’s rows took the §6.3 parse-failure path (bodies retained bit-faithfully; observable via ourios.miner.parse_failure.reason, RFC0023.6). Template mining contributes little on this corpus shape — and the B1/B2 numbers above show the floor it degrades to (first-class-column + time pruning over Parquet statistics) still clears every gate. Follow-up noted: the B2 bench’s busiest-template picker should exclude NO_TEMPLATE so the full-span arm measures a true template-exact query on such corpora.

Assessment. RFC 0023’s §5 is fully discharged (this entry is the .7 record); the RFC flips red → green alongside this entry. The §8-band thesis verdict on real, hostile-shaped production logs: pruning compounds with scale (B1), result-bound latency holds (B2), and the mining-fragmentation failure mode is now bounded, observable, and priced.

9.12 Results — 2026-07-09 (indicative, local M-series) — otel-demo v8 capture: C1 / C2

The run is dated 2026-07-09; its C2 verdict was re-scored under the per-service gate on 2026-07-10 (#444 / RFC 0006 §3.4.3), so the resolution dates below post-date the heading.

Corpus. corpus/otel-demo-v8 (published GitHub release): a 48-hour OTel-Demo 2.2.0 capture at 150 locust users with the adFailure + paymentFailure feature flags active — 690,355 OTLP LogsData batches / 4,948,596 log records / 2.96 GB uncompressed, the largest and most hostile real capture to date (deliberately injected failure modes, multi-service, long-horizon). Calibration manifest at testdata/calibration/otel-demo-v8.json (RFC 0024 §3.1).

C1 — bit-identical reconstruction: PASS, perfect. The corpus holds 4,948,596 records (the calibration manifest’s count); 17 of them (all kafka, 0.0003 %) took the §3.3 lossy-flag path with their bodies retained, and C1 = 1.000000 over the remaining 4,948,579 rows — the honesty contract holds at 4.9 M rows through failure-mode churn.

C2 — template-count convergence (bar: ratio ≥ 0.5 at 1 M lines, evaluated per service since #444): PASS. Under the per-service gate (RFC 0006 §3.4.3, amended 2026-07-10) the corpus passes: the only service that clears the 1 M-line evaluation floor is cart, which converges at ratio 1.000 with two templates. Every other service abstains for want of volume; the whole-corpus ratio (0.199, end template count 14,631, sample cadence 4,833) is retained below as a diagnostic — it is a category error to grade a multi-service corpus as one Drain stream (§3.4.3 rationale). The per-service decomposition (splitting on service.name and re-running the gates per service) localises the whole-corpus fragmentation completely:

servicelinesend templatesC2
cart2,756,3312ratio 1.000 PASS
recommendation971,49017abstain (< 1 M)
currency597,2591abstain (< 1 M)
ad486,7263abstain (< 1 M)
kafka136,79014,608abstain (< 1 M)

The gate folds over the gated services (those ≥ 1 M lines): cart is the sole such service and it passes, so the corpus passes. cart clears the formal gate at 2.76 M lines with two templates; the smaller services abstain below the 1 M-line floor, so they are not graded — though their observed counts (1–17 templates over 0.5–1.0 M lines) sit at the same near-flat convergence. The kafka broker, also abstaining, is the outlier: it mints 14,608 templates on 2.8 % of the lines. Mechanism (measured): kafka’s cleaner logs emit 3-token lines whose third token is a unique offset-bearing path (Deleted log /tmp/kafka-logs/…/00000000000000000429.log.deleted., 11,651 distinct) — one varying token in a 3-token line is similarity 2/3 ≈ 0.67, below the strict 0.7 threshold (§3.1 no-silent-merges), so each line mints a template; the 4-token siblings of the same family (0.75) merge fine. The failure-flag confound turned out to be a red herring. #444 settled how to handle the fragmentation (2026-07-10, maintainer-approved): of the three options — tokenizer masking, length-aware thresholding, and accept-and-scope-C2-per-service — option 3 shipped (the per-service gate, RFC 0006 §3.4.3, PR #451); masking is parked as a future strategic RFC (no commitment; a Collector transform or redaction processor can polish high-cardinality infra tokens upstream) and length-aware thresholding was rejected. The safety story held throughout (bounded memory per RFC 0023, per-service C1 perfect).

The per-service decomposition is now the first-class bench gate (ourios-bench --gates c2 prints it whenever any service bucket exists — distinct service.name values plus any <unknown>/<other>, so a single-service or plain-text corpus shows its one gated row too); template creation is a globally-monotonic event attributed to the minting service, so per-service creations partition the whole-corpus count exactly (2 + 17 + 1 + 3 + 14,608 = 14,631) in O(services) memory — no per-service id set. As of #444 (option 3) this decomposition is the gate: C2 is evaluated per service and folds over the services that clear the 1 M-line floor, with the whole-corpus ratio kept as a diagnostic (RFC 0006 §3.4.3).

What the fragmentation actually costs — B2 pricing (indicative, local M-series). Running the B2 windowed query on the fragmented (kafka) vs. converged (cart) service isolates the impact:

servicetemplates1 h-window queryrow groups pruned
cart23.66 ms48 / 49
kafka14,6083.40 ms48 / 49

The deployed time/column pruning floor is identical whether a service has 2 templates or 14,608 — a 1 h window prunes 48 of 49 row groups either way (reconfirming the RFC 0023 graceful-degradation result on a fresh corpus). Fragmentation does not cost query latency or pruning. What it costs is template-exact query precision: probing cart’s dominant template (id 1 in this run — a run-specific identifier, not a canonical one) recovers 1.78 M / 2.76 M rows (one template is most of the corpus) but only 11,523 / 136,790 on kafka, because kafka’s dominant event is scattered across ~11,651 ids — a single template_id probe recovers only that one id’s slice (11,523 rows), not the full dominant event. So the fragmentation is a query-capability / thesis-value tradeoff, not a performance one; the pruning path degrades to the first-class-column floor unharmed. #444 accepted that tradeoff on hostile infra logs: the per-service gate makes C2 acceptance honest without masking, and any future masking is deferred to an upstream Collector processor or a dedicated RFC.

9.13 Results — 2026-07-12 (indicative, ci-runner) — RFC 0031 comparative program vs Grafana Loki (runs #8–#18)

Purpose. The first recorded numbers for the RFC 0031 comparative program — Ourios against Grafana Loki, the incumbent CLAUDE.md §1 defines the project against. These are the §7 calibration inputs the RFC’s open questions ask for, not gate verdicts: the L-gate margins are the RFC’s proposed values (M_L1..M_L4 = 10, F_L6 = 3, wired as ComparativeMargins::default()), the §5 gate scenarios (RFC0031.2–.11) are still red stubs, and the harness reports each pair under its provisional margin rather than asserting it. Every “PASS”/“fail” below is provisional pending the §7 freeze — a maintainer step; the open inputs are enumerated in point (4) of the closing Assessment.

Corpus. corpus/otel-demo-v8 (the §9.12 capture): 4,948,596 log records, 2.96 GB uncompressed — the RFC 0031 §3.3 headline corpus (real OTLP, failure flags active, kafka fragmentation and all). Both systems ingest the identical OTLP stream; an OTLP partialSuccess in any push response fails the run, so neither side can silently drop lines. Reference system. grafana/loki:3.5.3, digest-pinned (sha256:3165cecce301ce5b9b6e3530284b080934a05cd5cafac3d3d82edcb887b45ecd), single-binary mode, fed over its native OTLP endpoint. Flag deviations from stock are documented below — all ingest-replay accommodations, all in Loki’s favour, per the §3.7 anti-strawman commitment. Hardware. ci-runnerindicative, not the §1 baseline; the authoritative baseline-8vcpu-32gib run remains a maintainer opt-in per RFC 0031 §3.2. Bytes-read, the primary channel, is CPU-insensitive by construction, but nothing here is quoted as authoritative. Runs. comparative-bench.yml dispatch runs (curated by hand as ever — no workflow writes §9), each with one harness delta under test. Counted runs are equivalence-gated passes over the full corpus; the two diagnostic failures (#11/#13) are listed with exactly what they carry:

runworkflow run iddelta under test
#829171354194honest-metric baseline (§3.6 amendment wired)
#929174022848+ single-pass count/materialize scan (#485)
#1029174342843+ late materialization (#486)
#1129186113326L3 diagnostic: Loki 0-rows, pre-salvage panic — no counted numbers
#1229188179299+ L3 trace pair (#487/#488)
#1329189430335L3 diagnostic recurrence (on the #489 branch): L3 timed out; the salvaged report’s other pairs are counted where tabulated
#1429190408893+ trace_id/span_id blooms (#489; pre-merge on the PR branch, since merged)
#1529192897795+ L1 template pair (#492; pre-merge, since merged)
#1629199815903+ selective-resource diagnostic, first picker (produced a vacuous duplicate of the L6 k=100 pair — the fix is what #493 merged; the run’s L1/L3 pairs measured and passed, so it counts toward the streaks)
#1729203804795+ selective-resource diagnostic pair, fixed picker (#493; pre-merge, since merged)
#1829210202343+ latency_p50 channel (#495; pre-merge, since merged) — bytes unchanged from #17; adds the §3.6 latency numbers below

In every counted run, RFC0031.1 result-set equivalence held on every pair: the two systems’ answers, keyed (timestamp_unix_nanos, body_bytes), were multiset-identical at 4.9 M-record scale. Runs #11/#13 were L3-flicker diagnostics (an ingester-visibility artifact, fixed in #490 — see the deviations list); their table rows above note exactly what each carries. Every dispatched run appears in the table, and the per-class tables below carry a row for every run in each quoted streak (L1: #15/#16/#17; L3: #14/#15/#16/#17), so the streaks audit from this entry alone.

The metric (§3.6 as amended 2026-07-12). The Ourios figure is the total bytes fetched from object storage per query: count scan + row materialization + template-registry derivation. Loki is reported on two channels: storage-side (query-stats compressedBytes + headChunkBytes — the conservative apples-to-apples counterpart of Ourios’s fetched compressed-Parquet bytes; the harness evaluates gates primarily on this) and totalBytesProcessed (decompressed engine-side work, which overstates Loki’s storage reads by the chunk compression ratio; reported as context). Which channel the frozen §7 gates ride is an open maintainer decision.

Program history — the biased ruler, retired. Runs #5–#7 predate the §3.6 measurement-fidelity amendment and measured the Ourios side as the count scan alone (e.g. run #7’s severity figure of 609,498 B and its “146.9×”-style ratios), silently excluding the row-materialization and registry IO while Loki’s counterpart figure includes delivering results. Those runs are program history only and are not citable; every number below is on the honest total.

L1 — template-exact lookup (must-win, the flagship class): provisional PASS, widest margins. Pair: template_id == 4323 (2 rows) vs the LogQL line-filter needle "Updated connection-accept-rate max connection creation rate to" over every stream — the picker proves the two select identical row sets before the pair counts. Loki has no template concept, so its honest equivalent is a substring scan of the whole corpus; Ourios rides the writer’s existing bloom filter on template_id.

runourios bytesloki storage-sideloki processedstorageprocessed
#151,358,683104,825,4282,468,065,72677.2×1,816.5×
#161,358,683105,191,9562,469,772,35277.4×1,817.8×
#171,358,683105,579,5102,474,713,32177.7×1,821.4×

Above the provisional M_L1 = 10 on both channels, in every run since the pair landed (third consecutive pass at #17). The Loki side is structural: no template id → nothing to prune with.

L3 — trace correlation (must-win, OTLP-native): provisional PASS after blooms. Pair: every log line for one trace_id (9 rows). trace_id is high-cardinality by construction, so it cannot be a Loki label (§3.3’s machine-checked disallowlist); Loki’s honest query is a structured-metadata filter over all streams.

runourios configourios bytesloki storage-sideloki processedstorageprocessed
#12no bloom — trace_id column scanned corpus-wide72,935,984102,835,8032,419,117,7831.41×33.2×
#14+ trace_id/span_id blooms (#489)4,812,668105,353,8372,476,749,58521.9×514.6×
#15reproduction4,812,668102,133,8662,404,486,16921.2×499.6×
#16reproduction4,812,668104,656,5702,456,853,96921.7×510.5×
#17reproduction4,812,668105,251,5472,465,855,69521.9×512.4×

Run #12 is the honest before-picture: without blooms Ourios itself had to fetch the trace_id column corpus-wide, and the storage-side ratio (1.41×) was nowhere near the margin. The blooms (implemented in #489; the RFC 0005 §3.6 amendment recording them, with this as its measured evidence, is #491) collapse the fetch 15×, and the pair has now passed the provisional margin on both channels three runs in a row. As with L1, Loki’s side is structural: a trace cannot be pre-narrowed to a label stream, so it scans and decompresses everything in the window.

L2 — severity predicate (must-win family): parity-plus storage-side, ~33× processed — not a provisional 10× pass. Pair: lowest-volume single-severity_text band on the highest-volume service, full corpus span, 1 row. The run series doubles as the read-path optimisation ledger (component split: count scan + materialize + registry):

runleverourios bytes (count + mat + reg)loki storageloki processedstorageprocessed
#8baseline4,270,091 (609,498 + 3,146,731 + 513,862)2,880,78489,184,7110.67×20.9×
#9single-pass scan (#485)3,660,593 (0 + 3,146,731 + 513,862)3,158,32398,114,7030.86×26.8×
#10late materialization (#486)2,549,129 (0 + 2,035,267 + 513,862)2,751,83485,261,7181.08×33.4×
#12reproduction (no L2 delta)2,549,1292,779,80086,255,9011.09×33.8×
#13reproduction2,549,1293,349,89798,253,3431.31×38.5×
#14reproduction2,549,1293,224,893100,044,0701.27×39.2×
#15reproduction2,549,1292,688,94283,216,8951.05×32.6×
#16reproduction2,549,1292,673,54582,919,2331.05×32.5×
#17reproduction2,549,1293,224,528100,198,4661.26×39.3×

(Run #8’s Loki side: 2,880,784 storage / 89,184,711 processed.) Across the later reproductions the storage-side ratio sits at 1.05–1.31× and processed at ~33–39×, the spread being entirely Loki-side wobble (below). Reading: on the honest metric Ourios went from losing the storage channel (0.67×) to parity-plus via two read-path fixes, and wins decisively on engine work — but this is not a 10× storage-side pass, and no amount of wobble makes it one. The remaining named levers: the constant 513,862 B template-registry derivation, 20–29 % of every small-answer query’s total (the RFC 0033 cached-template-map candidate), and write-side page/row-group sizing.

Time-window browses (L6 floor family): published loss on the storage channel. Pairs: all lines of the highest-volume service in a clean k-row window (the promoted-column bloom’s worst case), plus run #17’s diagnostic — the same shape scoped to the lowest-volume service (“ad”, ~34 s window), where the service.name bloom could in principle skip. Floor gate as reported here: a bytes-read floor analog (Ourios ≤ 3× Loki, i.e. ratio ≥ 0.33) — the harness applies the §7 F_L6 factor to this entry’s bytes channels. Note the §5 gate as written (RFC0031.7) defines the L6 floor on latency p50 — measured in run #18 (see the latency section below), where the gate as written passes on all three window pairs; the bytes framing here remains the conservative reporting channel pending the §7 freeze.

runpairourios bytesloki storage-sideloki processedstorage ratioprocessed ratio
#8k=1005,094,79016,25063,5950.003 fail0.012 fail
#8k=20009,736,28572,5241,809,5230.007 fail0.186 fail
#10k=1002,257,86716,25063,5950.007 fail0.028 fail
#10k=20004,528,42972,5241,809,5230.016 fail0.40 pass
#17“ad” k=100 (diagnostic)1,757,48931,616687,0430.018 fail0.39 pass

This is the honest loss the RFC’s L6 disposition anticipated, and it is published as §5 RFC0031.11 demands: on a browse-k-rows query Loki reads only the tiny chunk slice its label stream + time index point at, while Ourios pays fixed per-query costs (the registry constant plus row-group-granularity materialization) that dwarf a k-row answer. The #486 late-materialization fix halved the loss and lifted k=2000 past the processed floor; storage-side stays 0.007–0.018 vs the 0.33 floor on current code. Run #17’s diagnostic sharpens the why: scoping to a low-volume service improves Ourios only ~22 % and flips the processed floor to pass, but there is no bloom collapse — v8’s hour partitions each hold roughly one row group containing all services, so the promoted service.name bloom has nothing to skip. The tier-changing lever is write-side layout (service clustering / row-group sizing — hazard #4 territory, an RFC-level change), not query-side tuning.

Latency (§3.6 channel, run #18 — the program’s first). Median of 7 warm repetitions per pair per system, measured only on correctness-verified pairs; Ourios timed in-process, Loki over localhost HTTP (negligible at these magnitudes; stated because latency is corroborating, not sole-gating):

pairourios p50loki p50ratio (>1 = Ourios faster)
severity (1 row)82.0 ms875.0 ms10.7×
L3 trace (9 rows)74.6 ms24,101.9 ms323×
L1 template (2 rows)75.7 ms23,321.5 ms308×
window k=10040.2 ms13.8 ms0.34
window k=200085.9 ms294.8 ms3.43
selective-resource k=10038.8 ms51.2 ms1.32

Two findings this channel settles. First, the young-engine latency risk the RFC hedged against (“a latency loss + bytes-read win = sound architecture, young implementation”) did not materialize: Ourios answers every pair in 39–86 ms — a flat, fixed-cost-shaped profile — while Loki spans 13.8 ms to 24.1 s, and on the needle classes the wall-clock gap is interactive-vs-batch (75 ms vs 23–24 seconds). Second, scenario RFC0031.7 evaluated as written — on latency — PASSES on all three window pairs (0.34, 3.43, 1.32, all ≥ 1/3 at F_L6 = 3), and Ourios is outright faster on two of the three; the storage-channel loss published above is real as a bytes statement, but the RFC’s own L6 gate holds the floor. Which channel the frozen L6 gate uses is part of the §7 decision.

Determinism note. For repeated measurements of the same build and configuration, Ourios’s bytes are byte-identical (the store build is deterministic) — differences between runs are exactly the harness/optimisation deltas the table names, which is what lets the run series read as an optimisation ledger. Loki’s storage-side figure wobbles run to run (severity pair: 2.67–3.35 MB) with chunk boundaries and flush timing; ratios quoted against Loki carry that band.

Documented Loki flag deviations (all in Loki’s favour, per §3.7). The committed harness starts Loki with, and comments, exactly these deviations from stock:

  • -validation.reject-old-samples=false — the frozen corpus is weeks old; stock Loki would reject the replay outright.
  • -querier.query-ingesters-within=0 — stock Loki (default 3 h) skips ingesters for queries over weeks-old ranges, making rows still in unflushed low-volume chunks invisible (the run #11/#13 L3 flicker; diagnosed via ingester.totalReached: 0, fixed in #490). Disabling the cutoff means ingesters are always consulted — without it Loki’s answer to an old-range query is silently incomplete.
  • Raised ingestion + per-stream rate limits (-distributor.ingestion-rate-limit-mb=512, -distributor.ingestion-burst-size-mb=1024, -ingester.per-stream-rate-limit=512MB, -ingester.per-stream-rate-limit-burst=1GB) — replay is far faster than the capture’s real-time rate.
  • Raised internal gRPC message caps (-server.grpc-max-recv-msg-size-bytes=16777216, -server.grpc-max-send-msg-size-bytes=16777216) — runs #2–#4 failed on the same ~5.27 MB internal message regardless of our outer batch size: a single kafka-service LogsData line’s content alone inflates past Loki’s stock 4 MiB internal cap. Raising it (standard operator tuning) lets Loki accept the data at all, preserving the identical-ingest precondition the equivalence check requires.

Assessment. (1) The two classes the thesis stakes itself on hardest — L1 template lookup and L3 trace correlation — pass their provisional must-win margins on both channels, reproduced across three consecutive runs, and in both cases Loki’s cost is structural rather than tuning: no template concept, and no way to index a trace id. (2) L2 is parity-plus on storage and a ~33× processed win, honestly short of a 10× storage claim, with two named levers still on the table. (3) The window browses are a published storage-channel loss whose mechanism is understood (fixed per-query costs vs v8’s one-row-group-per-hour layout); the lever is write-side and RFC-sized. (4) Nothing here is frozen: the §7 inputs — the primary metric channel (storage-side vs processed), the must-win margins and floor factors, and whether the time-window pairs reclassify from gated floor to diagnostic — are open maintainer decisions, and this entry is the calibration evidence for them, not their resolution.

9.14 Results — 2026-07-13 (indicative, ci-runner) — comparative run #20: frozen gates on main, RFC 0033 acquisition

First dispatch on main after the §7 partial freeze and after the RFC 0033 cached template map merged (#511–#513). Job: run #20 (29255000054), exit 0.

Frozen gates. All asserting gates pass on mainM_L1/M_L3 storage margins and the F_L6 latency floors held; equivalence held on every pair. The dispatch is functioning as the regression gate the freeze intended (run #19 proved it on the branch; this run proves it on main).

RFC 0033 acquisition (the run’s purpose). Every pair reports:

template-map acquisition (RFC 0033): cold (audit fold, 513862 B; no artifact published)
  • The registry component is byte-identical to run #8’s baseline (513,862 B constant per body-rendering query): the cache regressed nothing, exactly as the advisory design promised.
  • But the write-through never published on this corpus, so no pair ever ran warm and the RFC0033.6 corpus gate (warm/cold ≤ 1/10) could not be measured.
  • The explanation consistent with the run’s outputs is §3.2’s size abstention: the artifact is uncompressed JSON carrying every (template_id, version) canonical template string, while the 513,862 B it must undercut is zstd-compressed Parquet of the same strings (plus their event history). On v8’s template set the JSON evidently meets or exceeds the fold, and the guard refuses a publish that would make warm acquisition cost more bytes than the fold it replaces. (A publish IO failure would leave the same “no artifact” label; the §3.7 publish-outcome telemetry distinguishes the two in a served process, but the bench harness does not export metrics — the amendment run should print the outcome explicitly.)

Consequences recorded.

  1. RFC 0033 status reverted green → red (this PR): RFC0033.6’s corpus arm is undischarged. The local-shape arm (55.8× on the 64-event fixture) stands.
  2. M_L2 stays frozen-deferred — §7’s unfreeze condition (the RFC 0033 warm measurement on the headline corpus) was not met.
  3. The lever is an artifact encoding amendment (format_version 2, compressed body). The same template strings zstd-compress into the 513,862 B audit Parquet with full event history alongside, so a compressed artifact is expected to land well below the fold size — to be measured, not assumed. Abstention semantics stay: publish only when the artifact beats the fold.

9.15 Results — 2026-07-14 (indicative, ci-runner) — comparative run #21: the v2 compressed artifact publishes and runs warm

Dispatched from the RFC 0033 v2 implementation branch (PR #522, the measure-before-merge step). Run 29343438434.

The RFC 0033 answer. The zstd artifact published on the corpus (no abstention — the run #20 ambiguity is resolved by the new per-pair outcome labels), and every measured pair ran warm:

template-map acquisition (RFC 0033): warm (one artifact GET, 187904 B compressed)
  • warm = 187,904 B (the compressed artifact, GET cost) vs cold = 513,862 B (the audit fold, byte-identical to run #8) — warm/cold ≈ 1/2.73, a ~326 KB cut off every body-rendering query’s honest total.
  • The original RFC0033.6 ratio gate (≤ 1/10) does not pass on this corpus: the artifact is O(live template state), the fold is O(audit history), and otel-demo-v8 is young — the amended gate (≤ 1/2, dated 2026-07-14 in the RFC) asserts the real margin and ages upward. See the §5.6 amendment for the full argument.

The test failure is not an Ourios finding. The run exited 1 on one pair: loki returned 0 of 9 expected rows for [trace correlation, L3] before timeout — the Loki-side low-volume-chunk race (the run #12-era flicker), resurfacing on the shared runner despite the #490 flag fixes. All other pairs measured; the report and every RFC 0033 number printed before the panic. A rerun for a clean L3 pair is queued as run #22.

9.16 Results — 2026-07-14 (indicative, ci-runner) — runs #22 and #23: the v2 artifact asserting, M_L2 unfrozen

Two dispatches after the RFC 0033 v2 merge (#522):

  • Run #22 (29352282162, from main): exit 0 — the clean-record run. All then-frozen gates passed, the L3 pair measured cleanly (run #21’s Loki-side flake did not recur), and every pair ran warm on the compressed artifact.
  • Run #23 (29353634499, from the M_L2-unfreeze branch): exit 0 — the first run with the full assertion set live. L2 processed (PRIMARY, frozen 10) 43.97×; L2 storage-side floor (frozen 11/10) 1.49×; L1 storage 108.3× and L3 storage 24.9× against their frozen 10s; latency floors held; and the RFC 0033 §5.6 acquisition gate asserted warm = 187,905 B compressed on every pair against the 513,862 B fold (ratio ≈ 1/2.73, gate ≤ 1/2).

With #528 merged, §7’s measurable gates (M_L1, M_L2, M_L3, F_L6) are all enforcing on every comparative dispatch; M_L4/F_L7 stay deferred until measured. RFC 0033’s §5 is fully discharged: the corpus arm passed as measured (#21), and passed again as an asserting gate (#23) — the status flips red → green with this record.

9.17 Results — 2026-07-17 (indicative, ci-runner) — L4 frequency aggregation measured (PR #536 arc)

The last unmeasured must-win class. The L4 workstream’s own dispatch sequence (~23 real comparative-bench runs across the arc — a numbering distinct from §9.16’s) fixed three genuine harness bugs early (LogQL escaping, a control-flow ordering bug, a missing picker row ceiling), then spent the balance of the runs on a persistent completeness shortfall that no harness-side fix closed: Loki never returned 100% of any L4 candidate’s expected rows on this corpus. Every mechanism checkable from the harness side was ruled out directly — exact (timestamp, body) ingester dedup (corpus analysis found zero collisions), push-path drops (partial_success asserted clean on every push), Loki’s own warn/error logs (silent), and its loki_discarded_samples_total accounting (zero, of any kind). The residual matches open upstream grafana/loki#10658 (wide-time-range queries silently missing a small percentage of lines, no maintainer-identified root cause). RFC 0031 §7 records the resulting amendment: L4_COMPLETENESS_MARGIN = 0.90, checked per group_key with phantom-cell and per-key-overcount hard-fails — the full five-iteration comparator design trail lives there.

The measured pair (picker floors L4_MAX_ROWS = 100_000, L4_MIN_AVG_INTERVAL_SECONDS = 100 — lower-frequency candidates measure more completely; mechanism uncharacterized, NOT dedup): template_id=60 (Periodic task <type> generated), param(0), bucket(12h), 1,197 expected rows, group cardinality 4.

run (workflow ID)completenessstorage-side (loki/ourios)processed (loki/ourios)
29573249312 (2026-07-17, first clean pass)1167/1197 = 97.5%3.73×87.1×
29598833238 (2026-07-17)1164/1197 = 97.2%3.72×86.8×
29608796312 (2026-07-17)1141/1197 = 95.3%3.70×86.5× (run failed on the unrelated L3 flicker; L4 itself passed and its report printed)
29614831613 (2026-07-17)1149/1197 = 96.0%3.69×86.6×

Ourios’s side is constant at 47,995,205 B total (the honest §3.6 metric). Four consecutive equivalence-verified measurements in a 3.69–3.73× / 86.5–87.1× band: the shape mirrors L2 pre-freeze — a strong processed-channel win with storage closer to parity. M_L4 stays §7-deferred (both channels reported, nothing asserted); the proposed freeze shape on #498 is the L2 precedent — processed-channel must-win at 10× plus a storage-side floor (L2’s is frozen at 1.1×; L4’s measured 3.69–3.73× storage band would clear a similar floor with real headroom).

Follow-on hardening, so the 2 h dispatch confirms rather than discovers (#538/#499, closed via #539–#542): a proptest property suite over the margin comparator (its properties were verified by manually re-introducing the three historical comparator bugs and confirming each is caught — the evidence trail is PR #539’s record, not a standing mutation-testing harness), per-pair completeness recorded as a machine-readable artifact on every dispatch, a backdated wide-time-range arm in the per-PR loki-interop job running the dispatch’s exact Loki flags (one shared constant — config drift between the 1-minute test and the 2 h run is now unrepresentable), and a dispatch class filter for targeted re-runs.

9.18 Results — 2026-07-18 (indicative, ci-runner) — the M_L4 freeze’s first asserting run

Workflow run 29659514874, dispatched from the freeze branch (PR #548) with the L4 gates enforcing for the first time — M_L4 = 10 on the processed channel (primary) plus the 1.1× storage-side floor (m_l4_storage_floor_tenths = 11), the L2 shape per the §7 decision (maintainer, 2026-07-18). Exit 0; every frozen gate Decided { pass: true }:

gateverdict
L4 processed (PRIMARY, must-win 10)86.60×
L4 storage floor (11/10)3.70×
L2 processed (PRIMARY, must-win 10)43.73×
L2 storage floor (11/10)1.41×
L1 storage (PRIMARY, must-win 10)102.10×
L3 storage (PRIMARY, must-win 10)23.36×
L6 latency floors (factor 3, both window pairs)0.52× / 3.94×

L4 completeness this run: 1151/1197 = 96.2% — inside the §7 margin and the §9.17 band (95.3–97.5%). (Latency-floor advantages are oriented loki_p50/ourios_p50 — above 1 means Ourios faster; the floor passes at ≥ 1/3.) With this run the dispatch asserts every §7 value except F_L7 (deferred until L7 is first measured): an L4 band degradation below either frozen value now fails the run instead of printing a smaller ratio.

9.19 Results — 2026-07-20 (indicative, ci-runner) — first D1/D2 sustained-ingest soak (#558)

Purpose. The first run of the RFC 0009 D1/D2 soak harness (#558, ourios-bench soak + soak-bench.yml): the full in-process pipeline — IngestPipeline group commit (100 ms WAL batch window) → seal → sweep → compact — under one hour of paced synthetic OTLP load. Record timestamps ride a ×60 synthetic clock and the same synthetic “now” feeds the sweeps, so hour-sealing exercises continuously; D1’s ack latencies are wall-clock. Workflow run 29717165102; the JSON report is the run’s artifact. Hardware: ci-runner (4 vCPU) — indicative, not the §1 baseline.

Numbers (release build, defaults: total offered load 100,000 lines/s — the paced target, not D1’s per-core bar — batch 1,000, 4 workers, 10 s sampling):

measurevaluebarverdict
sustained rate359,997,000 lines acked in 3,600.1 s, 0 failed batches (harness-computed 99,995 lines/s over its unrounded load wall)target held
ack p50 / p95 / p99 / max103.05 / 157.27 / 172.68 / 227.94 msp99 ≤ 200 mslatency bar PASS
per-core rate24,999 lines/s/core (4 workers)≥ 100 000 lines/s/core (§D1)D1 FAIL as normalized
D2 backlogmax 1 partition, 60 compactions over 185 samples, final 0 (returned to zero)bounded, drains in-windowD2 PASS
WAL at last sample397 segments, 53,146,830,018 Bdisk note for longer soaks

Reading. D2 is a clean pass: compaction kept pace with a full hour at target with a backlog that never exceeded one partition. D1 splits: the machine sustained the 100k lines/s target with ack p99 inside the bar, but normalized per core (÷4 workers on 4 vCPU) it lands at ~25k lines/s/core against the ≥ 100k/core bar. Open reading for the maintainer: whether the bar means single-core-scaled throughput (then this is a real 4× efficiency gap to close) or per-node throughput on baseline hardware (then the authoritative baseline-8vcpu-32gib run decides). The commit path’s fsync overlap needs ≥ 2 threads, so a literal 1-worker measurement under-credits by construction. Either way the harness now measures instead of guessing, and the number is honest: no bar was reworded to fit the result.

9.20 Results — 2026-07-20 (authoritative, baseline-8vcpu-32gib) — D1 capacity probes: the single-tenant ceiling

Purpose. §9.19 left D1’s per-core bar as an open reading and noted the run had paced at the target rather than probing capacity. These are the first capacity probes, on the §1 baseline class (8 dedicated vCPU / 32 GiB) — baseline runs are maintainer opt-in per RFC 0031 §3.2, and this one was. Ad-hoc VM run (not a workflow dispatch): ourios-bench soak at 0979d14, release build; per-probe JSON reports retained by the maintainer alongside this record’s source run log.

Ladder (10-minute probes, 8 workers, one tenant, ×60 synthetic clock):

offered loadachievedack p50 / p99D2 backlog
200,000 lines/s86,132 lines/s5,951 / 6,262 msmax 1 partition, drained — PASS
400,000 lines/s85,879 lines/s5,968 / 6,309 msmax 1 partition, drained — PASS
800,000 lines/s85,911 lines/s5,972 / 6,319 msmax 1 partition, drained — PASS

The identical ~86k plateau at every offered rate, with ack latency pinned at ~6 s, is a saturated pipeline: at saturation the harness’s in-flight bound (a 512-batch semaphore in the load loop, #558) sets the latency (512 batches × 1,000 lines ÷ 86k lines/s ≈ 6 s) while the plateau itself is the pipeline’s service rate — raising the permit count would lengthen the queue, not the throughput, because the per-tenant miner hand-off is in-order and sequential by design (CLAUDE.md §3.7 per-tenant trees; the least-common-mechanism choice’s deliberate flip side). Stated carefully: ≈ 86k lines/s is the observed single-tenant ceiling under this probe configuration (batch 1,000, 8 workers, this corpus shape), consistent with the sequential-miner explanation; a different batch size or record mix could move the number some, but not onto a cores axis.

Tenant-parallel check (8 concurrent single-tenant soak processes, 2 workers each, 5 minutes at offered 100k each — an approximation: separate WALs/stores per process): 41,895–43,335 lines/s each, ≈ 341k lines/s aggregate — ~4× the single-tenant ceiling on the same box. Node ingest capacity scales with tenant parallelism, not core count.

Reading. The strict per-core bar (≥ 100 000 lines/s/core) is not merely uncalibrated — for single-tenant load it measures a dimension the architecture deliberately does not scale on. D1’s falsifier (“a meaningful share of production traffic per node”) is a per-node, multi-tenant statement: approximated here at ≈ 341k lines/s (≈ 29 B lines/day) — a multi-process stand-in, not yet a per-node measurement (separate WALs/stores neither share a commit stream nor contend on one store); the honest in-process --tenants N measurement is tracked in #567. D2, by contrast, passed at full saturation on every probe. The bar recalibration (per-node multi-tenant must-win + per-tenant ceiling as recorded diagnostic, the RFC 0011 must-win/diagnostic precedent) is a pending maintainer decision; until it lands, D1 stays FAIL-as-written and this record is the evidence, not the verdict.

9.21 Results — 2026-07-20 (authoritative, baseline-8vcpu-32gib) — in-process multi-tenant ceiling + the serialization profile

Purpose. The §9.20 tenant-parallel figure (≈ 341k lines/s) was a multi-process approximation flagged as such; #567’s in-process --tenants N mode (merged #570) is the honest instrument. Ad-hoc VM runs at 9ad3158-era code; the per-run JSONs and the profile artifacts are retained by the maintainer outside the repository (the gitignored local scratch/ tree), as with every ad-hoc VM record in this series.

Finding 1 — node capacity is FLAT across tenants. 10-minute saturating soaks, 8 workers, one shared WAL/commit stream: 1 tenant ≈ 86.0k, 8 tenants 85.8–86.2k (three offered rates), 16 tenants 87.3k lines/s. The multi-process 341k was ~4× optimistic precisely because separate processes had separate commit streams.

Finding 2 — the ceiling is software serialization, not hardware. Profile at saturation (flamegraph + per-thread pidstat): 1.2 of 8 cores busy — ~85% idle; no thread above ~33%. Root cause (crates/ourios-ingester/src/receiver/pipeline.rs:314–354 at that commit): the global WAL-seq gate + the global miner mutex serialize all tenants, with the miner match AND the sink emit (including size-triggered Parquet encode + store put — I/O) inside the single-file section. Issue #571; design → RFC 0035 (specified). D2 passed at full saturation in every run.

9.22 Results — 2026-07-20 (authoritative, baseline-8vcpu-32gib) — RFC 0035 Design A prototype A/B

Purpose. The RFC 0035 §6 pre-implementation measurement: main (9ad3158 lineage) vs the Design A prototype (rfc0035-prototype 987b781 — ordered mining under the gate, sink emit + triggered publish moved to a bounded concurrent pool, crude quiesce barrier). Same VM, back-to-back 10-minute saturating soaks (8 tenants, offered 800k, 8 workers). Artifacts retained by the maintainer outside the repository (gitignored local scratch/ tree).

armnode capacityack p50 / p99 (at saturation)D2
BEFORE (main)82,100 lines/s6,183 / 7,023 msPASS
AFTER (prototype)132,289 lines/s3,830 / 4,629 msPASS

Design A multiple on the baseline class: 1.61× (implied residual serial fraction ≈ 0.62 — the ordered miner phase + WAL group commit). Honest note: this is below the M-series indicative 1.82× — the prediction that the baseline multiple would land higher was wrong; the EPYC’s slower single-thread makes the still-serial ordered phase relatively costlier. Saturation ack latencies are queue-bound in both arms (the in-flight bound), per §9.20’s reading — the p99 bar applies at sustained rates below capacity. This 132k lines/s/node figure is the §6 input to RFC 0035’s target and to the RFC 0034 D1 recalibration; whether Design B (§4) is ever escalated is judged against the recalibrated bar, not the old per-core one.

9.23 Results — 2026-07-21 (authoritative, baseline-8vcpu-32gib) — the RFC0034.2 / RFC0035.4 asserting soak

Purpose. The asserting run for the recast D1 bar: RFC 0034’s RFC0034.2 (per-node must-win with the observable below-saturation condition) and RFC 0035’s RFC0035.4 (the serialization is actually relaxed) in one measurement — the RFC 0034 §7 one-run-two-records question resolved as one run, one record, cited by both. Ad-hoc VM run at main d2c622e (the RFC 0035 production implementation, #577, plus its review fixes, #579), release build; the JSON report is retained by the maintainer outside the repository (gitignored local scratch/ tree), as with every ad-hoc VM record in this series.

Shape (the RFC 0034 §3.1 asserting shape): one-hour soak, soak --tenants 8 (N = cores), offered exactly 100,000 lines/s — the bar rate, paced, not a saturating probe — batch 1,000, 8 workers, ×60 synthetic clock, 30 s sampling.

measurevaluebarverdict
achieved rate99,921 lines/s = 99.92% of offered (359,726,000 lines acked, 0 failed batches)achieved ≥ 99% of offered — the observable below-saturation condition (§D1 / RFC 0034 §3.1)D1 PASS as recast
ack p50 / p95 / p99 / max95.68 / 117.00 / 153.63 / 723.70 msp99 ≤ 200 ms at the sustained ratelatency bar PASS
D2 backlogmax 8 partitions, 480 compactions over 116 samples, final 0 (returned to zero)bounded, drains in-windowD2 PASS
per-core rate12,490 lines/s/corediagnostic, informational (RFC 0034)recorded

Reading. The recast D1 must-win asserts for the first time and passes: 0.08% pacing loss at the bar rate is achieved ≈ offered, which is the below-saturation proof, and the p99 (153.63 ms) is measured over that same run, inside the 200 ms bar. The p50 rides the 100 ms group-commit window (the CLAUDE.md §3.4 batch default) — ack latency at this rate is floored by batched fsync, not queue-bound. The 723.70 ms max is a tail spike outside the bar’s percentile; the bar is p99, and it holds. One honesty note: the harness’s printed per-core “FAIL” line is the pre-RFC-0034 bar mechanically applied — per RFC 0034 that number (12,490 lines/s/core) is now a recorded diagnostic, and the recalibrated per-node must-win is what judges the run. This run satisfies RFC0034.2 and RFC0035.4 simultaneously: the production Design A sustains the recast bar with margin to spare over the pre-RFC ~82k saturation baseline (§9.22), with D2 passing over a full hour of sustained multi-tenant load.

9.24 Results — 2026-07-21 (authoritative, baseline-8vcpu-32gib) — first authoritative comparative run: all frozen gates pass

Purpose. The RFC 0031 comparative program’s move from indicative to authoritative — the last open decision on the #498 scoreboard. Every number in the §9.13–§9.18 series was measured on ci-runner and labelled indicative; per RFC 0031 §3.2 the baseline-hardware run is a maintainer opt-in, and this one was. Ad-hoc VM run on the §1 baseline class (8 dedicated vCPU / 32 GiB), not a workflow dispatch, but an exact replica of the comparative-bench.yml dispatch recipe at main 9deecb1: frozen corpus/otel-demo-v8 (4,948,596 records), grafana/loki:3.5.3 digest-pinned via Docker, OURIOS_COMPARATIVE_CLASSES=all, release build, every §7 FROZEN gate asserting. Run logs and the machine-readable comparative-results.json artifacts for both runs are retained by the maintainer outside the repository (the gitignored local scratch/ tree), as with every ad-hoc VM record in this series.

Two runs, recorded honestly. Run 1 (5,798 s) measured every pair and passed every gate it printed — L2 39.51× processed / 1.275× storage floor, L1 97.56× storage, L4 84.93× processed / 3.59× storage floor, both L6 latency floors — but failed overall: the L3 pair hit the known transient Loki-visibility flicker (loki returned 0 of 9 expected rows … before timeout) — the class addressed by the #490 flag fixes, previously hit in runs #11/#13/#21 — so the harness hard-failed the run rather than salvage it. The decision rule applied: one deliberate, configuration-identical retry; had the flicker recurred, the next step would have been engineering, not rerolling. It did not recur. Run 2 (5,556 s) is the counted authoritative run: exit 0, 1 passed / 0 failed, all 11 frozen gate decisions Decided { pass: true }, L3 measured cleanly, RFC 0033 acquisition warm on every body-rendering pair (one artifact GET, 187,906 B compressed), equivalence held on every pair, and L4 completeness 1147/1197 = 95.8% — inside the §7 0.90 margin and the §9.17 band (95.3–97.5%).

Run 2 — the counted numbers (honest §3.6 totals; Loki bytes per channel; latency oriented loki_p50/ourios_p50, where > 1 = Ourios faster):

pairourios bytesloki storage / processedgate verdictslatency
L1 template (2 rows)1,032,727101,021,242 / 2,397,510,168storage PRIMARY 97.82× pass; processed 2,321.5× context90.44×
L3 trace (9 rows)4,486,712101,021,242 / 2,397,510,168storage PRIMARY 22.52× pass; processed 534.4× context77.99×
L2 severity (1 row)2,223,1732,754,940 / 85,310,490processed PRIMARY 38.37× pass; storage floor 1.239× vs 11/10 pass3.77×
L4 frequency (1,197 rows)47,995,205172,776,288 / 4,086,073,975processed PRIMARY 85.14× pass; storage floor 3.60× vs 11/10 pass
L6 window k=1001,931,91116,250 / 63,595latency floor 0.370 pass (≥ 1/3); storage-loss diagnostics published, not gated0.37
L6 window k=20004,202,47372,524 / 1,809,523latency floor 4.341 pass4.34
selective-resource “ad” k=100 (diagnostic)1,431,53331,657 / 239,072floor-reference pass (0.76)0.76

Reading 1 — the frozen-gate program confirms at authoritative class. Every §7 FROZEN value asserted and passed on the baseline hardware with no recalibration. On the bytes channels the margins are equal or wider than the ci-runner series: L1 storage 97.82× vs the §9.13 calibration streak’s 77.2–77.7× (and in the neighbourhood of the later asserting runs’ 102–108×); L3 22.52× vs 21.2–21.9×; L2 storage floor 1.239× inside its 1.05–1.49× history. L4’s 3.60× storage floor sits a whisker below the §9.17 indicative band (3.69–3.73×) — Loki-side storage wobble scale (§9.13’s determinism note), and more than 3× the frozen 1.1× floor either way; L4 processed (85.14×) is inside its 84.9–87.1× history. Bytes-read being CPU-insensitive by construction (§9.13), agreement here is expected — but now it is measured, and the thesis’s must-win claims are no longer resting on indicative hardware alone.

Reading 2 — the needle latency ratios compress on dedicated hardware, exactly as caveated. L1 90.44× and L3 77.99× against the ci-runner indicative 308×/323×: Loki’s corpus-wide needle scans drop from 23–24 s to ~10 s on the faster box while Ourios stays in its flat fixed-cost profile (57–129 ms warm p50 across every pair). This is §9.13’s / #498’s “a tuned environment compresses latency ratios; the structural asymmetries remain” caveat, now measured rather than hedged: the wall-clock gap on the needle classes is still interactive-vs-batch (~110–129 ms vs ~10 s), the L6 floors still hold (0.37 / 4.34 / 0.76 diagnostic), and the bytes asymmetry — the primary channel — did not compress. Quote the latency channel from this entry, not the indicative one.

Reading 3 — post-RFC-0035 code, gates held without recalibration. 9deecb1 carries the RFC 0035 Design A ingest restructuring (#577/#581). Every read-path byte count and every frozen gate held with no adjustment, consistent with RFC0035.5’s guarantee that the concurrency change alters no on-disk artifact.

With this record the #498 authoritative-rerun checkbox — the last open decision on that scoreboard — is discharged. The remaining storage-side lever named in §9.13 (write-side layout, hazard #4) stays parked on its own line, unchanged by this run.

9.25 Results — 2026-07-22 (indicative, local M-series) — D2 / D3 after the RFC 0036 sorted compaction

Purpose. RFC 0036 slice B (RFC0036.3) makes compaction sort the partition by (promoted service.name, time_unix_nano) via the §3.2 external merge sort and rotate compacted row groups at the smaller compacted threshold (the fixed 32 MiB in effect at this run; later adaptive — §9.30). §5 requires that D2/D3 — RFC 0009’s compaction-throughput and file-band properties — survive the sort. This is the “first measurement” §7 asks for, at red: the band from which the sorted D2 is set. Indicative, not authoritative — the baseline-VM sorted-vs-unsorted rerun is deferred to validated (a paid, maintainer-opted run, per RFC 0031 §3.2 / the “bench on ci-runner first” discipline).

Hardware / run. Local M-series developer machine (P-cores; taskpolicy -B to escape the Claude-Code background-QoS E-core throttle), release build at the slice-B head. The ourios-bench compaction bench, band-scale one-shot mode (OURIOS_COMPACTION_BASELINE=1 FILES=32 ROWS=4800 BODY_BYTES=4096 — the exact §9.7 shape, ~453 MiB of input in one partition), plus the criterion micro-sweep to confirm the groups still execute with the sort in the path.

measureresult (sorted, this run)§9.7 reference (unsorted, baseline-8vcpu-32gib)verdict
D2 compaction throughput32 files (452.9 MiB) → 1 in ~3.2 s = ~138 MiB/s (137–144 over three runs); 153,600 rows conserved166.8 MiB/skeeps up — ≫ any per-partition seal rate (≤ ~1 MiB/s/partition at the 256 MiB / 300 s seal cadence), so a backlog still drains
D3 small-file size bandoutput 452.7 MiBIN the 256 MiB–2 GiB band; 0% of live files < 128 MiB (target < 5%)456.7 MiB, 0% < 128 MiBPASS (unchanged) — still one file per partition, still in-band

Reading — “sorting is not free,” measured honestly. Sorted D2 on this box is ~138 MiB/s against §9.7’s 166.8 MiB/s unsorted. The two numbers are not the same hardware (local M-series vs the 8 vCPU EPYC-Milan baseline), so this is not a clean sorting-overhead delta — it is a fresh indicative measurement of the sorted path, plus the observation that adding a full external-merge-sort pass (decode → per-input stable sort → spill/merge, or the in-memory skip-spill short-circuit) to the rewrite lands in the same order of magnitude as the prior unsorted copy-through and comfortably clears “keeps up.” The clean same-hardware sorted-vs-unsorted delta is a validated baseline-rerun item; it is deliberately not asserted here. The D2 band is set from this measurement (indicative floor ~120 MiB/s on this class) and is not an in-repo wall-clock gate — wall-clock gates flake, so the in-repo RFC0036.3 assertion is structural (D3 file band + the §3.2 memory bound); the throughput lives here in §9 and in the bench (RFC 0036 §6).

Reading — D3 holds unchanged. The headline: the sort did not touch the file band. Compaction still emits exactly one file per partition, 452.7 MiB, squarely in the H4 256 MiB–2 GiB target with zero sub-128 MiB files — the RFC 0036 §3.3 amendment drops the compacted row-group threshold (32 MiB, ~14 groups here) but leaves the file band untouched, and D3 measures files. The structural side (one output file, small-file count → 1, rows conserved, sorted layout declared) is pinned deterministically in ourios-parquet’s rfc0036_3_compaction_properties_preserved (tests/it) and the merge proptests; the §3.2 peak-memory bound (forced-spill residency = one input + F×batch, never whole-partition) in rfc0036_3_forced_spill_peak_far_below_whole_partition. These wall-clock figures are the indicative stamp; the authoritative sorted D2/D3 awaits the validated baseline rerun (RFC0036.5’s frozen-gate pass will accompany it).

9.26 Results — 2026-07-22 (authoritative, baseline-8vcpu-32gib) — RFC 0036 comparative rerun: no regression + the single-file-harness finding

Purpose. RFC0036.5’s deferred validated item: rerun the frozen RFC 0031 comparative dispatch at post-RFC-0036 main (HEAD 5e5aa66) on baseline hardware, and confirm the frozen gates still pass on the sorted-compaction code. The v8 corpus (frozen corpus/otel-demo-v8 release), one Loki container + one replay, same methodology as §9.24.

Hardware / run. Fresh ccx33 (8 dedicated vCPU EPYC-Milan / 32 GiB = baseline-8vcpu-32gib), release build, Docker for the Loki testcontainer, box deleted on exit. ~75 min/attempt.

Result — every measured frozen gate passes, in §9.24’s band (no regression):

gatethis run (post-0036)§9.24 (pre-0036)verdict
L1 template storage (PRIMARY, margin 10)99.86× / 99.35×97.82×PASS
L2 severity processed (PRIMARY, margin 10)43.53× / 42.12×38.37×PASS
L2 storage floor (11/10)1.40× / 1.36×1.239×PASS
L4 frequency processed (PRIMARY, margin 10)85.41× / 85.52×85.14×PASS
L4 storage floor (11/10)3.62× / 3.63×3.60×PASS
L6 k=2000 latency floor (factor 3)4.09 / 3.984.341PASS

The finding — RFC 0036 is not exercised by this harness, and the run proves it. Every ourios_bytes_read came back byte-for-byte identical to §9.24 (L1 1,032,727; L2 2,223,173; L4 47,995,205; L6 k=100 1,931,911; L6 k=2000 4,202,473). build_comparative_store writes exactly one ingest file per partition (append_record keeps one Writer per partition), so compact_partition no-ops (it needs ≥ 2 files) and RFC 0036’s compaction-time sort never runs. The comparative store is therefore identical pre/post-0036 — which is why the gates pass unchanged (no regression) and why this harness cannot measure RFC 0036’s window-materialization win. Making it accrue multiple files per partition then compact would re-base RFC 0031’s frozen-gate store; that is deferred as future harness work, not a validated blocker (the RFC0036.2 bytes channel is a §2.2 diagnostic, not a gate).

Two orthogonal noise items (neither an RFC 0036 layout regression): attempt 1 measured L3 cleanly (21.6× storage) but landed the L6 k=100 latency floor at 0.311 < 0.333 (marginal tiny-query noise; attempt 2 passed it at 0.364); attempt 2 hit the known Loki L3 flicker (0/9 rows before timeout — the §9.13 class, #490). One justified retry per the retry-once rule; no third.

9.27 Results — 2026-07-22 (in-repo, deterministic) — RFC0036.2 window-materialization before/after

Purpose. The RFC0036.2 materialization diagnostic that §9.26’s harness cannot produce, measured directly on a genuinely-compacted store. ourios-querier’s rfc0036_2_materialization_before_after (tests/it) runs the identical L6 k=100 window query (1 s at the 10 ms grid, one service) against two stores built from the same synthetic multi-service hour, and reads the materialization bytes — the compressed column chunks of the row groups a service = target ∧ time ∈ window scan cannot prune — from each file’s footer.

storesurvivors / groupsmaterialization bytesquery scanned
before — one unsorted ingest file (128 MiB groups)1 / 1100,520,1551 (the whole file)
after — compacted, §3.1-sorted (32 MiB groups)2 / 670,018,0752

Reading. Identical 100-row answer from both; the sort takes the window from materialising the whole file (the single-row-group unsorted ingest parquet — no row group prunes) to a contiguous minority (2 of 6 groups) — a 1.43× materialization-bytes win. Modest by design, and honestly so: the compacted file is physically ~2× larger (six 32 MiB groups compress a little worse than one 128 MiB group — the RFC 0036 §3.3 pruning- granularity-over-bytes trade), and §2.2’s ~188 KB registry floor is exactly why the gate is the scanned-row-group bound (enforced by rfc0036_2_window_materialization_bound), not a bytes ratio. count- scan stats.bytes_read is not the materialization term — it reads only the small filter columns and points the wrong way; the footer survivor-chunk sum is the RFC §9 metric.

9.28 Results — 2026-07-22 (indicative, local M-series, synthetic-compressible) — RFC 0036 §7 compacted row-group threshold sweep (16 / 32 / 64 MiB)

Purpose. RFC 0036 §7’s first open box defers the authoritative 16/32/64 MiB compacted-threshold sweep to the paid baseline-8vcpu-32gib harness. This is the in-repo, indicative half. §9.27’s before/after used a near-incompressible random payload (chosen to cross 32 MiB with few rows) and so read the compacted file as ~2× larger on disk — a worst-case artifact of incompressible bytes, not what real logs do. Real logs are compressible and have per-service locality, and RFC 0036 sorts by service.name, which clusters similar lines. This sweep re-measures the trade on a compressible, service-clustered synthetic corpus to correct that impression and trace the actual curve. Indicative, not authoritative — local hardware, synthetic corpus; the v8-corpus L6-scanned-bytes-vs-L1/L3 sweep stays deferred to validated (RFC 0036 §7).

Hardware / run. Local M-series developer machine (P-cores; taskpolicy -B to escape the Claude-Code background-QoS E-core throttle), dev build at the sweep head. ourios-querier’s rfc0036_7_compacted_threshold_sweep (tests/it, #[ignore]d like the RFC0005.6 sizing test — not a CI gate; the gate is rfc0036_2_window_materialization_bound). It drives the shipped compaction path with the new explicit-threshold seam (compact_partition_with_flush_threshold).

Corpus. 6 promoted services, each with its own fixed log phrase (distinct vocabulary, so the §3.1 sort clusters like text) plus a handful of small varying fields — request id, user, amount, status/region enums, and 8 hex chars of per-line entropy that hold ZSTD to a realistic ratio. 2,160,000 rows (360,000/service), ~230–260 B bodies, 467,239,238 B (445.6 MiB) of raw body text, written as two interleaved ingest files then compacted. Measured compression: 7.20× (raw body ÷ the 61.85 MiB compacted file) — squarely in the realistic 5–15× band, not the ~2.4× of random ASCII.

thresholdcompacted file (on disk)row groupswindow materialization (survivors / bytes)scanned / prunedwindow rows
16 MiB65,376,242 B (62.35 MiB)51 / 15,370,891 B (14.66 MiB)1 / 43,000
32 MiB64,856,419 B (61.85 MiB)31 / 30,346,974 B (28.94 MiB)1 / 23,000
64 MiB64,856,419 B (61.85 MiB)31 / 30,346,974 B (28.94 MiB)1 / 23,000

Finding 1 — the ~2× on-disk bloat is a random-bytes artifact (the headline). On compressible, service-clustered data the compacted file does not grow as the threshold shrinks. 32 and 64 MiB produce a byte-identical file; 16 MiB is larger by only 519,823 B (+0.80%) — the cost of two extra row groups’ footer/index entries and slightly-shorter compression windows. §9.27’s “compacted file ~2× larger” was the incompressible payload amplifying that per-group overhead against near-zero codec gain; it does not generalise to real logs. The §3.1 service sort, if anything, helps compression by clustering like lines — the finer-threshold file barely moves.

Finding 2 — finer thresholds buy real pruning granularity, nearly free. The same fixed 30 s one-service window materialises 14.66 MiB at 16 MiB vs 28.94 MiB at 32/64 MiB — the finer threshold halves (1.97×) the bytes a window browse must fetch, for a +0.80% file-size cost. The answer is identical (3,000 rows) at every threshold; only the IO changed. This is the pruning-granularity-over-bytes trade RFC 0036 §3.3 exists to make, now measured on realistic data: it is a good trade, and it gets better the finer the threshold, bounded only by footer/index overhead.

Finding 3 — 32 and 64 MiB are near-identical because arrow’s default row-group row cap sets a ~30 MiB granularity floor, finer than either byte threshold. The compacted writer sets no max_row_group_size, so parquet-rs’s default 1,048,576-row cap applies. At this corpus’s ~30 encoded bytes/row that cap fills a group at ~30 MiB, which trips before the 32 MiB byte flush (and well before 64 MiB): so 32 and 64 MiB are both row-capped at ~3 groups of ~30 MiB (byte-identical files), and only 16 MiB (~590 k rows → ~15 MiB, under the cap) is genuinely byte-governed → 5 finer groups.

Correction to an earlier framing. A prior draft of this finding called the row cap a “gap” and suggested raising max_row_group_size “so the byte threshold bites.” That is backwards: raising the cap would let the 32/64 MiB byte flush govern and produce fewer, coarser groups (~2 of ~32 MiB) — the wrong direction for pruning. The row cap is a granularity floor that is currently helping. The lever that unambiguously improves window pruning is a smaller byte threshold (16 MiB halves window materialization here), the §7 authoritative-sweep question — not a larger row cap. Making group sizing byte-uniform (raising the cap) is a separate predictability choice, not a pruning win. Net: finer effective groups win the materialization trade at sub-1% disk cost, and the byte-threshold value — not the row cap — is the lever.

Conclusion. 32 MiB stays a reasonable, defensible shipped default (it already delivers the pruning mechanism the RFC0036.2 gate enforces). The compressible-data trade leans, if anywhere, toward smaller thresholds (16 MiB halves window materialization for +0.80% on disk) — the opposite of the “smaller = bloated file” worry §9.27’s random payload suggested. 16 MiB is flagged as a candidate for the authoritative v8 sweep to evaluate against L1/L3 neutrality (more, smaller row groups add footer/page-index bytes to every scan — the term this in-repo corpus is too small to price against the comparative gates); the default is not changed here — that is a maintainer decision on the authoritative numbers. The interim OURIOS_COMPACTED_RG_BYTES env knob (RFC 0036 §7) lets an operator retune without a rebuild in the meantime.

9.29 Results — 2026-07-22 (indicative, local M-series, real-corpus subset, Ourios-only, no Loki) — RFC 0036 window-materialization before/after on the v8 capture

Purpose. The real-otel-demo-corpus analogue of §9.27’s synthetic 1.43× before/after — the piece §9.26 structurally could not produce (its single-file-per-partition harness no-ops compact_partition). This measures the RFC 0036 window-materialization win on the actual v8 capture (a subset), Ourios-only. Indicative, not authoritative — local hardware, a corpus subset, no Loki, no baseline VM; the authoritative full-v8 + Loki arm stays deferred (RFC0036.2 §5 / §9.26).

What made this measurable — the opt-in compacted harness path. §9.26’s finding was that ourios-bench’s build_comparative_store writes one ingest file per partition, so compaction no-ops and RFC 0036’s sort never runs. This slice adds an opt-in builder, build_comparative_store_compacted[_with_threshold], that round-robins each partition’s rows across two interleaved ingest files, then calls compact_partition on every partition — so the consolidated file is §3.1-clustered by (service.name, time), rotates row groups, and declares sorting_columns. The default build_comparative_store path — the frozen RFC 0031 dispatch’s store — is byte-for-byte untouched (a separate function; the frozen gates are not re-based).

Hardware / run. Local M-series developer machine (P-cores; taskpolicy -B to escape the Claude-Code background-QoS E-core throttle), dev build. ourios-bench’s rfc0036_realcorpus_window_materialization_before_after (tests/rfc0036_realcorpus.rs, #[ignore]d, skips with a clear message when the gitignored capture is absent so CI/other machines never fail). The store is built two ways from the identical subset — before = default single-file build_comparative_store (no compaction), after = build_comparative_store_compacted_with_threshold — then the same L6-shape window query runs against each.

Corpus / query. First 120,000 LogsData batches of otel-demo-v8/logs.jsonl (513,752,573 B of the 2.96 GB capture, ~21 h of wall-time). The measurement picks the busiest compacted partition (most row groups), then the most-prunable real service in it — which resolves to ad, the lowest-volume otel-demo service and the exact §9.13 run #17 case — and the busy hour as the window: service == "ad" | range(2026-07-07T08:00:00Z, 2026-07-07T09:00:00Z). Materialization bytes = the footer survivor-chunk sum (the RFC 0036 §9 metric: compressed column chunks of the row groups a service = ad ∧ time ∈ window scan cannot prune, keyed on the effective_time_unix_nano column the querier prunes on — not the count-scan stats.bytes_read); the live query’s row_groups_scanned cross-checks the footer prediction.

storesurvivors / groupsmaterialization bytesquery scanned / prunedrows
before — single unsorted ingest file1 / 13,806,3061 / 0 (whole file)10,110
after — compacted, §3.1-sorted @ 2 MiB1 / 7731,5211 / 610,110

Reading — a 5.20× real-corpus materialization-bytes win, identical answer. The sort takes the one-service window from materialising the whole hour (the single unsorted ingest row group — nothing prunes) to one of seven clustered row groups (ad lands in a single group; the other six prune on plain footer service.name statistics). Same 10,110-row answer; the live query confirms the footer prediction (before scans 1/1, after scans 1/7). This is larger than §9.27’s synthetic 1.43× — and honestly so: §9.27’s random payload was incompressible and forced the compacted file ~2× larger, whereas real logs compress and the §3.1 service.name sort clusters like lines, so finer clustered groups prune sharply (the §9.28 finding-2 mechanism, now on real data).

The threshold caveat — the then-fixed 32 MiB default did not prune a real hour (§9.28 finding 3 confirmed; fixed by the §9.30 adaptive amendment). Real per-hour v8 volume (~a few MiB compressed) is far below the fixed 32 MiB MAX_COMPACTED_RG_BYTES (the old COMPACTED_ROW_GROUP_FLUSH_BYTES), so at 32 MiB the busy hour compacts to a single row group and nothing prunes — the measurement skips with a “raise subset / lower threshold” message (verified). The win therefore requires a finer compacted threshold (2 MiB here) to rotate the real hour into the several service-clustered groups the pruning mechanism needs. This is not a defect of the layout — it is the same row-cap/volume reality §9.28 finding 3 documented on synthetic data, now reproduced on the real corpus: on realistic per-hour volumes the byte threshold must be finer than 32 MiB to bite. Indicative sensitivity on this subset: 2 MiB → 7 groups, 5.20×; 4 MiB → 2 groups, 1.32×; 32 MiB → 1 group, no prune. The finer the threshold, the sharper the window prune — the §9.28-flagged lever, confirmed on real data.

Disposition (superseded 2026-07-22 — see §9.30). This complements §9.26 (no-regression, but structurally could not show the win) and §9.27 (synthetic 1.43×) with a real-corpus before/after (5.20× at an explicit 2 MiB threshold). Its central finding — that the fixed 32 MiB shipped default is inert on a real per-hour v8 hour (compacts to one group, prunes nothing, the measurement skipped), and the win only appears at a hand-set finer threshold — is exactly what motivated the RFC 0036 §3.3 adaptive-threshold amendment (2026-07-22): the threshold now scales as clamp(input_total / 8, 1 MiB, 32 MiB), so a small real hour floors at 1 MiB and prunes without any operator tuning. §9.30 re-runs this same measurement at the adaptive default and records the win there. The authoritative full-v8 L6-scanned-bytes-vs-Loki arm, and the ceiling/target-K sweep, stay deferred to the paid baseline-8vcpu-32gib harness (RFC 0036 §7 / RFC0036.2).

9.30 Results — 2026-07-22 (indicative, local M-series, real-corpus subset, Ourios-only, no Loki) — RFC 0036 window-materialization at the ADAPTIVE default

Purpose. §9.28/§9.29 established that a fixed compacted row-group threshold is the wrong shape: 32 MiB is inert on a real per-hour v8 hour (a few MiB compressed → one row group → nothing prunes; §9.29 skipped at the 32 MiB default), while the same fixed value fragments large hours. The RFC 0036 §3.3 adaptive amendment (2026-07-22, maintainer-approved) replaces it with adaptive_flush_bytes = clamp(estimated_output_bytes / 8, 1 MiB, 32 MiB) — target ~8 groups per partition, floored at 1 MiB (the lever for small hours) and capped at 32 MiB (the old fixed value, now the ceiling for huge hours). This run is the decisive check: re-run §9.29’s real-corpus before/after at the adaptive default (OURIOS_V8_COMPACTED_RG_BYTES unset) and confirm the win now appears at the shipped default — not only under a hand-set threshold. Indicative (local, subset, Ourios-only, no Loki), same caveats as §9.29.

Hardware / run. Local M-series developer machine (P-cores; taskpolicy -B), dev build. ourios-bench’s rfc0036_realcorpus_window_materialization_before_after (tests/rfc0036_realcorpus.rs, #[ignore]d), threshold env unset so the adaptive default governs. Same corpus and same selection procedure as §9.29 (not a fixed query — the harness derives the target/window from the data): the first 120,000 LogsData batches (513,752,573 B) of otel-demo-v8/logs.jsonl, then it dynamically picks the busiest compacted partition (most row groups), the most-prunable real service in it — which again resolves to ad (lowest-volume otel-demo service, the §9.13 run #17 case) — and that partition’s own time span as the window. Here the selected partition is the 07:00–08:00 hour, so the query is service == "ad" | range(2026-07-07T07:00:00Z, 2026-07-07T08:00:00Z) (a different hour from §9.29’s dynamically-selected 08:00–09:00 — expected, since the partition is re-chosen from the store the adaptive build produced, not hard-coded). Materialization bytes = the footer survivor-chunk sum (RFC 0036 §9 metric, keyed on effective_time_unix_nano); the live query’s row_groups_scanned cross-checks the footer prediction.

storesurvivors / groupsmaterialization bytesquery scanned / prunedrows
before — single unsorted ingest file1 / 13,812,5811 / 0 (whole file)9,984
after — compacted, §3.1-sorted @ adaptive (floored to 1 MiB)1 / 21500,5371 / 209,984

Reading — a 7.61× real-corpus materialization-bytes win at the shipped default, identical answer. With the threshold unset, the adaptive value floors at 1 MiB for this small real hour and rotates it into 21 service-clustered row groups; the ad window lands in a single group and the other 20 prune on plain footer service.name/time statistics. The sort takes the one-service window from materialising the whole hour (3,812,581 B, one unsorted group) to one clustered group (500,537 B) for the identical 9,984-row answer — the live query confirms the footer prediction (before scans 1/1, after scans 1/21). This is the point of the amendment: the win now appears at the shipped default, where the fixed 32 MiB threshold had skipped (§9.29). It is even sharper than §9.29’s explicit-2 MiB 5.20× — the 1 MiB adaptive floor is finer still, so it clusters into more, tighter groups.

Disposition. The adaptive default makes RFC 0036 non-inert on real v8 — the fix §9.28/§9.29 pointed at, now measured. The rfc0036_2_* in-repo gates recompute T = adaptive_flush_bytes(input_total) and track the layout whatever it resolves to. The authoritative full-v8 L6-scanned-bytes-vs-Loki arm and the ceiling/target-K sweep stay deferred to the paid baseline-8vcpu-32gib harness (RFC 0036 §7 / RFC0036.2).

Roadmap to MVP

Living document. Refreshed at phase boundaries (§4) and whenever a merged PR materially changes the current state in §3. Last updated: 2026-07-27 — the typed-promotion arc landed end-to-end and the dashboard decision is made. RFC 0042 (typed numeric promotion, RFC 0022 §7.1 enacted) went spec → green in two days (#647–#653), closing with RFC0042.9 verified on live telemetry: the agent queried its own spend over MCP — the sum(attr.cost_usd) by attr.model query returned 35.28 USD across 10 requests from the typed Float64 column. Getting the capture to flow fixed a latent env bug (#654: per-signal OTLP endpoints are used as-is per spec, so the dogfood env now carries explicit /v1/<signal> paths). RFC 0041 went specifiedgreen the same date: build now, Perses first — the three plugins shipped in the dedicated ourios-perses-plugin repository (PRs #1–#6), and the capstone FinOps dashboard (RFC0041.6, examples/perses/, #661) rendered unmodified against the live dogfood capture. One recorded deferral: RFC0041.5’s latest matrix leg waits for the next server release (the first with typed columns); Grafana remains an ungated follow-up. The unreleased breaking changes on main since v0.5.0: #641 (RFC0002.21 severity) and #645 (Helm otel values) — the next tag is not a patch.

Prior entry — 2026-07-25: the self-observability arc landed and the RFC 0036 arc closed. RFC 0038 (self-tracing), RFC 0039 (inbound trace-context propagation) and RFC 0040 (DataFusion operator instrumentation) are all green: Ourios continues a caller’s trace instead of starting its own, and a finished physical plan is reconstructed post-hoc into a span tree by ourios-df-otel — a crate carrying only datafusion + opentelemetry, kept extractable for upstream. RFC 0037 (GenAI / structured-event logs) is green. v0.5.0 shipped. §3’s ladder now covers RFC 0001 through RFC 0041. RFC 0041 (dashboard datasource plugins) was drafted with §5/§6 deliberately empty — its §7 asked whether the work is worth doing at all. (Resolved in the current entry: yes, Perses first.)

One unreleased breaking change sat on main behind v0.5.0 at this entry: RFC0002.21 (#641), unspecified severity aligned with the OTel SDK. (The current entry tracks the full unreleased-breaking list.)

Prior entry — 2026-07-21: the comparative program closed and the ingest-capacity arc landed; §3’s ladder now covers RFC 0001 through RFC 0036. RFC 0031’s first fully authoritative comparative run (baseline-8vcpu-32gib, benchmarks.md §9.24) passed all 11 frozen gate decisions — L1 97.82× / L3 22.52× storage-primary, L2 38.37× / L4 85.14× processed-primary, both L6 latency floors — and the RFC flipped to validated (accepted is a maintainer flip). The D1 arc shipped alongside: RFC 0034 (specified, enacted) recast D1 as a per-node bar, and RFC 0035 (green) split ingest into an ordered mining phase and a concurrent encode/publish phase to clear it — the §9.23 asserting soak holds an offered 100k lines/s per node (99.92% achieved, p99 153.63 ms) on the baseline hardware. RFC 0036 (specified) opens the next arc: write-side layout (compaction-time service/time sort), the remaining storage lever against hazard #4. (That arc has since closed — see the current entry.)

Prior entry — 2026-07-15: a month of post-MVP shipping work landed since the prior entry below; §3’s RFC ladder now covers RFC 0001 through RFC 0033 and §5’s deferred-capabilities table (eight rows) is rewritten: six have shipped outright (the WAL, the OTLP wire endpoints, the snapshot mechanism, the §6.8 telemetry surface, the query DSL, and the ourios-server binary + Helm chart), multi-tenancy-at-runtime is partially landed (auth + tenant binding shipped via RFC 0026 accepted; rate-limit/ eviction/lifecycle orchestration is still open), and the Perses datasource plugin remains fully deferred. Current work is RFC 0031 (comparative evaluation against Grafana Loki) — a post-MVP thesis-strengthening effort, not a new MVP gate — with the L1/L3/L6 classes frozen and gate-enforcing per its §7 and the last must-win class (L4, frequency aggregation) mid-dispatch. Phases 1–3 in §4 are all complete; that section is historical narrative only from this point forward.

Prior entry — 2026-06-15: RFC 0013 (object storage, S3-compatible) drafted → specifiedred (first shipping-milestone spine; store module skeleton + §5 stubs landed); RFC 0009 (background compaction) flipped to validated (RFC0009.7 D2/D3/B2-post measured on baseline-8vcpu-32gib, §9.7); RFC 0005 (Parquet storage) and RFC 0010 (audit-stream / drift queries) flipped to green (RFC0005.6 row-group sizing landed; RFC 0010’s eight §5 drift scenarios all pass). Earlier, on 2026-06-14, RFC 0001, RFC 0008, and RFC 0011 flipped to accepted (maintainer sign-off). RFC 0001 reached validated first (C1/C2 pass authoritatively on the benchmarks.md §1 baseline hardware, §9.6; A1 is diagnostic per RFC 0011); RFC 0008’s validated is vacuous (no thesis gate); RFC 0011 is a tuning RFC. The §§4+ phase narrative below predates this and is not re-verified here (PR #41 RFC 0005, then PR-D through PR-G landed ourios-parquet end-to-end: schemas, writer, reader, audit stream). The deferred-capabilities table in §5 is unchanged: WAL durability and the OTLP wire endpoints stay post-MVP.

This document answers two questions in one place: what does “MVP” mean for Ourios, and how far are we from it. The artifact is parallel to hazards.md and benchmarks.md: hazards say what we mustn’t break, benchmarks say what success looks like, and this file says how we get from here to there.


1. What “MVP” means here

MVP for Ourios is thesis-proving, not production-ready.

The thesis (CLAUDE.md §2) claims that Parquet + Drain-derived template mining + DataFusion collapses the inverted index, the compression layer, the storage tier, and the query engine into one stack of off-the-shelf parts plus thin glue. That claim is falsifiable. The MVP is the smallest stack that lets us run the thesis-gate benchmarks in benchmarks.md on a real corpus and either confirm the claim or kill it.

Production-shape concerns — gRPC OTLP receiver, WAL durability, snapshot mechanism, Helm chart, the full §6.8 telemetry surface, the RFC 0002 query DSL — are deliberately out of MVP scope (§5). Each is a real shipping concern, but none of them changes the answer to “does the thesis hold.” We defer to keep the critical path as short and honest as possible.


2. The MVP gate: thesis benchmarks

Four gating [THESIS] goals in benchmarks.md define MVP-done. Hitting all four on a representative corpus means the thesis holds; missing any of them means a pillar (CLAUDE.md §2) is wrong and a PR won’t fix it — an RFC will.

GateWhat it measuresWhy it matters
B1Predicate-pushdown query latency on time/template/tenant filtersPillar 1 (footer reads + min/max stats skip row groups) actually skips
B2Template-exact query latency (where template_id = X)Pillar 2’s template_id column is a usable index, not a curiosity
C1Bit-identical reconstruction rate over the corpusThe hardest invariant (CLAUDE.md §3.3) holds in practice, not just in unit tests
C2Template-count convergence (Drain finds a small, stable number of templates)Pillar 2 (template mining) extracts the structure we believed was there

A1 (end-to-end compression vs. zstd-alone) was a fifth gating goal, but RFC 0011 (accepted) demoted it to a recorded diagnostic: it is refuted on every corpus class — including the maximally-templated one — for structural reasons (the more templated a corpus, the more a whole-stream byte codec captures the same redundancy), so template mining’s compression value is logical / query-pruning, captured by B1/B2, not on-disk bytes vs a codec. A1 is still measured and recorded (benchmarks.md §7/§9 — the columnar queryability premium + a codec-regression guard) but does not block MVP-done or any RFC’s validated.

A2, B3, C3, C4, D*, E* in benchmarks.md are relevant but not MVP-blocking — they’re tuning goals, honesty goals, or post-MVP shipping concerns.


3. Current state (as of 2026-07-25)

The thesis is proven on representative corpora. All four gating thesis-gates pass authoritatively on the benchmarks.md §1 baseline hardware (the §9.4 / §9.6 runs), so the MVP thesis-proving bar (§2) is met:

GateResultSource
B1 predicate-pushdownPASS — 34.2× / 25.4× vs zstdcat | grep at ~1 GB, exact row-count agreement§9.4
B2 template-exactPASS — windowed latency flat across 0.57→1.04 GB; flat on HDFS_v1 (11.2 M rows, 1/14 row groups)§9.4
C1 reconstructionPASS1.000000 on HDFS_v1 (11.2 M lines, authoritative)§9.6
C2 template convergencePASS — 40-template plateau, sub-linear, formal gate applies§9.6

A1 (compression vs zstd) fails, but RFC 0011 (accepted) reclassified it a recorded diagnostic, not a gate: the failure is structural and template mining’s value is logical / query-pruning, captured by B1/B2 (see benchmarks.md §2 / §7).

RFC ladder status:

RFCAreaStatus
0001Template mineraccepted
0002Query DSLgreen
0003OTLP receiver (gRPC + HTTP)green
0004Configuration policygreen
0005Parquet storagegreen — all 14 §5 scenarios pass; RFC0005.6 row-group sizing is the #[ignore]d tests/sizing.rs (manual cargo test -p ourios-parquet --ignored, not CI-gated per §7)
0006Bench harnessgreen
0007Querier (DataFusion + logs DSL)validated
0008WALaccepted
0009Background compactionvalidated — §5 RFC0009.1–.6 pass; RFC0009.7 D2/D3/B2-post measured authoritatively on baseline-8vcpu-32gib (§9.7: D3 in 256 MiB–2 GiB band, D2 166.8 MiB/s, B2-post ≈6.1×)
0010Audit-stream / drift queriesgreen — all 8 §5 scenarios pass (crates/ourios-querier/tests/drift.rs); discharges RFC 0001 H5.3; §9 items are accepted-gating; general audit aggregation deferred (§3.2)
0011A1 re-scopeaccepted
0012meta: CLAUDE.md §2 pillar-#2 wordingaccepted
0013Object storage (S3-compatible)green — S3 backend + conditional-PUT publish + consumer migration all landed
0014Ingest write path: record sink and flush policygreen
0015Fuzzing harness: cargo-fuzz + ClusterFuzzLite CIgreen
0016Query-serving endpoint: HTTP query API over the logs DSLgreen
0017Read-time template registry & query-row renderinggreen
0018OTLP log-spec compliance amendmentsgreen
0019Storage-backend selection (local vs S3)accepted
0020Server configuration file (YAML + env-var substitution)green
0021Coordinated DataFusion / Arrow upgradegreen — phase 1; phase 2 gated on upstream (DataFusion 55)
0022Queryable attribute columns (RFC 0005 amendment)green
0023Bounded template memory (RFC 0001 amendment)green
0024OTLP-envelope property testing (RFC 0006 amendment)green
0025Absent-body representation (RFC 0005 amendment)green
0026Authentication and tenant binding (ingest + query)accepted
0027MCP query surface (agent-facing read tools)accepted
0028Build-feedback program (test-harness + workspace decomposition)green
0029OIDC bearer layer (issuer-agnostic, Dex-validated)green
0030TLS/mTLS on the data-plane listenersgreen
0031Comparative evaluation against Grafana Lokiaccepted (2026-07-22) — all four must-win classes measured, §7 gates frozen and CI-enforcing; the first fully authoritative run (benchmarks.md §9.24, baseline-8vcpu-32gib) passed all 11 frozen gate decisions; losses published per §5 (L6 storage is a recorded diagnostic, not a win)
0032Query-schema and cost-model resource for the MCP surfacegreen
0033Cached template-map artifactgreen
0034D1 re-scope: per-node ingest-throughput baraccepted (2026-07-22) — enacted: RFC0034.1–.3 satisfied by the §9.20–§9.23 measurement series (a re-scope RFC with no thesis-gate of its own; specifiedaccepted)
0035Ingest concurrency (ordered mining, concurrent encode/publish)green — §9.22 A/B plus the §9.23 asserting soak; the #578 sweep-publish durability window closed alongside
0036Write-side layout (compaction-time service/time sort)accepted (2026-07-22) — all five §5 green (real compaction); RFC0036.2 scanned-count gate + in-repo before/after (§9.27, 1.43×); baseline no-regression (§9.26) + §7 threshold sweep (§9.28); the comparative harness single-file limit + the row-cap interaction are documented follow-ups
0037GenAI / structured-event log supportgreen
0038Self-tracing (OTel traces for Ourios itself)green — request-scoped spans on ingest, query, MCP and sweep (never per-record); traces configured through the universal OTEL_* env vars, not bespoke config
0039Inbound trace-context propagationgreen — all four §5 arms; the caller’s trace continues across the ingest spawn and into /mcp, and the caller’s sampling decision governs
0040DataFusion operator instrumentationaccepted (2026-07-28) TERMINAL — ourios-df-otel walks a finished ExecutionPlan and backdates one span per operator from BaselineMetrics; build-vs-adopt was spiked both ways before committing (datafusion-tracing drops every operator span on multi-partition plans)
0041Dashboard datasource plugins (Grafana / Perses)accepted (2026-07-28) TERMINAL — three plugins shipped in ourios-perses-plugin (PRs #1–#6); RFC0041.6 FinOps dashboard committed (examples/perses/, #661) and rendered from the live capture; RFC0041.5’s recorded deferral tracks in the plugin repo until the next server release; Grafana an ungated follow-up
0042Typed numeric promotion (RFC 0022 amendment)accepted (2026-07-28) TERMINAL — all nine §5 incl. RFC0042.9 verified on live spend (sum(attr.cost_usd) = 35.28 USD over MCP from the typed column)

Crates — all twelve product crates are implemented (ourios-core, -config, -miner, -wal, -parquet, -ingester, -querier, -server, -bench, -semconv, -telemetry, -df-otel; a thirteenth, -testgen, is dev-only):

  • ourios-miner — the Drain-derived miner, RFC 0001 accepted: (severity, scope) keying, three-zone confidence, widening + type-expansion with audit events, 256 B param-overflow spill, bit-identical reconstruction + the H7.3 render contract, structured-body canonical encoding, and §6.9 snapshot + v2 restore. Zero #[ignore]/todo!() acceptance stubs.
  • ourios-wal — RFC 0008 accepted: append/sync, crash recovery (the real-SIGKILL CI gate), snapshot-restore, segment rotation, group-commit batched fsync, checkpoint-driven truncation; §5 arms .1–.10 green.
  • ourios-parquet — RFC 0005 §3: atomic-publish writer + reader with the §3.9 compat contract, the §3.7 audit-event series, and the §3.6 encoding policy (dict + page index + template_id bloom filter).
  • ourios-ingester — RFC 0003 green: the OTLP gRPC + HTTP receiver with WAL-before-ack, per-ResourceLogs tenant derivation, the windowed group-commit coordinator, and the startup recovery driver; also hosts the RFC 0009 compaction runner and the RFC 0035 two-phase pipeline (ordered mining, concurrent encode/publish).
  • ourios-querier — RFC 0007 validated / RFC 0002 green: the logs DSL over DataFusion with predicate + partition (time-window) pruning, alias resolution, the RFC 0010 drift query, param(n)/bucket(width) aggregation (RFC 0002’s L4 amendment), and the RFC 0032 query-schema + cost-model MCP resource.
  • ourios-bench — RFC 0006 green: drives the A1/B1/B2/C1/C2 measurements over OTLP-Demo + LogHub corpora, records results to benchmarks.md §9, and (RFC 0031) runs the comparative dispatch against a real Loki container.
  • ourios-df-otel — RFC 0040 green: a post-hoc ExecutionPlan → OTel span-tree walk, backdating each operator span from its BaselineMetrics timestamps. Its runtime dependencies are datafusion and opentelemetry alone — no ourios-* crate among them — so it stays extractable as an upstream contribution.
  • ourios-core / -config / -semconv / -telemetry / -server — shared types + tenancy + record/audit shapes; the RFC 0004 miner tunables (split out per RFC 0028 §3.2); the weaver-generated OTel name constants; the OTel export surface — metrics (RFC 0018) and, since RFC 0038/0039, traces; the two-role binary, now with TLS/mTLS (RFC 0030), an OIDC bearer layer (RFC 0029), and the S3-native Helm chart, deploy-validated on kind.

The full cargo test --all-features suite is green in CI — the cargo test job gates every PR on the exact head; the coverage job runs alongside it but is informational (continue-on-error), not gating.

What remains is no longer “reach MVP” — that bar cleared a month before this entry (§3’s gate table is unchanged and still authoritative) and the shipping milestone that followed (WAL, wire endpoints, DSL, auth, S3, Helm — the whole §5 table below except Perses) is substantially done. What’s actually open:

  • The Perses plugins — RFC 0041 is accepted (2026-07-28, terminal): all three plugins shipped in ourios-perses-plugin, the RFC0041.6 dashboard is committed in this repo and verified rendering. What remains is RFC0041.5’s recorded deferral (the latest e2e leg + wire-level sum, unblocked by the next server release, tracked in the plugin repo) and the Grafana datasource as an ungated follow-up.
  • RFC 0040 → accepted — done (2026-07-28), alongside RFC 0041 and RFC 0042: all three flipped accepted on maintainer sign-off.
  • Scattered §7/§9 open items on already-green/validated RFCs (e.g. the recurring D1/D2 soak cadence now that the harness has shipped (§9.19/§9.23), RFC 0021’s phase 2 gated on upstream DataFusion 55, RFC 0028’s musl cargo-dist re-add, RFC 0031’s deferred F_L7) — none block anything downstream.

4. Path to MVP — three phases

Phase scope only; per-PR breakdown lives in the planning that opens each phase, not in this doc, so the file stays stable as mid-stream design decisions land.

Phase 1 — Finish the miner

Goal: the miner mines, audits, retains bodies, reconstructs. By the end of this phase the miner self-contained covers RFC 0001 §6.2 / §6.3 / §6.4 / §6.5 / §6.6 end-to-end and most §5 scenarios are green.

Capabilities to land:

  • Drain tree (root → length-N nodes → prefix nodes → leaves) with descend.
  • Best-candidate selection in MinerCluster::ingest via sim_seq (replaces the exact-match HashMap placeholder).
  • widen step + template_widened audit emission + type-expansion + template_type_expanded audit + degenerate- template guard.
  • Three-zone confidence branching (clean / lossy / parse-failure)
    • body retention in the lossy zone.
  • Separators preservation through the ingest pipeline + reconstruct() + lossy_flag semantics per §6.6.
  • Per-parameter byte-limit check + OVERFLOW marker + forced body retention.
  • MinerCluster::ingest consumes a structured OtlpLogRecord (per RFC 0001 §6.1 as amended), not a raw &str. The body_kind = String / body_kind = Structured fork lands with the §6.2 algorithm rewrite (a follow-on PR to the §6.1 amendment). Severity, scope, and the OTLP-canonical JSON encoding for structured bodies all flow through the miner from this phase forward.

Unblocks: thesis gates C1 (reconstruction) and C2 (template-count convergence). RFC 0001 §5 scenarios H1.*, H2.*, H5.*, H7.*, §3.3.1, RFC0001.* should mostly flip in this phase.

Phase 2 — Records to Parquet

Goal: mined records become Parquet files. By the end of this phase a corpus run produces on-disk Parquet that any DataFusion-aware reader can open.

Capabilities to land:

  • New crate ourios-parquet.
  • Record schema matching the amended RFC 0001 §6.1: identity + partitioning columns, the OTLP-derived columns (time_unix_nano, severity_number + severity_text, scope_name + scope_version, attributes, resource_attributes, trace_id + span_id + flags, event_name, dropped_attributes_count), and the body / miner-derived columns (body_kind, body?, params, separators, confidence, lossy_flag).
  • Writer: record batch → Parquet file (with row-group sizing from hazards.md H4 — target 128 MB–1 GB row groups).
  • Reader: Parquet file → record batch (for verification + the Phase 3 DataFusion path).
  • Audit-event Parquet stream (the contract called out in RFC 0001 §9 “Cross-RFC contracts pending”).

Unblocks: thesis gate A1 (compression ratio). The Parquet column codec earns its share of the 50–200× headline only once records actually land on disk in this format.

Out of MVP scope, parked here: background compaction (small-file problem, hazards.md H4) — corpus runs are bounded, a single Parquet file per phase is acceptable; production compaction is a post-MVP PR.

Phase 3 — DataFusion + bench

Goal: the thesis-gate benchmarks run.

Capabilities to land:

  • New crate ourios-querier — register the Phase 2 Parquet files with DataFusion and accept raw SQL. No DSL — RFC 0002’s surface is a post-MVP concern; the bench can use SQL directly.
  • New crate ourios-bench — corpus runner that reads pre-recorded OTLP LogsData test data into a stream of OtlpLogRecords, hands them to the miner, writes Parquet, runs the A1/B1/B2/C1/C2 measurements, and reports numbers that go into benchmarks.md §9 (Status). No network receiver in MVP — the bench reads OTLP from disk, not from a gRPC/HTTP listener (those stay post-MVP per §5).
  • testdata/corpus/ — anonymised real-log corpus committed to the repo (or a download script if size demands), serialised as OTLP LogsData (canonical JSON or protobuf) so the bench exercises the same record shape an OTel deployment would produce.

Unblocks: thesis gates B1 (predicate-pushdown latency) and B2 (template-exact latency). At the end of this phase, benchmarks.md §7 (the thesis-gate summary) has measured numbers for every [THESIS] row, and either the thesis holds or it doesn’t.


5. Deliberately out of MVP

Each item is a real production concern. The reason it was deferred for MVP is “answering ‘does the thesis hold?’ doesn’t require it,” not “we don’t think it matters.” As of this entry, seven of the eight original rows have shipped outright — the Perses row’s plugin half landed with RFC 0041 green (CRDs/operator stay ungated) — and one (multi-tenancy at runtime) is partially landed, as part of the post-MVP shipping milestone (§3); the table below records what shipped and what’s still genuinely open.

CapabilityWhy deferred for MVPStatus
Write-ahead log (ourios-wal)Corpus replay is bounded and reproducible; durability is irrelevant for thesis-provingLanded — RFC 0008 accepted: append/sync, real-SIGKILL crash recovery, snapshot-restore, group-commit batched fsync
OTLP wire endpoints (gRPC + HTTP listeners)Bench reads OTLP from disk, not the network — see Phase 3Landed — RFC 0003 green: gRPC + HTTP receivers, WAL-before-ack, per-ResourceLogs tenant derivation
Snapshot mechanism (RFC 0001 §6.9)Corpus runs from cold start; replay budget mootLanded — part of RFC 0008 (accepted), v2 restore format
Full §6.8 telemetry surfaceOne or two metrics suffice for the bench; the §3.1.2 mandatory set is a production observability concernLanded — OTel meters + OTLP metric exporter (RFC 0018 green); Ourios’s own logs ship via its own OTLP exporter (dogfooded: one deployment ingests another’s telemetry). Traces landed too (2026-07-24/25): RFC 0038 green gives request-scoped spans on ingest/query//mcp/sweep, RFC 0039 green continues an inbound caller’s trace rather than starting a new one, and RFC 0040 green adds a DataFusion operator span tree under a query. All three signals are now configured through the standard OTEL_* env vars
Query DSL (RFC 0002)Raw SQL through DataFusion serves the bench; DSL is operator UXLanded — RFC 0002 green, including the param(n)/bucket(width) aggregation amendment
Multi-tenancy at runtime (rate limits, eviction, lifecycle)Bench uses one tenant; the type is in place but no orchestration around itPartially landed — authentication + enforced tenant binding shipped (RFC 0026 accepted); rate-limit/eviction/lifecycle orchestration is still open, tied to an operator-console RFC that hasn’t been drafted (RFC 0001 §9)
ourios-server binary + Helm chartBench is a binary in ourios-bench; full deployment shape is shipping concernLanded — two-role binary with TLS/mTLS (RFC 0030) + OIDC (RFC 0029); S3-native Helm chart shipped and deploy-validated on kind
Perses dashboard integration (datasource plugin + possible CRDs)The data plane has to work first — a Perses plugin queries a query interface that doesn’t exist yet. A native datasource plugin is small and downstream-friendly once the query API is stable; CRDs / operator (PersesDashboard-style declarative pipeline + miner config) would extend Ourios into managed-service territory, which contradicts CLAUDE.md §1’s “Not a managed service” lineLanded (the plugin half) — RFC 0041 green (2026-07-27): three plugins (Datasource, LogQuery, TimeSeriesQuery) shipped in the dedicated ourios-perses-plugin repository, and the committed FinOps dashboard (RFC0041.6, examples/perses/) rendered unmodified against the live dogfood capture. Both hosts were spiked and measured first; RFC0041.5 carries a recorded deferral until the next server release; the Grafana datasource is an ungated follow-up. CRDs/operator still requires a meta: RFC against CLAUDE.md §1 first, no commitment to land

Note on OTLP scope (historical). The pre-amendment roadmap listed “OTLP receiver (gRPC + HTTP)” as a single post-MVP item. PR #20 + #21 split that scope: the OTLP record shape (OtlpLogRecord consumption, the canonical JSON encoding, the OTLP-aligned Parquet schema) was in MVP from the start — it’s a prerequisite for thesis-gate C2’s validity, because the template-count convergence the corpus measures has to be over records that look like real OTel traffic, not over flat-text caricatures of it. Only the wire endpoints — the actual gRPC/HTTP listeners that decode OTLP off the network — were deferred past MVP, and that’s the row in the table above; RFC 0003 (green) has since landed them, so nothing in this note is still-open scope.


6. Update cadence

This file refreshes:

  • After every merged PR that materially changes §3 (current state) — the merging PR’s author (or their drafting assistant) updates the table and the §5 scenario count.
  • At phase boundaries (§4) — when Phase 1 finishes, §3’s current state and §4’s “blockers” tables are reconciled, and the next-phase opening planning PR is summarised here.
  • When a thesis-gate result lands in benchmarks.md §9 — this doc gets a one-line note in §3 acknowledging the result.

The doc is intentionally not refreshed on every spec edit — RFC patches and hazards.md edits don’t change the road map unless they change what MVP requires. If you find yourself updating §3 every PR, the doc has become an activity log; the fix is to be more selective, not to stop updating.

RFCs

Referenced from CLAUDE.md §5.1. This document is the minimum viable RFC process for Ourios. It will grow as the project does.

When an RFC is required

Per CLAUDE.md §5.1, an RFC precedes implementation for any change that touches:

  • An architectural pillar (CLAUDE.md §2).
  • An invariant (CLAUDE.md §3).
  • A hazard (CLAUDE.md §4 / docs/hazards.md).
  • The on-disk Parquet schema (CLAUDE.md §3.5).
  • A new crate (CLAUDE.md §7).

Bug fixes, dependency bumps, and internal refactors do not need RFCs. When in doubt, assume RFC.

File layout

  • Filename: NNNN-short-kebab-title.md, e.g. 0001-template-miner.md.
  • Numbers are assigned in merge order. Draft PRs may use the next free number provisionally; if two drafts collide, the later-merged one renumbers.
  • One file per RFC. Supersessions are recorded in the frontmatter of both the old and new RFC.

Required frontmatter

---
rfc: NNNN
title: Short descriptive title
status: drafted | specified | red | green | validated | accepted | rejected | superseded
author: Name <email>
drafting-assistance: Claude   # omit if no LLM drafted
created: YYYY-MM-DD
supersedes: —                 # or RFC NNNN
superseded-by: —              # or RFC NNNN
---

The maturity stages (drafted through validated) are gates an RFC moves through before it becomes binding; accepted is the terminal post-maintainer-signoff state; rejected and superseded are the off-ramps. See docs/verification.md §3.

Required sections

Every RFC has at least:

  1. Summary — 3–5 sentences. The commitment, not the rationale.
  2. Motivation — why this change now, and why at this layer.
  3. Proposed design — precise enough that two engineers would produce the same implementation.
  4. Alternatives considered — one paragraph each. “I have not heard of it” is not acceptable.
  5. Acceptance criteria — normative scenarios, one per invariant or hazard the RFC touches. Format: structured prose with Given / When / Then / And leading clauses; each scenario carries an id of the form H1.1, §3.4.2, or RFC<NNNN>.<m>, referenced from the test code so the mapping is greppable. See docs/verification.md §2.
  6. Testing strategy — mapped to CLAUDE.md §6.2; references the §5 scenario ids and names the technique (proptest, corpus, criterion) for each.
  7. Open questions — everything unresolved, as a checklist.
  8. References — paper citations, related RFCs, CLAUDE.md sections constrained.

Additional sections are welcome when they clarify. Do not pad for the sake of the template.

Lifecycle

The five-stage maturity model. An RFC moves through these stages before becoming binding; the status: frontmatter field tracks the current stage so reviewers and tooling see it without reading the body.

  1. Drafted — PR opened with status drafted. Sections §§1–4 and §§7–8 are filled. Discussion happens in PR review.
  2. Specified — §5 acceptance criteria are written, every invariant and hazard the RFC touches has at least one scenario, and review has confirmed the criteria are testable in principle.
  3. Red — test stubs exist and fail. Implementation may begin.
  4. Green — all acceptance criteria pass; unit + property + corpus tests green.
  5. Validated — thesis-gates in docs/benchmarks.md §7 pass on representative corpora. Maintainer flips status to accepted.

A regression detected after Validated either reopens the RFC (if a criterion is invalidated) or spawns a tuning RFC per benchmarks.md §7 (if a thesis-gate degrades). See docs/verification.md §3.

Two terminals reachable from any stage:

  • Superseded — a later RFC replaces part or all of this one. Both frontmatters are updated. The superseded RFC is not deleted.
  • Rejected — closed PR or status flipped to rejected. The file is kept for the record.

Diagrams

When an RFC needs a diagram (state machine, sequence flow, schema relationship, decision tree), it is authored in Mermaid, embedded as a fenced ```mermaid block in the markdown. Mermaid is chosen for the same reasons we chose markdown over a binary doc format: text-based source is reviewable in PR diffs, version-controllable, and lets the RFC itself remain a single self-contained file.

Lectures (docs/talks/) use a different convention: hand-drawn SVGs (Excalidraw export, or hand-authored to match) committed under docs/talks/img/. Lectures benefit from a “manuscript / blackboard” aesthetic that Mermaid does not provide; RFCs benefit from the diff-ability that Excalidraw does not provide. Do not mix the two conventions.

The mdBook build has the mdbook-mermaid preprocessor enabled (book.toml), with the Mermaid runtime vendored at the repo root (mermaid.min.js, mermaid-init.js) so the rendered book is self-contained. The CI book job and the Pages workflow install the mdbook-mermaid binary before building. To work on diagrams locally, cargo install mdbook-mermaid --locked (the preprocessor binary) — the vendored runtime is already committed.

Relationship to architecture docs

An accepted RFC is a contract for how something will be built. Once the subsystem is stable, the RFC graduates to docs/architecture/<subsystem>.md — a living document describing the system as it actually is. The RFC stays in place as the historical decision record; the architecture doc is what a new contributor reads first.

RFC 0001 — Template miner


rfc: 0001 title: Template miner (Drain-derived online log parsing) status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-04-24 supersedes: — superseded-by: —

RFC 0001 — Template miner

Status note. accepted (2026-06-14, maintainer sign-off — the terminal ladder status per docs/rfcs/README.md). Reached validated the same day on the evidence below; accepted records the maintainer’s final sign-off on the template-mining pillar. The docs/verification.md §3 / docs/rfcs/README.md ladder reserves validated for every thesis-gate the RFC’s pillars touch passing on representative corpora (benchmarks.md §7). The template-mining pillar’s gates are C1 (reconstruction fidelity) and C2 (template-count convergence) — both pass on the representative LogHub HDFS_v1 corpus (≈ 1.47 GiB, 11.2 M lines — above benchmarks.md §8’s ≥ 1 GiB canonical floor, so representative; and well past C2’s ≥ 1 M-line formal-gate threshold, so that gate applies rather than abstains), authoritatively on the benchmarks.md §1 baseline hardware: C1 1.000000, C2 a 40-template plateau (diagnostic local run benchmarks.md §9.5, authoritative baseline-8vcpu-32gib rerun §9.6 — identical verdicts, as expected of deterministic gates). A1 (compression vs zstd) is a diagnostic, not a gate (RFC 0011, already encoded in the §7 gate table): it fails on every corpus class including the maximally-templated one, for structural reasons — template mining’s compression is logical/query-pruning (B1/B2), not on-disk bytes.

Reached green first (2026-06-13): all §5 acceptance criteria pass with live tests (zero #[ignore]/todo!() stubs) — miner-internal (tokenize/mask/sim-seq, the three-zone confidence model, fresh-leaf + widening + type-expansion with audit events, the (severity_number, scope_name) template key, the 256 B param-overflow spill + telemetry, bit-identical reconstruction + the H7.3 render contract, structured-body canonical encoding, the §6.9 snapshot + v2 restore) plus the relocated cross-crate criteria (query semantics RFC0001.5/.6, time-preserved RFC0001.10, §3.7.3 per-ResourceLogs tenant derivation, drift H5.3 via RFC 0010, the §6.7 alias index via RFC 0005 §3.7).

Terminal step: accepted is the maintainer’s final sign-off (docs/rfcs/README.md). NB the A1 re-scope’s own RFC (RFC 0011) is still drafted; the §7 gate table already reflects the demotion, but RFC 0011 should be accepted to fully ratify that chain.

How to read this document. §§1–4 are the design contract — the what and the why. §5 lists the normative Given / When / Then scenarios — the contract — grouped by parent (hazard, invariant, RFC-internal). §6 is the precise specification the ourios-miner crate is implemented against; its opening paragraphs name the gaps between the published algorithm and a production miner that §6.1–§6.9 then close. §7 records the alternatives we evaluated and rejected. §8 maps each §5 scenario to the technique that tests it.

Cross-references to CLAUDE.md sections are in square brackets, e.g. [§3.1], and name the invariant the section must preserve.

1. Summary

Ourios implements a Drain-derived online template miner (ourios-miner) that converts each ingested OTLP LogRecord into a structured Parquet record. The record shape is the OTLP LogRecord (with its inherited Resource and InstrumentationScope context) plus the miner-derived columns (template_id, template_version, params, separators, body_kind, body?, confidence, lossy_flag); see §6.1 for the full schema and the 2026-05-13 amendment that aligned it to OTLP. The miner is per-tenant by construction [§3.7], uses a three-zone confidence model that retains the original line in the lossy zone [§3.1], audits every template widening [§3.1], captures inter-token separators in a parallel array so that bit-identical reconstruction is the default rather than a property-test exception [§3.3], bounds parameter values at 256 B with overflow to a side body column [§3.2], and tracks template structural changes via a monotonic template_version so that schema drift across deploys is a first-class query rather than a silent count drop [§3.5]. The 50–200× figure is a logical reduction (lines → (template_id, params)), realised as query pruning (gates B1/B2), not on-disk bytes versus a byte codec — see RFC 0011, which demoted the compression-vs-zstd ratio (A1) to a diagnostic.

2. Motivation

This is the load-bearing pillar of the project [§2.2]. Three sub-questions justify it.

Why template mining at all. A typical service emits 10²–10⁴ distinct printf templates over its entire lifetime, but raw log volume is dominated by the parameters substituted into those templates. Storing the template once per tenant and the parameters per occurrence makes that redundancy explicit — and explicit redundancy stacks with byte-level codecs rather than fighting them. zstd over flat log text recovers ~10× on typical workloads; doing the structural work first leaves zstd a column of short, repetitive parameters that dictionary-encode well, where the codec then earns its keep again. The 50–200× headline (README.md, [§2.2]) is the product of these two layers, not a claim about either alone.

Why online vs. offline. Operators expect logs to be queryable within seconds of ingest, not minutes. Any batch clustering window long enough to do offline hierarchical clustering well is a window the operator is blind in. Drain’s fixed-depth tree gives O(d) lookup per line at the cost of being slightly less accurate than the best offline parsers — an acceptable trade because §3.1’s audit and confidence machinery surfaces the inaccuracy rather than hiding it.

Why this layer. The compression is structural, not statistical. Doing it before Parquet’s byte codecs means each Parquet column sees small, dictionary-friendly values; doing it after means we have already paid for storing the redundancy and zstd has to find it again from the bytes. The order matters.

3. Background: Drain as published

A restatement of He et al., ICWS 2017, in the notation this RFC uses downstream. Citations are by paper section/figure.

3.1 Tree structure (paper §3.2, Fig. 2)

A fixed-depth parse tree, depth d (default 4 in the paper). Three node kinds, in order from root:

  • Root. Single node; routes by token count.
  • Length-N node. One per observed token count N. Children are prefix nodes keyed by the first token of the line.
  • Token-prefix nodes at depths 2..=d. Each is keyed by the token at position (depth - 1) of the line.
  • Leaf log groups at depth d + 1. Each leaf holds a template — a sequence of N tokens, where each position is either a fixed string or the wildcard <*>.
                   root
                    │
        ┌───────────┼───────────┐
       len=4       len=5       len=6      ← length groups
        │           │           │
    ┌───┴───┐    ┌──┴──┐     ┌──┴──┐      ← prefix nodes (depth 1)
   "user"   …  "GET"  …    "INFO"  …
    │           │           │
   ┌┴┐         ┌┴┐         ┌┴┐
   …  …        …  …        …  …          ← prefix nodes (depth 2)
   │           │           │
 [leaf]      [leaf]      [leaf]           ← log groups

3.2 Similarity function (paper §3.3)

For a candidate line L = (t_1, …, t_N) and a leaf template T = (τ_1, …, τ_N):

simSeq(L, T) = (count of positions i where t_i == τ_i or τ_i is <*>) / N

Wildcards in the template count as matches. The line length and the template length are equal by construction (the length-N node selected the leaf candidates).

3.3 Threshold st (paper §3.4)

A configured value st ∈ (0, 1]. After computing simSeq against every leaf at the current parent, the leaf with the highest simSeq is the candidate. If simSeq(L, T_best) ≥ st, the line attaches to T_best; otherwise a new leaf is created. The paper reports st = 0.4 as a default; see §6.3 for why Ourios overrides this.

3.4 New-log-group creation vs. leaf update (paper §3.5)

If simSeq(L, T_best) < st, a new leaf is created at the parent prefix node, with L as its initial template (no wildcards yet). Otherwise T_best is updated: at every position where t_i ≠ τ_i, the template position is replaced with <*>. The template never becomes more specific over time, only more general; positions can become wildcards but cannot become fixed again.

3.5 Worked example

A fabricated illustration (no testdata/corpus/ exists yet; this example will be replaced with one drawn from the corpus once it lands).

Line A:  user 42 logged in from 10.0.0.1
Line B:  user 17 logged in from 10.0.0.2
Line C:  user 99 logged out from 10.0.0.7

After preprocessing (§4.2), numbers and IPs are masked, so the miner sees:

Line A:  user <NUM> logged in from <IP>
Line B:  user <NUM> logged in from <IP>
Line C:  user <NUM> logged out from <IP>

All three are length 6. They route to root → len=6 → "user".

A walks the prefix path further (depth 2: token at position 1 is the masked <NUM> placeholder, treated as a fixed token at this stage). It is the first line, so a leaf is created with template user <NUM> logged in from <IP>.

B walks the same path. The candidate leaf has simSeq(B, T_A) = 6/6 = 1.0 ≥ st. B attaches; the template is unchanged.

C walks the same path. The candidate leaf has simSeq(C, T_A) = 5/6 ≈ 0.833. With st = 0.7 (Ourios default, §6.3), 0.833 ≥ 0.7, so C attaches. Token position 4 (in vs out, 1-indexed against the masked sequence) becomes <*>. The template widens to user <NUM> logged <*> from <IP>. This is a template widening event and must emit an audit record per §6.4.

4. Background: Drain3 extensions (not in the paper)

Drain3 (logpai/Drain3) is the maintained Python implementation. It adds several capabilities beyond the 2017 paper. Each is recorded here as adopt, adopt with modification, or reject, with one sentence of rationale.

4.1 Persistent state — adopt with modification

Drain3 supports JSON snapshots to file, Redis, or Kafka. Ourios adopts the snapshot concept but commits to a file/object-storage backend; Redis and Kafka are out of scope (CLAUDE.md §3.6 names object storage as the source of truth). Snapshot target, cadence, and scope are open questions in §9.

4.2 Pre-tree-walk masking — adopt with modification

Drain3’s most important extension: regex-based masking of common parameter shapes (IPs, UUIDs, numbers, hex, timestamps, file paths) before the tree walk, so high-cardinality tokens never become tree branches. Without this, the tree explodes into one branch per IP address.

The Ourios modification: a masked token is not discarded. It becomes a typed parameter attached to the wildcard slot it created. The masking layer emits (type_tag, original_bytes) pairs; the tree walk treats the type tag as the token (so <NUM> matches <NUM> for tree-routing purposes) while the original_bytes flow into params so reconstruction can recover the line exactly. Paper-pure Drain loses the original token; Ourios retains it as a parameter. This is what makes [§3.3] reconstruction possible at all.

4.3 Variable-length wildcards — adopt with constraint

Drain3’s MaskingInstruction allows a single regex to match a variable-length run of tokens (e.g. a multi-token user-agent string). Ourios adopts this where the run is bounded at parse time and produces exactly one typed parameter in the output. Reject: unbounded variable-length wildcards, because they break leaf identity (two lines with the same template structure but different run lengths would land in different length-N nodes and never deduplicate).

4.4 Dynamic / adaptive threshold — reject

Drain3 supports auto-tuning the similarity threshold per leaf based on observed cluster sizes. Ourios rejects this. CLAUDE.md §3.1 fixes threshold ≥ 0.7 as a project-level invariant; auto-tuning would silently move the merge boundary across deploys, defeating the audit contract. Threshold tuning is a config decision per tenant, never a runtime decision per leaf.

4.5 Other Drain3 features

  • Parameter-naming hints. Drain3 lets users name <*> slots via the masking config (e.g. <IP:client_addr>). adopt — the type-tag mechanism in §4.2 already requires a slot name; using the Drain3 hint format keeps configs portable.
  • Built-in metrics surface. Drain3 exposes a set of state counters via callback. replace — Ourios exposes OTel metrics directly per §6.8 (instrumented via the meter API), with names that match [§3.1]’s required set rather than Drain3’s internal names.
  • Parameter masking after the fact. Drain3 has utilities to retroactively mask params in already-clustered lines. reject — Ourios masks once, at ingest, deterministically. Retroactive masking would invalidate already-written Parquet files.

5. Acceptance criteria

Per docs/verification.md §§2–3, every CLAUDE.md §3 invariant and every docs/hazards.md hazard this RFC touches has at least one numbered scenario below. Scenarios use the bold-leading-clause format (verification.md §2.1) and the id grammars (§2.2): H<n>.<m> for hazard-rooted, §3.<n>.<m> for invariant-rooted, RFC0001.<m> for design-internal commitments. Test code carries each id in a doc comment per §2.3 so grep -R "H1.1" . resolves bidirectionally between RFC and tests.

The hazards in scope are H1, H2, H5, H7; the invariants are §3.1, §3.2, §3.3, §3.5, §3.7. H3 (WAL durability) and H4 (small files) are owned by the ourios-wal and ourios-parquet RFCs; H6 (DSL) is owned by RFC 0002; §3.4 (WAL-before-ack) and §3.6 (object-storage-as-truth) are touched only via §6.9’s persistence direction and the primary obligation lives in those other RFCs.

5.1 Hazards

Scenario H1.1 — Semantically distinct templates do not silently merge

  • Given a corpus containing user logged in <*> and user logged out <*>
  • When similarity threshold is 0.7 (the default)
  • Then the two remain distinct template_ids
  • And any widening produces an audit event recording both old and new templates

Scenario H1.2 — Lossy-zone match retains body

  • Given a line whose best match has confidence in the lossy zone (floor ≤ x < threshold)
  • When the line is ingested
  • Then the body column contains the original line bytes
  • And the row carries lossy_flag = false (the flag is reserved for tokenizer / preprocessing failure per §6.6 — the lossy zone retains the body but reconstruction still succeeds)

Scenario H1.3 — Every widening emits an audit event

  • Given any sequence of inputs that triggers a template widening
  • When the widening completes
  • Then an audit event exists naming the old template, the new template, the tenant id, the timestamp, and the event_type

Scenario H1.4 — severity_number is part of the template key (no INFO/ERROR silent merge)

  • Given two OtlpLogRecords with identical body_kind = String bodies and identical scope_name, but severity_number = 9 (INFO) and severity_number = 17 (ERROR)
  • When both are ingested via MinerCluster::ingest
  • Then the emitted records carry distinct template_ids
  • And no widening or merge ever produces a single template_id covering both severity buckets
  • (Operationalises the §6.1 Template-key composition commitment that severity_number is part of the key regardless of body_kind.)

Scenario H1.5 — scope_name is part of the template key (no cross-scope silent merge)

  • Given two OtlpLogRecords with identical body_kind = String bodies and identical severity_number, but scope_name = Some("lib.auth") and scope_name = Some("lib.payments")
  • When both are ingested
  • Then the emitted records carry distinct template_ids
  • And no widening or merge ever produces a single template_id covering both scopes
  • And a third record with scope_name = None shares a template_id with neither (it lives in the (severity, None) bucket per §6.1)

Scenario H2.1 — Oversized parameter triggers OVERFLOW marker and forced body retention

  • Given a tenant configured with the default 256 B per-parameter byte limit
  • And a log line whose masked parameter value exceeds 256 B (e.g. an embedded stack trace)
  • When the line is ingested
  • Then the corresponding Param entry has type_tag = OVERFLOW carrying (length, sha256_prefix) instead of the original value
  • And the body column contains the original line bytes regardless of lossy_flag
  • And ourios.miner.params.overflow (attributes ourios.tenant, ourios.service) increments

Scenario H2.2 — Per-service overflow rate above 1% raises an alert

  • Given the ourios.miner.params.overflow.utilization gauge (attributes ourios.tenant, ourios.service) for some service
  • When the rolling rate exceeds 0.01
  • Then the documented alert rule fires (the rule ships alongside §6.5’s metric definition)

Scenario H5.1 — Wildcard widening increments template_version and emits template_widened

  • Given a leaf at (template_id = X, template_version = V)
  • When an attach widens a previously-fixed token at position i into <*>
  • Then the leaf’s template_version becomes V + 1
  • And an audit event with event_type = template_widened is emitted naming the new wildcard position(s)

Scenario H5.2 — Type expansion increments template_version and emits template_type_expanded

  • Given a leaf whose wildcard slot s has slot_types[s] = {NUM}
  • When an attach maps a typed parameter of type = STR into slot s
  • Then slot_types[s] becomes {NUM, STR}
  • And template_version increments
  • And an audit event with event_type = template_type_expanded is emitted naming the slot and the newly-added ParamType

Scenario H5.3 — Drift query returns templates that gained a version in window

  • Given the template_audit event stream contains template_widened and template_type_expanded events for templates A and B in the window [t1, t2]
  • When the §6.7 drift query runs against [t1, t2]
  • Then the result includes both A and B with their widening counts

Scenario H7.1 — Reconstruction property holds across the corpus

  • Given the committed testdata/corpus/ (anonymised, fixed)
  • When every line is ingested through the miner
  • Then for every emitted record r where r.lossy_flag = false, reconstruct(r) == r.ingested_bytes holds byte-for-byte
  • And property failure is a build break, not a regression

Scenario H7.2 — Tokenizer failure sets lossy_flag = true and retains body

  • Given a line containing an embedded NUL byte (or another tokenizer-failure mode listed in §6.6)
  • When the line is ingested
  • Then a parse-failure record is emitted
  • And the record’s lossy_flag is true
  • And the record’s body column contains the original line bytes

Scenario H7.3 — Reader emits body verbatim when lossy_flag is true

  • Given a record with lossy_flag = true
  • When the reader renders the row
  • Then the rendered bytes are the body column verbatim (byte-for-byte, no prefix or in-band marker)
  • And the rendered row carries the §6.6 reconstruction signal Reconstruction::RetainedVerbatim (the out-of-band warning marker the §6.6 Reader render contract defines)
  • And reconstruct() is NOT called for that row

Scenario H7.4 — Widened literal slot reconstructs via STR fallback

  • Given a leaf whose template gains a new <*> slot at position i via the §6.2 widening of an originally-literal token
  • When the triggering line is attached
  • Then the line’s record carries params[slot_for_i] = { type_tag: STR, value: L_tok[i] }
  • And reconstruct(record) == ingested_bytes holds

5.2 Invariants

Scenario §3.1.1 — Default similarity threshold is 0.7

  • Given a tenant configuration with no threshold override
  • When the miner is initialised for that tenant
  • Then the effective threshold is 0.7

Scenario §3.1.2 — Mandatory metric set is exposed

  • Given the mandatory set defined by §6.8’s table — the ourios.miner.* metric instruments in the semconv registry (semconv/registry/, surfaced as generated ourios_semconv constants) that the miner registers on the ourios.miner meter when it is constructed (the ourios.miner.alias.* counters are registered separately by the alias map, §6.7, and are out of this scenario’s scope)
  • When a small representative workload exercises every instrument (a normal line, a near-duplicate that widens a template, an oversized-param line, and a parse-failure line) and the meter is collected via an SDK in-memory reader
  • Then the collected metric stream contains every metric named in §6.8’s table — each appearing on its first real measurement, carrying the registry’s required attributes (no synthetic zero-traffic points) — (ourios.miner.template.count, ourios.miner.merges, ourios.miner.confidence, ourios.miner.confidence.p50, ourios.miner.confidence.p01, ourios.miner.body_retention.utilization, ourios.miner.parse_failures, ourios.miner.params.overflow, ourios.miner.params.overflow.utilization, ourios.miner.template.version_changes, ourios.miner.duration) with the instrument kinds and attributes listed there

Scenario §3.2.1 — Default per-parameter byte limit is 256

  • Given a tenant configuration with no per-parameter byte limit override
  • When the miner is initialised for that tenant
  • Then the effective limit is 256 bytes

Scenario §3.2.2 — Configured limit above 1 KiB is rejected at startup

  • Given a tenant configuration with param_byte_limit > 1024
  • When the miner is initialised
  • Then initialisation fails with an error citing the §3.2 ceiling
  • And the process refuses to start serving that tenant

Scenario §3.3.1 — Separators array captured on every successful tokenization

  • Given a line that tokenizes successfully
  • When the line is ingested
  • Then the emitted record’s separators.len() == tokens.len() + 1
  • And the per-row precondition for H7.1 holds (the reconstruction proptest then asserts byte equality)

Scenario §3.5.1 — Snapshot format carries a leading version byte

  • Given a serialised snapshot artefact written by the miner
  • When the artefact is inspected
  • Then byte 0 is the snapshot format version

Scenario §3.5.2 — Unknown snapshot version triggers full WAL replay

  • Given a snapshot artefact whose leading version byte is unknown to the running miner
  • When the miner loads the snapshot at startup
  • Then the snapshot is rejected
  • And the miner falls back to full WAL replay rather than misinterpreting the bytes

Scenario §3.5.3 — Known-version restore + tail replay is equivalent to a full rebuild (2026-06-12 amendment)

  • Given a tenant tree snapshotted at WAL high-water mark S, with further frames appended after S
  • When recovery restores the snapshot and replays only the frames above S
  • Then the recovered tree state (leaves, template_ids, template_versions, slot types, structured-template-id map) equals the control tree built by ingesting every record from scratch
  • And no frame at or below S reaches the miner (no double-apply — the v1 hazard that gated restore)

Scenario §3.5.4 — Stale snapshot degrades loudly, not silently (2026-06-12 amendment)

  • Given a snapshot at high-water mark S, a Parquet checkpoint at X > S, and a WAL whose surviving segments start above S but retain every frame above X (externally truncated — WAL segment files manually unlinked; the RFC 0008 §6.7 retain floor prevents this arising internally, and legitimate housekeeping never removes a frame above X)
  • When recovery runs
  • Then the snapshot is restored and the surviving frames are replayed under the per-consumer horizons (the data side is complete: every missing frame was ≤ X, hence already in Parquet)
  • And a structured warning is emitted naming the gap between S and the oldest surviving frame, so the possible template re-minting inside it is surfaced (hazard #5, observable via the RFC 0010 drift query) rather than silent

Scenario §3.7.1 — Tenants’ template trees never cross-pollinate

  • Given a MinerCluster ingesting interleaved lines from synthetic tenants A and B
  • When the corpus is fully ingested
  • Then no template mined under tenant A appears in tenant B’s tree
  • And no template mined under tenant B appears in tenant A’s tree
  • (Implements docs/benchmarks.md E2.)

Scenario §3.7.2 — Same structural template in two tenants gets distinct template_ids

  • Given tenants A and B independently emit the structurally identical template user <NUM> logged in from <IP>
  • When both are ingested
  • Then tenant A’s template_id for that template differs from tenant B’s template_id
  • And no template_id is shared across tenants
  • And template_ids are guaranteed unique across the entire cluster (not just per tenant)

Scenario §3.7.3 — Tenant derivation runs per ResourceLogs, not per export batch

  • Given a single OTLP ExportLogsServiceRequest carrying two ResourceLogs whose Resource.attributes resolve to distinct tenants A and B under the configured derivation rule
  • When the receiver fans the batch out per RFC 0003 §6.3 and the miner ingests both per-tenant streams
  • Then every LogRecord under ResourceLogs[0] is mined under tenant A
  • And every LogRecord under ResourceLogs[1] is mined under tenant B
  • And no record ever appears in the wrong tenant’s tree
  • (Operationalises the §6.1 Tenant derivation commitment that the derivation rule runs once per inherited Resource, not once per export batch.)

5.3 RFC-internal design commitments

Scenario RFC0001.1 — Fresh-leaf creation does not emit an audit event

  • Given a parent prefix node with no leaves yet
  • When a line creates the first leaf at that node
  • Then no event is appended to the audit stream for that creation
  • And ourios.miner.template.count increments to reflect the new leaf

Scenario RFC0001.2 — Degenerate-template guard rejects fully-wildcard widening

  • Given a leaf whose template, after a candidate widening, would have zero non-wildcard tokens
  • When the candidate widening is attempted
  • Then the widening is rejected
  • And the line is treated as a parse failure (confidence = 0, body retained, ourios.miner.parse_failures increments)
  • And an audit event with event_type = template_widening_rejected_degenerate records the rejection

Scenario RFC0001.3 — Tokenizer is Unicode whitespace only; punctuation stays in tokens

  • Given a line key=value, other=42 (no whitespace adjacent to the punctuation)
  • When the line is tokenized
  • Then it produces two tokens (key=value, and other=42)
  • And no token boundary is introduced at =, ,, :, ;, [, ], (, or )

Scenario RFC0001.4 — Confidence ratio = simSeq / threshold; decision boundary at 1.0

  • Given a tenant with threshold = 0.7
  • And a line whose simSeq against the best candidate is 0.7
  • When the line is ingested
  • Then the emitted record’s confidence == 1.0
  • And the line takes the clean-attach branch
  • And the same simSeq under threshold = 0.5 would yield confidence == 1.4 (the ratio reframes scale-invariantly across tenants)

Scenario RFC0001.5 — Bare template_id = X spans all versions of leaf X

  • Given leaf X with versions 1, 2, 3 attached over time
  • When a query runs where template_id = X
  • Then the result includes rows attached against (X, 1), (X, 2), and (X, 3)
  • And no alias resolution is involved (this is by-construction, since template_id is stable across widenings of one leaf)

Scenario RFC0001.6 — Bare template_id = X does NOT follow alias chains

  • Given two distinct leaves X and Y that the alias index records as semantically equivalent
  • When a query runs where template_id = X
  • Then only rows whose template_id == X are returned; rows with template_id == Y are NOT included
  • And where template_id.resolves_to(X) (RFC 0002 §5.4) is the explicit form that includes Y’s rows

Scenario RFC0001.7 — Combined widening + type-expansion increments version twice and emits two events in order

  • Given a leaf at version V where a single attach both introduces a new wildcard slot AND introduces a previously-unseen ParamType into an existing slot
  • When the attach completes
  • Then the leaf’s template_version == V + 2
  • And the audit stream contains two events for this attach: a template_widened event for the new wildcard, immediately followed by a template_type_expanded event for the type expansion (in that order)

Scenario RFC0001.8 — ourios.miner.confidence.p50 and ourios.miner.confidence.p01 are emitted as gauges

  • Given a running miner with a non-empty ourios.miner.confidence histogram for some (ourios.tenant, ourios.service)
  • When the miner’s meter is collected via an SDK in-memory reader
  • Then ourios.miner.confidence.p50 and ourios.miner.confidence.p01 (attributes ourios.tenant, ourios.service) are present as gauges
  • And each value matches the corresponding quantile of the same-attributed histogram (computed in-process on a short ticker per §6.8)

(The dotted-semconv rename landed in the 2026-06-08 amendment; the open fork is whether confidence.p50 / confidence.p01 become backend-derived quantiles over the exported histogram rather than in-process gauges. That is a contract change to the §3.1.2 mandatory set and would be made — possibly superseding this scenario — under its own review, not folded into the rename.)

Scenario RFC0001.9 — body_kind = Structured short-circuits to a structured-template id

  • Given an OtlpLogRecord whose body is Body::Structured(AnyValue) (any non-String AnyValue variant carried verbatim per RFC 0003 §6.4)
  • When the record is ingested
  • Then the §6.2 algorithm skips tokenize/mask/descend per step 0 and allocates or reuses the structured-template id for (severity_number, scope_name, BodyKind::Structured)
  • And the emitted record has body_kind = Structured
  • And the emitted record’s body carries the Ourios canonical body encoding of that AnyValue (per the §6.1 encoding rule)
  • And params and separators are empty
  • And confidence == 1.0 (the §6.1 sentinel)
  • And lossy_flag == false

Scenario RFC0001.10 — time_unix_nano is preserved verbatim from the wire

  • Given an OtlpLogRecord with time_unix_nano = 1_715_700_000_000_000_000
  • When the record is ingested and committed to Parquet
  • Then the emitted row has time_unix_nano == 1_715_700_000_000_000_000
  • And a query WHERE time_unix_nano BETWEEN 1_715_600_000_000_000_000 AND 1_715_800_000_000_000_000 returns the row
  • (Gates docs/benchmarks.md B1 — time-range queries — by making the underlying column measurable.)

Scenario RFC0001.11 — severity_number = 0 and scope_name = None are distinct key buckets

  • Given four OtlpLogRecords with identical body_kind = String body, varying only in (severity_number, scope_name) across (0, None), (0, Some("lib.x")), (9, None), (9, Some("lib.x"))
  • When all four are ingested
  • Then four distinct template_ids are emitted, one per key bucket
  • And no widening or merge ever coalesces the severity_number = 0 (UNSPECIFIED) bucket with any specified-severity bucket
  • And no widening or merge ever coalesces the scope_name = None bucket with any scope_name = Some(_) bucket
  • (Locks the §6.1 explicit edge-case rules: 0 = UNSPECIFIED is a valid OTLP severity that gets its own bucket, and absent scope is its own bucket.)

Scenario RFC0001.12 — Alias assertion is durably recorded and appears in the per-tenant map

  • Given tenant T with two distinct leaves A and B (A < B) and no existing alias set
  • When an operator asserts B is an alias of A
  • Then an alias_asserted audit event is durably recorded on the §6.4 stream under the §3.4 WAL-before-ack barrier before the assertion is acknowledged, naming tenant_id = T, the anchor representative_id = A, member_ids = [B], the actor, and the timestamp — so the asserted set is {A} ∪ {B} = {A, B}
  • And after the per-tenant projection rebuilds, tenant T’s alias map contains an equivalence class with members {A, B} whose derived canonical representative is A (the smallest member)

Scenario RFC0001.13 — resolves_to(rep) returns all members and excludes non-members

  • Given tenant T whose alias map records the set {A, B} (per RFC0001.12) and an unrelated leaf C in no set
  • When the querier compiles template_id.resolves_to(A) for tenant T
  • Then the predicate expands to template_id IN {A, B}
  • And resolves_to(B) expands to the same {A, B} (expansion is by the set, not the direction of assertion)
  • And resolves_to(C) expands to exactly {C}

Scenario RFC0001.14 — Cross-tenant isolation: an alias in tenant A never affects tenant B [§3.7]

  • Given tenant T1 whose alias map records {A, B} and tenant T2 that has the same template_ids A and B but no alias assertion
  • When the querier compiles template_id.resolves_to(A) once for T1 and once for T2
  • Then for T1 it expands to {A, B}
  • And for T2 it expands to exactly {A}
  • (Locks §3.7: alias sets are per-tenant; an assertion in one tenant is invisible to every other.)

Scenario RFC0001.15 — Retraction removes any member, including the canonical, and is itself audited

  • Given tenant T whose alias map records the equivalence class {A, B} (A < B, so A is the derived canonical)
  • When an operator retracts member A — the canonical / smallest member — from the class
  • Then an alias_retracted audit event is durably recorded (same WAL-before-ack barrier and field shape as RFC0001.12) whose asserted set names A (here as representative_id, the operator’s anchor) plus an empty member_ids, and the actor
  • And after the projection rebuilds, A is removed from the class, leaving {B} — a single member, which is no longer an alias set, so resolves_to(A) expands to exactly {A} and resolves_to(B) expands to exactly {B}
  • (Locks the representative-independent retraction rule: retracting any member is well-defined even when it is the canonical/smallest; the canonical is re-derived as min of the remainder, and a class that drops to one member ceases to be an alias set.)

Scenario RFC0001.16 — A non-aliased id resolves to itself

  • Given tenant T with leaf Z and no alias assertion naming Z
  • When the querier compiles template_id.resolves_to(Z)
  • Then the predicate expands to exactly {Z} — identical to the base-member behaviour and to bare template_id = Z (RFC0001.6)

6. Proposed design

The Ourios miner in detail. This is the section that the ourios-miner crate is implemented against; §5’s Acceptance criteria operationalise the commitments here, and §8 maps each §5 scenario to the technique that tests it.

Why §6 exists. Published Drain (§3) and Drain3 (§4) do not address the properties Ourios requires. Each row below is a gap this section closes:

Gap in published DrainOurios invariant that fills it§6 subsection
No confidence score on a match[§3.1] body retention below threshold§6.3
No audit trail on group merges[§3.1] merge audit events§6.4
No inter-token whitespace preservation[§3.3] bit-identical reconstruction§6.6
No per-parameter byte bound[§3.2] param length limit, overflow to body§6.5
No multi-tenant scoping of the tree[§3.7] per-tenant template trees§6.1
No template versioning / drift story[§3.5], hazard H5§6.7

6.1 Data model

Amendment 2026-05-13. Section rewritten to align the record schema with the OTLP LogRecord shape — the project’s stated ingest contract per docs/glossary.md (entry OTLP: “we do not invent our own format”). The investigation that surfaced the gap is docs/architecture/otlp-log-format.md. The pre-amendment schema treated logs as raw text strings; the amended schema treats every log as a structured OTLP record from the moment it enters the system. §6.2’s algorithm and its ingest signature were aligned to this amendment in a companion edit the same day (see §6.2’s amendment note below): the body.kind fork is at the top of the algorithm, the descent step incorporates the §6.1 template-key tuple, and the MinerCluster::ingest signature now takes a structured OtlpLogRecord rather than a raw &str.

The miner emits one record per ingested OTLP LogRecord. The record shape mirrors the wire shape of OTLP logs (the opentelemetry-proto LogRecord plus its inherited Resource and InstrumentationScope context) plus the miner-derived columns that template mining produces.

Record columns

The record carries three groups of columns. The OTLP-derived group preserves the structured shape the wire promised; the miner-derived group is what this RFC introduces; the reconstruction group exists only when the body was mineable (body.kind = String).

Identity and partitioning:

FieldRust type (informal)SourcePurpose
tenant_idTenantIdderived from Resource.attributesMulti-tenant scoping [§3.7]; default rule below
template_idu64miner-allocatedCluster-wide unique; see “Template identity”
template_versionu32miner-allocatedIncrements on widening; see “Template version”

OTLP-derived columns (faithful to opentelemetry-proto):

FieldRust type (informal)OTLP sourcePurpose
time_unix_nanou64LogRecord.time_unix_nanoEvent time at source; 0 = unknown. Required for thesis-gate B1 (time-range queries)
observed_time_unix_nanoOption<u64>LogRecord.observed_time_unix_nanoCollector observation time
severity_numberu8LogRecord.severity_numberOTLP SeverityNumber: 0 = UNSPECIFIED (a valid OTLP value for records that omit severity), 1..=24 = TRACE..FATAL with sub-levels. Part of the template key (see below); 0 is a distinct key value — UNSPECIFIED records cluster together, never with TRACE/INFO/etc.
severity_textOption<String>LogRecord.severity_textSource’s original severity string
scope_nameOption<String>InstrumentationScope.nameLibrary/module emitter; part of the template key (see below)
scope_versionOption<String>InstrumentationScope.versionDrift / debugging
attributesVec<KeyValue>LogRecord.attributesPer-occurrence structured context
dropped_attributes_countu32LogRecord.dropped_attributes_countTruncation indicator
resource_attributesVec<KeyValue>Resource.attributesSource identity (service.name, host.*, etc.)
trace_idOption<[u8; 16]>LogRecord.trace_idTrace correlation
span_idOption<[u8; 8]>LogRecord.span_idTrace correlation
flagsu32LogRecord.flagsLower 8 bits = W3C trace flags
event_nameOption<String>LogRecord.event_nameIdentifier for structured-event records

Amendment 2026-06-11 — the effective timestamp lives in RFC 0005, not here. RFC 0005 §3.2 (amendment of the same date) adds a writer-derived effective_time_unix_nano Parquet column — time_unix_nano when non-zero, else observed_time_unix_nano, else 0 — following the OTLP logs data model’s recommendation (“Use Timestamp if it is present, otherwise use ObservedTimestamp”). The record shape above is unchanged: the miner emits no new field, the Parquet writer computes the column from the two timestamp fields already listed, and the wire time_unix_nano is stored verbatim including 0 — scenario RFC0001.10 (verbatim preservation) remains intact and normative. Time partitioning and the DSL time window key off the derived column (RFC 0005 §3.4 / RFC 0002 §6.2).

Body and miner-derived reconstruction:

FieldRust type (informal)SourcePurpose
body_kindBodyKindderived from LogRecord.bodyDiscriminator: String | Structured (see “Body representation”)
bodyOption<String>LogRecord.bodyUTF-8 (the in-memory record type; the RFC 0005 Parquet column is BYTE_ARRAY). When body_kind = Structured: the Ourios canonical body encoding of the AnyValue (see “Body representation” for the rule). When body_kind = String lossy: the original line. When overflow: per §6.5.
paramsVec<Param>from maskingOne entry per <*> slot. Always empty when body_kind = Structured
separatorsVec<Separator>from tokenizetokens.len() + 1 entries. Always empty when body_kind = Structured
confidencef32miner-derivedsimSeq / threshold at attach time. 1.0 (sentinel) when body_kind = Structured
lossy_flagboolminer-derivedTrue iff reconstruct(record) ≠ ingested_body_bytes is possible. Always false when body_kind = Structured (the verbatim body column is the source of truth)

Where:

  • Param = { type_tag: ParamType, value: Bytes }. ParamType is one of IP, UUID, NUM, HEX, TS, PATH, STR, OVERFLOW. STR is the unmasked-wildcard fallback — used when a slot was created by template widening of a previously-fixed literal token (the literal itself becomes the param value); OVERFLOW carries (length: u32, sha256_prefix: [u8; 8]) instead of the original value (§6.5). params.len() == count(<*> in template), always (in the body_kind = String branch); §6.2 enforces this when a widening introduces new wildcard slots.
  • Separator is a small inline byte string (typically 1–3 bytes in practice). Encoding in Parquet is an implementation detail that does not affect this RFC.
  • KeyValue mirrors the OTLP KeyValue message: a key: String and a value: AnyValue. AnyValue is a discriminated union over string | bool | int | double | bytes | array | kvlist. Storing AnyValue faithfully in Parquet (rather than flattening to a string) is what keeps query expressions like attributes["client.address"] = "10.0.0.1" typed.
  • BodyKind is a two-variant enum (String, Structured) — not the full AnyValue discriminator. The body column carries the encoded AnyValue payload; body_kind is the cheap routing flag the query planner uses to decide whether reconstruction is defined for this row.

Body representation (AnyValue handling)

OTLP’s LogRecord.body is AnyValue — string, bool, int, double, bytes, array, or kvlist. The spec is explicit (Logs Data Model §Body): “Body MUST support AnyValue to preserve the semantics of structured logs emitted by the applications.” Real OTel emitters send structured Body routinely, not just text.

Ourios distinguishes two body shapes at ingest:

  • body_kind = StringLogRecord.body is AnyValue::String. The miner runs the §6.2 algorithm over the unwrapped string: tokenize, mask, descend the tree, attach to or create a leaf. params, separators, confidence, lossy_flag are populated per the existing semantics.
  • body_kind = StructuredLogRecord.body is any other AnyValue variant (kvlist, array, int, double, bool, bytes). The miner does not run the §6.2 algorithm. The body is encoded with the Ourios canonical body encoding (see The Ourios canonical body encoding below) and stored in the body column; no template is mined, no params/separators are emitted. template_id is allocated per the Template-key composition rule below — for this branch the key is (severity_number, scope_name, BodyKind::Structured), so all structured-Body records sharing a (severity, scope) share one template_id. The leaf the id points at carries the Structured marker and an empty body_template. confidence = 1.0 (sentinel), lossy_flag = false (the canonically-encoded body is authoritative; nothing is reconstructed from a template).

This is the conservative default. It preserves the structural content of the body (the Ourios canonical body encoding below makes [§3.3] reconstruction well-defined for the structured branch: stored_bytes ↔ AnyValue is bidirectional and byte-deterministic), it avoids inventing template structure for arbitrary AnyValue trees, and it sidesteps the spec ambiguity of “what is the template for {"msg": "x", "user_id": 42}.” A future opt-in mine-inner-field mode (e.g., mine body.kvlist["msg"] as the line if present) is a configurable knob, not the default; that decision lives with the maturity-stage move from redgreen once corpus evidence informs which inner-field conventions are worth specifying.

A third path — render-to-string + mine (canonicalise structured Body to JSON-ish text and run it through the §6.2 mining algorithm) — was rejected because mining over the JSON serialisation produces token templates that depend on the serialiser’s whitespace and field-ordering choices, which is both fragile (changing serialisers shifts every template) and defeats the §3.3 reconstruction guarantee for any record where the original wire form was protobuf rather than JSON. Storing the canonical encoding (without mining over it) is different from this rejected path: storage is faithful, it just doesn’t get a template extracted.

The Ourios canonical body encoding (body_kind = Structured).

Amendment 2026-06-09 (no canonical OTLP JSON exists). This paragraph previously called the encoding “the OTLP-canonical JSON encoding per the OTLP specification’s HTTP/JSON binding,” implying a spec-defined canonical form. There is none. Per the OTLP spec, the OTel common docs, and a maintainer answer (Josh Suereth, 2026-06-09): OTLP/JSON is the proto3 JSON mapping plus a short closed list of deviations (hex trace_id/span_id, integer enums, ignore-unknown-fields, lowerCamelCase) — with no normative rules on whitespace, key/field ordering, or number canonicalisation, and OTLP does not require lossless translation between formats. The text below is reframed to state the rule as an Ourios-local deterministic encoding, not an OTLP conformance point, and renamed to “the Ourios canonical body encoding.” No code and no RFC status change here; the encoder is ourios-core’s otlp::canonical (a separate follow-up PR aligns its doc comments).

Amendment 2026-06-11 (doubles round-trip bit-exactly — #130). The rule below previously left double precision implicit, and decode(encode(x)) drifted 1–2 ULP for ~12% of arbitrary finite f64. Investigating #130 located the loss on the decode side, not the encoder: the emitter already produces shortest-round-trip digits (serde_json’s Ryu f64 formatter — with-serde adds no custom double formatter), but serde_json’s default float parsing is approximate. The rule now pins both halves: doubles are emitted as shortest-round-trip JSON numbers and decoded with correctly-rounded float parsing (serde_json’s float_roundtrip feature, declared load-bearing in ourios-core), so the f64 round-trip is exact for every finite double — the faithfulness guarantee below holds for arbitrary doubles, not just “nice” ones. Non-finite doubles (NaN, ±∞) have no JSON-number form and encode as null — bytes that do not decode back; that pre-existing gap is explicitly out of scope here and stays open. No RFC status change.

The body column carries the Ourios canonical body encoding of the AnyValue: a proto3-JSON form, defined below. This is an Ourios-local deterministic convention, not an OTLP-mandated canonical form. OTLP defines no canonical or byte-deterministic JSON encoding. Its only normative JSON rules are the proto3 JSON mapping (per the protobuf spec) plus a short closed list of OTLP-specific deviations — trace_id / span_id as hex strings (not applicable to a body AnyValue, which carries no IDs), enum values as integers, ignore unknown fields, and lowerCamelCase field names. The spec is silent on whitespace, key/field ordering, and number canonicalisation, and OTLP does not require lossless translation between formats. There is therefore no “canonical OTLP JSON” to reference; the byte-stable encoding below is Ourios’s own, chosen so the body column is byte-deterministic (storage dedup) and the [§3.3] reconstruction guarantee is well-defined.

The concrete rule is the proto3 JSON mapping as emitted by opentelemetry-proto’s with-serde feature via serde_json:

  • field names in lowerCamelCase;
  • int64 / uint64 values as decimal strings (proto3 JSON’s canonical emit form; decoders accept a JSON number or string);
  • double values as JSON numbers in shortest-round-trip form, decoded with correctly-rounded float parsing — decode(encode(x)) is bit-exact for every finite f64 (#130; see the 2026-06-11 amendment above);
  • bytes as base64;
  • KvlistValue and ArrayValue element order preserved as received — not sorted (this is explicitly not RFC 8785 / JCS canonical JSON);
  • byte-deterministic because the proto types have a fixed serde field order and serde_json serialisation is deterministic.

Canonical byte examples: {"intValue":"-42"}, {"doubleValue":2.71}, {"boolValue":true}, {"bytesValue":"<base64>"}, {"arrayValue":{"values":[…]}}, {"kvlistValue":{"values":[{"key":"…","value":{…}}]}}.

“Deterministic” here means byte-identical. Re-encoding the same in-memory AnyValue with this encoder yields byte-for-byte identical output (within a fixed opentelemetry-proto / serde_json version). This is the byte-level reading, not a weaker struct-level one: it is what lets the body column be deduplicated and lets two receivers handling the same logical AnyValue produce the same stored bytes. The receiver path: OTLP/gRPC (protobuf wire) decodes to an in-memory AnyValue and re-encodes here; OTLP/HTTP+JSON decodes the incoming JSON to an in-memory AnyValue and re-encodes the same way, so the stored bytes do not depend on the producer’s whitespace, field order, or int64-as-number-vs-string choice.

The faithfulness guarantee — stored_bytes decode back to the original in-memory AnyValue — is an Ourios guarantee delivered by this encoder/decoder pair, not an OTLP lossless promise (OTLP makes none). lossy_flag = false for structured rows rests on this Ourios guarantee, not on any OTLP conformance claim.

Duplicate keys. OTLP KvlistValue is a repeated KeyValue that the data model treats as a map with unique keys; the data-model map equality is order-insensitive, but OTLP does not define wire-order equality, which is why preserving received order (above) is the safe, spec-permitted choice. A KvlistValue carrying duplicate keys is non-conforming OTLP input with no defined semantics. Ourios does not silently dedup or reorder such input: it preserves the entries verbatim in the encoding (so the round-trip stays faithful) and makes no map-semantic guarantee for it, rather than inventing one.

Template-key composition

A template’s identity (the discriminator the Drain tree uses to decide “is this the same template?”) depends on the body shape:

  • body_kind = String — key tuple is (severity_number, scope_name, masked_body_tokens).
  • body_kind = Structured — key tuple is (severity_number, scope_name, BodyKind::Structured). All structured-Body records sharing a (severity_number, scope_name) share one template_id. This intentionally forfeits structured-body shape clustering — the rationale is that the structured-Body branch’s value comes from the faithful preservation of attributes and the canonically-encoded body, not from grouping similar AnyValue shapes. Operators who need shape-level clustering can opt into a future body_shape_fingerprint column (a stable hash over the AnyValue’s structural skeleton — kvlist key-set, nested shape, leaf-type sequence; values ignored) as a reserved extension; the gate for adding it is “we have a concrete consumer,” not “it might be useful.”

The bullet rationale below applies to both branches:

  • severity_number is part of the key because INFO and ERROR versions of the same body text are semantically distinct events. “user logged in” at INFO is a routine signal; “user logged in” at ERROR is an alarm (or an emitter bug) — collapsing them to one template_id would surface either as the other on query, which is a [§3.1] “no silent merges” violation in disguise. The OTLP-spec-valid severity_number = 0 (UNSPECIFIED) is a distinct key value, not coalesced with any specified severity.
  • scope_name is part of the key because the same body text emitted from two different instrumentation scopes (myapp.login vs myapp.checkout) describes two different events. The scope is the OTel-canonical “which code path emitted this,” directly analogous to the package/logger name in traditional logging frameworks. Records with no scope (scope_name = None) cluster as their own (severity, None) bucket.
  • resource_attributes are NOT part of the key. They identify who sent the record (service, host, k8s pod), not what event was emitted. The tenant_id derivation (below) already encodes the partition decision over Resource. Folding Resource into the template key would explode template cardinality proportionally to the deployment fleet size without adding semantic discrimination — the same myapp.login template from two replicas of service.name = api is the same template.
  • event_name is not in the key today but is reserved as a candidate addition. RFC 0001 stays at the OTLP-canonical severity+scope key; promoting event_name into the key is a follow-up RFC patch once corpus evidence justifies it.

The Drain tree’s implementation of this tuple (extra prefix levels above length-N, tuple-keyed leaf lists, separate trees per (severity, scope), etc.) is §6.2 implementation territory and may be revisited based on cardinality observations from the corpus benchmark. The RFC pins only the semantic key.

Tenant derivation

tenant_id is derived per ResourceLogs group, not per OTLP export batch. Each ResourceLogs carries its own Resource.attributes, and a single OTLP export can contain multiple ResourceLogs groups from different sources — so one export can route records to multiple tenants. The derivation runs once per inherited Resource; the resulting tenant_id applies to every LogRecord under that ResourceLogs group (across all its ScopeLogs), and the receiver fans the records out into per-tenant streams.

The default per-Resource rule:

tenant_id := resource.attributes["service.name"]   if present
          ?: <operator-required fallback rule>

service.name is the conventional OTel unit of “what application emitted this,” and it maps directly onto Ourios’s per-tenant template-tree partitioning ([§3.7]). Operators with a different multi-tenant model (per-namespace, per-customer-id-attribute, composite of multiple attributes) configure an alternative rule; the receiver does not invent a tenant identity that the operator hasn’t declared.

If a ResourceLogs group’s Resource resolves to no tenant under either the default rule or the operator’s fallback, the receiver rejects the entire export batch with a controlled error (no panic, no silent assignment to a “default” tenant; the sender sees the failure and either fixes its emitter or its deployment). Per-Resource rejection within an otherwise-valid batch is not supported in this RFC — the all-or-nothing failure mode is simpler to reason about for the sender, and OTLP’s batch-level acknowledgement model fits all-or-nothing more naturally than partial-success. The receiver-side specification of this rejection path (and any future opt-in for partial acceptance) lives in RFC 0003 — OTLP receiver (forthcoming).

Template identity

template_id is a cluster-wide unique monotonic u64 (with each tenant seeing a monotonic subsequence), allocated when a new leaf is created and never reused or reassigned. The id space is shared across tenants so that the same u64 value never refers to two different leaves; the per-tenant subsequence guarantee preserves [§3.7] by making each tenant’s allocation order observable in isolation. Cross-tenant content identity is intentionally not guaranteed — two tenants emitting the structurally identical template (same (severity_number, scope_name, masked_body_tokens) tuple) will have different template_ids, so a template_id alone never links structurally-equivalent templates across tenants. (The u64 value itself is cluster-wide unique, per the previous paragraph; what is not guaranteed is that the same template across two tenants resolves to the same id.) This preserves [§3.7] (per-tenant template trees) by construction; cross-tenant analytics that need content identity (deduplication across tenants for storage savings, shared template dashboards) are an opt-in concern and are not provided by the miner. A future template_fingerprint side column may carry a canonical content hash over (severity_number, scope_name, masked_body_tokens) for opt-in cross-tenant use; the gate for adding it is “we have a concrete consumer,” not “it might be useful.”

Template version. template_version starts at 1 when the template is created and increments by 1 on every widening event: either a new wildcard slot opens (a previously fixed token at position i becomes <*>), or an existing wildcard’s typed parameter set changes (e.g. a <NUM> slot starts seeing <STR> values). To detect the second case, every leaf carries — alongside its template — a slot_types: Vec<HashSet<ParamType>> indexed by wildcard slot, recording every ParamType observed in that slot. A type expansion is the addition of a ParamType to one of these sets. The pair (template_id, template_version) uniquely identifies one structural state of a template. Queries against template_id = X return all versions; queries against (template_id, template_version) = (X, V) return only the named state. The DSL surface is RFC 0002’s concern, not this RFC’s, but the data model must support both.

Why two integers and not a content hash. A content hash makes identity global by construction; in a multi-tenant backend that is a tenant-isolation leak rather than a feature. A content hash also makes template_version redundant — once the canonical template string changes, the hash changes, so versioning collapses into alias-mapping between hashes. Per-tenant monotonic ints with an explicit version field are smaller in the Parquet column, easier to reason about under [§3.7], and keep (template_id, template_version) as a meaningful compound key.

6.2 Algorithm

Amendment 2026-05-13. Rewritten to take a structured OTLP LogRecord rather than a raw &str, in line with the §6.1 amendment. The algorithm now opens with the body.kind fork from §6.1’s Body representation: AnyValue::String runs the Drain mining steps (the prior algorithm, preserved verbatim below); every other AnyValue variant short-circuits to the structured emit per §6.1’s Template-key composition fork. Step 3’s descent now incorporates (severity_number, scope_name) into the tree key, again per §6.1 — the implementation choice (extra prefix layers, tuple-keyed leaf lists, separate trees per (severity, scope)) stays in §6.2 as the algorithm’s responsibility, but the semantic key is pinned by §6.1. The ingest signature on MinerCluster becomes ingest(record: &OtlpLogRecord); pre-amendment callers were ingest(tenant_id, raw: &str).

The miner sees an already-tenant-resolved (tenant_id, record: OtlpLogRecord) pair. The receiver (RFC 0003) is responsible for resolving tenant_id per ResourceLogs and fanning records into per-tenant streams before the miner sees them; §6.1’s Tenant derivation pins that contract.

For each ingested OTLP LogRecord:

0.  match record.body.kind:

      AnyValue::String(s):
          # Continue with the Drain mining algorithm in steps
          # 1–5 below, treating `s` as the `L_raw` of the prior
          # spec. body_kind = String.

      AnyValue::Bool | Int | Double | Bytes | Array | KVList:
          # Structured short-circuit per §6.1 *Body
          # representation*. The miner does NOT run the Drain
          # mining steps. body_kind = Structured.
          encoded = encode_canonical_body(record.body)
              # Ourios canonical body encoding (a proto3-JSON form;
              # lowerCamelCase fields, int64/uint64 as decimal
              # strings, bytes as base64, kvlist/array order
              # preserved — NOT sorted). This is an Ourios-local
              # deterministic convention, NOT an OTLP-mandated
              # canonical form: OTLP defines no canonical JSON and
              # requires no lossless translation. For records over
              # OTLP/gRPC the receiver decodes protobuf and
              # re-encodes here; for OTLP/HTTP+JSON it decodes to
              # the in-memory AnyValue and re-encodes the same way,
              # so stored bytes are byte-identical regardless of the
              # producer's whitespace / field order / int64 form.
              # The lossy_flag = false promise rests on this Ourios
              # round-trip guarantee — see §6.1 for the why.
          template_id = allocate_or_reuse_structured_template_id(
              record.severity_number,
              record.scope_name,
          )
              # Per §6.1 *Template-key composition*, the
              # structured-Body key is (severity_number,
              # scope_name, BodyKind::Structured). All structured
              # records sharing a (severity, scope) share one
              # template_id. The leaf the id points at carries the
              # `Structured` marker and an empty body_template.
          attach_structured(record, encoded, template_id,
                            confidence = 1.0,
                            lossy_flag = false)
              # confidence = 1.0 sentinel; lossy_flag = false
              # because the canonicalised body is authoritative,
              # nothing is reconstructed from a template.
          return

1.  L_tok, separators = tokenize(L_raw)
        # tokenize splits on Unicode whitespace only — every
        # codepoint matching `char::is_whitespace()` (ASCII space,
        # tab, CR, LF, plus the broader Unicode whitespace classes
        # U+0085, U+00A0, U+1680, U+2000–U+200A, U+2028, U+2029,
        # U+202F, U+205F, U+3000). Every other byte (including
        # punctuation such as `=`, `:`, `,`, `;`, `[`, `]`, `(`,
        # `)`) stays inside a token; structured separators are the
        # masking layer's responsibility (§4.2 / step 2). The
        # captured whitespace runs go into `separators` so that
        # reconstruction (§6.6) is byte-identical.
        # On failure (malformed UTF-8, embedded NUL, line longer
        # than max-line-bytes): emit a parse-failure record and
        # increment ourios.miner.parse_failures. Skip the rest.
        # Note: an empty-after-whitespace string (the AnyValue
        # carries `""` or only whitespace) is not a parse failure
        # — it has zero tokens and the miner short-circuits with
        # the cluster's `NO_TEMPLATE` sentinel rather than
        # descending the tree. The pre-amendment cluster code
        # already routes this case; the spec just records it.

2.  L_masked, typed_params = mask(L_tok)
        # mask applies the configured masking rules in order;
        # any token matching a rule is replaced with its type
        # tag (e.g. <IP>) and the original bytes are pushed
        # into typed_params with that tag. Unmasked tokens
        # remain literal.

3.  parent = tree.descend(record.severity_number,
                           record.scope_name,
                           len(L_masked),
                           L_masked[0..d-1])
        # Per §6.1 *Template-key composition*, the discriminator
        # for "is this the same template?" is the tuple
        # (severity_number, scope_name, masked_body_tokens).
        # Step 3 incorporates severity_number and scope_name into
        # the descent key alongside the masked-token prefix used
        # by published Drain. The implementation may layer extra
        # prefix levels above the length-N node, key leaf lists
        # by (severity, scope), or maintain separate trees per
        # (severity, scope) — the choice is cardinality-driven
        # and revisitable from corpus observations. The
        # severity_number = 0 (UNSPECIFIED) and scope_name = None
        # cases are valid distinct key positions; they cluster as
        # their own buckets, never coalesced with any specified
        # severity or named scope.
        # if a node along the path does not exist, create it.

4.  candidate = argmax over leaf in parent.leaves of
                  simSeq(L_masked, leaf.template)
    if candidate is None:
        # no leaves under parent yet; create one. Creation does not
        # emit an audit event — `ourios.miner.template.count` already reflects
        # leaf allocation, and §6.4 reserves the audit stream for
        # widening events whose semantics need cross-referencing.
        leaf = new Leaf(template = L_masked)
        parent.leaves.push(leaf)
        # On fresh-leaf creation the template is L_masked verbatim,
        # so every <*> in it came from mask(); params == typed_params.
        attach(L_masked, typed_params, separators, leaf,
               confidence = 1.0, lossy_flag = false)
        return

5.  similarity = simSeq(L_masked, candidate.template)
    confidence = similarity / threshold

    if similarity >= threshold:
        # clean or lossy attach; widen the template if needed.
        # widen() returns:
        #   widened           — the new template (existing fixed
        #                       positions that mismatched L_masked
        #                       become <*>)
        #   new_wildcards     — the set of positions that just
        #                       became <*> (the audit payload)
        widened, new_wildcards = widen(candidate.template, L_masked)
        if new_wildcards > 0:
            candidate.template = widened
            candidate.version += 1
            emit_audit(event_type = template_widened,
                       template_id = candidate.id,
                       old_version, new_version = candidate.version,
                       positions_widened = new_wildcards.positions,
                       ...)
            ourios.miner.merges.inc()

        # Build the params array. One entry per <*> in the (possibly
        # just-widened) template, in template order. For each slot:
        #   - if the slot existed before this attach AND mask() emitted
        #     a typed_params entry for it, use that entry verbatim.
        #   - if the slot is a fresh wildcard from this widening (the
        #     position held a literal token in candidate.template before
        #     the widen call), the original literal at that position in
        #     L_tok is captured as { type_tag: STR, value: L_tok[pos] }.
        # Without this step §6.1's "one entry per <*> slot" invariant
        # is violated and §6.6's reconstruct() has no value to insert
        # at the freshly-widened position.
        params = build_params(candidate.template, typed_params,
                              L_tok, new_wildcards)

        # Type-expansion: if any wildcard slot now sees a typed param
        # whose type tag is not already in that slot's observed-type
        # set, widen the slot's type set, bump the version, and
        # audit. The leaf carries `slot_types: Vec<HashSet<ParamType>>`
        # alongside its template (data model in §6.1).
        new_types = update_slot_types(candidate, typed_params)
        if not new_types.is_empty():
            candidate.version += 1
            emit_audit(event_type = template_type_expanded,
                       template_id = candidate.id,
                       old_version, new_version = candidate.version,
                       slots_expanded = new_types,
                       ...)

        attach(L_masked, params, separators, candidate,
               confidence,
               lossy_flag = false)  # §6.6: lossy_flag is set only on
                                    # tokenizer/preprocessing failure,
                                    # not on confidence < 1.0
        return

    if similarity >= floor:
        # lossy zone: the line is "close" but doesn't meet
        # threshold. Create a new leaf rather than force-merging.
        # Body retention is unconditional in this branch.
        leaf = new Leaf(template = L_masked)
        parent.leaves.push(leaf)
        # As in the candidate-is-None branch, the new leaf's template
        # is L_masked verbatim, so params == typed_params.
        attach(L_masked, typed_params, separators, leaf,
               confidence,
               lossy_flag = false,
               body = L_raw)  # forced body retention
        ourios.miner.body_retention.utilization.observe(retained = true)
        return

    # similarity < floor: parse failure
    ourios.miner.parse_failures.inc()
    emit_failure_record(L_raw, reason = "no candidate above floor")

Branching invariants:

  • Step 0’s structured short-circuit never enters the Drain mining steps (1–5). Structured-Body records do not widen, do not emit template_widened or template_type_expanded audit events, do not contribute to ourios.miner.merges, and never carry params/separators. The structured branch’s [§3.1] preservation is vacuous: no template merge happens, so no silent merge is possible.
  • The tree only deepens on first observation of a (severity_number, scope_name, length, prefix tokens) shape (the §6.1 template-key tuple, anchored at this section’s step 3).
  • Leaves are split (new leaf created) when the best candidate is in the lossy zone; they are never split when the candidate is clean.
  • A leaf is widened (wildcards introduced) when a clean attach would otherwise mismatch positions. Every widening emits an audit event (§6.4).
  • A leaf’s wildcard slot is type-expanded when an attach maps a typed parameter whose ParamType is not already in that slot’s slot_types[slot] set. Type expansion increments template_version and emits a template_type_expanded audit event (§6.4).
  • A single attach can trigger both wildcard-widening and type-expansion in the same leaf; in that case template_version increments twice and two audit events are emitted, in that order.
  • The leaf’s template_version only increments on widening or type-expansion, not on a clean attach. Structured-Body leaves are never widened or type-expanded; their template_version stays at 1 for the lifetime of the leaf.

6.3 Confidence scoring [§3.1]

confidence = simSeq / threshold. The ratio framing makes the decision boundary land at confidence == 1.0 regardless of the configured threshold, which gives ourios.miner.confidence.p50 and ourios.miner.confidence.p01 ([§3.1] required metrics) a stable interpretation across tenants with different thresholds: the p01 value tells you how close the bottom 1% of attaches are to the merge boundary. A collapsing p01 means many lines are barely passing — a tuning signal even though the threshold itself has not moved.

Three zones, with concrete defaults:

  • confidence ≥ 1.0 (i.e. simSeq ≥ threshold): clean attach. No body retention.
  • floor / threshold ≤ confidence < 1.0: lossy zone. The line attaches to a freshly created leaf rather than being force-merged into the candidate (see §6.2 step 5). body is retained unconditionally; lossy_flag follows the §6.6 rule (set only on reconstruction failure, not on lossy zone alone — the body is available either way).
  • confidence < floor / threshold: parse failure. ourios.miner.parse_failures increments; the line is written to a failure record with the original bytes intact.

Defaults. threshold = 0.7, floor = 0.4. The threshold floor is fixed by [§3.1] (“threshold ≥ 0.7, lowering requires an RFC, not a config change”); the lossy-zone floor is a tuning knob between threshold and 0. floor = 0.4 matches the paper’s reported default threshold, on the reasoning that lines below the paper’s own bar are likely genuinely different events. Tuning the floor is a per-tenant config decision; it is not load-bearing for any invariant.

6.4 Merge policy [§3.1]

A template widening (per §6.2 step 5) is the operation that [§3.1] calls a “merge.” Every widening emits an audit event with the schema:

{
  event_type: AuditEventType,  # enum:
                               #   template_widened
                               #   template_type_expanded
                               #   template_widening_rejected_degenerate
  tenant_id: TenantId,
  template_id: u64,
  old_version: u32,
  new_version: u32,
  old_template: String,        # canonical form, with <*> for wildcards
  new_template: String,
  triggering_line_hash: [u8; 16],  # blake3 of L_raw, for cross-ref
  triggering_line_sample: Option<String>,  # first 256 B of L_raw
  positions_widened: Vec<u16>, # token positions that became <*>
                               # (empty for template_type_expanded)
  slots_expanded: Vec<SlotExpansion>,
                               # slot index + newly added ParamType(s)
                               # (empty for template_widened)
  timestamp: SystemTime,
}

event_type is the field §6.7’s drift-detection query filters on. ourios.miner.merges increments on every event whose event_type is template_widened or template_type_expanded (the two structural widenings); template_widening_rejected_degenerate is recorded but does not increment ourios.miner.merges. The audit stream is written to the same WAL as the data records and ends up in a dedicated audit Parquet file per tenant per compaction window (schema in ourios-parquet’s RFC, not this one).

Default policy: strict. Widening is permitted whenever the clean-attach path in §6.2 would otherwise mismatch positions. The audit event is mandatory — no widening, of any reason, ever proceeds without one. Code paths that would emit a widening without emitting an audit event are blocked at PR review per hazards.md H1.

WAL durability ordering of audit events. A single attach may emit two audit events in order (RFC0001.7: template_widened immediately followed by template_type_expanded) and one data record. The contract this RFC requires from the future ourios-wal RFC is an ordering-plus-durability-barrier: a data record carrying template_version = V must not become durable before every audit event justifying the leaf’s progression to V is durable. Crash recovery may then observe some prefix of [event_1, event_2, …, data_record], but never a state in which the data record exists without the events that caused its version stamp. Any framing strategy that satisfies this — a composite multi-record frame, batched-fsync ordering, a two-phase write-then-link, anything else — is acceptable; the framing is ourios-wal’s choice, the ordering barrier is this RFC’s requirement. Without it, replay would bump template_version fewer times than the in-memory leaf did and the surviving data records would reference a version the audit stream cannot substantiate.

Degenerate template guard. If a widening would leave the template with zero non-wildcard tokens (the entire template becomes <*> <*> … <*>), the widening is rejected, the line is treated as a parse failure (confidence = 0, retain body, increment ourios.miner.parse_failures), and an audit event with event_type = template_widening_rejected_degenerate records the rejection. A fully-wildcard template provides no parsing value and would swallow arbitrary lines.

6.5 Parameter handling [§3.2]

Per-parameter byte limit. Default 256 B, configurable up to 1 KiB (the [§3.2] ceiling). Above 1 KiB requires an RFC.

Overflow behaviour. When a parameter value (post-masking) exceeds the configured limit, the parameter slot is replaced by an OVERFLOW marker:

Param {
  type_tag: ParamType::OVERFLOW,
  value: encode(length: u32, sha256_prefix: [u8; 8]),
}

The original line L_raw is captured into the body column unconditionally (overflow forces body retention, regardless of lossy_flag). The 8-byte SHA-256 prefix lets queries “find rows where this exact long parameter occurred” without storing the long value in the columnar data. Reconstruction honours overflow: reconstruct(record) falls back to body when any param has type_tag == OVERFLOW.

Telemetry. Two metrics for [§3.2] and hazard H2:

  • ourios.miner.params.overflow (counter, attributes ourios.tenant, ourios.service): increments per overflow.
  • ourios.miner.params.overflow.utilization (gauge, attributes ourios.tenant, ourios.service): rolling overflow rate. Alert at > 0.01 per service per [§3.2].

6.6 Body reconstruction [§3.3]

Amendment 2026-06-08 (reader render contract). H7.3 (§5) referenced “the §6.6 warning marker,” but §6.6 defined no such marker — the prose Reader behaviour paragraph it replaced described one only informally. This amendment adds the Reader render contract subsection below, defining the marker as a structured, out-of-band per-row Reconstruction signal (Faithful | RetainedVerbatim) the reader attaches to the rendered row — never a mutation of the body bytes — and pinning the lossy short-circuit H7.3 requires (return body verbatim, do not call reconstruct). The clean-path read-time template lookup (a registry mapping (template_id, template_version) → tokens at read time) is explicitly out of scope and deferred to the querier’s reader-materialisation story (RFC 0007). RFC 0001 stays specified; this clarifies the §6.6 contract, it does not change the on-disk schema or the mining algorithm.

Capture, always. Every successful tokenization in §6.2 step 1 populates the separators array with the bytes between adjacent tokens (and the leading and trailing bytes of the line). The array length is tokens.len() + 1. There is no whitespace heuristic and no “is this whitespace trivial” decision — the bytes are captured verbatim. Storage cost is bounded (typical separator is one space; the array dictionary-encodes well in Parquet) and the implementation has no fuzzy boundary that could decide to drop bytes silently.

Reconstruction function.

fn reconstruct(record: &Record) -> Bytes {
    if record.lossy_flag {
        return record.body.expect("lossy implies retained body");
    }
    if record.params.iter().any(|p| p.type_tag == OVERFLOW) {
        return record.body.expect("overflow implies retained body");
    }
    let template = lookup(record.template_id, record.template_version);
    let mut out = BytesMut::new();
    out.extend_from_slice(&record.separators[0]);
    for (i, token) in template.tokens.iter().enumerate() {
        match token {
            Token::Fixed(s) => out.extend_from_slice(s),
            Token::Wildcard(slot) => {
                out.extend_from_slice(&record.params[slot].value)
            }
        }
        out.extend_from_slice(&record.separators[i + 1]);
    }
    out.freeze()
}

lossy_flag semantics. Set to true if and only if reconstruction is not guaranteed to equal the ingested bytes:

  • The tokenizer failed (malformed UTF-8 inside a token, embedded NUL, line exceeded the configured max_line_bytes cap before tokenization completed).
  • A preprocessing rule explicitly rejected the line.

The lossy zone in §6.3 (low confidence) does not automatically set lossy_flag: the body is retained either way, and reconstruction from template + params + separators is still expected to match. The flag is reserved for the cases where the record genuinely cannot be reconstructed.

Reader render contract

The functions above run at write time: reconstruct is the property-test oracle and the in-process renderer the miner exercises while the template is in hand. The reader — the read-side path that materialises a stored Parquet row back into the effective original line for a query result — is a distinct caller, and H7.3 pins the contract it must honour. The contract below covers String-body rows (body_kind = String); structured bodies are out of scope (see the end of this subsection).

For a String-body row, the render result is (bytes, reconstruction). Rendering yields two things: the effective original line bytes and a per-row reconstruction signal

#![allow(unused)]
fn main() {
enum Reconstruction {
    Faithful,         // bytes == ingested line, reconstructed from template
    RetainedVerbatim, // bytes are the retained `body` column, not reconstructed
}
}

The Reconstruction signal is the “§6.6 warning marker” that H7.3 references. It is structured, out-of-band metadata attached to the rendered row — not a mutation of the body bytes. A consumer (the DSL output layer, RFC 0007; a UI) renders RetainedVerbatim as a “rendered from the retained body bytes, not reconstructed” warning beside the row, exactly as it would render any other per-row annotation.

A body-byte annotation is explicitly rejected as the marker. Prefixing the body with a sentinel string, wrapping it in a marker character, or otherwise editing the bytes to carry the warning would break the verbatim guarantee: the whole point of the lossy path is that an operator asking “show me what was actually logged” gets the ingested bytes back unchanged [§3.3]. The marker therefore lives beside the bytes, never inside them.

Lossy path (H7.3). For a row with lossy_flag = true — the tokenizer-failure / explicit-rejection cases enumerated under lossy_flag semantics above — the reader returns the body column verbatim with Reconstruction::RetainedVerbatim, and does not invoke reconstruct: no template lookup, no token walk. The same short-circuit applies to a String-body row carrying an OVERFLOW param (§6.5): its retained body is returned verbatim with Reconstruction::RetainedVerbatim. reconstruct’s own lossy_flag / OVERFLOW early returns remain in place as a defensive guard for callers that reach it anyway; the reader’s contract is to short-circuit before that call, so the guard is belt-and-braces, not the primary mechanism.

Structured bodies are out of scope of this amendment. A body_kind = Structured row renders its body column from the Ourios canonical body encoding (the §6.1 rule, exercised by the RFC0001.9 structured short-circuit). This amendment defines the Reconstruction marker for the implemented String path only; how a structured render maps to a Reconstruction signal is left open here, to be settled when structured-body rendering is wired.

Clean path. For a faithful row (lossy_flag = false, no OVERFLOW param, template available) the reader invokes reconstruct(record) and attaches Reconstruction::Faithful. This path requires resolving (template_id, template_version) → tokens against a template registry available at read time. That registry — how the reader obtains the template a stored row was mined against, given that the miner’s in-memory tree is an ingest-side structure — is a separate concern and out of scope of this amendment. Today reconstruct is exercised only where the template is already in hand (the write-side property test H7.1, and H7.4); the read-time lookup mechanism is future work, tracked with the querier’s reader-materialisation story (RFC 0007). This amendment pins the render contract and the lossy path; it does not specify a template-registry design.

The reader never silently substitutes one rendering for the other: every rendered String-body row carries its Reconstruction signal, and the lossy/clean branch is selected solely by the row’s lossy_flag (and the §6.5 OVERFLOW case), never inferred from the bytes.

Property test. For every row r in the corpus where r.lossy_flag == false:

reconstruct(r) == ingested_bytes(r)

Failure is a build break, not a regression — [§3.3] and hazard H7 both name this as the property test that gates merges.

6.7 Template versioning and drift [§3.5], hazard H5

A template’s structural state changes over time as widenings (§6.4) and parameter-type expansions accrue. Each change increments template_version and emits an audit event of type template_widened or template_type_expanded. ourios.miner.template.version_changes counts these.

Two distinct cross-cutting questions. “Same leaf, different structural snapshots” and “different leaves that mean the same thing” are separate problems, with separate query forms:

  • Cross-version (within one leaf). A leaf’s template_id is stable across every widening of that leaf (§6.1, “Template identity”); only template_version advances. So a literal predicate where template_id = X already returns rows from every version of leaf X by construction — no alias resolution required. To pin to a single structural snapshot, query where (template_id, template_version) = (X, V).
  • Cross-alias (across leaves). When a deploy changes a log line enough that the miner allocates a new leaf instead of widening the existing one, the operator has two template_ids for what is semantically the same template. Resolving “all rows for the thing X represents” then requires walking an alias set that spans leaves. RFC 0002 §5.4 exposes this as where template_id.resolves_to(X); bare where template_id = X does not follow alias chains.

The data model in §6.1 supports both shapes: cross-version is free because template_id is stable across widenings; cross-alias is served by a separate alias index that maps a representative template_id to the equivalence class of template_ids the operator (or a future inference layer) considers semantically the same template.

Alias index lifecycle. Cross-alias is structurally distinct from cross-version: widening is intra-leaf and increments template_version (one template_id, several versions); aliasing is inter-leaf and groups template_ids the miner allocated separately (different template_ids, the operator asserts they mean the same thing). template_widened events therefore do not populate the alias index — they live on the cross-version axis.

Alias write path: operator-driven and audited [§3.1]. The alias index is operator-driven, never silently inferred. An alias assertion — “leaf B is the same template as leaf A” — is an explicit operator action recorded as an audited, durable event, exactly as §3.1 requires of any merge (“every merge emits an audit event; explicit”). Automatic inference is the precise failure mode §3.1 forbids: an auto-aliased cross-semantic merge is a silent merge by another name. The amendment below replaces the previous “no creation event / produced out of band” deferral with the mechanism.

Amendment (alias write path, 2026-06-07). Resolves the §9 open question “Alias index creation mechanism.” The alias index is produced by operator-driven, audited, reversible assertions on the §6.4 audit stream; automatic inference is deferred to a possible future propose → operator-confirm layer (proposals never enter the active index unconfirmed). Adds the alias_asserted / alias_retracted audit events, the per-tenant alias-map projection the querier reads, and §5 scenarios RFC0001.12–RFC0001.16. The RFC re-enters the ladder at specified until those scenarios land (precedent: the RFC 0003 served-binary amendment).

The alias model. An alias set is an equivalence class of template_ids within one tenant [§3.7]. Membership — which template_ids are in the class — is the only thing that carries contract weight: resolves_to expands by membership and nothing else (RFC0001.13). The class also has a canonical representative, defined as the numerically smallest member of the materialized set. The canonical representative is a derived display/identity convenience, not what defines membership: it gives the set a stable name and is deterministic regardless of assertion order, but it plays no part in deciding who belongs to the class. This derivation rule is an evolvable implementation detail, not a contract — it may change (for example, letting an operator designate a preferred representative) without changing alias-set semantics, precisely because resolves_to expands by membership regardless of which member is canonical. (This is distinct from the event-level representative_id below, which is merely the operator’s anchor id for an assertion and need not equal the derived canonical.) Membership is cross-leaf only: it groups template_ids the miner allocated as separate leaves. It never crosses the cross-version axis — every version of a single leaf already shares one template_id (§6.1, “Template identity”), so template_version is not an alias concern and the alias index holds no template_version field. The two axes stay disjoint: widenings move a leaf along template_version; aliasing groups distinct template_ids.

The assertion event. Aliasing is expressed by two new AuditEventType variants on the §6.4 audit stream, carrying an alias-specific payload:

{
  event_type: AuditEventType,  # alias_asserted | alias_retracted
  tenant_id: TenantId,
  representative_id: u64,       # operator's anchor id for this
                               # assertion — names one member of the
                               # asserted set; carries no contract
                               # weight beyond that and need not equal
                               # the set's derived canonical (smallest)
  member_ids: Vec<u64>,        # the other ids grouped by / removed
                               # from the set in this assertion
  actor: ActorId,              # operator / API principal that
                               # issued the assertion — aliasing is
                               # never anonymous (§3.1 "explicit")
  reason: Option<String>,      # operator-supplied justification
                               # (e.g. "deploy 2026-06 re-split the
                               # login template"), <= 256 B
  timestamp: SystemTime,
}

The asserted set of an event is the full union {representative_id} ∪ member_idsrepresentative_id is one named member of that set, never excluded from it. An alias_asserted event groups its entire asserted set into one equivalence class; an alias_retracted event removes every id in its asserted set from their class. Membership is therefore defined purely by the union of ids in the event, independent of which id the operator chose as the anchor.

These events flow through the same audit stream as template_widened (§6.4) and inherit its durability contract: they are written to the WAL and become durable under the §3.4 WAL-before-ack barrier before the assertion is acknowledged to the operator. An assertion that has not hit the WAL is not acknowledged — there are no in-memory-only aliases. Unlike widenings, alias events are not emitted on the ingest hot path; they originate from an explicit operator/control-plane call, so they do not gate attach latency. They do not increment ourios.miner.merges (that counter is reserved for the two structural widenings, §6.4); alias activity is counted separately as the ourios.miner.alias.assertions / ourios.miner.alias.retractions counters enumerated in §6.8’s telemetry table (mandatory, ourios.tenant attribute), keeping operator-driven aliasing first-class telemetry per [§3.1] / §6.3 alongside ourios.miner.merges.

Materialization and storage. The durable alias event log (the alias_asserted / alias_retracted stream above) is the source of truth; the queryable per-tenant alias map is a projection built by folding that log per tenant. Each alias_asserted unions its full asserted set ({representative_id} ∪ member_ids) into one equivalence class, merging any pre-existing classes that share a member so that overlapping assertions converge on a single class regardless of arrival order; each alias_retracted removes its asserted set’s ids from their class. The canonical representative of each materialized class is then derived as min(members) — so it re-derives automatically when membership changes and never depends on which id any event named as its anchor. A class that drops to a single member is no longer an alias set (that lone id resolves only to itself, RFC0001.16). The folded result is persisted as a per-tenant artifact (one map per tenant, not per partition) that the querier reads at compile time — note: that persisted artifact is the deferred cache of the 2026-06-12 amendment below; in v1 the querier folds the map directly from the audit stream and no artifact exists yet. Three venues were considered:

  • Per-tenant projection from the audit/alias event log (chosen). Aliases are a tenant-scoped projection rebuilt from the durable event log and persisted as a small per-tenant alias-map artifact under the tenant root. This reuses the §6.4 audit infrastructure end-to-end (no new write plane), matches the tenant scope of the data (§3.7), and keeps the truth (the event log) append-only and replayable while the map is a cache that can always be rebuilt by re-folding the log.
  • Tenant-root aliases.parquet / JSON written directly (rejected as the source of truth). A directly-mutated file has no audit trail of its own; it would either duplicate the event log or become an unaudited mutation point, violating §3.1’s “every merge is audited.” It survives only as the serialization format of the projection above — a storage-layer detail (RFC 0005), not the model.
  • Extending the partition Manifest (rejected). The manifest is partition-scoped (Manifest { generation, files }, one per partition); alias sets are tenant-global. Putting a tenant-global structure in a partition-local manifest would fragment one logical map across every partition and force N-partition fan-in on every query. Poor fit.

Eventual consistency / read staleness. The querier and the ingester/control-plane run in separate processes (§6.6 names the same seam for the live template registry). The alias map the querier reads is therefore eventually consistent with the latest assertion: an alias asserted or retracted at t becomes visible to queries once the projection is rebuilt and republished, not instantaneously. This is the same staleness window the §6.6 querier↔registry seam already accepts. Staleness is bounded in both directions by the projection refresh / snapshot cadence:

  • A not-yet-visible assertion makes resolves_to(X) return a subset of the eventual membership — it never returns rows from a set the operator did not yet assert, so it can only temporarily under-include.
  • A not-yet-visible retraction leaves a stale projection still expanding to the old, larger set, so resolves_to(X) can temporarily over-include a member the operator already removed.

Both are transient and self-correcting on the next projection rebuild, and neither cross-contaminates across tenants (§3.7) or fabricates a grouping no operator ever asserted — the over-inclusion is always a previously asserted membership, never a phantom one. The bound on the window (snapshot cadence) is a storage/serving-layer knob, deferred to the RFC 0005 storage decision (see §9).

Amendment 2026-06-12 — the storage decision is made (v1), by the RFC 0005 line. alias_asserted / alias_retracted persist as event_kind 4 / 5 on the RFC 0005 §3.7 audit stream, and in v1 the querier derives the per-tenant alias map at query-compile time by scanning the tenant’s audit stream for those kinds and folding them through this section’s projection semantics (RFC 0005 §3.7.1) — there is no persisted map artifact yet, so “the projection is rebuilt and republished” above reads, in v1, “the audit events are durably written and flushed”; the staleness bound is audit-flush visibility rather than a snapshot/rebuild cadence, with the same bounded under-/over-inclusion directions. The cached per-tenant artifact (file/format/cadence) stays deferred behind the RFC 0009 §3.4 manifest fork; because the event log remains the source of truth, adding the cache later changes no query-visible semantics — the same v1-full-replay-now / accelerate-later shape as §6.9’s snapshot.

Reader / query contract. RFC 0002’s template_id.resolves_to(X) (RFC0002.9, §5.4) loads the requesting tenant’s alias map at compile time and expands X to its alias-set membership: template_id IN {X} ∪ members(set containing X). With no assertions for the tenant, or for an X in no set, the membership is exactly {X} — identical to today’s base-member stub and to bare template_id = X for that id (RFC0001.6 still holds: bare equality never follows alias chains). Expansion is by the set, not by direction: passing any member of a set (representative or not) resolves to the whole set.

Reversibility. An operator can retract an alias; the retraction is itself an alias_retracted audit event with the same durability and isolation guarantees, and the projection drops the retracted membership on its next rebuild. Aliasing and un-aliasing are both explicit, both audited, never silent — closing the loop with §3.1.

sequenceDiagram
    participant Op as Operator / control plane
    participant WAL as WAL (§3.4 barrier)
    participant Log as Alias event log (§6.4 stream)
    participant Proj as Per-tenant alias-map projection
    participant Q as Querier (resolves_to)
    Op->>WAL: assert alias {rep, members, actor, reason}
    WAL-->>Op: ack (only after fsync — §3.4)
    WAL->>Log: append alias_asserted (durable)
    Log->>Proj: fold per tenant (rebuild + publish)
    Q->>Proj: load tenant alias map at compile
    Q->>Q: resolves_to(X) -> template_id IN members(set with X)
    Note over Op,Proj: retraction is the symmetric alias_retracted event

Future work — automatic inference. A later layer may propose aliases from a post-deploy heuristic (e.g. a burst of template_widened / fresh-leaf allocations correlated with a deploy timestamp). Such proposals are never written to the active alias index directly; they enter a review queue and become real only via the same operator-confirmed alias_asserted event specified above. This RFC does not specify that layer — see §9.

Drift detection as a first-class query. “Templates that gained a new version in the window [t1, t2]” is a query against the audit event stream:

SELECT template_id, MIN(old_version), MAX(new_version),
       COUNT(*) AS widening_count,
       MIN(timestamp), MAX(timestamp)
FROM template_audit
WHERE event_type IN ('template_widened', 'template_type_expanded')
  AND timestamp BETWEEN $t1 AND $t2
GROUP BY template_id
ORDER BY widening_count DESC

(SQL shown for spec clarity; the user-visible form is the RFC 0002 DSL, not raw SQL — see hazard H6.) Operators use this query after deploys to spot templates whose structure changed; a sudden cluster of template_widened events correlated with a deploy timestamp is exactly the H5 detection signal.

6.8 Telemetry [§3.1], §6.3

Amendment 2026-06-03. Telemetry export is realigned from a Prometheus client/scrape model to the OpenTelemetry SDK (the maintainer direction recorded against RFC 0009 §3.6 and the roadmap §5 note). This amendment fixes the export architecture and the Prometheus-era terminology (registry → meter provider, scrape → OTLP push, labels → attributes) throughout §§6.8–6.9 and the §5 scenarios.

Amendment 2026-06-08 (dotted-semconv migration — landed). The metric and attribute names are now the dotted-ourios.miner.* scheme, defined in the semconv/registry/ weaver registry alongside the compaction set (RFC 0009 §3.6) and consumed through the generated ourios-semconv constants. The table below lists the registry names. Instrument kinds are unchanged from the original set; the confidence.p50 / confidence.p01 gauges remain in-process views of the confidence histogram (RFC0001.8) — the question of whether they become collector-/backend-derived quantiles over the exported histogram is a genuine contract change to the §3.1.2 mandatory set and stays deferred to its own review, not folded into this rename.

The registry names were audited against the OpenTelemetry semantic-conventions naming rules (the canonical docs/general/{naming,metrics}.md plus the weaver registry policies): counters drop the Prometheus _total suffix and take singular {annotation} units; the two fraction-of-total gauges use the conventional .utilization segment (unit 1) rather than a bespoke .ratio; and the per-line elapsed-time histogram is ourios.miner.duration (UCUM s), the conventional segment for a discrete operation’s elapsed time, not latency.

Export architecture (OTel SDK + OTLP)

Metrics are instrumented through the OpenTelemetry meter API and exported via the OTel SDK’s OTLP metric exporter (push, over OTLP to a collector / endpoint). There is no prometheus client crate and no /metrics scrape endpoint; any Prometheus compatibility is a downstream collector concern, not Ourios’s.

The dependency split follows the standard OTel layering so the heavy SDK and transport crates do not leak into every library:

  • Instrumented crates (ourios-miner, ourios-parquet, ourios-ingester, ourios-querier) depend only on the lightweight opentelemetry API crate and resolve instruments through global::meter("ourios.<subsystem>"). No SDK, no OTLP, no transport dependency in a library crate.
  • A new ourios-telemetry crate owns the heavy deps — the opentelemetry_sdk and opentelemetry-otlp crates (the upstream package names, underscore and hyphen respectively) plus the OTLP transport. It exposes an init() that builds the OTLP push MeterProvider (periodic-reader export, interval configurable), installs it as the process-global provider, and returns a guard whose shutdown() flushes pending metrics on exit. The binary (ourios-server) calls init() once at start-up; benches and integration tests call the same entry point or substitute an in-memory reader. Adding this crate extends the CLAUDE.md §7 target layout; the new-crate commitment is blessed here, in this RFC, per §7’s rule.

Dimensions are OTel attributes, not Prometheus labels, and OTel splits them in two: resource attributes identify the telemetry producer and are set once on the MeterProvider; data-point attributes vary per measurement. Ourios’s own identity — service.name = ourios-<role> (e.g. ourios-ingester, ourios-querier, matching the role the ourios-telemetry crate initialises the provider for; with service.version, etc.) — is a resource attribute: per the semantic conventions it MUST be set once on the provider’s Resource and MUST NOT be repeated on individual data points.

The per-measurement dimensions in the table below — among them ourios.tenant, the originating service of the ingested logs, and per-metric dimensions like event_type — are data-point attributes. A single ingester multiplexes many tenants and many source services, and [§3.1] / [§3.2] require per-(tenant, service) breakdowns — notably the §6.5 / H2.2 per-service overflow alert — which a single producer-level resource attribute could not provide. The service dimension here is the log’s source service (the value §6.1’s tenant derivation reads), distinct from Ourios’s own service.name — it must not reuse that reserved resource key. It is exported under the dedicated ourios.service attribute key; the tenant dimension is ourios.tenant and the merges change-kind is ourios.miner.template_change (registry enum widened / type_expanded). All three are defined in semconv/registry/attributes.yaml and consumed through the generated ourios-semconv constants.

The mandatory set is defined by the semconv registry: the ourios.miner.* entries in semconv/registry/, surfaced as the generated ourios_semconv::OURIOS_MINER_* constants, are the source of truth for which metrics the miner must expose. Each is registered as an instrument on the ourios.miner meter when the miner is constructed.

OTel’s metric model is collect-on-read: a reader / exporter sees the data points produced during a collection cycle, and an instrument contributes a data point on its first real measurement. The miner emits no synthetic zero-traffic points — so every exported series carries the registry’s required attributes, with no sentinel attribute value to collide with a real tenant / service or violate the template_change enum. §3.1.2 is verified by exercising every instrument with a small representative workload and collecting the metric stream (an SDK in-memory reader in tests): the registry pins the mandatory set; the collection proves each instrument is registered and emits real data under the required attributes.

The metrics enumerated in [§3.1] are mandatory. Full set (the dotted-ourios.miner.* registry names; the dimensions shown are exported as attributes, not labels — tenant is ourios.tenant, service is ourios.service):

MetricInstrument kindAttributesSource invariant / hazard
ourios.miner.template.countgaugetenant[§3.1]
ourios.miner.mergescountertenant, template_change[§3.1], H1
ourios.miner.alias.assertionscountertenant[§3.1], §6.7, H5
ourios.miner.alias.retractionscountertenant[§3.1], §6.7, H5
ourios.miner.confidencehistogramtenant, service[§3.1], §6.3
ourios.miner.confidence.p50gaugetenant, service[§3.1]
ourios.miner.confidence.p01gaugetenant, service[§3.1]
ourios.miner.body_retention.utilizationgaugetenant[§3.1], [§3.3]
ourios.miner.parse_failurescountertenant, service[§3.1]
ourios.miner.params.overflowcountertenant, service[§3.2], H2
ourios.miner.params.overflow.utilizationgaugetenant, service[§3.2], H2
ourios.miner.template.version_changescountertenant[§3.5], H5
ourios.miner.durationhistogramtenanthot-path budget (D1)

ourios.miner.confidence.p50 and ourios.miner.confidence.p01. The ourios.miner.confidence histogram is the source of truth; the two gauges are convenient named views derived from it in-process. The miner recomputes them on a short ticker (default 10 s, configurable; the cost is one quantile evaluation over the histogram per tenant per service per tick — negligible relative to the hot path) and caches the value between ticks so a metric export cycle never blocks on recomputation. The gauges exist so alerting rules and runbooks can name them directly per [§3.1] rather than spelling out a histogram_quantile(...) expression at every reference.

The histogram bucket boundaries are tuned to straddle the decision boundary at 1.0 (see §6.3): default buckets [0.1, 0.3, 0.5, 0.7, 0.9, 0.95, 1.0, 1.05, 1.2, 1.5, 2.0, +Inf].

6.9 Persistence and recovery

Amendment 2026-06-10 (snapshot store / cadence / scope resolved). The three §9 open questions deferred from this section — target store, cadence, and scope — are now pinned. The snapshot is a rebuildable recovery-acceleration cache, not durable state (the WAL is the truth per [§3.4]), which is what licenses the choices: local disk, WAL-adjacent (object storage deferred, see §9); per WAL-segment-rotation cadence, recording the WAL high-water mark; per-tenant scope. The format is a leading u8 version byte then a payload; recovery dispatches on byte 0. In v1, recovery replays the full WAL in both the known- and unknown-version branches, because the resume-from- high-water-mark optimisation needs the RFC 0008 §6.7 checkpoint / replay-from-offset API, which is not yet implemented; the format records the high-water mark so the optimisation can be switched on later without a format change. This makes the §3.5.1 / §3.5.2 acceptance criteria buildable. The matching §9 entries are marked RESOLVED.

Amendment 2026-06-12 (v2 — restore switched on). The RFC 0008 §6.7 checkpoint / offset-carrying-sink API this section was gated on is now specified to land (RFC 0008’s same-day §6.1/§6.7 amendment is the other half of this design), so the known-version branch of the recovery algorithm below restores the tree and replays only the WAL tail above the snapshot’s recorded high-water mark S — exactly as written in step (2), with no format change (the mark has been in the payload since v1). Three rules complete the design. Per-consumer horizons: RFC 0008’s replay delivers every surviving frame with its offset; the recovery driver suppresses per consumer — the Parquet path consumes only frames above the RFC 0008 checkpoint X (below it they are already published), the miner only frames above S (below it they are already folded into the snapshot; re-feeding would double-apply — the v1 hazard, now resolved by routing rather than by refusing to restore). The rule covers both orderings: in the steady state S ≥ X the miner consumes a suffix of what Parquet consumes; with a lagging snapshot (S < X) the miner additionally consumes the (S, X] frames the floor retained, closing its state gap while Parquet suppresses them. Truncation floor: the ingester passes the latest durable snapshot’s S to Wal::housekeeping as a retain floor, so the WAL never unlinks a frame no snapshot has captured (RFC 0008 §6.7 — closes the S < X template-drift hole, hazard #5). Stale-snapshot fallback: if recovery nevertheless finds the WAL truncated past S (external mutation — segment files manually unlinked from wal_root; the floor prevents the gap arising internally), it restores the snapshot, replays the surviving frames above S, and emits a structured warning naming the gap. The data side is complete provided the truncation did not exceed X — legitimate housekeeping never unlinks a frame above the checkpoint, so everything missing is in Parquet; manual deletion beyond X would be unrecoverable acknowledged-data loss, which is exactly why segment removal is reserved to housekeeping and never an operator action. Templates first seen inside the (S, X] gap may re-mint (drift surfaced via RFC 0010, not silent). New acceptance criteria: §3.5.3 (restore-equivalence), §3.5.4 (stale-snapshot fallback); the end-to-end driver contract is RFC 0008’s RFC0008.10.

Hot path. The per-tenant tree lives in process memory on the ingester. Tree operations (descend, simSeq, attach, widen) are hot-path; persistence does not happen synchronously per line.

Durability via WAL replay. [§3.4] (WAL-before-ack) means every line that reached the miner is in the WAL before the ingester acknowledged it. The tree state is therefore derivable from the WAL: a cold start with no snapshot replays the WAL in order through the miner and reconstructs the trees. This is correct but slow at scale; the snapshot mechanism is an optimisation on top.

Replay mode. Cold-start replay re-walks attach, widen, and expand_slot_types against the same code path live ingest uses. Doing so naively would re-fire every counter increment, every histogram observation, and every gauge update for the entire replay window, polluting steady-state metrics for the post-restart horizon (a 10-minute replay on a high-volume tenant could shift ourios.miner.merges by orders of magnitude in a few seconds). The miner therefore runs in replay mode until the WAL cursor reaches the live tip: domain events are processed and tree state is mutated exactly as in live ingest, but updates to the §6.8 metrics are suppressed (counters do not increment, histograms do not observe, gauges retain their previous value or, if the miner has never served live traffic, their zero / empty initialisation value). Suppressing the update path means the replay window contributes no data points — each instrument still surfaces on its first live measurement once replay completes (§3.1.2’s registry-defined set is satisfied by instrument registration plus real post-replay traffic, not by replay-window points). A single wal_replay_progress gauge (attribute ourios.tenant, value: fraction of the tenant’s replay window completed in [0.0, 1.0]) is exposed during replay so operators can see the cold-start curve and confirm replay finished. This metric is replay-only and is not part of the §3.1 mandatory set; it is documented here, not in §6.8’s table.

Snapshot mechanism. A snapshot is a rebuildable recovery-acceleration cache, not durable state. The WAL is the durable truth ([§3.4]); the snapshot exists only to shorten cold-start replay. A lost, absent, or corrupt snapshot is never a data-loss event — it degrades to a full WAL replay (the same path a miner that never wrote a snapshot takes). This framing is what licenses the store and recovery choices below: because the snapshot is cache, it may live on local disk, and a reader that cannot trust it may discard it without ceremony.

Target store: local disk (WAL-adjacent); object storage deferred. Snapshots are written to a local artefact next to the WAL (e.g. under the WAL root), not to object storage. [§3.6] makes local disk legitimate here precisely because the snapshot is cache, not truth — the constraint [§3.6] imposes is that no feature rely on local disk being durable beyond the WAL horizon, and snapshot recovery never does: anything the snapshot would have accelerated is still in the WAL. Object-storage snapshots are explicit future work (see §9); they would couple to the RFC 0009 §3.4 atomic-publish manifest to define a durable, multi-writer publish point, and are deferred for that reason.

Scope: per-tenant. One snapshot artefact per tenant tree, matching [§3.7]’s per-tenant trees. Recovery loads the latest snapshot per tenant independently; there is no cluster-wide combined artefact.

Cadence: per WAL-segment rotation. A snapshot is taken at WAL-segment-rotation boundaries. The snapshot records the WAL high-water mark — the WalOffset (RFC 0008 §6.1) up to which its tree state reflects appended frames — so that a future optimisation can resume replay from there rather than from the start of the log.

Format: a leading u8 version byte, then the payload. Byte 0 is the snapshot format version; the remaining bytes are that version’s serialised payload. The payload captures the per-tenant state needed to reconstruct the miner: the tree leaves (template token sequence, template_id, template_version, the (severity_number, scope_name) template key of §6.1, and the per-slot slot_types of §6.1), the structured-template-id map allocated in §6.2’s structured short-circuit, and the WAL high-water mark above. The concrete payload codec is an implementation detail behind the version byte — the version byte is what makes format evolution safe, so this RFC pins the framing, the captured state, and the rule that the reader dispatches on byte 0, and deliberately does not pin a specific serialisation codec.

Recovery algorithm. On ingester restart, per tenant:

  1. Load the latest snapshot artefact for the tenant, if one exists.
  2. If byte 0 is a known version: deserialise the payload, restore the tree, then replay only the WAL tail above the snapshot’s recorded high-water mark S — the driver delivers each replayed frame to the miner only when its offset is > S (per-consumer routing, RFC 0008 §6.6; active as of the 2026-06-12 v2 amendment above). If the surviving WAL no longer reaches back to S, apply the stale-snapshot fallback of the v2 amendment: restore, replay what survives, warn.
  3. If byte 0 is an unknown version, or the snapshot is absent or corrupt: discard it and replay the full WAL via Wal::replay (RFC 0008 §6.1 API, RFC 0008 §6.6 recovery procedure), rebuilding the tree from scratch.

v1 scope — rebuild from a full replay; do not restore yet. (Superseded by the 2026-06-12 v2 amendment above — restore is now switched on; this paragraph is retained as the record of why v1 refused to restore.) The restore-then-replay-the-tail path in step (2) requires the RFC 0008 §6.7 checkpoint / replay-from-offset API (Wal::checkpoint and the CHECKPOINT sidecar), which was not yet implemented at the time. Restoring a tree from a snapshot and then replaying the full WAL (the only replay available without offset support) would double-apply every frame the snapshot already captured, corrupting the tree. So until offset-resume landed, recovery ignored the snapshot payload and rebuilt the tree from a full Wal::replay in both branches — the known-version branch did not restore. What landed in v1 was the snapshot format (the leading version byte and the recorded high-water mark) and the version-dispatch + WAL-fallback contract; v2 switches the restore path on with no format change, exactly as planned. v1 fully satisfied §3.5.1 (the artefact carries a leading version byte) and §3.5.2 (an unknown version is rejected and falls back to full WAL replay).

Snapshot-load telemetry. The wal_replay_progress gauge (above) remains the replay-only signal. A snapshot-load-outcome signal — distinguishing “snapshot restored,” “unknown version → full replay,” and “absent/corrupt → full replay” — is named here in prose; its concrete metric and attribute names go through the semconv weaver registry when the slice is implemented (§3.1.2), and are not invented as flat names in this RFC.

Migration. When the in-memory data model in §6.1 changes (new field, retired field, semantic change), the snapshot format’s version byte increments. Old snapshots are read-compatible only if the change is additive (new optional fields tolerated). For breaking changes, snapshots from the prior version are discarded and the tree is rebuilt from WAL replay. [§3.5]’s schema-change discipline applies: the change goes through an RFC.

7. Alternatives considered

Alternatives to Drain itself, evaluated as primary algorithms. Each is rejected for the reason given; some have a possible secondary role noted.

Spell (LCS-based online parser)

Spell uses longest-common-subsequence to compare a new line against existing templates. Per-line cost is O(template_count × line_length) without depth bounding, which is several orders of magnitude slower than Drain’s O(d) tree walk at the template counts we expect (10²–10⁴ per tenant). LCS also makes parameter positions ambiguous on lines where the same token recurs, because the LCS alignment can shift; Drain’s positional matching gives unambiguous parameter slots. Rejected as the primary algorithm.

IPLoM (iterative partitioning)

IPLoM does three passes over the entire log, each splitting clusters by a different criterion (token count, position, token uniqueness). This requires the full log up front and is offline by design. Rejected as the primary algorithm. Possible secondary role: a periodic offline reconciliation pass could use IPLoM to detect template fragmentation that Drain’s online structure missed (e.g. two leaves that should have been one because their discriminating token was spurious). This is a follow-up RFC topic, not a §6 commitment.

LenMa (length-based clustering)

LenMa groups lines by token-count length, then finds templates within each length group via a similarity-based second pass. The length-only initial grouping is close to Drain’s first level, but the absence of the token-prefix tree leads to more spurious merges within a length group (any two same-length lines are candidates, not just same-length-and-same-prefix lines). Drain’s tree is a strict refinement of LenMa’s grouping. Rejected as the primary algorithm — Drain dominates on the same workload.

LogPPT / LILAC / LLM-based parsers

Transformer-based parsers achieve higher accuracy on benchmark corpora (LogPAI scores) but require model inference per line. At the D1 hot-path budget (≥ 100k lines/s/core), per-line transformer inference is infeasible without specialised hardware that contradicts §1’s “single Rust binary” framing. Rejected as the primary algorithm. Possible secondary role: offline labeling-aid on the testdata/corpus/ to bootstrap a labeled set for confidence calibration; or as a periodic reconciliation pass similar to IPLoM. Both are deferred to follow-up RFCs.

Offline clustering (e.g. nightly hierarchical agglomerative)

Quality is high; latency is unacceptable. Logs ingested at 14:00 would not be queryable until the next clustering window completes. This contradicts §2’s online motivation. Rejected as the primary algorithm. Possible secondary role: the same reconciliation pass mentioned under IPLoM and LLM-based could use offline clustering to validate Drain’s online output and surface drift; a follow-up RFC if and when reconciliation becomes a real concern.

8. Testing strategy

Mapping to [§6.2]. Each technique below names the §5 scenarios it operationalises; the test code carries the matching id in a doc comment per docs/verification.md §2.3 so grep -R "H1.1" . resolves bidirectionally between RFC and tests.

  • Unit tests for tree operations: tokenize, mask, descend, simSeq, widen, attach, build_params. Each operation tested in isolation against fabricated inputs. Covers: RFC0001.3 (tokenizer whitespace-only), RFC0001.4 (confidence ratio + decision boundary), RFC0001.7 (combined widening + type-expansion in one attach).

  • proptest for §6.6 reconstruction: for every generated line shape (length, separator distribution, masking outcome), reconstruct(mine(line)) == line or mine(line).lossy_flag == true. Property failure blocks merge. Covers: H7.1, H7.4, §3.3.1.

  • Corpus tests on testdata/corpus/ (fixed, anonymised; see docs/benchmarks.md §1): assert bounds on ourios.miner.template.count, ourios.miner.merges, reconstruction accuracy, parameter overflow rate. Regressions are build failures, not warnings. Covers: H1.1 (login/logout corpus arm), H7.1 (corpus arm).

  • Confidence calibration test: on a labelled subset of the corpus, verify the three-zone classification in §6.3 against the human labels. Covers: H1.2.

  • Merge-audit assertion (negative + positive): no widening or type-expansion completes without a matching audit event, and fresh-leaf creation does not emit one. Runs on every corpus pass and on the synthetic widening fixtures. Covers: H1.3, H5.1, H5.2, RFC0001.1 (negative — no event on creation), RFC0001.2 (rejection event for degenerate widening), RFC0001.7 (event ordering arm).

  • Multi-tenant isolation (negative test): interleave lines from two synthetic tenants through a single MinerCluster; assert that templates mined under tenant A never appear in tenant B’s tree and vice versa. Implements docs/benchmarks.md E2. Covers: §3.7.1, §3.7.2.

  • Per-ResourceLogs tenant derivation (miner-side stub): assert that when records carrying distinct derived tenant_ids arrive in the same ingest sequence, each lands in its derived tenant’s tree. The receiver-side test — that the wire-decode layer actually derives tenant_id per ResourceLogs.resource rather than per ExportLogsServiceRequest — is owned by RFC 0003 (see RFC 0003 §6.3); RFC 0001 owns only the miner-side contract. Covers: §3.7.3.

  • OTLP-aligned template-key tests: hand-curated OtlpLogRecord fixtures exercising the §6.1 Template-key composition tuple. Assert that varying only severity_number produces distinct template_ids, varying only scope_name produces distinct template_ids, the severity_number = 0 (UNSPECIFIED) and scope_name = None edge buckets are each their own key value, and body.kind != AnyValue::String short-circuits per §6.2 step 0 with the §6.1 sentinel confidence = 1.0, lossy_flag = false. The time_unix_nano round-trip is a small unit test against the §6.1 record schema. Covers: H1.4, H1.5, RFC0001.9, RFC0001.10, RFC0001.11.

  • Drift detection test: ingest a corpus where a template deliberately drifts mid-stream; assert that the drift query in §6.7 returns the drifted template within the expected window. Covers: H5.3.

  • Crash recovery test (snapshot + WAL replay): SIGKILL the ingester between snapshot writes; assert that recovery reconstructs the same tree state that was acknowledged before the kill. Also corrupt the snapshot’s leading version byte and assert WAL fallback. This is [§3.4]’s crash-recovery test extended to cover the miner’s persistence layer. Covers: §3.5.1, §3.5.2.

  • Restore-equivalence test: snapshot a tree at high-water mark S, append further frames, recover via restore-plus-tail-replay, and assert tree-state equality against a from-scratch control (the recovered state is compared field-by-field via the §6.9 snapshot payload of both trees, so the comparison itself can’t hide drift). A counter on the test sink asserts no frame ≤ S reached the miner. The stale-snapshot arm deletes the segments holding (S, tail]’s prefix, recovers, and asserts the structured warning names the gap while the surviving frames still fold. Covers: §3.5.3, §3.5.4 (the end-to-end driver half is RFC 0008’s RFC0008.10).

  • Configuration tests: assert default values and the rejection of out-of-bounds settings at startup. Covers: §3.1.1 (default threshold = 0.7), §3.2.1 (default param byte limit = 256), §3.2.2 (limit > 1 KiB rejected).

  • Metric collection test: assert the mandatory set equals the generated ourios_semconv::OURIOS_MINER_* constants (the registry is the source of truth), then construct a miner, ingest a small representative workload that exercises every instrument, collect its meter (global::meter("ourios.miner")) via an SDK in-memory reader, and assert the collected stream contains every §6.8 metric name (each appearing on its first real measurement, with the required attributes), with the instrument kinds and attributes in §6.8’s table, and that the ourios.miner.confidence.p50 / ourios.miner.confidence.p01 gauges track the same-attributed ourios.miner.confidence histogram quantiles. Covers: §3.1.2, RFC0001.8.

  • Data-model contract tests: small unit tests against the template_id query semantics that RFC 0002’s DSL compiles to. These cover the cross-version vs. cross-alias distinction at the data-model layer; the DSL surface itself is tested in RFC 0002. Covers: RFC0001.5, RFC0001.6.

  • Alias write-path tests: assert that an operator alias assertion emits a durable alias_asserted audit event under the §3.4 barrier and that folding the event log produces the expected per-tenant alias map; that resolves_to expands by the set (representative or member) and to {X} for an un-aliased id; that a retraction emits alias_retracted and drops membership on rebuild; and that an alias asserted in one tenant is invisible to another. The resolves_to DSL surface itself is exercised in RFC 0002 (RFC0002.9); these tests own the §6.7 write-path and per-tenant-map contract. Covers: RFC0001.12, RFC0001.13, RFC0001.14, RFC0001.15, RFC0001.16.

  • Reader behaviour test: assert the §6.6 Reader render contract — for a lossy_flag = true row the reader returns the body bytes verbatim (no in-band prefix/marker) carrying Reconstruction::RetainedVerbatim, and does not call reconstruct(); for a faithful row it calls reconstruct() and carries Reconstruction::Faithful. The reader never silently substitutes one rendering for the other, and never mutates the body bytes to carry the marker. (The clean-path read-time template-registry lookup is out of scope of this scenario per §6.6.) Covers: H7.3.

  • Overflow-path tests: synthesize a parameter exceeding the configured byte limit; assert the OVERFLOW marker, forced body retention, and metric increments. Wire the alert-rule fixture for the >1% rate trigger. Covers: H2.1, H2.2.

  • Tokenizer-failure tests: feed lines with embedded NULs, malformed UTF-8, and over-cap lengths; assert the parse-failure path retains the body and sets lossy_flag = true. Covers: H7.2.

  • Benchmark (criterion): per-line miner latency (target: median ≤ 10 µs/line on the §1 hardware baseline), ingest throughput (target: ≥ 100k lines/s/core, per docs/benchmarks.md D1). No §5 scenario; satisfies thesis-gate D1 directly at the Validated stage.

9. Open questions

Decisions explicitly deferred. Each must be resolved before this RFC’s status flips to accepted.

Persistence (from §6.9) — RESOLVED (2026-06-10). The three sub-questions are pinned in §6.9; the resolutions are recorded here and the remaining future work is the two items below them.

  • Snapshot target storeRESOLVED: local disk (WAL-adjacent); object storage deferred. The snapshot is a rebuildable recovery-acceleration cache, not durable state (the WAL is the truth per [§3.4]), so it lives next to the WAL on local disk. [§3.6] permits this because recovery never relies on the snapshot surviving — a lost snapshot degrades to a full WAL replay. See §6.9.
  • Snapshot cadenceRESOLVED: per WAL-segment rotation. A snapshot is taken at segment-rotation boundaries and records the WAL high-water mark (the WalOffset it was taken at). See §6.9.
  • Snapshot scopeRESOLVED: per-tenant. One snapshot artefact per tenant tree, matching [§3.7]; recovery loads the latest snapshot per tenant. See §6.9.
  • Object-storage snapshots (remaining future work). Pushing snapshots to object storage would couple to the RFC 0009 §3.4 atomic-publish manifest for a durable, multi-writer publish point; deferred until that line settles.
  • Resume-from-high-water-mark replayRESOLVED (2026-06-12): switched on as §6.9 v2. The RFC 0008 §6.7 checkpoint / offset-carrying-sink API is specified (RFC 0008’s same-day amendment); the known-version branch restores and replays only the tail above the snapshot’s high-water mark, with per-consumer routing, a housekeeping retain floor, and a stale-snapshot fallback. No format change was needed — exactly as this entry predicted. Acceptance: §3.5.3 / §3.5.4 + RFC0008.10. See the §6.9 v2 amendment.

Algorithm tuning (open until corpus exists).

  • Floor default 0.4 — confirm against the corpus. If the lossy zone is too wide (many lines retained that “should have” been parse failures), tighten; if too narrow (too many parse failures on lines a human would accept), loosen. This is per-tenant tunable; the question is the out-of-the-box default.
  • Tree depth d. Paper default 4; Drain3 default 4. Open question: do any of our representative corpora benefit from d = 3 or d = 5?
  • Max children per node. Drain3 caps at 100; the cap acts as a safety against unbounded fan-out from a bad masking rule. Confirm 100 is right for our corpora, or motivate a different number.

Edge cases.

  • Lines that contain a literal <*> (the wildcard sentinel we use in template strings) — escape on tokenize, or replace with a non-collision character (e.g. U+E000)?
  • Multi-line log entries (stack traces). Paper assumes single-line. Ourios position: deferred to RFC TBD on the OTLP receiver, since multi-line reassembly happens before the miner sees the line.

Multi-tenancy and operational lifecycle.

  • Tenant lifecycle. §3.7 commits to per-tenant trees but does not name when a tree is allocated (lazily on first ingest? eagerly via a control-plane command?), nor whether tenants can be paused, evicted under memory pressure, or deleted. Likely deferred to a future operator-console RFC, but the bookend events (TenantInitialised, TenantPaused, TenantDeleted) need to exist somewhere before §3.7 is operationally complete.
  • Per-tenant fairness and back-pressure. A noisy tenant can monopolise WAL bandwidth, blow up the tree, and starve well-behaved tenants. RFC 0001 has no rate-limit or back-pressure event in scope; this overlaps with the OTLP receiver’s responsibility and likely lives in a future ourios-ingester RFC.
  • Alias index creation mechanism (from §6.7) — RESOLVED (2026-06-07). Of the three candidates (operator-driven, automatic-inference, deferred entirely), the maintainer chose operator-driven + audited: an alias is an explicit, audited, reversible operator assertion on the §6.4 stream, never silently inferred (§3.1). The write path, the alias_asserted / alias_retracted events, the per-tenant alias-map projection the querier reads, and the eventual-consistency semantics are specified in §6.7 (“Alias write path”); the acceptance criteria are RFC0001.12– RFC0001.16 in §5.3. RFC 0002’s template_id.resolves_to(X) now has a defined backing index. Remaining future work: automatic inference is deferred to a possible propose → operator-confirm layer (proposals never enter the active index unconfirmed); see the §6.7 “Future work” note. The physical alias-map file/format and snapshot cadence are a storage decision owned by the RFC 0005 line, not RFC 0001 — RFC 0001 owns the model, the write path, and the criteria (sibling to the issue #147 split). Update 2026-06-12: the RFC 0005 line has made the v1 half of that decision — alias events persist as kinds 4–5 on the RFC 0005 §3.7 audit stream, and the querier derives the map by folding that stream at compile time (RFC 0005 §3.7.1; §6.7 amendment of the same date). The cached per-tenant artifact remains deferred behind the RFC 0009 §3.4 manifest fork.

Cross-RFC contracts pending.

  • Querier ↔ live template registry (from §6.6). Reconstruction’s lookup(template_id, template_version) is called by ourios-querier, which runs in a separate process from the ingester that owns the live tree. Candidates: querier reads snapshots from object storage (eventually consistent with live), querier asks the ingester via RPC at query time (couples query latency to ingester health), or templates ride a separate Parquet side-stream alongside records (eventually consistent, no RPC, new data plane). RFC 0002 needs the answer before its DSL can compile; this RFC names the seam.
  • Audit-event Parquet schema (from §6.4). The Rust audit struct is specified in §6.4; the on-disk Parquet column layout for template_audit belongs to a future ourios-parquet RFC. The §6.7 drift query assumes the schema exposes event_type, template_id, old_version, new_version, and timestamp as columns suitable for predicate pushdown.

Deferred to follow-up RFCs.

  • Reconciliation pass (IPLoM / offline clustering / LLM-based labeling) — if real-world drift turns out to be more than §6.4’s online widening can handle, a periodic offline pass becomes interesting. RFC at that point.
  • Cross-tenant template_fingerprint side column — only if a concrete consumer materialises (storage dedup across tenants, shared dashboards). Until then, do not add.

10. References

  • He, P., Zhu, J., Zheng, Z., Lyu, M.R. “Drain: An Online Log Parsing Approach with Fixed Depth Tree.” ICWS 2017.
  • Drain3: https://github.com/logpai/Drain3 (specific commit pinned in this RFC at the Specified-gate PR).
  • LogPAI logparser benchmark: https://github.com/logpai/logparser
  • CLAUDE.md §§ 2, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 4, 6.2, 6.3, 6.6.
  • docs/hazards.md H1, H2, H5, H7.
  • docs/benchmarks.md C1, C2, C3, C4, D1, E1, E2.
  • docs/rfcs/0002-query-dsl.md §5.4 (template primitives in the DSL surface; required to expose drift detection).
  • docs/verification.md §§ 2, 3, 6 (the maturity model and the acceptance-criteria contract this RFC will inherit at the Specified gate).
  • Future: docs/architecture/miner.md (this RFC graduates there on acceptance).

RFC 0002 — Query DSL


rfc: 0002 title: Query DSL — the Ourios logs query language (Branch B, surface β) status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-04-24 supersedes: — superseded-by: —

RFC 0002 — Query DSL

Status: accepted (2026-08-25, maintainer sign-off). Terminal. This RFC’s §9 header gated accepted on a readability pass with non-author reviewers plus a LogQL/Insights migration sketch; both are resolved (not waived) — the artifacts and the pass evidence are recorded on the §9 checkbox (docs/guides/query-cookbook.md, PR #742). Thesis-gates stand passing in docs/benchmarks.md §7.

Status note. The prior decision (§3) is resolved: the predicate sublanguage takes Branch B (distance from OTTL), on the β (pipe-composable) top-level surface. Decided 2026-06-07 from the audience analysis in §3.6 (primary: Perses dashboard authors; future: MCP agents). This RFC is now green — all 11 §5 acceptance criteria (RFC0002.1–.11) have passing tests (crates/ourios-querier/tests/it/rfc0002_dsl.rs), landed across PRs #143 (this spec) and #144–#154 (the red gate + implementation): the Branch-B parser + structured surface → one IR, the IR→DataFusion compile, YAML-embeddability + the structured JSON Schema, and resolves_to alias-set expansion via the RFC 0001 §6.7 operator alias map. §6 gives the design, §7 the grammar, §5 the criteria. Per the docs/rfcs/README.md ladder, validated and finally accepted (a maintainer flip) follow the §9 validation. Hazard 6 (CLAUDE.md §4 — no DataFusion/SQL leakage) constrains the whole design. Amendment 2026-07-15 (RFC 0031 L4): §6.3/§7 gain the param(n) and bucket(width) aggregation group terms (grammar v1.1, a §6.6 minor version) and §5 gains RFC0002.12–.16 (aggregation execution). The green above refers to RFC0002.1–.11; the new scenarios are red (#[ignore]’d stubs, the docs/verification.md §3 two-loop, landed in crates/ourios-querier/tests/it/rfc0002_dsl.rs) and are discharged by the implementing slices.

1. Summary

Ourios exposes a logs query DSL that does not leak DataFusion/SQL to users (CLAUDE.md §4 hazard 6). This RFC specifies it:

  • Predicate sublanguage — Branch B (distance from OTTL). An Ourios-native, query-ergonomic syntax over the OTel data model (the ingest contract): bare top-level fields (body, severity, trace_id), resource. / attr. prefixes, bare-identifier severity (severity >= error), first-class template + OTel-canonical primitives.
  • Top-level surface — β (pipe-composable). A predicate followed by pipe stages: … | range(-1h, now) | count by template_id | sort count desc | limit 10. Compact, single-line, and embeddable as a YAML scalar in Perses dashboards.
  • Two front-ends, one core. The string DSL (for humans, esp. Perses YAML) and a structured JSON surface (for MCP agents + programmatic clients) parse to the same query IR and compile to the same DataFusion LogicalPlan. Agents emit JSON, not syntax.

The design rests on ourios-querier (RFC 0007), whose execution layer (predicate pushdown, tenant isolation, QueryStats) is already implemented and tested (RFC 0007 §5 criteria all live; the RFC itself stays specified pending this DSL); this RFC adds the user-facing language in front of it.

2. Motivation

2.1 Why a DSL at all?

CLAUDE.md §4 hazard 6 commits Ourios to a DSL that does not leak DataFusion SQL. The reasons are stability (evolve the backend without breaking user queries), safety (full SQL exposes cross-tenant joins, unbounded scans, recursive CTEs we cannot audit), and fit (logs are a narrow domain; a narrow DSL is more ergonomic than a general one). This is branch-agnostic.

2.2 Why the prior decision mattered

“OTTL-inspired” was not a free decision. Borrowing OTTL syntax in a query context promises OTTL-literate users that their mental model transfers; if the syntax looks the same but behaves differently (OTTL mutates; a query filters), the surface actively misleads. §3 records how that decision was made.

3. The prior decision (resolved): distance from OTTL

Both positions were defensible; §3.1–3.4 keep the honest case for each for the record. §3.5 records the resolution; §3.6 the reasoning.

3.1 The case for borrowing (Branch A) — not chosen

  • Positive transfer for Collector-literate SREs. Engineers who write Collector/OTTL pipelines reuse that mental model at zero onboarding.
  • Reduces bikeshed surface. A pinned external spec inherits decisions rather than re-litigating them.
  • Ecosystem alignment. Diverging on surface syntax in the OTel orbit can read as gratuitous.
  • OTTL’s path grammar is correct about the data model, which any alternative must address anyway.

3.2 The case for distancing (Branch B) — chosen

  • The OTTL-literate population is a minority of OTLP users — most emit logs via an SDK and never touch OTTL.
  • Collector ergonomics become query verbosity. resource.attributes["service.name"] == "api" is loud in a query.
  • Shared syntax + different semantics misleads. Unfamiliar syntax is a safer failure mode than almost-familiar-but-wrong.
  • No evolving external spec to track (OTTL has had breaking changes).
  • Design freedom for query-context idioms (severity >= error, attr.foo).

3.3 What is shared regardless of branch

  • The OTel data model is the schema of log records (the ingest contract, not a design choice): attributes, resources, severity, body, timestamps, trace context.
  • The template + correctness primitives (template_id, confidence, lossy; drift-alias membership via resolves_to) are first-class (§6.3).
  • The compilation target is a DataFusion LogicalPlan, no SQL leakage (§6.5).

3.4 Consequences

DimensionBranch A (borrow)Branch B (distance)
Onboarding for Collector-literate SREsNear-zeroMild (new syntax, familiar semantics)
Onboarding for SDK / dashboard usersSame (OTel data model)Same
Maintenance costTrack pinned OTTL, amend on bumpsOwn the grammar
Same-syntax/different-meaning confusionRealAvoided
Spec sizeSmallerLarger (owned)
Ecosystem signallingAligned with OTelIndependent (in the data model: still aligned)
Design freedomConstrained by OTTLFree within the OTel data model

3.5 Resolution

Branch B (distance from OTTL), surface β (pipe-composable). Decided 2026-06-07 by the maintainer, on the audience analysis in §3.6 in lieu of the formal user research originally gated here (§9 now scopes that research to the accepted gate, not specified).

3.6 Why — the two audiences

The decision turns on two audiences that re-weight §3.1–3.4:

  1. Primary — Perses dashboard authors (declarative YAML/CRDs). Queries live as string scalars in versioned YAML. Brevity and low bracket/quote density win (readable scalars, clean diffs); the audience thinks in dashboard query languages (PromQL/LogQL), not OTTL. Branch B’s flat syntax + the β pipe surface embed cleanly on one line; Branch A on surface α would be multi-line and bracket-heavy.
  2. Future — MCP agents. Borrow-but-diverge (Branch A + the §6 divergence list it required) is the worst case for LLMs: strong public-OTTL priors pull a model toward real-OTTL constructs we do not support → plausible-but-invalid queries. A small, self-owned grammar (Branch B) has no priors to fight, is cheaper to embed in an MCP tool schema, and is enforceable with grammar-constrained decoding. And — decisively — agents need not generate syntax at all: they target the structured surface (§6.4).

The one strong case for Branch A (onboarding + signalling for Collector-literate SREs) lands on the audience that is not primary here, while its costs (semantic-confusion in the overlap zone; an externally-driven breaking cadence against long-lived dashboards + cached agent schemas) land squarely on these two. Distancing on surface syntax costs little ecosystem goodwill because we stay faithful to the OTel data model (§3.3) and because bespoke query syntax is the norm (LogQL, PromQL, CloudWatch Insights all diverge from any transformation language).

The full audience analysis is the drafting-assistance recommendation that informed this decision; its three OTel-ecosystem questions (is there an OTel query language to align with? is OTTL the expected querying surface? Perses+OTel query conventions?) are folded into §9.

4. Design principles

  1. Familiarity beats cleverness. A first-time reader understands a query within 30 seconds without a reference. No heavy sigils.
  2. No DataFusion/SQL leakage (CLAUDE.md §4 hazard 6). If explaining a surface form requires naming a DataFusion type, the form is wrong.
  3. Predicate, then pipeline. A query is a predicate (the where) followed by ordered stages (range, aggregate, sort, limit, project). Each reads independently.
  4. Template + OTel-canonical fields are first-class vocabulary, not pseudo-columns: template_id, confidence, lossy (drift-alias membership via resolves_to); service, trace_id, span_id, scope (the primary correlation/query dimensions per OTel maintainer guidance, §6.2).
  5. Every query has a time range — explicit range(...) or a tenant-configurable default window. Never an unbounded scan.
  6. One core, two surfaces. The string DSL and the structured surface are equivalent front-ends over one IR (§6.4); neither can express a query the other cannot.
  7. YAML-embeddable. A query is expressible as a single-line scalar that survives a YAML round-trip — a first-class constraint for the Perses audience, not an afterthought.
  8. The grammar is owned and versioned by this RFC (§7), not “inspired by” anything. Compatibility pledges are written, not implied.

5. Acceptance criteria

Given/When/Then, ids greppable from tests: each test carries the docs/verification.md §2.2 doc-comment form — /// Scenario RFC0002.<n> — <title>. plus /// See docs/rfcs/0002-query-dsl.md §5.. These specify the parser + compiler that front-ends the (already-implemented, RFC 0007 §5) execution layer.

  • RFC0002.1 — A Branch-B predicate parses and compiles to a filter [CLAUDE.md §4 hazard 6]

    • Given a Branch-B predicate (e.g. template_id == 42 and severity >= error)
    • When it is parsed and compiled
    • Then it yields the query IR and an internal DataFusion Filter (a private compilation artifact — never surfaced through the public API, RFC0002.3). Predicates over RFC 0007 §4.3’s pushdown keys prune the scan per that section’s split — template_id skips row groups (B1), time_unix_nano prunes partitions and row groups, tenant_id prunes partition directories (not row groups); for the subset the current ourios_querier structured request can express (template + time) the DSL result is identical to it. Severity compiles via the §6.2/RFC0002.5 severity_number mapping (the column is RFC 0005’s severity_number), not the severity_text equality the current request supports, and predicates over non-indexed fields (service, attr.*) compile to a correct Filter with no row-group-pruning claim (indexed service.name pushdown would be a future RFC 0005 §3.6 amendment).
  • RFC0002.2 — String DSL and structured surface compile to the same plan [§6.4]

    • Given a query expressed both as a β string and as the structured JSON surface
    • When both are compiled
    • Then they produce the same query IR (and hence the same LogicalPlan) — the one-core/two-surfaces invariant.
  • RFC0002.3 — No DataFusion/arrow/SQL leakage [CLAUDE.md §4 hazard 6]

    • Given the public DSL API (parse, compile, error types)
    • When a query parses, compiles, or fails
    • Then no datafusion/arrow/SQL type or message appears in any public signature or error string (compile- and string-level boundary test, mirroring RFC0007.3).
  • RFC0002.4 — A query without an explicit range gets the tenant default window [§4 P5]

    • Given a query with no range(...) stage
    • When it is compiled in a tenant context with a default window W
    • Then the plan carries a time-column filter equal to W — never an unbounded scan.
  • RFC0002.5 — Bare-identifier severity maps to its SeverityNumber [§6.1]

    • Given severity >= error (and warn, info, debug, trace, fatal)
    • When compiled
    • Then each maps, case-insensitively, to the §6.1 SeverityNumber for that level (error → 17, etc.) and compiles identically to the numeric form (severity >= 17). The name→number mapping is the documented §6.1 one (Ourios’s, aligned with the OTel ranges) — not an OTel-standardised threshold.
  • RFC0002.6 — First-class OTel-canonical fields resolve correctly [§6.2]

    • Given service, trace_id, span_id, scope used as bare fields
    • When compiled
    • Then each resolves to the RFC 0001 §6.1 column / resource-attribute it names (serviceresource["service.name"]), with no string-flattening required of the user.
  • RFC0002.7 — Parse/serialise round-trip is idempotent

    • Given any well-formed query (property-generated)
    • When parsed → serialised → parsed
    • Then the second parse equals the first (AST idempotence).
  • RFC0002.8 — A malformed query yields a specific, leak-free error

    • Given a syntactically or semantically invalid query
    • When parsed/compiled
    • Then it returns a specific error citing the offending token/clause and the §7 grammar — never a panic, never a DataFusion message.
  • RFC0002.9 — Template primitives compile [§6.3]

    • Given template_id == 42, resolves_to(42), lossy == true, confidence < 0.7
    • When compiled
    • Then each compiles to the documented plan (resolves_to expands to the alias-set membership of RFC 0001 §6.7), without leaking the underlying representation.
  • RFC0002.10 — A query is a YAML-safe single-line scalar [§4 P7]

    • Given the canonical serialisation of any well-formed query
    • When embedded as a YAML scalar and round-tripped through a YAML parser
    • Then the recovered string parses to the same query (the Perses- embedding guarantee).
  • RFC0002.11 — The structured surface validates against its published schema [§6.4]

    • Given the structured (MCP) query surface
    • When a request is validated against the published JSON schema
    • Then well-formed requests pass and compile; malformed ones are rejected by the schema before reaching the planner.

Amendment 2026-07-15 — aggregation execution criteria (RFC 0031 L4). RFC0002.12–.16 below are added together with the §6.3 param(n) / bucket(width) group terms; they specify lifting the querier’s explicit aggregation-stage rejection (today ourios-querier compile::validate rejects count/agg_fn stages as “not yet supported”) for the count family. Per the docs/verification.md §3 two-loop they are red (#[ignore]’d stubs in crates/ourios-querier/tests/it/rfc0002_dsl.rs) and turn green in the implementing slices; the status note’s green refers to RFC0002.1–.11. Execution of the sum/min/max/avg stages is discharged by the 2026-07-23 amendment (RFC0002.17–.20) below.

  • RFC0002.12 — count [by …] executes end-to-end and matches a naive oracle [§6.5]

    • Given a populated tenant store and a query <predicate> | range(…) | count by <field, …> over ordinary §7 fields (e.g. template_id, service) — and the bare count (no by)
    • When the querier executes it
    • Then the result is the group_key → count map (bare count: the single total) and equals a naive oracle computed outside the query path by filtering and counting the same rows — and the count stage is no longer rejected by compile::validate.
  • RFC0002.13 — count by param(n), bucket(w) yields the L4 grouped-count map [§6.3 amendment; RFC0031.5]

    • Given a predicate that pins exactly one template_id (§6.3 amendment pinning rule) and the stage count by param(0), bucket(5m) over rows of that template
    • When the querier executes it
    • Then the result is the (bucket, group_key) → count map — buckets the half-open epoch-aligned windows [k·w, (k+1)·w) over the effective timestamp (§6.2 amendment 2026-06-11), group keys the stored string form of params slot 0 — equal to a naive oracle, and shape-identical to the map RFC 0031 §3.5 / RFC0031.1 compares for L4 equivalence.
  • RFC0002.14 — param(n) misuse is a specific compile-time error [§6.3 amendment]

    • Given (i) service == "api" | count by param(0) (no template_id pin), (ii) template_id == 4 or template_id == 7 | count by param(0) (a disjunction pins nothing), (iii) resolves_to(4) | count by param(0) (an alias set, not a pin), and (iv) param(0) outside a by-list (as a predicate path or a project field)
    • When each is parsed/compiled
    • Then each fails with a specific, leak-free error (RFC0002.8): (i)–(iii) at compile time, citing the single-template pinning rule; (iv) at parse time — group_term is grammatically confined to by-lists (§7 v1.1) — citing the grammar. No query reaches execution.
  • RFC0002.15 — short/NULL params rows are excluded and tallied [§6.3 amendment]

    • Given rows of the pinned template whose params list is shorter than n + 1 (or whose slot n value is NULL) alongside rows carrying slot n
    • When count by param(n), … executes
    • Then the short/NULL rows contribute to no group (no synthetic absent bucket), the returned groups equal the naive oracle over the remaining rows, and the number of excluded rows is reported per query (a QueryStats field, surfaced on the RFC 0016 query-metrics path) so the exclusion is observable, not silent.
  • RFC0002.16 — the aggregation path’s honest bytes total is the group-column scan alone [RFC 0031 §3.6]

    • Given an L4-shaped query (template_id == N | range(…) | count by param(n), bucket(w)) executing with the RFC 0031 §3.6 honest-total accounting
    • When the harness sums the per-query components
    • Then the total is the count-scan component only: within the surviving (unpruned) row groups, the column chunks read are those of the predicate + group-term columns — template_id, the effective-time column, and params iff a param(n) term is present — and never body/separators; the row-materialization component is zero (an aggregation returns the map, not rows) and the template-map-acquisition component (registry_bytes_read) is zero (nothing is rendered). This is the pruning claim RFC0031.5 divides against Loki.

Amendment 2026-07-23 — scalar aggregation execution (sum/min/max/avg). RFC0002.17–.20 below discharge the “later obligation” the 2026-07-15 amendment left open: they lift compile::validate’s rejection of the agg_fn stages for scalar aggregation over a promoted attribute — a log attribute (attr.<k>) or a resource attribute (resource.<k>). The value is read by try_cast to Float64 (promoted columns are Utf8, §6.1) — the pragmatic path, chosen over typed promoted columns (an RFC 0022 / RFC 0005 schema change with a migration). try_cast (not cast, which errors) is required so an unparseable value yields NULL rather than failing the query; it is excluded from the scalar, so the aggregate never errors on dirty data. Grouping, the window, the single-template param(n) pinning rule, and the honest-bytes accounting are inherited unchanged from RFC0002.12–.16.

  • RFC0002.17 — sum/min/max/avg(attr.<k>) [by …] executes end-to-end and matches a naive oracle [§6.5]

    • Given a populated tenant store, <k> promoted (RFC 0022), and a query <predicate> | range(…) | <fn>(attr.<k>) [by <field, …>] for <fn> in {sum, min, max, avg} — grouped and bare (no by)
    • When the querier executes it
    • Then the result is the group_key → value map (bare: the single scalar over all matching rows), each value the <fn> of the row values parsed as Float64, equal to a naive oracle computed outside the query path over the same rows; each group also carries its COUNT(*), and the agg_fn stage is no longer rejected by compile::validate.
  • RFC0002.18 — unparseable / NULL values are excluded from the scalar, not errored [§6.1]

    • Given rows of the grouped set whose promoted attr.<k> value is absent, NULL, or not a base-10 number, alongside rows carrying a numeric value
    • When <fn>(attr.<k>) executes
    • Then the non-numeric rows contribute to no scalar (the try_cast to Float64 yields NULL and the aggregate skips NULLs); the returned value equals the oracle over the numeric rows alone, and a group all of whose values are non-numeric carries value = null. The query never fails on dirty data, and those rows still count toward the group’s COUNT(*) and the query total (matching a plain count). A non-finite scalar result — NaN/±inf from a crafted "NaN"/"inf" input or from sum overflow — is likewise degraded to value = null: JSON has no representation for it, so surfacing it would fail serialization and 500 the whole query.
  • RFC0002.19 — a non-promoted or non-attribute aggregate path is a specific compile-time error [§6.3]

    • Given (i) sum(attr.<k>) where <k> is not promoted in the scanned range, (ii) sum(body) / sum(ts) / sum(param(0)) (a non-attribute path), and (iii) two aggregation stages in one pipeline
    • When each is compiled
    • Then each fails with a specific, leak-free error (RFC0002.8): (i) names the key and the storage.promoted_attributes sublist to add (identical to the group-by hint), (ii) states scalar aggregates require a promoted attribute path, (iii) states a query takes at most one aggregation stage. No query reaches execution.
  • RFC0002.20 — the aggregation result surface carries the scalar value [§6.4; RFC 0016]

    • Given a <fn>(attr.<k>) query answered over the JSON /v1/query and the MCP query_logs surfaces
    • When the response is serialized
    • Then each group carries {key, count, value} with value the scalar (a JSON number), and a bare count query omits value; the two surfaces answer identically (the MCP adapter adds only the protocol), and count/aggregate queries continue to reject a trailing limit (group-limiting is still unimplemented).

§6.1 amendment (2026-07-25) — unspecified severity in ordering comparisons. SeverityNumber = 0 is the OTel logs data model’s unspecified severity, and real sources emit it (Claude Code’s GenAI events, ETW LOG_ALWAYS, Google Cloud DEFAULT). Ourios previously compiled severity ordering to a bare numeric comparison, so a minimum-severity floor such as severity >= trace excluded those records — the inverse of the OTel Logs SDK, whose minimum_severity drops a record only when its SeverityNumber “is specified (i.e. not 0)”, leaving unspecified records to “bypass minimum severity filtering”. The data model sanctions the special case directly: “Special handling MAY be given to SeverityNumber=0 when it is used to represent an unspecified severity” in less-than / greater-than comparisons. Being the inverse of the reference SDK is not defensible for a backend that presents itself as OTLP-native, so the floor semantics change to match. RFC0002.21 pins the result.

  • RFC0002.21 — unspecified severity bypasses a minimum-severity floor [§6.1 amendment]
    • Given rows whose severity_number is 0 (unspecified) alongside rows with specified severities
    • When a floor (>= / >) is applied with a threshold above 0
    • Then the unspecified rows match — aligning with the OTel Logs SDK’s minimum_severity, which unspecified records bypass
    • And a ceiling (< / <=) with a threshold above 0 excludes them, so a predicate and its negation still partition the rows (without this, 0 < 17 would make an unspecified row match both >= error and < error)
    • And an explicit threshold of 0 keeps ordinary numeric semantics, so severity > 0 still means “has a specified severity” rather than matching rows that have none
    • And the rule is compiled into the predicate rather than applied after the scan, so a row group whose severity range is entirely 0 is not pruned by a floor — a post-filter would leave the old min/max pruning in place and silently skip whole files of unspecified rows

6. Design

6.1 Predicate sublanguage (Branch B)

A predicate is a boolean expression over paths, operators, and literals against the OTel log data model. The bare literal true is the match-all predicate (for queries that filter only by range/other stages); false matches nothing.

Paths.

  • Top-level fields are bare identifiers mapping to the OTel log data-model fields: body (Body — an OTel AnyValue: string, bool, int, double, bytes, array, or kvlist/map), severity (SeverityNumber), ts (Timestamp), observed_ts (ObservedTimestamp), trace_id (TraceId), span_id (SpanId), scope (InstrumentationScope name), flags (TraceFlags). (Backend treatment of structured body vs attr.* is not uniform across the ecosystem; the DSL keeps the split explicit rather than flattening.)
  • Resource attributes: resource.<key> where <key> is the OTel attribute key taken literally including dots (resource.service.name → resource attribute "service.name"). Bracketed form resource["..."] for any key not expressible as dotted bare identifiers — characters outside the bare-identifier set, a segment starting with a digit, or a reserved-word collision (resource["k8s.pod.name"], resource["3rd.party"]).
  • Log-record attributes: attr.<key> (attr.http.status_code → attribute "http.status_code"); bracketed attr["..."] for the same non-bare-identifier cases.
  • Severity: severity compares against a bare severity name (severity >= error), case-insensitive, or a numeric form (severity >= 17). All severity comparisons — including ordering (</<=/>/>=) — are defined on the OTel SeverityNumber, never on the free-form severity_text (per the OTel comparing severity guidance). Bare names map to the floor of the matching OTel SeverityNumber range: trace→1, debug→5, info→9, warn→13, error→17, fatal→21. The spec standardises the ranges and says to compare on SeverityNumber; this name→number mapping is Ourios’s, aligned with those ranges, not separately mandated by OTel.

Operators. Comparison: ==, !=, <, <=, >, >=, =~ (regex match), !~ (regex non-match). Boolean: and, or, not, with terse aliases &&, ||, !; grouping with ().

Literals. Double-quoted strings ("api"), numbers (500, 0.7), booleans (true/false), null, duration literals (30s, 1h, 1d, 1w), and RFC 3339 timestamps.

Functions (read-only, bespoke names tuned for queries) — boolean predicate terms: matches(path, regex), contains(path, s), starts_with(path, s), ends_with(path, s). They require a string operand: applying one to a non-string path (severity, a numeric/bool attribute, lossy, ts) is a compile-time type error (RFC0002.8), not a silent coercion. (Scalar-returning functions such as len(path) are deferred: the grammar admits a call only as a boolean term, so a numeric len(...) > n would need a scalar-comparison form — added under a future minor version when a need surfaces.)

Worked predicate.

service == "api" and severity >= error and attr.http.status_code == 500

6.2 First-class OTel-canonical fields

Per OpenTelemetry maintainer guidance (the primary dimensions a log backend is judged on), these get named, bare surface rather than hand-written attribute lookups, resolving the last open question of the prior draft:

SurfaceResolves to (RFC 0001 §6.1)
serviceresource["service.name"]
trace_id, span_idthe dedicated columns (log↔trace correlation)
scopescope_name
severityseverity_number (via the §6.1 mapping)
tstime_unix_nano (the verbatim event timestamp)
observed_tsobserved_time_unix_nano

Amendment 2026-06-11 — range(...) filters the effective timestamp. This table previously noted that ts / time_unix_nano is “what range(...) filters”. Per RFC 0005 §3.2 (amendment of the same date), the time window shall compile against the derived effective_time_unix_nano column — time_unix_nano when non-zero, else observed_time_unix_nano.unwrap_or(0) (RFC 0005 §3.2 is the normative derivation; a record with neither timestamp stays at 0). The implementing slice follows this amendment; until it lands, the querier filters time_unix_nano directly. The change makes records whose source timestamp is unknown (time_unix_nano = 0 — ~15 % of real OTel-Demo corpora, per the OTLP logs data model’s “Use Timestamp if it is present, otherwise use ObservedTimestamp” recommendation) addressable by time. The bare ts field is unchanged — it still resolves to time_unix_nano, the verbatim wire value (RFC 0001 scenario RFC0001.10). For files written before the column existed the window applies effective := time_unix_nano (the RFC 0005 §3.9 documented default — exactly the pre-amendment behaviour), not the absent-OPTIONAL-column ⇒ predicate-false convention. The window bounds are half-openrange(from, to) selects from <= effective < to. The half-open shape is what the querier already implements today (over time_unix_nano) and matches RFC 0010’s locally-pinned [from, to) (which noted this RFC had not pinned boundary semantics; it now does).

trace_id / span_id literals are hex strings (32 and 16 hex digits respectively, no separators), parsed case-insensitively so uppercase OTLP/JSON ids are accepted; the canonical/serialised form is lowercase. The compiler hex-decodes them to match the stored byte columns — the OTLP/JSON id convention, consistent with RFC0003.6.

6.3 Template + correctness primitives

First-class vocabulary — Ourios-specific extensions (RFC 0001 §6.3/§6.7), not OpenTelemetry log-data-model fields; they live in the Ourios schema + query layer alongside the OTel-canonical fields of §6.2:

  • template_id == 42 — exact template; resolves to the template_id column.
  • resolves_to(42)X plus its drift aliases (the RFC 0001 §6.7 drift question); compiles to alias-set membership over template_id.
  • confidence — miner confidence (e.g. < 0.7); the confidence column.
  • lossy — the lossy-reconstruction flag; resolves to the RFC 0001 / RFC 0005 lossy_flag column (lossy == true).
  • render (pipe stage, §6.5) reconstructs the original line, honouring lossy.

The drift question is answered by resolves_to (alias membership). A bare drift predicate (“has this template drifted?”) is deferred: per RFC 0001 §6.7 drift is an audit-stream property, not a column in the RFC 0005 data files, so it needs an audit-stream query path — a future capability, not a row predicate in this grammar.

Amendment 2026-07-15 — aggregation group terms param(n) and bucket(width) (RFC 0031 L4). RFC 0031 §3.4 fixes a must-win query class L4 — count of one template over time, grouped by an extracted param — and scenario RFC0031.5 names the Ourios surface it measures: “columnar GROUP BY on template_id + a typed param column”. The DSL had no way to say that; this amendment adds it, discharging the §9 params[N] open item on its positional half. Two group terms join the by-list of the count and agg_fn stages (grammar delta in the §7 amendment). They are not general fields: project, predicates, and sort do not admit them — the §7 v1.1 group_term production confines them grammatically, so use outside a by-list is a parse error (RFC0002.14).

  • param(n) — the template’s parameter slot n, zero-based, addressing the positional RFC 0005 §3.2 params list (List<Struct{type_tag, value}>; RFC 0001 §6.1). Valid only when the query’s predicate pins exactly one template_id: params are positional per template, so grouping across templates by position aggregates unrelated values — meaningless, and rejected at compile time (RFC0002.14), never silently computed. The pinning rule is syntactic and decidable on the associative-normalised IR: the predicate must carry, at its top conjunctive level, at least one template_id == <N> comparison, and all such comparisons must name the same N. A template_id == N under or/not pins nothing. resolves_to(n) does not pin: it expands to an alias set, and drift aliases do not guarantee positional param alignment across the class — cross-alias positional grouping is exactly the meaningless case. (Named parameters via the template schema, the still-open §9 half, are the future route to alias-safe grouping.)

    Value semantics. The group key is the param’s original string form — the stored value bytes of slot n as UTF-8 (ourios_core::record::Param.value). The stored type_tag is recorded metadata, not a type promotion: param(n) never parses, coerces, or numerically compares the value, so a slot that captured "500" groups as the string "500". A param that overflowed the RFC 0001 §6.5 byte limit groups by its stored marker form — consistently “the string form on disk”. Typed (promoted) grouping is future work, not this amendment.

    Short/NULL disposition. A row whose params list is shorter than n + 1, or whose slot-n value is NULL, is excluded from the aggregation — it contributes to no group, and there is no synthetic “absent” bucket. Rationale: (1) equivalence — LogQL extraction (the RFC0031.1 comparator) produces no sample for a line the pattern does not match, so an absent-bucket key on the Ourios side would make the RFC 0031 §3.5 (bucket, group_key) → count maps structurally unequal for reasons that are artifacts of our sentinel, not of the data; (2) no sentinel key can be chosen that cannot collide with a real param value (params are arbitrary strings); (3) within one pinned template_id, a short params list is the anomaly path (version arity drift), and presenting an anomaly as a data value would be a quiet lie. The exclusion is not silent: the per-query excluded-row count is reported (RFC0002.15).

  • bucket(width) — fixed-width time bucketing usable in the same by-list (e.g. count by param(0), bucket(5m)), and freely without param(n) (e.g. count by service, bucket(1h) — no pinning requirement of its own). width is the existing §7 duration lexical form (30s, 5m, 1h, 1d, 1w); it must be positive (bucket(0s) is a compile-time error). It buckets the effective timestamp — the same derived effective_time_unix_nano column range(...) filters (§6.2 amendment 2026-06-11; RFC 0005 §3.2 derivation, including the §3.9 old-file default) — into half-open, epoch-aligned UTC windows [k·width, (k+1)·width) in nanoseconds since the Unix epoch. The epoch is UTC-midnight-aligned, so bucket(1d) windows are UTC calendar days; bucket(1w) windows are epoch-aligned 7-day spans (starting Thursdays), not ISO calendar weeks. The bucket key in the result map is the window start (serialised RFC 3339 UTC). A by-list admits at most one bucket(...) term and at most one param(n) per n; duplicates are a compile-time error. (The RFC 0031 harness pins the LogQL step/start to the same epoch-aligned boundaries so the two systems’ bucket keys coincide — a harness obligation, noted here for RFC0031.1.)

On the structured surface (§6.4) the group terms are the stage by-array elements { "param": <n> } (non-negative integer) and { "bucket": "<duration>" } (the §7 duration lexical string); the RFC0002.2 one-IR invariant extends to them, and the published JSON Schema gains both forms additively (§6.6 amendment). In the IR the by-list element widens from a plain field to a group term (Field::Param(u32)-style positional variant + a bucket term) — the exact Rust modelling is the implementing slice’s choice; this RFC constrains the surfaces and the semantics.

6.4 Two front-ends, one core

flowchart LR
  A["string DSL (β)<br/>Perses YAML, humans"] --> P[parser]
  B["structured surface<br/>JSON, MCP agents + clients"] --> V[schema validate]
  P --> IR[query IR]
  V --> IR
  IR --> C["compiler<br/>(no SQL leakage)"]
  C --> LP["DataFusion LogicalPlan<br/>(RFC 0007 execution layer)"]
  • String DSL (surface β) is the human surface (esp. Perses YAML). A query is a predicate optionally followed by |-separated stages:

    service == "api" and severity >= error | range(-1h, now) | count by template_id | sort count desc | limit 10
    

    A predicate-only / “no filter” query uses the match-all atom true, e.g. true | range(-1h, now) | limit 100.

    Stages: range(from, to) (each bound a relative duration, the now keyword, or an RFC 3339 timestamp — the §7 time form; defaults per §4 P5), count [by <field, …>] (comma-separated, per the §7 field_list) and other aggregations (sum, min, max, avg over a path), sort <field-or-aggregate> [asc|desc] (the §7 sort_key — a field or an aggregate output like count), limit <n>, project <field, …> / render. The whole query is expressible on one line as shown (whitespace around | is optional) — the §4 P7 YAML constraint.

  • Structured surface is the machine contract (MCP tool schema + programmatic clients): a top-level object { "predicate": <node>, "stages": [ <stage>, … ] } (stages optional, default []). A field is structured (no DSL path syntax for agents to build or escape): a bare top-level name string ("service", "severity", "body", "trace_id", …) or an attribute object { "resource": "<key>" } / { "attr": "<key>" } (<key> the raw OTel attribute key, e.g. "k8s.pod.name"). An op is a §7 cmp_op string ("==", ">=", "=~", …); a value is a JSON primitive (string / number / bool / null), with durations and timestamps carried as their §7 lexical strings ("1h", RFC 3339). A <node> is a comparison node { "field": …, "op": …, "value": … }, a call node { "call": "<fn>", "args": [ … ] } whose args follow the §7 typed signatures — matches/contains/starts_with/ends_with take [ <field>, <string> ] (<field> as above), resolves_to takes [ <number> ], a constant node { "const": true | false } (the §7 bool_lit match-all / match-none — { "const": true } is the “no filter” predicate), or a boolean node ({ "and": [ <node>, … ] } / { "or": [ <node>, … ] } with a child array; { "not": <node> } unary, per §7). Each <stage> is a tagged object covering the full §7 stage set — range/count/sum/min/max/avg/sort/limit/project/render. Its JSON Schema is published and versioned with the parser (snapshot- tested like the §7 grammar; RFC0002.11), and it compiles to the same IR as the string surface (RFC0002.2). It is the formalised, extended successor to the existing ourios_querier::QueryRequest (the RFC 0007 structured API) and is the stable surface agents target — no grammar generation required.

Both parse/validate to the same query IR and compile identically (RFC0002.2). The tenant is not expressed in either surface — it is supplied by the executing context (CLAUDE.md §3.7 multi-tenancy; enforced per RFC0007.5); a query without a tenant is an API usage error, not a cross-tenant scan.

6.5 Compilation target

Every construct compiles to a DataFusion LogicalPlan:

DSL constructDataFusion logical node
implicit from logsTableScan on the tenant’s log table
predicate / rangeFilter (range → time-column predicate)
count / aggregationsAggregate
sortSort
limitLimit
projectProjection
rendercustom projection honouring the three-zone reconstruction model
resolves_to(42)custom node expanding to alias-set membership

All but render and resolves_to are DataFusion’s built-in algebra; those two are the only Ourios extensions, both surface-independent.

Amendment 2026-07-15. The §6.3 group terms lower inside the existing count / aggregations row: param(n) compiles to a list-element extraction over the params column and bucket(width) to floor division of the effective-time column by the width (the half-open window [k*w, (k+1)*w) — floor, not toward-zero truncation, so the definition stays unambiguous for any signed timestamp representation), both as grouping expressions within the Aggregate node. No new logical node — the “only render and resolves_to” statement above is unchanged.

6.6 Stability and versioning

The grammar (§7) is owned and versioned by this RFC. Additions (new functions, new first-class fields) are minor versions. Behavioural changes that could alter a query’s result set are major versions, require an amending RFC + a deprecation window, and — because the Perses/MCP audiences persist queries (git-versioned dashboards, cached agent schemas) — ship with a documented migration. There is no external spec to shadow, so major versions are deliberate, not inherited.

Amendment 2026-07-15. The §6.3 group terms are an addition (new first-class grammar surface, no behavioural change to any existing query), i.e. a minor version under this section: grammar v1.1, v1.0 being the surface green as RFC0002.1–.11. The grammar carries no in-code version constant today, so this amendment record is the version record. The published structured schema’s $id major (…/structured-query/v1.json) is unchanged — the additions are backward-compatible (by arrays accept two new object forms). Extending structured_query.schema.json + its snapshot test, and the parser-side grammar snapshot (§8), are obligations of the implementing slice.

7. Grammar specification (owned by this RFC)

A compact EBNF; the canonical machine-readable grammar lives beside the parser and is snapshot-tested (§8). Kept small and regular so it doubles as a constrained-decoding grammar for the MCP surface (§3.6).

query        = predicate , { "|" , stage } ;
predicate    = or_expr ;
or_expr      = and_expr , { ("or" | "||") , and_expr } ;
and_expr     = unary , { ("and" | "&&") , unary } ;
unary        = [ "not" | "!" ] , ( comparison | call | bool_lit | "(" , predicate , ")" ) ;
bool_lit     = "true" | "false" ;   (* match-all / match-none; a bare `true` = no filter *)
comparison   = severity_cmp | scalar_cmp ;
severity_cmp = "severity" , ord_op , ( severity_name | number ) ;
scalar_cmp   = scalar_path , cmp_op , literal ;
ord_op       = "==" | "!=" | "<" | "<=" | ">" | ">=" ;   (* no regex — severity is numeric *)
cmp_op       = ord_op | "=~" | "!~" ;
call         = str_fn , "(" , path , "," , string , ")"
             | "resolves_to" , "(" , number , ")" ;
str_fn       = "matches" | "contains" | "starts_with" | "ends_with" ;
path         = field | "resource" , key_tail | "attr" , key_tail ;
scalar_path  = nonsev_field | "resource" , key_tail | "attr" , key_tail ;
field        = nonsev_field | "severity" ;
nonsev_field = "body" | "ts" | "observed_ts" | "trace_id" | "span_id"
             | "scope" | "flags" | "service" | "template_id"
             | "confidence" | "lossy" ;
key_tail     = ( "." , dotted_key ) | ( "[" , string , "]" ) ;
dotted_key   = ident , { "." , ident } ;
stage        = "range" , "(" , time , "," , time , ")"
             | "count" , [ "by" , field_list ]
             | agg_fn , "(" , path , ")" , [ "by" , field_list ]
             | "sort" , sort_key , [ "asc" | "desc" ]
             | "limit" , integer
             | "project" , field_list
             | "render" ;
agg_fn       = "sum" | "min" | "max" | "avg" ;
field_list   = field , { "," , field } ;
sort_key     = field | ident ;          (* ident = an aggregate output, e.g. count *)
literal      = string | number | boolean | "null" | duration | timestamp ;
severity_name = "trace" | "debug" | "info" | "warn" | "error" | "fatal" ;  (* case-insensitive; only as a `severity` RHS *)
time         = "now" | ( [ "-" ] , duration ) | timestamp ;   (* e.g. now , -1h *)
integer      = digit , { digit } ;
(* lexical: ident = letter , { letter | digit | "_" } ;
   string = '"' , { char | escape } , '"' ;
   char   = any Unicode scalar except '"' , '\' , or a line terminator
            (a literal newline must be written as the \n escape — queries
            are single-line, §4 P7 / RFC0002.10) ;
   escape = '\' , ( '"' | '\' | "n" | "t" | "r" | ( "u" , 4 * hex ) ) ;
   number = integer | float ;  float = integer , "." , digit , { digit } ;
   boolean = "true" | "false" ;
   duration = integer , ( "s"|"m"|"h"|"d"|"w" ) ;  timestamp = RFC 3339 ;
   digit = "0".."9" ;  letter = "a".."z" | "A".."Z" ;
   hex = digit | "a".."f" | "A".."F"
   — strings are double-quoted with backslash escapes; YAML embedding
   (RFC0002.10) wraps the whole query in a single-quoted YAML scalar so
   these double quotes need no YAML-level escaping *)

Amendment 2026-07-15 — grammar v1.1 (aggregation group terms). The EBNF above is v1.0, the surface shipped green as RFC0002.1–.11. v1.1 replaces the two aggregation stages’ field_list with a group_list and adds two productions:

stage        = (* v1.0 alternatives unchanged, except: *)
               "count" , [ "by" , group_list ]
             | agg_fn , "(" , path , ")" , [ "by" , group_list ] ;
group_list   = group_term , { "," , group_term } ;
group_term   = field
             | "param" , "(" , integer , ")"
             | "bucket" , "(" , duration , ")" ;

field_list remains as-is for project; duration and integer are the existing productions. Semantics — the single-template pinning rule for param(n), string-form group keys, the excluded-short-rows disposition, epoch-aligned half-open buckets over the effective timestamp — are the §6.3 amendment’s. The canonical machine-readable grammar and its snapshot update with the implementing slice (§6.6 amendment).

8. Testing strategy

Mapping to CLAUDE.md §6.2 and docs/verification.md §3 (red→green two-loop: #[ignore]’d stubs first, implementations second).

  • Unit tests — every grammar production has a positive and negative parse test.
  • Property tests — generate well-formed queries; assert the §5 round-trip idempotence (RFC0002.7) and that every generated query is a YAML-safe single-line scalar (RFC0002.10).
  • Compilation golden tests — every construct has a golden LogicalPlan (debug-rendered) checked in; the no-leakage boundary (RFC0002.3) is a compile + string test.
  • Equivalence tests — string vs structured surface compile to the same IR (RFC0002.2); a DSL query and the equivalent ourios_querier structured request return identical results + QueryStats (RFC0002.1).
  • Grammar snapshot — the EBNF / parser grammar is committed and snapshot-tested so changes are PR-visible (Branch B owns its grammar).
  • End-to-end — against the docs/benchmarks.md §1 corpora, pinned expected results for a query set spanning each construct.

9. Open questions

Narrowed by the §3 resolution. Must be resolved before accepted.

  • Pre-accepted validation. Resolved (2026-08-25). The artifacts are docs/guides/query-cookbook.md: sixteen sample queries — every one verified to parse against the shipped parser — each led by its plain-English intent, plus migration sketches from LogQL and CloudWatch Logs Insights. The non-author readability pass ran as the PR #742 reviews (maintainer decision of the same date: the review bots are the non-author readers): the reviewers read every sample against its English line and returned comprehension-level findings only — a mis-implying “reconstructed” on render, an ambiguous “anchored” on matches, and a preference for != over not … == — all wording, no query whose meaning failed to land, and each fix applied to the sheet. (Replaces the prior §9 user-research gate; not required for specified.)
  • OTel ecosystem alignment Resolved: OpenTelemetry defines the logs data model + API/SDK but no standard query/read language, and OTTL is a Collector transformation language, not a querying surface (see the OTTL README and the OTel logs spec linked in §11 References). There is no canonical OTel read syntax, and no Perses-specific query convention, to align to. Bespoke query syntax over the OTel data model is the norm (LogQL, PromQL, CloudWatch Insights), so Branch B carries no ecosystem-divergence cost — the alignment that matters is at the field semantics, which §6.1/§6.2 honour (ts/observed_ts/trace_id/span_id/flags/body/scope/ severity → canonical data-model fields; severity ordering on SeverityNumber).
  • --sql advanced-mode escape hatch — gated + sandboxed, or never? (Currently: never; reconsider under a separate RFC.)
  • Custom user functions — out for v1 (sandboxing is its own project).
  • params[N] positional access vs named parameters via the template schema. Resolved in part (amendment 2026-07-15): positional access is specified as the param(n) group term (§6.3 amendment; grammar v1.1, §7 amendment) — aggregation-only (by-lists), single-template_id-pinned, string-form group keys, scenarios RFC0002.12–.16. Named parameters via the template schema stay open — they are the future route to alias-safe (cross-version) grouping, which param(n) deliberately forbids.
  • In-path query cost estimator (“this will scan 400 GB”) before run.
  • Pagination / streaming surface for large result sets (mirrors RFC 0007 §8).

Resolved by this RFC (were open in the draft): branch (B), top-level surface (β), severity-text casing (case-insensitive, §6.1), agent- friendliness (the structured surface, §6.4), and first-class OTel- canonical fields (§6.2).

10. Alternatives considered

Alternatives that would replace the whole design, not just one branch.

  • Pure SQL (DataFusion dialect) — zero parser cost, but violates CLAUDE.md §4 hazard 6 (cross-tenant joins, unbounded scans) and binds the user surface to DataFusion. Rejected as default; possible future gated, sandboxed escape hatch under a separate RFC.
  • LogQL clone — label selectors are less expressive than the OTel log record; adopting them flattens structure and lies about the ingest contract. Rejected as the full DSL; its top-level shape survives as the chosen β surface.
  • CloudWatch Insights clone — proprietary, no open spec; attribute model differs from OTel. Rejected; its verb-per-line readability is the γ alternative we did not pick.
  • Branch A (borrow OTTL) on any surface — see §3; not chosen for the Perses/MCP audiences.

11. References

  • OpenTelemetry log data model: https://opentelemetry.io/docs/specs/otel/logs/data-model/
  • OpenTelemetry severity text conventions: https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitytext
  • OTTL (reference-only under Branch B): https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/pkg/ottl
  • LogQL: https://grafana.com/docs/loki/latest/query/
  • CloudWatch Logs Insights: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CWL_QuerySyntax.html
  • Perses (CNCF dashboards-as-code): https://perses.dev/
  • Apache DataFusion logical-plan documentation.
  • RFC 0031 §2.3/§3.4–§3.6/§5 (the comparative L4 class and the equivalence + honest-bytes contracts the 2026-07-15 amendment serves);
  • RFC 0001 §6.1/§6.3/§6.7 (the columns + template/drift primitives); RFC 0007 (the execution layer this DSL targets); CLAUDE.md §4 hazard 6 (no-leakage hazard) and CLAUDE.md §3.7 (multi-tenancy).

RFC 0003 — OTLP receiver


rfc: 0003 title: OTLP receiver — gRPC and HTTP wire endpoints for OpenTelemetry log ingest status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-05-13 supersedes: — superseded-by: —

RFC 0003 — OTLP receiver

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

How to read this document. Sections §§1–4 are the design contract — the what and the why. §5 lists the normative Given / When / Then scenarios — the contract the receiver crate is implemented against and tested for. §6 is the precise specification the receiver crate is implemented against. §7 records the alternatives we evaluated and rejected. §8 maps each §5 scenario to the technique that tests it. §9 lists open questions; §10 the references.

Cross-references to CLAUDE.md sections are in square brackets, e.g. [§3.4], and name the invariant the section must preserve. Cross-references to RFC 0001 use its section numbers directly (e.g. RFC 0001 §6.1).

1. Summary

The Ourios OTLP receiver accepts OpenTelemetry log batches over gRPC and HTTP, decodes them per the official opentelemetry-proto schema, derives a tenant_id per ResourceLogs group via an operator-configured rule (RFC 0001 §6.1 Tenant derivation), materialises each LogRecord.body into the Body::String(String) | Body::Structured(AnyValue) fork (the decoded AnyValue rides through verbatim — canonicalisation happens once, at ingest, inside the miner, per the amended §6.4), fans the batch out into per-tenant streams of OtlpLogRecord, hands each stream to ourios-miner, and acknowledges the OTLP request only after the WAL-before-ack invariant [§3.4] is satisfied. The default wire stack is tonic (gRPC) + axum/hyper (HTTP) against the official opentelemetry-proto Rust crate; the alternatives considered and rejected are embedding rotel as a library and running the OTel Collector out-of-process.

This RFC is the wire-decode contract that the §6.1 amendment of RFC 0001 (PR #21) and the §6.2 algorithm rewrite (PR #23) both implicitly require: the miner takes a structured OtlpLogRecord that something must produce. RFC 0003 is that something.

2. Motivation

2.1 The OTel-native commitment is not yet implemented

docs/glossary.md (entry OTLP) commits Ourios to OTLP as the sole ingest contract: “we do not invent our own format.” RFC 0001’s pre-amendment §6.1 record schema and the MinerCluster::ingest(_, raw: &str) signature treated logs as flat text strings, which the investigation in docs/architecture/otlp-log-format.md (PR #20) showed to be incompatible with that commitment. PRs #21 and #23 amended the miner’s data model and algorithm to consume structured OtlpLogRecords. No code yet produces those records. This RFC specifies the producer.

2.2 The receiver is the boundary that decides what “OTLP” means in practice

OTLP carries a structured LogRecord whose body is AnyValue (string, bool, int, double, bytes, array, kvlist), whose attributes are typed, whose Resource lives one container level up (per ResourceLogs), and whose timestamps and severity are first-class. Where in the pipeline these wire-level facts become the in-memory OtlpLogRecord the miner sees is a load-bearing decision: the receiver is where:

  • The wire format (protobuf vs JSON, gRPC vs HTTP) collapses to a single in-memory representation.
  • tenant_id is derived per ResourceLogs (RFC 0001 §6.1 Tenant derivation) and the batch fans out into per-tenant streams.
  • body.kind = Structured records have their AnyValue body carried verbatim into Body::Structured(AnyValue) — per the amended §6.4 the receiver never canonicalises; the miner encodes the tree to the Ourios canonical body encoding at ingest, whose round-trip stored_bytes ↔ AnyValue per RFC 0001 §6.1 Body representation makes the lossy_flag = false promise meetable.
  • The acknowledgement-after-durability sequencing ([§3.4]) is enforced.

Specifying these decisions in one place — and pinning them explicitly against the OTel spec rather than reinventing them — is what this RFC does.

2.3 Roadmap context

docs/roadmap.md §5 (post-#22) lists “OTLP wire endpoints (gRPC + HTTP listeners)” as post-MVP: the bench reads OTLP from disk (a corpus of pre-recorded LogsData), not from the network, so wire-decode is not on the C2 thesis-gate path. The record shape is in MVP — the miner consumes OtlpLogRecord from the corpus reader. This RFC is the spec for the post-MVP wire layer; landing the spec now (rather than after the bench) settles the design while the OTLP record shape is being implemented in the miner, so the receiver’s eventual implementation has nothing to redesign.

3. Background — OTLP wire formats

3.1 The OTLP message hierarchy

An OTLP log export is a single ExportLogsServiceRequest message carrying one or more ResourceLogs. (LogsData is the file-format equivalent message in logs.proto and shares the same resource_logs: ResourceLogs[] field shape; this RFC uses ExportLogsServiceRequest throughout, since that is the wire type both transports decode into.)

ExportLogsServiceRequest
└── resource_logs: ResourceLogs[]
    ├── resource: Resource           # per-source attributes (service.name, host.*, ...)
    ├── schema_url: string
    └── scope_logs: ScopeLogs[]
        ├── scope: InstrumentationScope     # name, version, attributes
        ├── schema_url: string
        └── log_records: LogRecord[]        # the actual log entries

A single export request can carry records from multiple sources (multiple ResourceLogs groups), each with its own Resource, and within each Resource multiple instrumentation scopes. The mapping from this hierarchy to per-tenant streams of records is the receiver’s responsibility (§6.4 below).

3.2 Two transports, three encodings

OTLP is defined for two transports:

  • OTLP/gRPC — the canonical transport. Service is opentelemetry.proto.collector.logs.v1.LogsService, method Export. Wire encoding: protobuf over HTTP/2.
  • OTLP/HTTP — POST against the /v1/logs path. Wire encoding chosen by the client per the Content-Type header:
    • application/x-protobuf (recommended by the spec; the same protobuf message as gRPC)
    • application/json (the proto3 JSON mapping with OTLP overrides — hex trace_id/span_id, base64 bytes)

The receiver MUST support both transports and all three encodings. The OTel emitter ecosystem is split: SDKs ship with gRPC by default, but the HTTP transport is widely used in constrained environments and as a Collector exporter. Refusing either transport reduces the receiver to a non-compliant subset.

3.3 Backpressure and partial-success in the OTLP response

The OTLP spec defines a partial-success response shape:

ExportLogsServiceResponse
└── partial_success: ExportLogsPartialSuccess (optional)
    ├── rejected_log_records: int64
    └── error_message: string

When set, this signals that the receiver accepted some records but rejected others (e.g., due to rate limiting, validation failures, etc.). The full-failure case uses a transport-level error (gRPC status code, HTTP non-2xx) rather than partial-success.

§6.7 below discusses how Ourios uses (or defers using) this field. For the initial design, the receiver uses all-or- nothing batch semantics — full-success or transport-level error — and reserves partial-success for a future RFC.

4. Background — Existing Rust OpenTelemetry ecosystem

4.1 opentelemetry-proto

The official Rust crate carrying generated bindings for every opentelemetry-proto message. Tracks upstream proto. Suitable as the in-memory representation between wire-decode and the miner. Trivially compatible with both tonic (gRPC) and prost (raw protobuf, used over HTTP).

4.2 tonic

The de-facto Rust gRPC framework: production-grade, async (tokio), supports the metadata, deadlines, and streaming features OTLP relies on. Standard choice for any Rust gRPC service. The LogsService server trait is generated by the tonic-build codegen step from the OTLP .proto files.

4.3 axum and hyper

axum is the conventional Rust HTTP framework for service endpoints, built on hyper and tower. Suitable for the OTLP/ HTTP transport. The endpoint handler decodes the request body (protobuf or JSON, dispatched on Content-Type) into the same in-memory ExportLogsServiceRequest representation tonic produces, and the two transport paths converge into a single business-logic layer.

4.4 rotel

A Rust-implemented OpenTelemetry Collector. Mature (production deployments exist), covers receivers, processors, exporters. Interesting as a possible library to embed (taking just its OTLP-receiver component) rather than as a separate process — see §7 for the build-vs-embed analysis. Note: rotel’s public API is collector-shaped (full pipeline), not “just the receiver” shaped, which complicates embedding.

4.5 OTel Collector (Go)

The reference collector implementation. Often deployed as a sidecar or daemonset that buffers, batches, and forwards telemetry to backends. For an Ourios deployment, a fronting Collector would terminate OTLP at the Collector and forward to Ourios via some other transport (or via OTLP again). §7 discusses this as a deployment option, not a code dependency.

5. Acceptance criteria

Each scenario carries an id of the form RFC0003.<m> that is referenced verbatim from each test’s leading doc comment (e.g. /// Scenario RFC0003.1 — WAL-before-ack.) so the spec↔test mapping is greppable (per docs/rfcs/README.md Required sections and docs/verification.md §2.3 — function names are not part of the contract, the doc-comment line is). Scenarios .1.11 cover the invariants and hazards the §6 design touches; .12.15 pin behaviour the OTLP spec mandates and that the §9 enrichments surfaced (empty request, compression, default path, concurrency).

Scenario RFC0003.1 — WAL-before-ack [§3.4]

  • Given a Receiver wired to a real Wal (opened with defaults) and a single OTLP ExportLogsServiceRequest carrying ≥ 1 LogRecord
  • When the receiver runs its accept path
  • Then the transport-level success response (gRPC OK / HTTP 2xx) is emitted only after the Wal::sync call covering the batch’s frame returns Ok(_) — measured by an AtomicBool set after sync returns Ok(_) (mirroring RFC0008.1; the probe inside sync would already be true mid-call). The response-writer asserts the flag is true before sending
  • And the pre-sync points (decode, tenant_derive, body_materialise, append) all observe the flag as false
  • And the WAL contains a single FrameKind::OtlpBatch frame (per RFC 0008 §3.2 + §6.2.3) whose payload decodes (via prost) to the input ExportLogsServiceRequest — verified post-response by shutting down the receiver (which drops its Wal handle, per RFC 0008 §3.1’s single-writer architecture — enforced by crates/ourios-wal/src/lib.rs:162) and then opening a second Wal to replay via Wal::replay, asserting one new frame whose payload round-trips via prost to the input request. This And is a content/existence check; the before-the-ack ordering is established by the AtomicBool probe in the preceding Then + And clauses, not by the replay (byte equality of the payload to any specific encoding is not required — protobuf has multiple wire encodings that decode to the same message; see RFC0003.2)
  • And the §6.5 step-5 miner-acceptance precondition for ack also holds: every record in the batch has been handed to MinerCluster::ingest and accepted before the ack fires (an instrumented MinerCluster stub records each ingest call; the response-writer asserts the per-batch accepted-count equals the batch’s record-count before sending)

Scenario RFC0003.2 — Crash-before-ack: at-least-once with retry tolerance [§3.4]

  • Given a receiver wired to a real Wal, an OTLP client that retries on transport timeout per the OTLP spec retry semantics, and a SIGKILL injected after Wal::sync returns Ok(_) but before the success response reaches the wire — i.e. anywhere in the §6.5 window between step 4 (fsync return) and step 6 (ack); both the step-4/5 and step-5/6 gaps reduce to the same duplicate-on-retry contract since the records are already durable
  • When the receiver process restarts, Wal::replay runs, and the client retries the timed-out export
  • Then the post-restart WAL contains the OtlpBatch frame the killed process had fsync’d before the kill — its payload decodes (via prost) to an ExportLogsServiceRequest semantically equivalent to the killed process’s input (the RFC0008.2 guarantee this RFC consumes; byte equality of the wire payload is not required, see the second And below for why)
  • And the client’s retry is accepted and produces a second OtlpBatch frame whose payload decodes to an ExportLogsServiceRequest semantically equivalent to the first (same resource_logs after prost decode — the wire bytes need not match, since the client may re-encode on retry: JSON field-ordering / whitespace, switched compression, etc.). This duplication is the at-least-once contract per the OTLP spec’s duplicate-data section (“duplicate data is a deliberate tradeoff for telemetry data”); the receiver implements no de-duplication in this RFC
  • And no special “retry” marker is appended; the receiver has no dedup key (§9 reserves any future dedup mechanism for a follow-up RFC) and cannot distinguish a retry from an independent batch carrying the same records

Scenario RFC0003.3 — Tenant fan-out [§3.7]

  • Given an OTLP batch containing exactly two ResourceLogs groups R_A (service.name = "svc-a") and R_B (service.name = "svc-b"), and an operator-configured tenant-derivation rule keyed on service.name
  • When the receiver processes the batch
  • Then the (tenant_id, OtlpLogRecord) pairs accepted by an instrumented MinerCluster stub for tenant_id_a are exactly those derived from R_A and contain no record derived from R_B
  • And the symmetric assertion holds for tenant_id_b
  • And each emitted OtlpLogRecord’s resource_attributes reflects the originating Resource verbatim — the receiver does not mix Resource attribute sets across the fan-out

Scenario RFC0003.4 — Tenant resolution failure rejects the entire batch [§3.7]

  • Given an OTLP batch where at least one ResourceLogs.resource lacks the attribute named by the operator’s tenant-derivation rule
  • When the receiver processes the batch
  • Then the receiver emits a transport-level error (gRPC INVALID_ARGUMENT / HTTP 400) whose payload names the failing ResourceLogs index and the missing attribute key
  • And no OtlpBatch frame from the batch is appended to the WAL (asserted by shutting down the receiver — dropping its single Wal handle per RFC 0008 §3.1’s single-writer architecture (enforced by crates/ourios-wal/src/lib.rs:162) — and then opening a second Wal and observing via Wal::replay that frame count and segment offsets are unchanged from the pre-batch snapshot)
  • And no record from the batch reaches MinerCluster::ingest — per-Resource partial acceptance is reserved per §6.3

Scenario RFC0003.5 — gRPC ≡ HTTP/protobuf decode equivalence

  • Given a byte-equal ExportLogsServiceRequest protobuf payload
  • When the payload is decoded via the tonic gRPC handler and via the axum HTTP handler with Content-Type: application/x-protobuf independently
  • Then the two resulting in-memory ExportLogsServiceRequest values are structurally equal (PartialEq), and every derived OtlpLogRecord from each path is field-for-field equal — including body, attributes, resource_attributes, trace_id, span_id, and dropped_attributes_count

Scenario RFC0003.6 — HTTP/JSON ↔ gRPC/protobuf equivalence with OTLP-JSON encoding rules

  • Given an ExportLogsServiceRequest carrying non-trivial trace_id and span_id bytes, a record with a bytes-typed AnyValue attribute, and at least one record whose severity_number exercises a non-default enum value
  • When the payload is serialised as gRPC + protobuf and as HTTP + application/json per the OTLP-JSON mapping (hex-encoded traceId / spanId, base64-encoded bytes, integer-encoded enums, lowerCamelCase field names) and each is independently decoded by the receiver
  • Then the two derived OtlpLogRecord sequences are equal at the AnyValue tree level — no byte-level canonicalisation is asserted at this layer (byte-level equivalence under the Ourios canonical body encoding is the miner’s ingest contract per the amended §6.4)
  • And the JSON decoder accepts whitespace and field- ordering variation (insignificant per proto3-JSON)
  • And the JSON decoder ignores unknown fields anywhere in the request body (top-level, nested, repeated) per the OTLP spec’s “receivers MUST ignore unknown fields” rule (forward-compatibility)

Scenario RFC0003.7 — Body::Structured carries the decoded AnyValue verbatim

  • Given a LogRecord whose body is an AnyValue of a non-string_value variant (kvlist_value, array_value, int_value, double_value, bool_value, or bytes_value)
  • When the receiver materialises the record via ourios_core::otlp::Body::from_any_value from either transport
  • Then the resulting OtlpLogRecord.body is Some(Body::Structured(av)) where av is structurally equal to the wire’s AnyValue (no canonicalisation, no reshape, no dropped fields)
  • And the same equality holds across the three transports, since RFC0003.5 and RFC0003.6 make the per-transport decodes equivalent at the AnyValue level

Scenario RFC0003.8 — Body::String reaches the miner as the unwrapped L_raw

  • Given a LogRecord whose body is AnyValue { value: Some(string_value(s)) }
  • When the receiver materialises the record
  • Then the resulting OtlpLogRecord.body is Some(Body::String(s)) where s is the original UTF-8 string (no wrapping, no quoting, no escaping)
  • And the value handed to MinerCluster::ingest equals s byte-for-byte (asserted by an instrumented MinerCluster stub that records each ingest call’s body argument — the receiver’s contract here is the pass-through, not anything about how the miner indexes or short-circuits on it)

Scenario RFC0003.9 — Edge OTLP fields pass through unchanged

  • Given a LogRecord with severity_number = 0 (UNSPECIFIED), no scope_name on its enclosing InstrumentationScope, and observed_time_unix_nano = 0 (proto3’s scalar default for an unset field — OTLP’s log-record section spells this out as “the value of 0 indicates unknown”)
  • When the receiver materialises the record
  • Then the derived OtlpLogRecord carries severity_number = 0 (kept as 0 because UNSPECIFIED is an explicit OTLP value per RFC 0001 §6.1, not absence), scope_name = None, and observed_time_unix_nano = None — the receiver applies the wire-0None rule for observed_time_unix_nano specifically (the Option<u64> typing in RFC 0001 §6.1 exists for this conversion; this scenario is the contract that owns the rule)
  • And the record is accepted by MinerCluster::ingest without rejection, coalescing, substitution, or any downcast to a “default” value

Scenario RFC0003.10 — dropped_attributes_count preserved verbatim

  • Given a LogRecord whose dropped_attributes_count is 42 on the wire
  • When the receiver materialises the record
  • Then the resulting OtlpLogRecord.dropped_attributes_count is exactly 42
  • And the receiver does not recompute the field — it reflects the wire-level claim only, even if a future receiver-side per-attribute truncation step would have dropped further attributes (a hypothetical such step is tracked as a §9 open question)

Scenario RFC0003.11 — Transport-level errors are controlled, not panics

  • Given any of:
    • a malformed protobuf payload (random bytes that fail prost::Message::decode),
    • an over-size request body exceeding the receiver’s configured request-size limit,
    • an HTTP request with an unrecognised Content-Type,
    • an HTTP POST to a path other than the configured /v1/logs (covered jointly with RFC0003.14),
    • or a gRPC client cancellation mid-decode
  • When the receiver handles the request
  • Then the receiver emits a controlled transport-level error — gRPC INVALID_ARGUMENT / RESOURCE_EXHAUSTED / CANCELLED as appropriate, or HTTP 400 / 413 / 415 / 404 as appropriate
  • And no part of the receiver panics or restarts; the process remains alive (each arm of the test asserts this after the request)
  • And no OtlpBatch frame is appended to the WAL (the rejected batch never reaches §6.5 step 3, so the persistence unit — the per-export frame — never lands)

Scenario RFC0003.12 — Empty ExportLogsServiceRequest returns success without WAL write

  • Given an ExportLogsServiceRequest that carries zero LogRecords — covered shapes are (i) resource_logs empty, (ii) resource_logs[i].scope_logs empty for every i, (iii) every resource_logs[i].scope_logs[j].log_records empty. All three shapes are tested
  • When the receiver processes the request via either transport
  • Then the receiver emits a transport-level success response carrying an ExportLogsServiceResponse with partial_success unset (per the OTLP spec’s otlpgrpc-response and otlphttp-response sections: “servers SHOULD treat empty as success”)
  • And the receiver does not invoke Wal::sync, no frame is appended (asserted via a test wrapper around the Wal handle that counts append and sync calls), and no record reaches MinerCluster::ingest

Scenario RFC0003.13 — Compression over HTTP: identity and gzip MUST be supported

  • Given an HTTP request whose body is the byte-equal ExportLogsServiceRequest payload of RFC0003.5, transported with Content-Encoding: identity (or absent) and with Content-Encoding: gzip independently
  • When the receiver processes each request
  • Then the two derived OtlpLogRecord sequences are equal — the OTLP spec mandates both encodings, and the receiver’s decode produces semantically identical results
  • And a request with an unsupported Content-Encoding (e.g. zstd, br) is rejected with HTTP 415 and a controlled error message; zstd support is deferred per §9

Scenario RFC0003.14 — Default /v1/logs path with configurable override

  • Given the HTTP listener bound with the default path configuration
  • When a POST arrives at /v1/logs
  • Then the receiver handles it via the OTLP/HTTP code path defined in §6.2
  • And a POST to any other path returns HTTP 404 (the “wrong path” arm of RFC0003.11)
  • And when the operator configures an override path (e.g. /otlp/v1/logs), it replaces /v1/logs as the accepted path without changing any other receiver behaviour (the configurability matches the Collector’s OTLP-receiver path knob, so deployments that need a non-standard prefix don’t have to front Ourios with a reverse proxy)

Scenario RFC0003.15 — Concurrent Export calls each obey WAL-before-ack independently [§3.4]

  • Given N ≥ 2 concurrent gRPC Export unary calls submitted to the receiver from independent client connections
  • When each call’s batch independently traverses the §6.5 sequence
  • Then each call’s ack is emitted only after its own batch’s Wal::sync returns Ok(_) and its own batch’s records have all been accepted by MinerCluster::ingest — the §3.4 AtomicBool of RFC0003.1 is per in-flight call, not process-global; a per-call probe records both the sync-completion and miner-acceptance ordering before the response-writer sends
  • And the WAL contains exactly one OtlpBatch frame per concurrent call (no call’s batch is lost to concurrency, asserted by shutting down the receiver — dropping its single Wal handle per RFC 0008 §3.1’s single-writer architecture (enforced by crates/ourios-wal/src/lib.rs:162) — and then opening a second Wal whose Wal::replay yields N frames whose payloads round-trip to the N input ExportLogsServiceRequests)
  • And the test does not assert any cross-call ordering — concurrent batches may interleave in the WAL as the tokio runtime chooses, which is consistent with the OTLP spec’s recommendation to support concurrent unary Export calls for throughput

Amendment (served-binary slice, post-green). Scenarios RFC0003.1–.15 are implemented and were green (exercised in-process: axum/tonic handlers via direct call / oneshot, the pipeline + WAL directly). This amendment adds RFC0003.16 — the end-to-end served-binary contract, which the §9 Receiver process model resolution settles — and re-enters the ladder at specified until RFC0003.16 lands. (Discharged: RFC0003.16 landed — the served-binary suite lives at crates/ourios-server/tests/it/rfc0003_16_served_binary.rs and all sixteen scenarios run green — so the ladder returned to green, which is what the 2026-08-25 accepted flip stands on.)

Scenario RFC0003.16 — Served binary: both transports bind, a client export round-trips, graceful shutdown [§3.4]

  • Given ourios-server started with the receiver role enabled (config-toggled per the §9 resolution), the gRPC listener bound on its configured port (default 4317) and the HTTP listener bound on its configured port (default 4318), both sharing one IngestPipeline over a single Wal
  • When a real OTLP client exports a non-empty batch (resolvable tenant) over each bound socket — gRPC Export and HTTP POST /v1/logs (application/x-protobuf) — and receives each transport response, and only then is the server signalled to shut down (this scenario pins the steady-state export→ack→shutdown path; in-flight-during- shutdown behaviour is out of scope here)
  • Then each client receives transport-level success (gRPC OK / HTTP 200) only after its batch is durable — the §6.5 WAL-before-ack contract holds end-to-end over a real socket, not just in-process
  • And the shutdown signal stops the listeners and exits the process cleanly, releasing the single Wal handle — without dropping it mid-fsync, and no already-acked batch is lost on the way out
  • And after that clean exit frees the single-writer handle (RFC 0008 §3.1), opening the WAL and running Wal::replay recovers each batch’s OtlpBatch frame — the durability check necessarily follows shutdown, since the WAL cannot be reopened while the server holds it

6. Proposed design

6.1 Overall shape

The receiver is a single Rust crate (ourios-ingester per the target layout in CLAUDE.md §7) exposing two listeners — gRPC on its own port, HTTP on its own port — that share a single business-logic layer. The business-logic layer accepts a decoded ExportLogsServiceRequest and:

  1. Iterates ResourceLogs[], deriving tenant_id per Resource via the operator-configured rule (RFC 0001 §6.1 Tenant derivation).
  2. For each ResourceLogs, iterates ScopeLogs[] and LogRecord[], materialising one OtlpLogRecord per record. The OtlpLogRecord is the in-memory shape RFC 0001 §6.1’s amended record table mirrors; it carries the inherited Resource attributes and the InstrumentationScope name and version as fields, so downstream code never needs to walk back up the OTLP hierarchy.
  3. For each LogRecord, materialises body into the Body::String(String) | Body::Structured(AnyValue) fork per ourios-core::otlp::Body::from_any_value. No canonicalisation runs at the receiver — the structured branch carries the decoded AnyValue verbatim per the amended §6.4.
  4. Hands each per-tenant stream to ourios-miner (one MinerCluster per process; the cluster routes internally per tenant_id).
  5. After the batch has been written to the WAL as a single OtlpBatch frame with fsync AND every record accepted by the miner, returns a transport-level success.

6.2 Wire stack defaults

  • gRPC: tonic + the opentelemetry-proto crate’s generated LogsServiceServer trait.
  • HTTP: axum on hyper. A single /v1/logs POST handler dispatches on Content-Type:
    • application/x-protobufprost::Message::decode into the same ExportLogsServiceRequest type the gRPC path produces.
    • application/json → proto3-JSON decode into ExportLogsServiceRequest. The decode handles whitespace and field-ordering variation natively (proto3-JSON spec); no separate canonicalisation pass — the Body::Structured(AnyValue) carried downstream is transport-agnostic at the AnyValue level.
  • Both listeners spawn off the same tokio runtime, share a single instance of the business-logic layer, and bind on operator-configured ports (defaults 4317 for gRPC and 4318 for HTTP, per the OTel convention; configurable). The receiver is a role of the ourios-server binary, enabled by config and sharing that binary’s tokio runtime alongside the other roles (e.g. the compaction daemon) — the §9 Receiver process model resolution. The served-binary contract is RFC0003.16.

6.3 Tenant fan-out

Superseded by RFC 0046 (2026-08-17). The tenant is no longer derived from the payload: every export names it out of band (X-Ourios-Tenant / x-ourios-tenant, required, one export = one tenant). The failure semantics moved with it: a missing or malformed selector is rejected before the payload is decoded (400 / INVALID_ARGUMENT naming the header, RFC0046.1/.7), and a selector outside the credential’s set is rejected after authentication (403 / PERMISSION_DENIED, RFC0046.2) — neither is the missing-attribute / ResourceLogs-index rejection RFC0003.4 described. The text below is kept as the historical design; RFC0003.3 and RFC0003.4 are replaced by RFC0046.3 and RFC0046.1/.2.

Per RFC 0001 §6.1 Tenant derivation, tenant_id is derived per ResourceLogs group, not per export batch. The receiver:

  • Resolves the tenant rule once per ResourceLogs.resource.
  • Groups the resulting OtlpLogRecords by tenant_id (a single batch can produce multiple per-tenant groups).
  • Hands each group to the miner via MinerCluster::ingest, which is already per-tenant-routed internally.

If any ResourceLogs.resource fails to resolve to a tenant under the configured rule, the receiver rejects the entire batch with a transport-level error naming the failing ResourceLogs index (the offset in the export’s resource_logs[]) and the missing attribute key. Per-Resource partial acceptance is reserved for a future RFC (see §9).

6.4 AnyValue canonicalisation happens once, at ingest

Amendment (PR introducing ourios-core::otlp::OtlpLogRecord). This subsection originally pinned the receiver as the place that canonicalises structured AnyValue bodies into OTLP-canonical JSON, with the in-memory record carrying pre-cached Bytes. The amended position carries the AnyValue itself on the in-memory record and defers canonicalisation to the storage layer (Parquet writer, when it lands).

Amendment 2026-06-10 (canonicalisation happens at ingest). The amendment above predated the implementation and placed the single canonicalisation pass at Parquet-write time. The merged implementation runs it at ingest: the miner’s ingest_structured encodes the AnyValue the receiver delivered with the Ourios canonical body encoding (RFC 0001 §6.1 The Ourios canonical body encoding), and the mined record carries those bytes from there — WAL, flush, and the Parquet writer (RFC 0005 §3.3) persist them verbatim. What the amendment above got right is preserved: the receiver still does not canonicalise, and the “mine inner field” optionality is intact because the miner receives the decoded tree — the encode point sits after any future inner-field hook would run. This note reconciles the text below to the implemented behaviour; the rationale is rewritten accordingly.

The receiver hands the miner an OtlpLogRecord whose body, when present and structured, carries the decoded AnyValue verbatim (Body::Structured(AnyValue)) — unchanged from the first amendment. The miner’s §6.2 step-0 short-circuit dispatches on the discriminator alone; only after taking the structured branch does ingest_structured encode the tree, once, via ourios_core::otlp::canonical::encode_any_value (infallible for every AnyValue the type system admits) into the Ourios canonical body encoding per RFC 0001 §6.1 Body representation. The record carries the encoded bytes in its body field from that point on; the Parquet writer stores them verbatim in the RFC 0005 §3.3 body column.

Rationale:

  • Optionality is not lost. RFC 0001 §6.1 reserves a future “mine inner field” mode (e.g. mine body.kvlist["msg"] as the line if present) gated on corpus evidence. That mode needs the structured tree — and ingest_structured receives the structured tree: the receiver hands Body::Structured(AnyValue) through untouched, so the exact place such a mode would hook in still sees the AnyValue. Only the stored form is the canonical bytes; encoding at ingest forecloses nothing.
  • Single canonicalisation pass. Whether the body arrived as gRPC-protobuf or HTTP-JSON, exactly one encode runs — once per structured record, at ingest. There is one transport-agnostic encoder (ourios-core’s otlp::canonical, operating on the decoded AnyValue); neither the receiver nor the writer needs to know a second strategy, and the writer’s contract shrinks to “persist the bytes.”
  • Miner hot path is unchanged. The §6.2 step-0 short-circuit still inspects only the discriminator (Body::Structured(_) vs Body::String(_)) before branching; no AnyValue walking decides the dispatch. The encode cost scales with body size, but it is paid exactly once per structured record regardless of which layer pays it — moving it to write time would buy no work back, while costing a rework of MinedRecord, the miner’s snapshot serialisation (RFC 0001 §6.9), and the render path (RFC 0001 §6.6), all of which carry the encoded body today. That churn would purchase only a mode with no RFC and no named consumer.

For Body::String(s), no canonicalisation is ever needed; the unwrapped string is passed through as L_raw.

6.5 WAL-before-ack sequencing

[§3.4] requires the receiver to acknowledge a non-empty batch only after the batch’s OtlpBatch frame is durably written. (The empty-batch fast path of RFC0003.12 is the explicit exception: no WAL write occurs, and success is returned without an OtlpBatch frame.) Concrete contract for the non-empty case:

  1. Receiver accepts the request and decodes to ExportLogsServiceRequest.
  2. Receiver fans out to per-tenant OtlpLogRecord streams (§6.3); body canonicalisation does not happen here, per the amended §6.4.
  3. Receiver appends the request as a single FrameKind::OtlpBatch frame (per RFC 0008 §3.2 + §6.2.3) whose payload is a protobuf-encoded ExportLogsServiceRequest decodable via prost — semantically equivalent to the input, but byte-equal to the wire payload is not required (per RFC0003.1 + .2 the contract is “you can recover the input message,” not “you get the bytes back”). For the gRPC and HTTP/protobuf paths the receiver MAY store the wire bytes verbatim; for the HTTP/JSON path no incoming protobuf bytes exist and the receiver MUST encode the decoded message.
  4. Receiver fsyncs the WAL segment(s) touched.
  5. Receiver hands records to the miner for templating.
  6. Receiver returns transport-level success.

The fsync-then-template ordering matters: a crash between (4) and (5) is recoverable (records replay from the WAL; the miner state is reconstructed); a crash between (3) and (4) loses those records but the client retries (no ack was sent); a crash between (5) and (6) is the “the server did the work and the client never heard about it” case, where client retries produce duplicates. This RFC implements no de-duplication: duplicates on retry are the explicit at-least-once contract per RFC0003.2 and §9 #1 (resolved by reference to the OTLP spec’s duplicate-data section). Any future content-hash or request-id dedup is purely additive on top of this baseline.

The receiver itself is post-MVP per roadmap.md §5 — the MVP bench reads OTLP from the on-disk corpus, bypassing this component entirely. The receiver therefore cannot be enabled until ourios-wal lands, and there is no MVP code path that acks a network request before durability. The append-then-fsync-then-ack sequence above is the only contract; no “WAL no-ops” mode exists, since that would violate [§3.4].

6.6 The OtlpLogRecord in-memory shape

Amendment (PR introducing ourios-core::otlp::OtlpLogRecord). Body now carries the decoded AnyValue rather than its OTLP-canonical JSON encoding (see amended §6.4). body_kind is derived from body rather than stored on the record, since the §6.2 step-0 fork only needs to read the discriminator.

Amendment 2026-06-11 — the effective timestamp is derived downstream, not here. RFC 0005 §3.2 (amendment of the same date) adds a writer-derived effective_time_unix_nano Parquet column (time_unix_nano when non-zero, else observed_time_unix_nano.unwrap_or(0) — RFC 0005 §3.2 is the normative derivation). The receiver’s contract is unchanged: time_unix_nano is carried verbatim from the wire including 0 (RFC 0001 scenario RFC0001.10), and the wire-0None rule for observed_time_unix_nano stands. No effective-timestamp field is materialised on OtlpLogRecord; the derivation happens at the Parquet writer from the two fields below, and never overwrites either.

The receiver materialises each wire-level LogRecord (plus its inherited Resource and InstrumentationScope context) into a single owned struct. The authoritative definition lives in the ourios-core::otlp module; the sketch below mirrors that module:

struct OtlpLogRecord {
    // Identity / partitioning
    tenant_id: TenantId,

    // OTLP-derived (per RFC 0001 §6.1)
    time_unix_nano: u64,
    observed_time_unix_nano: Option<u64>,
    severity_number: u8,
    severity_text: Option<String>,
    scope_name: Option<String>,
    scope_version: Option<String>,
    attributes: Vec<KeyValue>,            // opentelemetry-proto KeyValue
    dropped_attributes_count: u32,
    resource_attributes: Vec<KeyValue>,   // opentelemetry-proto KeyValue
    trace_id: Option<[u8; 16]>,
    span_id: Option<[u8; 8]>,
    flags: u32,
    event_name: Option<String>,

    // Body — None when the wire delivered no body
    body: Option<Body>,
}

enum Body {
    String(String),
    Structured(AnyValue),                 // opentelemetry-proto AnyValue
}

// `body_kind()` is a method on OtlpLogRecord that returns
// `Option<BodyKind>` derived from `body`; the discriminator
// is never stored.
enum BodyKind { String, Structured }

The Rust types are informal here; the precise definition lives in the ourios-core::otlp module — owning the type in ourios-core (rather than ourios-ingester) lets the miner take it without depending on the receiver crate, since the receiver doesn’t yet exist. The shape mirrors RFC 0001 §6.1 column-for-column so the Parquet writer can serialise a slice of these directly without a translation layer.

6.7 Backpressure (deferred)

The receiver does not apply rate limiting in this initial design. If the miner or the WAL is the bottleneck, the receiver holds the request open until the per-tenant queue drains, then acks. In practice this means OTLP clients see backpressure as elevated request latency rather than as partial_success.rejected_log_records. Whether to upgrade this to explicit partial-success is reserved for a future RFC (see §9). The full-failure path (transport error) covers the unresolvable-tenant and malformed-batch cases per §6.3 and §3.2.

6.8 Out of scope for this RFC

  • Metrics + traces ingest. OTel Collector and OTLP define endpoints for both; Ourios is a logs-only backend per CLAUDE.md §1. Receiver MAY accept metric/trace requests at the transport layer (returning a deliberate Unimplemented response) but this RFC does not specify that path.
  • mTLS / authn / authz. Production deployment concerns, out-of-band of the OTLP wire contract. A future RFC covers the authentication model (likely token-based per request with the resolved identity feeding the tenant-derivation rule).
  • Schema URL handling. ResourceLogs.schema_url and ScopeLogs.schema_url are separate OTLP fields and do not appear on the OtlpLogRecord shape in §6.6 — the receiver currently drops them. Rationale: RFC 0001 §6.1’s record schema does not include columns for them, no consumer references them yet, and Ourios does not interpret OTel semantic conventions. Whether to add resource_schema_url / scope_schema_url fields (or a Parquet column) is tracked as an open question in §9; until then the drop is deliberate, not an oversight.
  • Compactor / WAL implementation. Specified in the forthcoming ourios-wal RFC; this RFC’s contract with the WAL is just the append-then-fsync-then-ack sequence in §6.5.

7. Alternatives considered

7.1 Embed rotel as a library

rotel is a production-quality Rust OTel collector. Embedding it would give us a known-good OTLP receiver implementation without us building one. Rejected because:

  • rotel’s public API is collector-shaped (the full receivers→processors→exporters pipeline), not “just the receiver” shaped. Embedding it means embedding the entire pipeline machinery, then building Ourios as one of its exporters. That’s a deployment shape (out-of-process collector) wearing the costume of a code dependency, with the worst of both worlds: the dependency footprint of a full collector and the integration friction of an in- process one.
  • The OTel-receiver pieces of rotel are themselves built on tonic + opentelemetry-proto — the same primitives we would use directly. Embedding rotel adds a layer without removing one.
  • Build-vs-embed parity: our wire-decode layer is small (~a few hundred lines, almost all glue against generated protobuf bindings). The reuse argument doesn’t carry the weight it would for a complex piece of infrastructure.

7.2 Run the OTel Collector out-of-process and have it forward to us

Common deployment shape: a Collector terminates OTLP at the network edge, batches, and forwards to a backend. Rejected as the default because:

  • The Collector ACKs the OTLP client before our backend sees the data, breaking the WAL-before-ack contract [§3.4]. The only way to recover the contract is for our forwarding protocol from the Collector to be itself durable + ack- after-fsync — at which point that protocol is what we needed to spec, and we are back to writing a receiver.
  • Adds a deployment dependency (operator must install and configure the Collector) for no signal beyond what a direct receiver provides.
  • Configuration drift between the Collector’s input validation and ours becomes a real source of “works in one place, fails in the other” bugs.

That said: the Collector is a perfectly fine deployment option for operators who already run one (e.g., for trace sampling). The receiver in this RFC accepts OTLP from any source, including a Collector forwarder, so the deployment shape is not foreclosed; it just isn’t the default and doesn’t get to be on the WAL-before-ack path.

7.3 Hand-roll the protobuf without opentelemetry-proto

Writing our own protobuf bindings against the OTLP .proto files. Rejected because the official crate tracks upstream faithfully and is the canonical Rust binding for the OTLP messages. Re-implementing risks drift, especially on the JSON-encoding overrides (hex IDs, base64 bytes) which are spec-defined but easy to get wrong.

7.4 HTTP-only or gRPC-only

Supporting only one of the two transports. Rejected because the OTel ecosystem is split: SDK defaults are gRPC, but HTTP is widely used in constrained environments and is the standard exporter target for the Collector’s otlphttp exporter. Refusing either transport reduces the receiver to a non- compliant subset of OTLP and forces a class of operators to front Ourios with a converter (e.g., the Collector) — which re-introduces the WAL-before-ack problem of §7.2.

7.5 Synchronous AnyValue canonicalisation in the miner or the receiver

Amendment 2026-06-10 (canonicalisation happens at ingest). The conclusion this section originally reached — “canonicalise at the storage layer (Parquet writer)” — was superseded by the implementation; see the amended §6.4. The grounds on which variant (a) was rejected dissolved once RFC 0001 §6.1’s 2026-06-09 amendment pinned a single transport-agnostic encoder over the decoded AnyValue (ourios-core’s otlp::canonical): the miner needs no transport knowledge, and the encode cost is once per structured record regardless of which layer pays it. The original text is preserved below as the record of the evaluation.

Two related alternatives evaluated together:

(a) Canonicalise in the miner. Rejected because the miner’s hot path benefits from a constant-time write in the body_kind = Structured short-circuit (RFC 0001 §6.2 step 0); doing serialisation work there scales with body size on every structured record. The miner would also need to know the source transport (the two transports need different canonicalisation strategies), which is a layering inversion. (Superseded — see the amendment note above: this is the implemented design.)

(b) Canonicalise in the receiver before materialising OtlpLogRecord. This was the original §6.4 stance and was the basis on which §7.5(a) was rejected. Reversed by the §6.4 amendment: receiver-side canonicalisation forecloses the future “mine inner field” mode (RFC 0001 §6.1) which needs the structured tree, not pre-cached bytes; it also splits canonicalisation knowledge across two transports unnecessarily. (Still rejected — the receiver continues to hand the decoded AnyValue through verbatim. Only the “canonicalise at the storage layer” conclusion that this paragraph originally drew is superseded, per the amendment note above.)

8. Testing strategy

Mapped to the §5 scenarios. Each technique below names the scenario ids it covers; each test’s leading doc comment references the same id verbatim (/// Scenario RFC0003.1 — WAL-before-ack. etc., per docs/verification.md §2.3) so the spec↔test mapping is greppable.

  • WAL-before-ack and concurrency (RFC0003.1, RFC0003.15): integration tests against a real Wal (defaults), with an AtomicBool ordering probe mirroring RFC0008.1 — set after Wal::sync returns, asserted true by the response-writer and false by every pre-sync stage. RFC0003.15 spawns N ≥ 2 concurrent Export calls and uses a per-call probe so the invariant is checked independently per in-flight call.

  • Crash-before-ack (RFC0003.2): a child-process harness mirroring wal_crash_fixture (PR #126) runs a receiver binary wired to a real Wal, the parent SIGKILLs between Wal::sync return and ack-emit, the parent restarts the child and re-issues the export, and the assertion is that the post-restart WAL contains two OtlpBatch frames whose payloads each decode (via prost) to an ExportLogsServiceRequest semantically equivalent to the input (byte-equality is not required — see RFC0003.2 — but the second frame must round-trip to the same logical request) — the at-least-once contract per the OTLP spec’s duplicate-data section. The test explicitly does not assert dedup; RFC0003.2’s contract is “no loss + safe retry,” not exactly-once.

  • Tenant fan-out (RFC0003.3, RFC0003.4): unit tests with a hand-curated two-Resource batch and an instrumented MinerCluster stub that records every accepted (tenant_id, OtlpLogRecord) pair. A proptest strategy over tenant-derivation rules asserts the cross-contamination-free invariant for any rule that returns Some for both Resources. RFC0003.4 uses a hand-curated batch where one Resource lacks the rule’s attribute key.

  • Wire-decode equivalence (RFC0003.5, RFC0003.6): a proptest strategy generates ExportLogsServiceRequest payloads across the proto’s value space; each is serialised to gRPC + protobuf, HTTP + protobuf, and HTTP + JSON, decoded by the receiver, and the three resulting OtlpLogRecord sequences are asserted equal at the AnyValue level. The RFC0003.6 OTLP-JSON encoding-rule clauses (hex IDs, base64 bytes, integer enums, ignore unknown fields) use hand-curated payloads, since the proptest generator can’t reliably exercise spec-mandated forward-compatibility behaviour.

  • Body fork (RFC0003.7, RFC0003.8): table-driven tests over all seven AnyValue variants, each asserting that Body::from_any_value routes string_value to Body::String(s) (unwrapped) and every other variant to Body::Structured(av), where av is structurally equal to the input AnyValue and the inner oneof is moved, not cloned.

  • Edge OTLP cases (RFC0003.9, RFC0003.10): hand-curated LogRecords exercising severity_number = 0, scope_name = None, observed_time_unix_nano = 0, and non-zero dropped_attributes_count. Assertions pin the pass-through semantics on the derived OtlpLogRecord.

  • Transport-level errors + empty request (RFC0003.11, RFC0003.12): table-driven tests over each error arm (malformed protobuf, oversize, unrecognised Content-Type, wrong path, mid-decode cancellation) and the empty-request success arm. Each assertion pins the response status code, that no OtlpBatch frame is appended to the WAL and no record reaches the miner, and that the receiver process is still alive afterwards.

  • Compression and path (RFC0003.13, RFC0003.14): the gzip arm of RFC0003.13 uses flate2 to construct the Content-Encoding: gzip body; the unsupported-encoding arm asserts HTTP 415. RFC0003.14’s path arm covers the default /v1/logs, a wrong-path 404, and an operator-configured override path producing equivalent behaviour.

  • Conformance fuzzing (additive, not bound to a single scenario): proptest strategies derived from the proto definitions feed random valid batches through the receiver; the only assertion is “no panic; response is either success or a controlled transport-level error” — a backstop against decode paths the hand-curated cases miss.

  • Benchmarks (criterion, in ourios-bench): end-to-end latency from request arrival to ack-fires, for both transports, at batch sizes (1, 100, 1 000, 10 000 records per batch). RFC0003.15 throughput at N = 8 concurrent callers. Regressions block merges per CLAUDE.md §6.2.

  • Served binary (RFC0003.16): an integration test boots the ourios-server receiver role bound on ephemeral ports (127.0.0.1:0, reading back each OS-assigned port), exports a non-empty batch over each transport with a real client — a tonic gRPC client and an HTTP client (reqwest/hyper) — and asserts transport success for each. It then signals shutdown and waits for the server task to join cleanly, which releases the single Wal handle (RFC 0008 §3.1’s single-writer rule — the WAL cannot be reopened while the server holds it). Only after that join does the test open the WAL and Wal::replay it, confirming each batch’s OtlpBatch frame is durable — WAL-before-ack over a real socket, with no acked batch lost on the way out. Unlike RFC0003.1–.15 (in-process: direct handler call / oneshot), this is the only scenario that crosses a real socket.

docs/verification.md §3’s two-loop Red gate applies: the §5 scenarios become #[ignore]d test stubs at red stage, then get implementations as the receiver crate is built (the same two-loop pattern RFC 0008 §5 used to drive its red-gate scenarios — #[ignore]’d stubs first, implementations second).

9. Open questions

  • Retry-induced duplicate suppression. Resolved by §5 / RFC0003.2: a crash between miner-attach (step 5) and ack (step 6) in §6.5 produces duplicates on client retry, and that is the contract. The OTLP spec’s duplicate-data section (“the client may re-send … which may result in duplicate data on the server side. This is a deliberate choice and is considered to be the right tradeoff for telemetry data”) explicitly accepts at-least-once with duplicates; the Collector’s WAL guidance carries the same caveat. The receiver implements no de-duplication in this RFC. If a future RFC introduces a dedup mechanism (content-hash idempotency key, OTel SDK request-id header), it is purely additive — the at-least-once baseline is the floor, not a stop-gap.
  • ResourceLogs.schema_url / ScopeLogs.schema_url preservation. §6.8 records that schema URLs are currently dropped because no consumer references them and RFC 0001 §6.1’s record schema has no column for them. If a semantic-conventions-aware feature lands later (e.g., schema URL → attribute key mapping), OtlpLogRecord and the Parquet schema will need the two fields added. Tracked here so a future RFC does not re-derive the question.
  • Where exactly does canonicalisation cost land? Resolved (2026-06-10 §6.4 amendment, reconciling to the implementation): canonicalisation runs at ingest — the miner’s ingest_structured encodes the decoded AnyValue with the Ourios canonical body encoding (RFC 0001 §6.1) and the record carries the bytes from there; the Parquet writer (RFC 0005 §3.3) persists them verbatim. The receiver carries the decoded AnyValue verbatim and never canonicalises. The cost is once per structured record either way; placing it at ingest keeps MinedRecord, the snapshot format, and the render path on a single stored form.
  • dropped_attributes_count semantics on truncation. Preserve verbatim from the wire (current §6 design), sum across records, or recompute if the receiver itself drops attributes (e.g., for being over the 256-byte limit per RFC 0001 §3.2)? Current design says preserve; a future receiver- side truncation step would need to either recompute or use a separate column.
  • Receiver process model. Resolved (served-binary amendment): the receiver is a role of the ourios-server binary, enabled by config and sharing that binary’s tokio runtime alongside the other roles (e.g. the compaction daemon) — not a separate sidecar. Default ports 4317 (gRPC) / 4318 (HTTP) per §6.1; the end-to-end served contract (bind + client round-trip + graceful shutdown) is RFC0003.16.
  • Partial-success response semantics. Resolved (OTLP review): the all-or-nothing batch contract (§6.3 / RFC0003.4) is spec-compliant. OTLP mandates only 400 Bad Request + no client retry for permanently-bad/undecodable input (OTLP/HTTP Bad Data) and does not require accepting a valid subset; partial_success.rejected_log_records is supported but optional. We keep whole-batch rejection and defer partial_success to a future RFC if a concrete operator need surfaces (e.g. one failing tenant in a large multi-tenant batch). On full success partial_success stays unset — the normal OK path.
  • Authentication and tenant binding. If the receiver authenticates the client (mTLS, token), does the authenticated identity feed into the tenant_id derivation rule (e.g., as a constraint), or is it purely an access- control check decoupled from tenancy? A future authentication RFC settles this; the open question is flagged here so the tenant-derivation rule’s interface can grow into it.
  • Multi-line / non-UTF-8 body handling for String bodies. The miner’s tokenize step (RFC 0001 §6.2 step 1) has explicit failure modes (malformed UTF-8, embedded NUL, oversize). Should the receiver pre-validate and reject at the transport level, or pass through and let the miner emit a parse-failure record? Current design: pass through, per-record granularity is the miner’s concern.
  • Compression (gzip / zstd over HTTP). Resolved by §5 / RFC0003.13: the OTLP spec mandates that servers support identity and gzip; both are required acceptance criteria. zstd and br are out of scope for this RFC — a request carrying an unsupported encoding is rejected with HTTP 415. A future RFC may add zstd if operator demand surfaces; until then the 415 response is the contract.
  • Receiver-side OTel telemetry (eating our own dog food). The receiver should itself emit metrics about request rates, decode failures, fan-out latency. Specified where? Likely in the same RFC as the §6.8 telemetry surface (RFC 0001 §6.8); flagged here for tracking.

10. References

  • OTLP logs.proto: https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/logs/v1/logs.proto
  • OTLP logs_service.proto: https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/collector/logs/v1/logs_service.proto
  • OTLP common.proto (AnyValue, KeyValue): https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/common/v1/common.proto
  • OpenTelemetry Logs Data Model spec: https://opentelemetry.io/docs/specs/otel/logs/data-model/
  • OTLP transport spec (gRPC, HTTP, encodings): https://opentelemetry.io/docs/specs/otlp/
  • tonic: https://github.com/hyperium/tonic
  • opentelemetry-proto Rust crate: https://crates.io/crates/opentelemetry-proto
  • axum: https://github.com/tokio-rs/axum
  • rotel: https://github.com/streamfold/rotel
  • OpenTelemetry Collector: https://github.com/open-telemetry/opentelemetry-collector
  • Ourios investigation finding: docs/architecture/otlp-log-format.md
  • RFC 0001 §6.1 (record schema this RFC produces records for): docs/rfcs/0001-template-miner.md
  • CLAUDE.md §1 (Ourios is logs-only), §3.4 (WAL-before-ack), §3.7 (multi-tenancy not bolted on), §4 (hazards).

RFC 0004 — Configuration policy


rfc: 0004 title: Configuration policy — tunables vs invariants status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-05-18 supersedes: — superseded-by: —

RFC 0004 — Configuration policy: tunables vs invariants

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

1. Summary

Ourios exposes a small, deliberately bounded configuration surface to its operators. This RFC pins the line between tunables — knobs that can be set globally and overridden per tenant — and invariants — the CLAUDE.md §3 commitments that define what Ourios is. Tunables let an organisation place themselves on the accuracy-vs-compression spectrum without taking the whole product with them. Invariants are not configurable — every tenant gets the same [§3] guarantees, no matter what. The RFC names the current four tunables, the boundary they sit inside, and the escalation path for anyone who wants to cross it.

2. Motivation

2.1 Different organisations sit at different points

Dev clusters care about cheap ingest and aggressive compression and tolerate noisier templates. Production caps the noise and pays the storage. Some customers run high-cardinality logging from legacy apps; others run carefully structured loggers. A backend that bakes one trade-off into the algorithm is rigid and harder to adopt; a backend that lets users tune the trade-off within a guaranteed safety net is exactly Ourios’ thesis-shaped use case.

2.2 But the safety net is the product

CLAUDE.md §1 lists what Ourios is and is not. CLAUDE.md §3 lists the load-bearing invariants — strict thresholds, no unbounded params, bit-identical reconstruction, WAL-before-ack, schema migrations through RFC, single-source-of-truth in object storage, multi-tenancy from day one. Each of those is the answer to a specific failure mode (silent template merges, cardinality blow-ups, lossy reconstruction, lost acked data, …). If any of them is configurable per tenant, the product becomes configurable per tenant: query semantics, audit trail, storage guarantees all vary based on a knob a future operator forgot they flipped. The cognitive surface alone is a hazard.

2.3 Why pin this in an RFC

The boundary is a recurring question (it has already come up in maintainer discussion 2026-05-18; see docs/roadmap.md §5 for the Perses-integration variant of the same instinct). Pinning the two-class model now means:

  • New PRs that propose a tunable can be reviewed against a written rule rather than a half-remembered convention.
  • Future RFCs that want to break an invariant know they need a meta: RFC (per CLAUDE.md §6.2 precedent), not a runtime toggle.
  • Contributors reading the MinerConfig rustdoc see the category of each knob, not just its type.

3. Proposed design

3.1 Two-class model

Every operator-visible knob is exactly one of:

  • Tunable. Configurable globally; overridable per tenant. Validated at process startup; tenants whose override fails validation never serve traffic (RFC 0001 §3.2.2 already pins this contract for param_byte_limit; this RFC generalises it to all tunables).
  • Invariant. Not configurable. The same value applies to every tenant. Encoded as an algorithmic property of the code, not a field on MinerConfig. A change requires an RFC against CLAUDE.md §3; a waiver requires a meta: RFC.

There is no third category. A “default but overridable in production” knob is a tunable; a “default for now, may make configurable later” knob is an invariant — configurability is opt-in, never an implicit consequence of “we exposed a field.”

3.2 The current tunables (four)

These are the knobs MinerConfig exposes, with the current defaults and the RFC §3 invariant each lives inside:

TunableDefaultValidated rangeInside invariant
similarity_threshold0.7 (RFC 0001 §3.1.1)(0, 1]§3.1 — strict-by-default, RFC required to change the default below 0.7
similarity_floor0.4 (RFC 0001 §6.3)(0, similarity_threshold]§3.1 — bounds the §6.3 lossy zone; body retention in that zone is invariant
prefix_depth2 (Drain paper §3.2)0..=8 (RFC 0001 §6.1 — “configurable cap of ~8 is the realistic ceiling”)§3.1 — affects tree quality, not safety
param_byte_limit256 (RFC 0001 §3.2.1)1..=1024 (PARAM_BYTE_LIMIT_CEILING, RFC 0001 §3.2.2)§3.2 — bounds cardinality; overflow spilling is invariant

The §3 invariant column is load-bearing: a tunable that walks outside its validated range is rejected at startup, not mapped to a clamped value, because clamping silently moves a tenant onto a trade-off point the operator didn’t pick.

3.3 The invariants (not tunable)

These come from CLAUDE.md §3 and RFC 0001 §6.1 / §6.4 / §6.6 — they’re enforced in code, not exposed as fields:

  • Widening fires on every Fixed mismatch with a TemplateWidened audit event (§6.4). There is no allow_widening toggle; turning off widening means turning off the §3.1 audit signal and the miner’s compression story together. If a tenant doesn’t want template merging, they shouldn’t use a template-mining backend.
  • severity_number and scope_name are part of the §6.1 template-key composition. There is no respect_severity toggle; merging INFO and ERROR "user logged in" records is hazard H1.4 by construction.
  • Body is retained on every §6.3 lossy-zone and parse-failure attach. There is no LossyMode::Aggressive toggle; CLAUDE.md §3.1 reads “MUST retain the original body. No exceptions.”
  • Reconstruction is bit-identical on every record with lossy_flag = false. There is no accept_lossy_reconstruction toggle; CLAUDE.md §3.3 reads “rendering … must equal the original line byte for byte, or the line must be flagged lossy.”
  • Mining is per-tenant. There is no enable_cross_tenant_dedup toggle; CLAUDE.md §3.7 reads “every code path that touches data takes a tenant ID.”

The list is closed in the sense that any new knob that touches one of these areas is an invariant proposal, not a tunable proposal — the PR adding it goes through the §6 RFC process, not review.

3.4 Per-tenant override mechanism

MinerConfig is Clone + Copy + 'static and its docstring already says “per-tenant miner configuration.” The cluster holds a cluster default plus an optional per-tenant override; overrides are seeded before the tenant is first observed (or default-resolved at lazy TenantState allocation when no override exists). The algorithm code reads &MinerConfig from TenantState on every ingest — no global flag, no implicit “current tenant.”

Implementation detail (specified in the follow-up PR, not this RFC): seeding API on MinerCluster is with_tenant_config( tenant_id, config) or equivalent; the lookup is state.config inside the per-tenant store the cluster already maintains. No hot-path overhead beyond the existing &self.config deref.

3.5 Escalation path

If a future RFC proposes promoting an invariant to a tunable, the escalation is:

  1. A meta: RFC against CLAUDE.md §3 explaining why the invariant should no longer be load-bearing. Majority maintainer approval (the precedent is CLAUDE.md §6.2’s 2026-05-13 amendment).
  2. Only after the meta: RFC accepts does the implementation RFC propose the MinerConfig field and the validation bounds.

Going the other direction — promoting a tunable to an invariant — follows the same path: the meta: RFC justifies the loss of flexibility, the implementation RFC removes the field.

This is the only path. A PR that adds a “small, just-for-now” field that touches an invariant area is rejected.

4. Alternatives considered

4.1 Single flat config bag

Stuff everything (tunables + algorithmic constants) into one Config struct with no internal classification. Rejected: the cognitive surface concern in §2.2 — readers can’t see at a glance which fields are safe to override. Future PRs that add knobs have no anchored rule to be reviewed against.

4.2 Inline classification on each field via a marker trait

Tag each field with Tunable or Invariant via a Rust trait. Rejected: invariants aren’t fields at all — they’re algorithmic properties (widening fires, severity participates in the key, body retains). Marking them as fields-with-a-trait would imply the field is the source of truth, which it isn’t. The closed-set rustdoc in §3.2 / §3.3 is a stronger contract than a marker.

4.3 A DrainConfig separate from MinerConfig

External LLM proposal 2026-05-18 (Grok session — link in maintainer’s memory under reference_grok-design-conversations). Rejected: MinerConfig already exists and already covers three of the four tunables. A second config type duplicates the validation surface, splits the per-tenant override mechanism, and introduces a new boundary type to maintain. The naming convention “<subsystem>Config is the tunables surface, invariants live in code” is the simpler shape.

4.4 RFC the implementation, not the policy

Skip this RFC; let the implementation PR add prefix_depth to MinerConfig. Rejected: the boundary keeps coming up (docs/roadmap.md §5 Perses row, Grok DrainConfig, future CRD proposals); a one-shot implementation PR doesn’t give those recurrences an anchor to be reviewed against. The RFC is the artifact, the PR is the action.

5. Acceptance criteria

Scenario RFC0004.1 — Every tunable validates at startup

  • Given a MinerConfig constructed via try_new_full with a value outside the §3.2 ranges for any field
  • When the constructor is called
  • Then it returns Err(MinerConfigError::*) naming the offending field
  • And no MinerConfig instance is produced

Scenario RFC0004.2 — Per-tenant override is honoured

  • Given a MinerCluster with a default MinerConfig and a per-tenant override for tenant T that differs from the default in at least one tunable
  • When tenant T ingests a line that exercises the differing knob’s decision boundary
  • Then the cluster’s behaviour matches the per-tenant override, not the default

Scenario RFC0004.3 — No invariant-breaking field exists

  • Given the MinerConfig type as defined by this RFC
  • When cargo doc is rendered or the type is grep’d in CI
  • Then there is no allow_widening, respect_severity, lossy_mode, enable_cross_tenant_dedup, or accept_lossy_reconstruction field — adding one is a compile-time visible change that fails this scenario
  • And the implementation PR adds a test that pins the tunable-set against this RFC

6. Testing strategy

  • RFC0004.1 — exhaustive unit tests on try_new_full per failure variant (one test per MinerConfigError arm). Already partially in place; the follow-up implementation PR adds the PrefixDepthTooLarge variant + test.
  • RFC0004.2 — integration test in crates/ourios-miner/tests/ ingesting the same line through two tenants with different similarity_thresholds and asserting different template-allocation outcomes.
  • RFC0004.3 — a “tunable-set pin” test that uses a match against MinerConfig’s public fields (exhaustive on a struct pattern); adding a new field forces the test author to think through which side of the boundary it sits on, and reviewers see the change as part of the RFC against §3.

7. Open questions

  • Should the per-tenant override mechanism allow dynamic reconfiguration (operator API at runtime), or only at startup? RFC defers to the implementation PR’s preference; current proposal is startup-only because TenantState is allocated lazily and config is captured at allocation.
  • Does the documentation route stop at MinerConfig’s rustdoc, or does it also need a page under docs/architecture/? Defer until the implementation PR lands.

8. References

  • CLAUDE.md §1 (project charter), §3 (invariants), §3.7 (multi-tenancy from day one), §5.1 (RFC process), §6.2 (tests as specifications, 2026-05-13 meta: amendment).
  • RFC 0001 §3.1.1 (similarity_threshold default), §3.2.1 (param_byte_limit default), §3.2.2 (startup rejection contract), §6.1 (template-key composition, prefix-depth cap), §6.3 (three-zone model + floor default), §6.4 (widening + audit), §6.6 (reconstruction).
  • docs/roadmap.md §5 (deliberately-out-of-MVP table — Perses row is a related “is/is-not” discussion).
  • docs/hazards.md H1 (silent merges), H2 (cardinality blow-up), H7 (reconstruction).
  • Drain paper §3.2 (prefix tree, prefix-depth convention).

RFC 0005 — Parquet storage


rfc: 0005 title: Parquet storage — schema, writer, reader, audit stream status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-05-19 supersedes: — superseded-by: —

RFC 0005 — Parquet storage: schema, writer, reader, audit stream

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

Status note. green (2026-06-15) — every RFC0005 §5 acceptance criterion has a live, passing test. The prior drafted label was stale: the storage layer (schema, writer, reader, audit stream) landed early (PR #41 + the PR-D..G ourios-parquet series), and the ladder label was never advanced; this flip records reality. Scenario → test: .1 round-trip of every §3.2 column (rfc0005_1_*), .2/.3/.4 missing-OPTIONAL / unknown-column / missing-REQUIRED reader tolerance (rfc0005_2/3/4_*), .5 partition layout incl. non-ASCII tenant (rfc0005_5_*), .6 row-group size inside the H4 band (rfc0005_6_*, see below), .7 audit as a separate file series (rfc0005_7_*), .8 no body/params dictionary (rfc0005_8_*), .9 unknown ParamTypeUnknown (rfc0005_9_*), .10 schema is greppable / immutable (rfc0005_10_*), .11 row-vs-path validation on data + audit (rfc0005_11_*), .12 compaction audit round-trip (rfc0005_12_*), .13 effective-timestamp fallback (rfc0005_13_*, parquet + querier), .14 alias audit events back the v1 map (rfc0005_14_*).

RFC0005.6 is an #[ignore]d heavyweight test (tests/sizing.rs): it pushes >256 MiB through the production writer and asserts every non-final row group’s uncompressed total_byte_size ∈ [128 MiB, 1 GiB] per §3.5 / H4. Per §6 it is not run by CI (the project has no schedule: trigger — §7 open question); verify it manually with cargo test -p ourios-parquet --ignored (~7 s dev / ~1 s release).

Open for follow-up (§7, non-gating): compression-codec tuning (pending A1), bloom-filter FPR (pending B2), audit-event retention, and a scheduled-CI cadence for the slow sizing test.

1. Summary

Pins the on-disk Parquet contract that the ourios-parquet crate implements. The contract has four parts: (a) the data-file schema — a column-by-column mapping of RFC 0001 §6.1’s record schema (the planned MinedRecord Rust type — see §3.0) onto Parquet types, with tenant_id and time as Hive-style partition keys; (b) the audit-event file schema — a parallel file series carrying the TemplateWidened / TemplateTypeExpanded / TemplateWideningRejectedDegenerate records named in RFC 0001 §6.4; (c) the writer’s row-group / file sizing, compression codec, and encoding policy, all anchored to docs/hazards.md H4 and the CLAUDE.md §3.2 cardinality invariant; (d) the reader’s forward-compatibility contract (unknown columns ignored, missing columns surface as documented defaults). Together these are the §3.5 schema baseline: every column added after this RFC lands goes through an incremental amendment, every column removed requires the §3.5 migration path.

2. Motivation

2.1 Phase 2 needs an RFC, not a stub crate

docs/roadmap.md §4 opens Phase 2 with one capability — “mined records become Parquet files.” CLAUDE.md §3.5 reads “All schema changes go through the schema RFC process,” and docs/rfcs/README.md lists the on-disk Parquet schema in the “RFC required” set. A ourios-parquet crate that lands without a schema RFC immediately takes a schema commitment without going through the gate the project’s own rules require. RFC 0005 is that gate.

2.2 The schema is the contract with future data

Operators who run Ourios accrue Parquet files. A subsequent PR that adds a non-OPTIONAL column, renames a column, or changes a column’s type breaks every reader that opens an older file — and breaks every emitter against a deployment that hasn’t upgraded. Treating the schema as a written contract from PR-one forward prevents the silent format drift that turns a working backend into “redeploy and lose six months of logs.” It is also what makes CLAUDE.md §3.6 (“object storage is the source of truth”) durable: the truth has to be readable a year from now by code we haven’t written.

2.3 The Parquet pillar earns its compression here

Pillar 1 in CLAUDE.md §2 (“Parquet as the on-disk format”) is load-bearing for the thesis-gate A1 compression ratio. The encoding decisions in this RFC — which columns dictionary-encode, which carry bloom filters, which page indexes are enabled, what the row-group target is, how body is not dictionary-encoded because the CLAUDE.md §3.2 cardinality invariant forbids it — are where A1’s 50–200× promise gets paid. Pinning them in an RFC means those decisions are reviewable independently of the writer’s implementation and stable across PRs that touch the writer for unrelated reasons.

2.4 Why this is one RFC, not three

A natural split would be RFC 0005 (schema), RFC 0006 (writer), RFC 0007 (reader, audit). Rejected: the schema, the writer’s sizing/encoding policy, and the reader’s forward-compatibility contract are co-designed. Splitting them into three RFCs optimises for short documents but loses the cross-cutting constraints (e.g. “no dictionary on body” is a schema rule and a writer rule and a reader expectation). The RFC 0001 §6.8 telemetry surface and the eventual compaction policy are real post-MVP concerns and get their own RFCs.

3. Proposed design

3.0 Terminology note

This RFC uses MinedRecord as the planned Rust type name for the per-row record the miner emits, the same shape RFC 0001 §6.1 specifies but without yet naming a type. The §6.1 amendment uses “the record” / “the miner emits one record”; this RFC chooses MinedRecord for the type that backs the writer’s input and the reader’s output, and uses it consistently below. A follow-on PR to RFC 0001 may adopt the same name in §6.1; until then, treat the two terms as synonyms.

3.1 Scope and what this RFC pins

This RFC pins:

  • The Parquet logical schema (column names, types, repetition, nullability) for both the data-file series and the audit-event file series.
  • The on-disk partition layout (Hive-style: tenant_id=…/ year=…/month=…/day=…/hour=…/).
  • The writer’s row-group target, file-size target, compression codec, and per-column encoding policy (dictionary, page index, bloom filter).
  • The reader’s forward- and backward-compatibility contract.
  • The AnyValue encoding rule for OTLP attribute and body payloads.
  • The schema-evolution rules anchored to CLAUDE.md §3.5.

This RFC does not pin:

  • Background compaction (deferred per docs/roadmap.md §4 Phase 2 “Out of MVP scope, parked here” — a separate RFC after MVP).
  • Query-engine plumbing (DataFusion table provider registration, predicate-pushdown wiring) — that’s Phase 3 / RFC 0002 territory.
  • The wire-format receiver (gRPC / HTTP) — RFC 0003.
  • The body_shape_fingerprint and template_fingerprint reserved extensions named in RFC 0001 §6.1 — those gate on “we have a concrete consumer.”
  • A typed Parquet representation of AnyValue’s array / kvlist branches — see §3.3 (rejected for MVP; future RFC).

3.2 Data-file Parquet schema

The mapping below is the normative column set. Field order is the Parquet schema’s declared order; readers MUST address columns by name, not by ordinal.

tenant_id is row-level, the partition path is an index over it. tenant_id is a REQUIRED row-level column in every data file, listed in the schema table below. It is also replicated as the leading Hive partition key (§3.4) so DataFusion / Arrow can prune by tenant without opening files. Per docs/talks/0001-template-miner.md (“tenant_id is present on every row, not on every file … we never trust the file to tell us the tenant; we trust the row”) the row-level value is authoritative: the reader resolves tenant_id from the row, treats the partition path as a partition-pruning index, and errors on row-vs-path mismatch (§3.9). The time-bucket parts (year, month, day, hour) are pure-partition pseudo- columns derived from the effective timestamp (§3.4; equal to time_unix_nano whenever that is non-zero) rendered as UTC; they are not stored row-level and their schema-evolution contract follows §3.4 (the partition layout), not §3.8 (the row schema).

Identity (RFC 0001 §6.1 “Identity and partitioning”):

ColumnParquet logical typePhysical typeRepetitionNotes
tenant_idSTRINGBYTE_ARRAYREQUIREDAuthoritative tenant identifier; also replicated in the partition path (§3.4) for predicate-pushdown convenience. Row value wins on row-vs-path mismatch per §3.9
template_idINTEGER(64, signed=false)INT64REQUIREDMonotonic; bloom-filter coverage (§3.6)
template_versionINTEGER(32, signed=false)INT32REQUIREDStarts at 1; bumped on RFC 0001 §6.4 events

OTLP-derived columns (RFC 0001 §6.1 “OTLP-derived columns”):

ColumnParquet logical typePhysical typeRepetitionNotes
time_unix_nanoTIMESTAMP(NANOS, isAdjustedToUTC=true)INT64REQUIRED0 = unknown (OTLP convention); preserved verbatim from the wire (RFC 0001 scenario RFC0001.10). The time partition key derives from the effective timestamp (§3.4; equal to this column whenever it is non-zero). See “u64 → i64 overflow contract” below
observed_time_unix_nanoTIMESTAMP(NANOS, isAdjustedToUTC=true)INT64OPTIONALSame overflow contract as time_unix_nano
effective_time_unix_nanoTIMESTAMP(NANOS, isAdjustedToUTC=true)INT64OPTIONALWriter-derived (amendment 2026-06-11, §3.8 rule 1): time_unix_nano when non-zero, else observed_time_unix_nano, else 0. Drives the time partition key (§3.4) and the DSL time window (RFC 0002 §6.2). Never overwrites the wire time_unix_nano. Absent-column default is the row’s time_unix_nano (§3.9), not None
severity_numberINTEGER(8, signed=false)INT32REQUIREDOTLP SeverityNumber 0..24; part of template key
severity_textSTRINGBYTE_ARRAYOPTIONAL
scope_nameSTRINGBYTE_ARRAYOPTIONALPart of template key
scope_versionSTRINGBYTE_ARRAYOPTIONAL
attributesSTRING (canonical JSON)BYTE_ARRAYREQUIREDUTF-8 canonical JSON per §3.3 (mirrors RFC 0001’s Vec<KeyValue> — always present, possibly empty). For a record with no attributes, the writer emits the canonical empty array [] (two bytes — repetitive across no-attribute records so ZSTD compression collapses it). NULL is not a valid encoding; the round-trip rule is Vec::new()[]
dropped_attributes_countINTEGER(32, signed=false)INT32REQUIREDMostly zero
resource_attributesSTRING (canonical JSON)BYTE_ARRAYREQUIREDSame contract as attributes: REQUIRED, UTF-8 canonical JSON, empty Vec[], NULL not valid
trace_id(no logical type)FIXED_LEN_BYTE_ARRAY(16)OPTIONALOTLP / W3C Trace Context trace_id is 16 opaque bytes — not an RFC 4122 UUID. Parquet’s UUID logical type is deliberately not applied: downstream consumers (Arrow, DataFusion, ParquetTools) treat it as a typed UUID with RFC 4122 validation and formatting, which would misrepresent OTLP’s opaque-byte semantics
span_id(no logical type)FIXED_LEN_BYTE_ARRAY(8)OPTIONALSame opaque-byte contract as trace_id; no Parquet logical type exists for 8-byte opaque ids
flagsINTEGER(32, signed=false)INT32REQUIREDLower 8 bits = W3C trace flags
event_nameSTRINGBYTE_ARRAYOPTIONAL

Amendment 2026-06-11 — effective_time_unix_nano (derived event-or-observed timestamp). Measured across the OTel-Demo corpora (v5: 205,155 records; v6: 202,484), ~15 % of records carry timeUnixNano absent/0 — and 100 % of those carry observedTimeUnixNano (verified by sampling). Under the pre-amendment contract those records are unaddressable by time: the DSL window filters time_unix_nano, so they fall outside every real query window, and the bench’s zero-timestamp guard correctly refuses such corpora — blocking B1, the last unmeasured thesis gate. The OTLP logs data model anticipates exactly this case. Its Timestamp field definition reads:

Time when the event occurred measured by the origin clock, i.e. the time at the source. This field is optional, it may be missing if the source timestamp is unknown.

and its ObservedTimestamp field definition reads:

Time when the event was observed by the collection system. […] This field SHOULD be set once the event is observed by OpenTelemetry.

For converting OpenTelemetry log data to formats that support only one timestamp or when receiving OpenTelemetry log data by recipients that support only one timestamp internally the following logic is recommended:

  • Use Timestamp if it is present, otherwise use ObservedTimestamp.

This amendment adopts that recommendation as a derived, additive column, per the maintainer decision of 2026-06-11 (option 1: ingest-side, derived — not overwriting the wire value):

  1. Derivation rule. effective_time_unix_nano := time_unix_nano if time_unix_nano != 0 else observed_time_unix_nano.unwrap_or(0). The Parquet writer computes it from the row’s two existing timestamp fields when serialising — the same rule the §3.4 partition derivation already runs, now stored so queries can use it. MinedRecord (RFC 0001 §6.1) is unchanged; no new miner or receiver field exists, and the column is therefore outside the RFC0005.1 round-trip surface (derivable, not carried — its own assertions live in RFC0005.13). Both source fields are already covered by the §3.2 u64i64 overflow contract, so the derived value is always in-range.
  2. Derived, never overwriting. The wire time_unix_nano is stored verbatim, including 0 — RFC 0001 scenario RFC0001.10 (verbatim preservation) is explicitly intact.
  3. Storage. A new OPTIONAL column per §3.8 rule 1 (additive; old files lack it, the §3.9 default applies). Post-amendment writers always populate it (required-by-convention; 0 means genuinely timeless, mirroring the time_unix_nano sentinel); NULL appears only in pre-amendment files. The redundancy costs ≈ 8 B/row before encoding and almost always equals time_unix_nano, so DELTA_BINARY_PACKED + ZSTD collapse it (§3.6). A real column is what makes the window predicate prunable: a query-time fallback expression (CASE WHEN time_unix_nano != 0 THEN time_unix_nano ELSE observed_time_unix_nano ENDtime_unix_nano is REQUIRED with 0 as the unknown sentinel, so a plain coalesce would never fall back) would defeat row-group min/max pruning, which is the B1 mechanism.
  4. Partitioning. The §3.4 time-fallback derivation is this rule; the partition tuple and the stored column never disagree. Records with neither timestamp still land under the 1970 epoch partition exactly as before — only genuinely timeless records remain there.
  5. Query semantics. The DSL time window (range(...)) filters effective_time_unix_nano (RFC 0002 §6.2, amended the same date). The bare ts field still resolves to time_unix_nano, the verbatim wire value.
  6. Old-file read rule (the migration story). Files written before this amendment lack the column; the reader’s documented default (§3.9 rule 2) is effective := time_unix_nano — exactly the pre-amendment behaviour, so historical files keep answering time-window queries identically. No file rewrite is needed.
  7. Bench follow-up. The B1 zero-timestamp guard subsequently keys off the effective span — a code follow-up, not part of this amendment.

This resolves the measured v5/v6 corpus blocker. Acceptance is pinned by scenario RFC0005.13 (§5).

Body and miner-derived columns (RFC 0001 §6.1 “Body and miner-derived reconstruction”):

ColumnParquet logical typePhysical typeRepetitionNotes
body_kindINTEGER(8, signed=false)INT32REQUIRED0 = String, 1 = Structured
body(no logical type)BYTE_ARRAYOPTIONALOriginal bytes when retained per RFC 0001 §6.3 (lossy-zone retention) / RFC 0001 §6.5 (overflow forces retention); canonical-JSON AnyValue when body_kind = Structured; absent on clean-zone String rows. Intentionally no STRING logical type — the column carries raw bytes (potentially non-UTF-8 log lines or non-JSON binary), not text
paramsLIST<STRUCT<type_tag: INT32, value: BYTE_ARRAY>>as schemaREQUIREDAlways written (mirrors RFC 0001’s Vec<Param>); the list is empty (zero elements) when body_kind = Structured. NULL is not a valid encoding
separatorsLIST<BYTE_ARRAY>as schemaREQUIREDAlways written (mirrors RFC 0001’s Vec<Separator>); tokens.len() + 1 elements when body_kind = String, zero elements when body_kind = Structured. NULL is not a valid encoding
confidenceFLOATFLOATREQUIRED1.0 sentinel when body_kind = Structured
lossy_flagBOOLEANBOOLEANREQUIREDAlways false when body_kind = Structured

params’ nested struct uses the standard Parquet 3-level LIST encoding (list.element.<field>); separators uses the same 3-level shape with BYTE_ARRAY elements. The params.type_tag integer enum is 0..=7 matching RFC 0001’s ParamType ordering: IP, UUID, NUM, HEX, TS, PATH, STR, OVERFLOW. Adding a new variant is a §3.5 schema amendment (additive, but readers MUST know how to surface unknown variants — see §3.9).

u64i64 overflow contract for nanosecond timestamps. OTLP defines time_unix_nano and observed_time_unix_nano as uint64 nanoseconds-since-Unix-epoch; Parquet’s TIMESTAMP(NANOS) is backed by INT64. The 63-bit physical range tops out at i64::MAX ≈ 2^63 − 1 ns, which corresponds to 2262-04-11T23:47:16.854775807Z UTC. The writer rejects any record whose time_unix_nano or observed_time_unix_nano exceeds i64::MAX with a hard error naming the offending record and the offending field; no silent saturation, no wrap- around to negative values. The reader, conversely, never encounters out-of-range values (the file format itself can’t hold them), so reads are infallible on this axis. Operators running Ourios past year 2262 will need a schema migration (per §3.5 / §3.8) to either widen the physical type or re-base the epoch; that’s a future-RFC concern, not a post-MVP gap to plug here.

3.3 AnyValue encoding rule

OTLP’s LogRecord.attributes and resource_attributes are Vec<KeyValue> where each value is an AnyValue discriminated union (string | bool | int | double | bytes | array | kvlist). Recursive (array, kvlist) variants do not map cleanly onto Parquet’s flat-nested schema — Parquet supports LIST and STRUCT but the recursion depth has to be unrolled into the schema declaration, which means no fixed-depth schema can faithfully describe arbitrary AnyValue trees.

Amendment 2026-06-09 (no canonical OTLP JSON exists). This section previously called the encoding “OTLP-canonical JSON,” implying a spec-defined canonical form. Per an OTel-spec answer (no canonical OTLP JSON; OTLP requires no lossless translation), RFC 0001 §6.1 now frames the rule as the Ourios canonical body encoding — an Ourios-local deterministic proto3-JSON convention, not an OTLP conformance point. This section is reworded to defer to that rule and to drop the “canonical OTLP JSON” overclaim. No schema bytes and no status change.

Decision. attributes, resource_attributes, and the body column when body_kind = Structured are stored as a single BYTE_ARRAY carrying the Ourios canonical body encoding — RFC 0001 §6.1 (“The Ourios canonical body encoding”) is the single source of truth for the rule; this section does not restate it. In short it is a proto3-JSON form (lowerCamelCase fields, int64/uint64 as decimal strings, bytes as base64, kvlist/array order preserved — not sorted), and it is an Ourios-local deterministic convention, not an OTLP-mandated canonical form (OTLP defines no canonical JSON). The same rule applies to all three columns so operators don’t have to remember three encodings.

The rationale is on three legs:

  1. Faithfulness. The encoding is bidirectional — stored_bytes ↔ AnyValue round-trips byte-deterministically (the normative [§3.3] reconstruction guarantee for the structured branch). This is an Ourios guarantee delivered by RFC 0001 §6.1’s encoder, not an OTLP lossless promise (OTLP makes none).
  2. Schema simplicity. A single BYTE_ARRAY column versus a recursive STRUCT<string_value, int_value, ..., array_value: LIST<...>, kvlist_value: LIST<STRUCT<...>>> pseudo-schema with unrolled recursion depth.
  3. Query consumer absence. Phase 3’s thesis-gate B1/B2 queries are predicate-pushdown on template_id, tenant_id, and time_unix_nano — none of those require typed AnyValue predicates. The typed-attribute query path is a future RFC gated on a concrete consumer.

A reserved future amendment may add a parallel typed-attribute column set (likely a flattened attributes_str: MAP<STRING, STRING> for the common string-valued case, leaving complex values in the JSON column). The gate is “we have a concrete consumer,” not “it might be useful.”

Amendment 2026-07-03 (the consumer arrived). The reservation above is discharged by RFC 0022 (queryable attribute columns): the RFC 0002 DSL’s service / resource.<key> / attr.<key> predicates are the concrete consumer (#147). RFC 0022 chooses per-key promoted OPTIONAL columns over the MAP sketch (a map’s statistics and bloom filters are not key-scoped, so it cannot prune — see RFC 0022 §4) and extends the §3.6 encodings table when it lands. This section’s JSON columns remain the source of truth; no schema bytes change before RFC 0022’s green slices land (at red only failing stubs exist).

3.4 Partition layout on disk

Data files live at:

<bucket>/data/tenant_id=<tenant_id>/year=YYYY/month=MM/day=DD/hour=HH/<flush_uuid>.parquet

Audit-event files live at:

<bucket>/audit/tenant_id=<tenant_id>/year=YYYY/month=MM/day=DD/<flush_uuid>.parquet

The partition path segment is tenant_id= (not tenant=) so the Hive-style partition-discovery convention (column name = path segment key) resolves it to the same column name the row-level schema declares; the reader’s row-vs-path validation (§3.9) compares values across the two surfaces unambiguously.

Where:

  • <tenant_id> is the percent-encoded TenantId per RFC 3986 §2.1, with two project-specific overrides:
    • The input is the TenantId’s UTF-8 byte sequence (the TenantId newtype wraps a Rust String, which is already UTF-8). No Unicode normalisation is applied before encoding — the bytes are taken verbatim. This is deterministic and independent of the host’s locale.
    • The unreserved set (A-Za-z0-9, -, _, ., ~) is passed through unchanged. Every other byte is percent-encoded (%XX with upper-case hex digits). In particular / (path separator), = (partition key/value delimiter), and % (the escape introducer) are always escaped, regardless of whether RFC 3986 would treat them as reserved or unreserved in another context.
    • Decoding is the inverse; partition values that contain a malformed percent escape (e.g. %XY with non-hex digits) are a hard read error. Both writer and reader use this exact algorithm; the RFC0005.5 acceptance criterion’s non-ASCII sub-test pins it.
  • year / month / day / hour are derived from the effective timestamp (the next bullet; equal to time_unix_nano whenever that is non-zero) rendered as UTC. Audit-event partitioning stops at day=DD because audit volume is far lower than data volume; an hour-level partition for audit would produce many tiny files for no win.
  • time_unix_nano = 0 (OTLP “unknown” sentinel). The writer derives the partition tuple by first checking time_unix_nano; if it is 0, the writer falls back to observed_time_unix_nano. This derivation is the effective timestamp of the 2026-06-11 §3.2 amendment; the writer stores the same value in the effective_time_unix_nano column, so the partition tuple and the stored column never disagree. If observed_time_unix_nano is also absent or 0, the record is placed under the epoch partition year=1970/month=01/day=01/hour=00/ — operators see “unknown-time records cluster under 1970-01-01” as the documented signal, and an emitter-side investigation is the proper response. Rejecting the record was considered and rejected: §3.5 records are end-of-pipeline (the wire-decode receiver already accepted them), and a hard-reject here would silently drop data the WAL already acknowledged. Row-vs-path validation (§3.9) uses the same derivation rule, so a row at time_unix_nano = 0 placed under the 1970 partition validates cleanly.
  • <flush_uuid> is the writer’s flush identifier, pinned to UUIDv7 (RFC 9562). UUIDv7 places a millisecond-precision Unix timestamp in its high bits, so files in a partition sort naturally by creation time when listed lexicographically. This is normative — the writer MUST emit UUIDv7. Operators inspecting a bucket can rely on sort-order = creation-order for tooling like “show me the latest file in this partition.”

This is the production layout. The MVP corpus runner (ourios-bench in Phase 3) is allowed to emit all records to a single file under a degenerate partition path (tenant_id=corpus/year=2026/month=04/day=02/hour=10/) because corpus runs are bounded and producing 24 small files would distract from the thesis-gate measurements. The H4 file-sizing target (§3.5) is enforced on the production path; the corpus path is exempt.

3.5 Row group, file size, compression codec

Anchored to docs/hazards.md H4 and the small-file-problem detection threshold (file count must grow sub-linearly with bytes ingested):

  • Row-group size target. 128 MiB – 1 GiB uncompressed bytes per row group (binary units; the H4 target is written as “128 MB – 1 GB” but the operational detection threshold is in MiB, and Parquet byte counts in metadata are unprefixed binary bytes — RFC 0005 standardises on MiB/GiB throughout to avoid the ambiguity). The writer flushes a row group when its in- memory buffer crosses 128 MiB; row groups never exceed 1 GiB (the next row starts a new row group). Below 128 MiB only on the final row group of a file.
  • File size target. 256 MiB – 2 GiB compressed bytes post-compaction. The writer’s job is to land at the bottom of this range or below on its own (1024 MiB target uncompressed → typical 3–8× compression → ~128–340 MiB compressed file); compaction is deferred.
  • Compression codec. ZSTD level 3 for every column. ZSTD-3 is the Apache Arrow / DataFusion default and gives the best ratio-vs-throughput balance Ourios cares about; the thesis-gate A1 measurements will test whether the choice holds. Compression is orthogonal to per-column encoding (dictionary, RLE for booleans, RLE-encoded repetition / definition levels in LIST columns — all standard Parquet shapes that apply regardless of the chosen compression codec); §3.6 specifies the encoding policy.
  • Page size target. Default 1 MiB pages (Arrow default). Bloom filters and page index live on a per-column basis (§3.6).

The targets are floors and ceilings, not exact numbers. A writer flush forced by a time-based segment rotation (e.g. producing the audit-event file at end-of-day) may emit a small-row-group file; that’s an acknowledged corner case the compaction PR will sweep up. Steady-state production traffic must produce files inside the §3.5 range; the H4 detection metric (“fewer than 5 % of files below 128 MiB at steady state”) is the operational check.

3.6 Encoding policy

Per-column encoding decisions, anchored to query patterns (thesis-gate B1/B2) and the CLAUDE.md §3.2 cardinality invariant:

ColumnDictionaryPage indexBloom filterRationale
tenant_idyesnonoExactly one distinct value per file in valid data (§3.4 places each file under a single tenant_id=… partition, §3.9 errors on row-vs-path mismatch); dictionary encoding collapses the column to a one-entry dictionary plus an indexed RLE stream
template_idyesyesyesB2 (where template_id = X) is bloom-friendly; high cardinality but small relative to row count
template_versionyesyesnoAlways small per template
time_unix_nanonoyesnoDELTA_BINARY_PACKED Parquet encoding (the writer’s default for monotonic INT64 timestamps) plus ZSTD compression; min/max per page is what the window predicate prunes on in pre-amendment files (the §3.9 absent-column fallback) — effective_time_unix_nano below is the primary window column since the 2026-06-11 amendment
observed_time_unix_nanonoyesnoSame encoding/compression as time_unix_nano; the observation timeline is also broadly monotonic, so delta encoding pays
effective_time_unix_nanonoyesnoSame encoding/compression as time_unix_nano, which it almost always equals — DELTA_BINARY_PACKED collapses the redundancy. Min/max per page is what makes the B1 time-window predicate prunable on this column (amendment 2026-06-11)
severity_numberyesyesno0..24 — dict alone is enough
severity_textyesyesnoBounded set in practice
scope_nameyesyesnoBounded per deployment
scope_versionyesyesnoBounded per deployment
attributesnononoJSON BYTE_ARRAY, high entropy, dict would balloon
resource_attributesyesnonoRepetitive across rows of one tenant; dict pays
trace_idnoyesyesNear-random ids defeat min/max pruning, so dict loses and the page index’s column-index half is inert — it stays enabled for the offset index, which page-selective reads under filter pushdown need to fetch just the matched rows’ pages; the bloom is what makes the exact-id lookup prunable at all (amendment 2026-07-12, below)
span_idnoyesyesSame
flagsyesyesnoBounded
event_nameyesyesnoBounded
body_kindyesyesnoTwo values
bodynononoCLAUDE.md §3.2 invariant: bodies are unbounded by design. Dictionary encoding would balloon — overflow is the safety valve, dict is the failure mode
params (list values)nononoPer-row entropy too high
separators (list values)yesnonoAlmost always a single space — dict crushes it
confidencenoyesnoFloat, narrow range, page-index sufficient
lossy_flagn/ayesnoBoolean, RLE handles it
dropped_attributes_countyesyesnoAlmost always zero

Amendment (2026-07-12): bloom filters on trace_id and span_id. This table originally said “dict and bloom both lose” for the trace-context ids — right about dictionaries, measurably wrong about blooms. The two judgments conflate different costs: dictionary encoding loses because near-random values don’t repeat, but a bloom filter’s value is not compression — it is the ONLY pruning mechanism an exact-id lookup has, precisely because near-random ids defeat min/max statistics. RFC 0031 comparative run #12 (otel-demo-v8, 4.9 M records) measured the cost of the original decision: a 9-row trace lookup read 72,935,984 bytes — the trace_id column scanned corpus-wide. With blooms (run #14): 4,812,668 bytes, a 15.2× collapse, and the RFC 0031 L3 must-win passes at 21.9× storage-side / 514.6× processed-bytes against the reference system. Blooms are optional Parquet column-chunk metadata, not a schema element: files written without them remain readable, readers that don’t consult them are simply unaccelerated, and no migration exists to plan.

The body row is the only one bolded end to end (the lone bold cells elsewhere mark bloom decisions that carry their own rationale text): a writer that quietly enables dictionary encoding on body because Arrow’s default does so violates CLAUDE.md §3.2 (“Drain assumes parameters are short, variable bits. Reality: a params slot may capture an entire stack trace, request body, or base64 blob. Unbounded values destroy Parquet’s dictionary encoding and bloat files.”). The RFC 0001 §6.5 OVERFLOW marker is the design response in params; the body column is where retained originals land, and those are unbounded by construction.

3.7 Audit-event file schema

The audit stream carries the template events that RFC 0001 §6.4 names — TemplateWidened, TemplateTypeExpanded, TemplateWideningRejectedDegenerate — plus, per the 2026-06-03 amendment below, the Compaction event of RFC 0009 §3.6, and, per the 2026-06-12 amendment below, the alias_asserted / alias_retracted operator events of RFC 0001 §6.7, each with a kind tag and a timestamp. The contract from RFC 0001 §9 (“Cross-RFC contracts pending”) is fulfilled by this file series.

As in §3.2, tenant_id is a row-level REQUIRED column on the audit record (also replicated as the leading Hive partition key, §3.4); the time-bucket parts (year, month, day) are pure- partition pseudo-columns derived from timestamp. The reader’s row-vs-path validation (§3.9) applies identically here.

Event-kind mapping and dual-column storage. RFC 0001 §6.4 refers to these audit events by snake_case event_type strings; this RFC stores both an event_kind INT32 ordinal (compact, dictionary-encodes to a few bytes) and an event_type STRING column carrying the canonical string from the mapping table below (RFC 0001 §6.4 for the template kinds, RFC 0009 §3.6 for compaction). The string column is what RFC 0001 §9 names as the predicate-pushdown surface for the RFC 0001 §6.7 drift query; the ordinal is what the writer and reader use internally. Both columns are REQUIRED and the writer must keep them in sync per the mapping table — divergence is an implementation bug, not a degree of freedom. The normative mapping:

event_kind ordinalevent_type stringRust variantSource
0template_widenedTemplateWidenedRFC 0001 §6.4
1template_type_expandedTemplateTypeExpandedRFC 0001 §6.4
2template_widening_rejected_degenerateTemplateWideningRejectedDegenerateRFC 0001 §6.4
3compactionCompactionRFC 0009 §3.6 (amendment 2026-06-03)
4alias_assertedAliasAssertedRFC 0001 §6.7 (amendment 2026-06-12)
5alias_retractedAliasRetractedRFC 0001 §6.7 (amendment 2026-06-12)
6template_createdTemplate { change: Created }RFC 0017 §3.1
7record_quarantinedRecordQuarantinedRFC 0025 §3.3
8ingest_deniedIngestDeniedRFC 0026 §3.4
9conversation_erasedConversationErasedRFC 0047 §3.6 (amendment 2026-08-18: OPTIONAL erasure_conversation_id / erasure_partitions / erasure_rows / erasure_tuples columns, NULL for every other kind)

Adding a new ordinal is a §3.8 additive amendment; the mapping table is the source of truth and a new ordinal lands as a new row plus a new event_type string in the same PR. Renumbering an existing ordinal or renaming an event_type string is forbidden in-place (§3.8 rule 3: column-type changes go through add-new-column / migrate / drop).

Amendment 2026-06-03 — compaction audit events. RFC 0009 §3.6 routes a compaction audit event through this same stream (the “nothing happens silently to stored data” stance applied to file lifecycle, CLAUDE.md §3.1). A compaction event shares the common envelope (tenant_id, timestamp, event_kind = 3, event_type = "compaction") but has no template identity (and leaves reason NULL — the facts live in the compaction_* columns). Two changes accommodate it, both backward-compatible:

  1. The template-specific columns (template_id, old_version, new_version, old_template, new_template, positions_widened, slots_expanded, triggering_line_hash) are relaxed to OPTIONAL (§3.8 rule 6). They stay required-by-convention for the template event kinds (0–2) — the writer MUST populate them there, enforced in code/tests, so the template-event contract is unchanged — and are NULL for compaction. Existing audit files keep their (non-null) values, so no data migration is needed.
  2. New OPTIONAL compaction_* columns (below) carry the file set / generation / row count (§3.8 rule 1). They are NULL for the template kinds.

The RFC 0009 §7 fork (structured reason vs additive columns) is resolved here in favour of explicit columns: they are first-class queryable columns where a JSON blob in reason would be opaque to the query engine. The low-cardinality scalars (compaction_partition, compaction_generation) support predicate-pushdown — row-group skipping via min/max, e.g. “which compaction committed generation N”. compaction_output_file and the compaction_input_files LIST are high-entropy UUID names: queryable first-class (equality / array-containment filters) but not stats-pushdown-indexed, consistent with their no-dictionary / no-index encoding policy below — still far better than being unparseable inside a reason blob.

Amendment 2026-06-12 — alias audit events (issue #148). RFC 0001 §6.7 (amendment 2026-06-07) routes operator alias assertions through this same stream and its §9 resolution hands the storage half to “the RFC 0005 line”. This amendment is that half: the events get a home here, and §3.7.1 below pins how the querier turns them into the per-tenant alias map in v1. Two new kinds, alias_asserted (4) and alias_retracted (5), join the mapping table (§3.8 rule 1 territory — the ordinals match the constants ourios-core::audit already pins). An alias event shares the common envelope (tenant_id, timestamp, event_kind, event_type) and carries the RFC 0001 §6.7 payload in new OPTIONAL alias_* columns (§3.8 rule 1), following the compaction amendment’s pattern of kind-prefixed first-class columns rather than overloading the template columns or packing a blob into reason:

  • alias_member_ids is a LIST<INTEGER(64, signed=false)>, not canonical JSON. The §3.3-style canonical-JSON Utf8 alternative was considered and rejected on the same grounds the 2026-06-03 amendment rejected a structured reason: a list of ids is first-class queryable (equality / array-containment — “which assertions ever touched template X”) where a JSON blob is opaque to the query engine, and the §3.7 precedent for set-valued payload fields of scalars is already LIST (positions_widened, compaction_input_files). Canonical JSON earns its keep only for nested values (attributes, the template token arrays); a flat id set is not one. Schema evolution is unaffected either way — the column is OPTIONAL per §3.8 rule 1, so old files simply lack it and read back as None.
  • representative_id gets its own column (alias_representative_id) rather than reusing template_id. template_id’s contract is “the leaf the event applies to”, and the 2026-06-03 convention pins the template columns as required-by-convention for kinds 0–2 / NULL otherwise; stretching that to “non-null for alias kinds too, with anchor semantics” would fork the column’s meaning by kind. The kind-prefixed column keeps each kind’s payload→column mapping uniform: every kind populates exactly its own prefix plus the envelope.
  • reason is reused, not duplicated: it is already the generic OPTIONAL justification/diagnostic column. For alias kinds it carries the operator-supplied justification (RFC 0001 §6.7, ≤ 256 B); the in-memory empty-string-when-none convention maps to NULL on disk (round-trip rule: "" ↔ NULL).

The semantic value of an alias row is the asserted set {alias_representative_id} ∪ alias_member_ids (RFC 0001 §6.7); the writer stores the event’s member_ids verbatim (no sort/dedup normalization — round-trip is exact) and consumers fold it as a set, so element order and duplicates carry no meaning. An empty list is valid and distinct from NULL (member_ids: vec![] on a single-id retraction ↔ empty list; NULL means “not an alias row”), mirroring the positions_widened empty-list convention. Alias rows leave every template-specific and compaction_* column NULL; conversely the alias_* columns are NULL for all other kinds and required-by-convention non-null for kinds 4–5 (alias_member_ids possibly empty, reason per the operator’s optional input) — the §3.8 rule 6 convention, writer-enforced and test-pinned (RFC0005.14).

Unknown-event_kind tolerance. Today’s AuditReader hard-errors on an ordinal outside the mapping table (AuditReaderError::UnknownEventKind), with a documented deferral of the catch-all decision “until a real new variant lands”. Kinds 4–5 are that variant, so the rule is now pinned: a reader encountering an event_kind ordinal above its known range MUST NOT fail the file — it surfaces the row as an opaque unknown-kind event (envelope only), the ParamType::Unknown / §3.9 discipline applied to the kind enum, so every future §3.8 ordinal addition stays non-breaking for readers. Tolerance is not semantics: a fold defined over named kinds (the §3.7.1 alias fold reads kinds 4–5; the RFC 0010 drift query filters event_type strings) ignores unknown kinds by construction, and a future kind that participates in an existing fold must amend that fold’s spec. For already-deployed readers (which still hard-error) the exposure is bounded by §3.8 rule 6’s version-together argument: rows with kinds 4–5 are written only by post-amendment writers, so no previously-deployed reader is expected to encounter them. The implementation slice for this amendment (issue #148) extends the reader’s ordinal match to kinds 4–5, lands the tolerance rule, and retires the writer’s interim AliasEventNotYetPersistable rejection.

The row-level audit columns are:

ColumnParquet logical typePhysical typeRepetitionNotes
tenant_idSTRINGBYTE_ARRAYREQUIREDSame contract as data-file tenant_id: row authoritative, replicated in partition path, mismatch → reader error
timestampTIMESTAMP(NANOS, isAdjustedToUTC=true)INT64REQUIREDCluster clock at emit time (matches RFC 0001 §6.4 timestamp)
event_kindINTEGER(8, signed=false)INT32REQUIREDOrdinal per the mapping table above
event_typeSTRINGBYTE_ARRAYREQUIREDCanonical snake_case string per the mapping table above (RFC 0001 §6.4 for template kinds; RFC 0009 §3.6 for compaction); predicate-pushdown surface for the RFC 0001 §6.7 drift query
template_idINTEGER(64, signed=false)INT64OPTIONAL†The leaf the event applies to
old_versionINTEGER(32, signed=false)INT32OPTIONAL†Pre-event template version
new_versionINTEGER(32, signed=false)INT32OPTIONAL†Post-event template version (equal to old_version for the rejection variant)
old_templateSTRING (canonical JSON)BYTE_ARRAYOPTIONAL†The token sequence of the pre-event template (matches RFC 0001 §6.4’s non-optional old_template: String). For TemplateTypeExpanded and TemplateWideningRejectedDegenerate (variants where the template tokens don’t change), old_template == new_template
new_templateSTRING (canonical JSON)BYTE_ARRAYOPTIONAL†The token sequence of the post-event template (matches RFC 0001 §6.4’s non-optional new_template: String). Always set: TemplateWidened carries the post-widen template; TemplateTypeExpanded and TemplateWideningRejectedDegenerate carry the unchanged template (equal to old_template)
positions_widenedLIST<INT32>as schemaOPTIONAL†Written for template kinds; the list is empty for TemplateTypeExpanded (no positions involved) and TemplateWideningRejectedDegenerate (the would-be widening was rejected). For TemplateWidened, the positions that gained <*>. Mirrors RFC 0001 §6.4 positions_widened: Vec<u16>
slots_expandedLIST<STRUCT<slot_index: INT32, types_added: LIST<INT32>>>as schemaOPTIONAL†Written for template kinds; the list is empty for TemplateWidened and TemplateWideningRejectedDegenerate. For TemplateTypeExpanded, one element per slot whose type set grew, each carrying the wildcard-slot ordinal plus the ParamType ordinals added (RFC 0001 §6.4 slots_expanded: Vec<SlotExpansion>; SlotExpansion = { slot_index, types_added })
triggering_line_hash(no logical type)FIXED_LEN_BYTE_ARRAY(16)OPTIONAL†Blake3 hash of the raw triggering line L_raw (RFC 0001 §6.4 triggering_line_hash: [u8; 16]); enables cross-referencing the audit event with the data record that caused it
triggering_line_sampleSTRINGBYTE_ARRAYOPTIONALFirst 256 bytes of L_raw, UTF-8 lossy-decoded if necessary (RFC 0001 §6.4 triggering_line_sample: Option<String>); NULL when the sample was redacted for retention policy
reasonSTRINGBYTE_ARRAYOPTIONALThe degenerate-template guard’s diagnostic string for TemplateWideningRejectedDegenerate; the operator-supplied justification (≤ 256 B, RFC 0001 §6.7; "" ↔ NULL) for the alias kinds (4–5); NULL otherwise (NULL for compaction — the compaction_* columns carry the facts)
compaction_partitionSTRINGBYTE_ARRAYOPTIONALCompaction only. The compacted data partition, as the canonical year=…/month=…/day=…/hour=… key under the row’s tenant_id (RFC 0009 §3.4). NULL for all other kinds
compaction_input_filesLIST<STRING>as schemaOPTIONALCompaction only. The input file names that were merged away (RFC 0009 §3.6 ourios.compaction.files). NULL for all other kinds
compaction_output_fileSTRINGBYTE_ARRAYOPTIONALCompaction only. The consolidated output file name (the sole live file after the commit). NULL for all other kinds
compaction_generationINTEGER(64, signed=false)INT64OPTIONALCompaction only. The manifest generation the consolidation committed at (RFC 0009 §3.4). NULL for all other kinds
compaction_rowsINTEGER(64, signed=false)INT64OPTIONALCompaction only. Rows in the consolidated file — equal to the total input rows, the conserved count (RFC0009.2). NULL for all other kinds
alias_representative_idINTEGER(64, signed=false)INT64OPTIONALAlias kinds (4–5) only. The operator’s anchor id for the assertion/retraction — one member of the asserted set, not the set’s derived canonical (RFC 0001 §6.7). NULL for all other kinds
alias_member_idsLIST<INTEGER(64, signed=false)>as schemaOPTIONALAlias kinds (4–5) only. The other ids in the asserted set (RFC 0001 §6.7 member_ids: Vec<u64>), stored verbatim; the semantic value is the set {alias_representative_id} ∪ alias_member_ids. Empty list is valid (single-id retraction) and distinct from NULL. NULL for all other kinds
alias_actorSTRINGBYTE_ARRAYOPTIONALAlias kinds (4–5) only. The principal that issued the assertion — aliasing is never anonymous (RFC 0001 §6.7 actor: ActorId, non-empty). NULL for all other kinds

OPTIONAL† marks columns relaxed from REQUIRED by the 2026-06-03 amendment (§3.8 rule 6). They are required-by-convention for the template event kinds (event_kind 0–2): the writer MUST populate them there and a test asserts it, so the template-event contract is unchanged; they are NULL for compaction (kind 3) and, per the 2026-06-12 amendment, the alias kinds (4–5). Existing audit files keep their non-null values and read back as Some — no data migration.

The canonical-JSON encoding of old_template / new_template is ["lit0", "<NUM>", "lit2", ...] — the same shape the miner’s in-memory Vec<OwnedToken> produces.

Audit encoding policy (parallel to §3.6’s data-file table; the audit stream is low-volume so page indexes and bloom filters are unnecessary defaults, but the policy needs to be explicit under §3.1’s “RFC pins per-column encoding policy” commitment):

ColumnDictionaryPage indexBloom filterRationale
tenant_idyesnonoBounded per cluster
timestampnoyesnoDELTA_BINARY_PACKED Parquet encoding plus ZSTD compression (same shape as data-file time_unix_nano); page index supports time-range pruning on drift queries
event_kindyesyesnoA small bounded set (six ordinals today), plus future ordinals
event_typeyesyesnoSame bounded set as event_kind; predicate-pushdown surface for the RFC 0001 §6.7 drift query
template_idyesyesnoBounded by tenant template count; bloom filter is unnecessary at audit volume
old_version, new_versionyesnonoSmall per template
old_template, new_templatenononoPer-tenant repetitive but variable-length JSON; defer the dict decision until bench data exists
positions_widened (list values)yesnonoSmall INT32s
slots_expanded (list / struct values)yesnonoSame
triggering_line_hashnononoNear-random 16 bytes, dict loses
triggering_line_samplenononoHigh-entropy text, dict loses
reasonyesnonoGuard diagnostic strings plus, since the alias kinds, operator-supplied justifications — free text but rare and ≤ 256 B, so dict still pays at audit-event volumes
compaction_partitionyesyesnoBounded per tenant; page index supports range pruning on the compacted partition
compaction_input_files (list values)nononoUUID file names, near-random — dict loses
compaction_output_filenononoUUID file name, near-random — dict loses
compaction_generationyesnonoSmall monotonic integers per partition
compaction_rowsnononoHigh-cardinality counts; neither dict nor index earns its keep
alias_representative_idyesyesnoBounded by tenant template count — same shape as template_id
alias_member_ids (list values)yesnonoSame bounded id space; list volume is tiny (rare operator actions)
alias_actoryesnonoA small set of operators / API principals per tenant

Compression codec follows §3.5 (ZSTD-3 across every column). Anything not in the table above takes the writer’s defaults; the table covers every row-level column declared in §3.7.

Audit files are flushed independently of data files: a single write to the cluster’s audit sink does not force a data flush, and vice versa. The writer guarantees no audit event is lost across crashes by routing audit events through the same WAL path as data records (a contract that lands with the post-MVP ourios-wal crate; until then audit-event durability is in-memory and the corpus bench accepts that).

3.7.1 v1 reader-side alias-map derivation (amendment 2026-06-12)

In v1 there is no persisted per-tenant alias-map artifact: the audit stream is the alias store, and the querier derives the requesting tenant’s alias map at query-compile time. The derivation:

  1. Scan the tenant’s audit/ partition subtree for rows with event_kind ∈ {4, 5} — pruned by the tenant_id partition key plus the event_kind / event_type dictionary and page-index columns (the same partition-pruned scan shape as the RFC 0010 drift query). Alias events are rare operator actions, not ingest-volume data, so the scan is small by construction.
  2. Fold the matching events in timestamp (event-time) order through the RFC 0001 §6.7 projection semantics — each alias_asserted unions its asserted set into one equivalence class (merging classes that share a member), each alias_retracted removes its asserted set’s ids, canonical representative derived as min(members). Those semantics are owned by RFC 0001 §6.7 and implemented by ourios-core::alias::AliasMap; this RFC references them and does not restate them. The fold order is total and deterministic: (timestamp, file path lexicographic, within-file row index) — same-nanosecond ties within one file fold in row order (the sink’s append order), and ties across files break on the lexicographic file path (audit file names are unique per flush, so the order is stable across re-scans). The control plane is the single writer of alias events, so ties are not expected in practice; only an assert/retract pair over the same ids in the same nanosecond would be sensitive to the tiebreak.
  3. Hand the folded map to the RFC 0002 resolves_to compilation (RFC0002.9), which expands by set membership exactly as before — the derivation changes where the map comes from, not what it means.

Consistency bound. The derived map reflects exactly the alias events durably written and flushed to the audit stream at scan time. This is the eventual-consistency stance RFC 0001 §6.7 already takes (bounded under-inclusion for a not-yet-visible assertion, bounded over-inclusion for a not-yet-visible retraction, never cross-tenant, never a phantom grouping); in v1 the staleness window is audit-flush visibility rather than a snapshot/projection-rebuild cadence.

The cached artifact is deferred, not designed away. A materialized per-tenant alias-map file would be a pure recovery/latency cache over this derivation — its file format, publish point, and refresh cadence ride the RFC 0009 §3.4 atomic-publish manifest fork (issues #94 / #147) and are not pinned here. Because the audit stream remains the source of truth either way, introducing the cache later changes no query-visible semantics — the same “v1 full-replay now, accelerate later, no format change” shape RFC 0001 §6.9 pinned for the miner snapshot.

3.8 Schema-evolution policy

The §3.5 invariant from CLAUDE.md is normative: “All schema changes go through the schema RFC process.” RFC 0005 establishes the baseline schema; subsequent changes follow these rules:

  1. Adding a column. Always OPTIONAL. An amendment to this RFC names the column, its type, its default behaviour for readers that haven’t been upgraded, and its source/derivation. No data-migration is required — old files lack the column, readers surface None (or the documented default), new files include it.
  2. Renaming a column. Forbidden in-place. The path is: add the new name as a new optional column, dual-write for one release, deprecate the old name in a later RFC, drop the old name in the release after that.
  3. Changing a column’s type. Forbidden in-place. Add a new column (<name>_v2 or a semantically meaningful new name), migrate, drop. The amendment RFC pins the migration plan.
  4. Removing a column. Requires an RFC against CLAUDE.md §3.5. The migration plan accompanies the RFC: either every historical file is rewritten, or queries against the removed column become a documented error.
  5. Changing a column’s encoding policy (e.g. enabling dictionary on body, dropping a bloom filter). Permitted in an RFC patch — encoding is not part of the logical schema, so readers don’t break, but a benchmark must show the change doesn’t regress A1/B1/B2.
  6. Relaxing a column REQUIREDOPTIONAL. Permitted via an amendment that names the columns and the writer invariant that keeps them required-by-convention for the event/record kinds that always carry them (enforced by a test). No data- migration is required: existing files wrote the column for every row, so it reads back as Some everywhere; only new rows of a new kind may write NULL. The forward-compat caveat — a reader predating the amendment reads a relaxed column as REQUIRED and would mishandle a NULL — is bounded because (a) Ourios versions reader and writer together and (b) the rows that exercise the NULL belong to a kind introduced by the same amendment, so no previously-deployed reader is expected to read them. The reverse (OPTIONALREQUIRED, a tightening) is forbidden in-place — older files may already store NULL, which a REQUIRED column cannot represent — and, like rules 2 and 3, takes the add-new-column / migrate / drop path. First applied by the 2026-06-03 compaction-audit amendment (§3.7).

The PR description that touches the schema must explicitly call out which rule above applies, mirroring the CLAUDE.md §4 convention for hazard-touching PRs (“the PR description must explicitly address how the change preserves the invariant”).

3.9 Reader contract

The reader has three normative requirements:

  1. Unknown columns are silently ignored. A file produced by a future writer that adds columns the current reader doesn’t know about must read successfully; the unknown columns are dropped on the floor. This is what makes amendment-by-addition (§3.8 rule 1) cheap.
  2. Missing columns surface as documented defaults. A file produced by an earlier writer that lacks columns the current reader expects must read successfully; the missing columns default to:
    • OPTIONAL columns → None. Per §3.8 rule 1, every amendment-added column is OPTIONAL, and per §3.8 rule 6 a column relaxed REQUIREDOPTIONAL is read the same way — None when a row stores NULL (e.g. the template-specific columns on a compaction row), Some for the non-null values older files wrote. Together these cover the entire amendment surface; there is no “REQUIRED-added-in-amendment” case to default.

      Exception — effective_time_unix_nano (amendment 2026-06-11): the documented default when the column is absent (a file written before the amendment) is the row’s time_unix_nano, not None — i.e. effective := time_unix_nano, which is exactly the pre-amendment behaviour, so historical files keep answering time-window queries identically. Consumers that compile predicates over this column (the RFC 0002 §6.2 time window) MUST apply this substitution per-file; the querier’s general absent-OPTIONAL-column ⇒ predicate-false convention (RFC 0007 / RFC0007.4) does not apply to the time-window filter — compiling the window to false on old files would silently hide all pre-amendment data from every query.

    • The baseline REQUIRED columns still declared REQUIRED — the reader errors if they are missing. A file missing a baseline REQUIRED column (the common envelope: tenant_id, timestamp, event_kind, event_type) is corrupted or written by an incompatible writer; falling through to a made-up default would corrupt downstream query results.

  3. Row-vs-path partition validation. For every row read under a partition-aware path (i.e. via Reader::open_partition or the DataFusion ListingTable integration that feeds a partition tuple in), the reader compares the row-level tenant_id against the partition path’s tenant_id segment and the row’s derived UTC year / month / day / hour against the path’s time-bucket segments. The derivation algorithm is identical to the writer’s in §3.4: prefer time_unix_nano if non-zero, else fall back to observed_time_unix_nano if present and non-zero, else the 1970-01-01T00 epoch. Using the same algorithm on both sides guarantees that a row written under one bucket validates under the same bucket. Mismatch is a hard read error that names the offending row and the partition path. The row value is authoritative (the talk and RFC 0001 §6.1’s row-as-source-of-truth rule); the path is the partition- pruning index. A diagnostic Reader::open_file helper that opens a single file without a partition tuple skips this validation and surfaces records as-stored — that mode is not exposed through the production query path.

Unknown ParamType ordinals (i.e. a value the reader doesn’t know about) are surfaced as ParamType::Unknown — a reserved catch-all variant. Queries against records carrying unknown variants pass through to the application layer to decide what to do (the RFC 0001 §6.6 reconstruction path treats unknown variants as lossy and falls back to the body column, which is why RFC 0001 §6.5’s overflow-forces-body-retention rule is paired with this).

3.10 Crate shape

crates/ourios-parquet/ per the §7 target layout in CLAUDE.md. The public surface is intentionally small:

  • Schema — a singleton describing the data-file schema; one function per amendment that gates an additive column.
  • AuditSchema — the parallel singleton for the audit stream.
  • Writer — opens a file at a partition path, appends rows in the §3.2 column order, rotates row groups at the §3.5 threshold.
  • Reader — opens a file (or a directory of files; partition discovery is part of the reader’s job), surfaces records as MinedRecords with the §3.9 contract.
  • AuditWriter / AuditReader — same shapes for the audit series.

No trait abstraction over Writer or Reader until a second implementation is named in an RFC. Pre-abstracting when only one consumer exists picks an axis for the trait before the shape of the second consumer is visible, and an extracted trait that turns out to fit only one consumer is harder to re-shape than the concrete type would have been. Phase 3’s DataFusion table provider is one consumer of Reader; the bench is another; both are concrete, neither demands a trait.

4. Alternatives considered

4.1 Apache Iceberg or Delta Lake on top of Parquet

A table-format layer (Iceberg, Delta) would give us schema evolution, snapshots, and time-travel queries for free. Rejected for MVP: both pull in a large dependency surface (metastore plumbing, transaction logs, manifest files) for features (snapshots, time-travel) the thesis gates don’t need. A future RFC can adopt Iceberg as a layer over the Parquet files defined here — Iceberg is additive on top of Parquet, so the §3.2 schema doesn’t need to change. Adopting it now would multiply the dependency footprint without moving the thesis.

4.2 Apache Arrow IPC files instead of Parquet

Arrow IPC is faster to read into Arrow memory but lacks Parquet’s row-group pruning, page index, and bloom filters — the exact features Pillar 1 of CLAUDE.md §2 names as load-bearing for thesis-gate B1. Rejected for the same reason Parquet was chosen in the first place.

4.3 Typed STRUCT encoding of AnyValue

Encode the OTLP AnyValue discriminated union as a recursive Parquet STRUCT, with one optional field per variant and explicit recursion-depth unrolling for array / kvlist. Rejected for MVP: Parquet’s flat-nested model doesn’t support true recursion; any encoding caps recursion depth at the schema declaration, which is a hard limit operators can’t override without a schema change. Canonical JSON in a BYTE_ARRAY is unambiguously faithful and defers the typed-attribute query story to a future RFC with a named consumer.

4.4 One concatenated file series (data + audit)

Carry audit-event rows in the data file with a discriminator column. Rejected: audit volume is orders of magnitude smaller than data volume; co-locating them defeats partition pruning for both (“give me all widening events” would have to scan the data partition, “give me all log records at time T” would scan through audit rows). The two-file-series shape is the natural operational separation.

4.5 Compaction in MVP

Background compaction (small-file consolidation) was considered for Phase 2. Rejected: docs/roadmap.md §4 Phase 2 explicitly parks it post-MVP, on the rationale that corpus runs are bounded and a single Parquet file per phase is acceptable. Production deployments accumulating sustained traffic will need compaction before the H4 file-size detection threshold fires; that’s a post-MVP RFC.

4.6 Apache Avro for the audit-event stream

Avro is a natural fit for sparse event streams. Rejected: Pillar 1 commits the project to Parquet end-to-end; running two file formats in one bucket doubles the operational surface (reader libraries, schema-registry-shape, partition-discovery code) for the marginal benefit of slightly better encoding of a column the bench won’t measure.

5. Acceptance criteria

Scenario RFC0005.1 — Round-trip preserves every §3.2 row-level column

  • Given a MinedRecord populated with every row-level column in §3.2 (every OPTIONAL field set to Some, every variant of body_kind exercised across a batch — including the row-level tenant_id)
  • When the batch is written to a Parquet file by the writer and read back by the reader via Reader::open_partition (the production query path)
  • Then for every column whose Rust type in MinedRecord is a raw byte container (trace_id: Option<[u8; 16]>, span_id: Option<[u8; 8]>, body: Option<Bytes>), the recovered bytes equal the original bytes byte-for-byte
  • And for every typed column (integers, floats, booleans, timestamps, enum ordinals, plain strings, the params and separators lists), the recovered value equals the original under the column’s Rust-level equality — UTF-8 equality for String, numeric equality for integers/floats/timestamps, element-wise equality for Vec<T>
  • And for the canonical-encoded structural columns (attributes: Vec<KeyValue> and resource_attributes: Vec<KeyValue> — encoded with the Ourios canonical body encoding as a BYTE_ARRAY on disk per §3.3), the recovered Vec<KeyValue> equals the original under structural equality (the encoding is bidirectional and byte-deterministic per RFC 0001 §6.1, so structural equality is the testable property at the MinedRecord boundary; byte equality on the encoded bytes follows as a corollary but is not the primary assertion)
  • And the round-trip equality assertion does not include the pure-partition pseudo-columns (year, month, day, hour); those are covered by RFC0005.5 (partition layout) and RFC0005.11 (row-vs-path validation)

Scenario RFC0005.2 — Missing column tolerance (old-file reader path)

  • Given a Parquet file produced by a hand-rolled writer that omits an OPTIONAL column the current schema declares
  • When the current reader reads the file
  • Then records surface with None for the absent column
  • And no error is raised

Scenario RFC0005.3 — Unknown column tolerance (forward compatibility)

  • Given a Parquet file produced by a hand-rolled writer that includes a column the current reader’s schema does not declare
  • When the current reader reads the file
  • Then the unknown column is silently ignored
  • And every declared column reads through correctly
  • And no error is raised

Scenario RFC0005.4 — Baseline REQUIRED column missing → reader errors

  • Given a Parquet file produced by a hand-rolled writer that omits one of the §3.2 baseline REQUIRED columns
  • When the current reader attempts to read it
  • Then the reader returns an error naming the missing column
  • And no records are surfaced

Scenario RFC0005.5 — Partition layout follows §3.4

  • Given a record stream spanning two tenants, three hours, and one of the records carries a tenant id with non-ASCII characters
  • When the writer flushes records to the bucket
  • Then files are placed under data/tenant_id=<tenant_id>/year=YYYY/month=MM/day=DD/hour=HH/<flush_uuid>.parquet, where <tenant_id> is the percent-encoded TenantId per §3.4 and <flush_uuid> is the UUIDv7 flush identifier per §3.4
  • And every record inside a file shares the partition tuple

Scenario RFC0005.6 — Row-group size lands inside H4 target

  • Given a corpus run producing more than 256 MiB of mined records under the production writer (not the corpus-mode single-file path)
  • When the writer flushes Parquet files
  • Then every emitted row group’s total_byte_size (the uncompressed size field on RowGroup in the Parquet metadata — equal to the sum of its column chunks’ total_uncompressed_size) is at least 128 MiB and at most 1 GiB
  • Except the final row group of a file, which may be smaller

Scenario RFC0005.7 — Audit-event stream is a separate file series

  • Given a corpus run that triggers at least one RFC 0001 §6.4 event_type = template_widened event (the Rust variant is TemplateWidened)
  • When the cluster’s audit sink flushes
  • Then audit events land under audit/tenant_id=<id>/..., not interleaved with the data file series
  • And the emitted audit record is populated for every row- level column declared in §3.7’s audit-schema table, with NULL appearing only on the explicitly-OPTIONAL columns documented for the variant (e.g. reason is NULL for template_widened; slots_expanded is an empty list)

Scenario RFC0005.8 — body column carries no dictionary encoding

  • Given a corpus run that retains at least 100 unique high- entropy body strings (e.g. via RFC 0001 §6.3 lossy-zone or RFC 0001 §6.5 overflow)
  • When the writer flushes the Parquet file
  • Then the body column chunk’s compression codec is ZSTD (Parquet CompressionCodec field)
  • And the body column chunk’s encodings list does NOT include PLAIN_DICTIONARY or RLE_DICTIONARY (Parquet Encoding enum)
  • And the body column chunk’s dictionary_page_offset is unset (None) in the column-chunk metadata — there is no dictionary page on disk for this column

Scenario RFC0005.9 — Unknown ParamType ordinal surfaces as Unknown

  • Given a Parquet file with a params.type_tag value that the current reader’s ParamType enum doesn’t recognise (e.g. ordinal 99)
  • When the reader reads it
  • Then the resulting Param.type_tag is ParamType::Unknown
  • And the record’s reconstruct call surfaces it as lossy (consistent with RFC 0001 §6.6’s fallback path)

Scenario RFC0005.10 — Schema declaration is greppable and immutable

  • Given the Schema singleton defined in ourios-parquet
  • When the test suite extracts the column list from Schema and compares it against the column list pinned in this RFC
  • Then the two lists are equal in name, type, and repetition, in declared order

Scenario RFC0005.11 — Row-vs-path validation on partition mismatch

  • Given a Parquet file whose row-level tenant_id, or the row’s UTC year / month / day / hour as derived by the §3.4 algorithm (prefer time_unix_nano if non-zero, else observed_time_unix_nano if non-zero, else the 1970 epoch), disagrees with the partition-path segments the file lives under
  • When the reader opens the file via Reader::open_partition
  • Then the reader returns a hard error naming the offending row, the row’s value, and the partition path’s value
  • And no records are surfaced from the file
  • And a row with time_unix_nano = 0 and a non-zero observed_time_unix_nano placed under a partition path derived from the observed-time fallback validates cleanly (the same algorithm runs on both sides)

Scenario RFC0005.12 — Compaction audit event round-trips (amendment 2026-06-03)

  • Given a compaction audit event (event_kind = 3, event_type = "compaction") carrying a partition key, an input file set, an output file, a manifest generation, and a row count
  • When it is written to the audit stream and read back
  • Then the common envelope (tenant_id, timestamp, event_kind, event_type) and the compaction_* columns are populated with those values
  • And every template-specific column (template_id, old_version, new_version, old_template, new_template, positions_widened, slots_expanded, triggering_line_hash) reads back as None / null
  • And a template_widened event written to the same stream still populates all of those template columns and reads back its compaction_* columns as None — i.e. the writer keeps each kind’s required-by-convention columns non-null (§3.8 rule 6)

Scenario RFC0005.13 — Effective-timestamp fallback (amendment 2026-06-11)

  • Given a record with time_unix_nano = 0 and observed_time_unix_nano = T (non-zero)
  • When the writer flushes it and a time-window query whose window contains T runs over the store
  • Then the stored effective_time_unix_nano equals T
  • And the file lands under the partition tuple derived from T (§3.4)
  • And the query returns the row — the time window filters effective_time_unix_nano (RFC 0002 §6.2)
  • And the stored time_unix_nano is still 0 — the wire value is never overwritten (RFC 0001 scenario RFC0001.10)
  • And given a pre-amendment file lacking the effective_time_unix_nano column, the same time-window semantics apply with effective := time_unix_nano (§3.9) — i.e. exactly the pre-amendment behaviour, no error, no hidden rows

Scenario RFC0005.14 — Alias audit events round-trip and back the v1 map derivation (amendment 2026-06-12)

  • Given an alias_asserted event (event_kind = 4, event_type = "alias_asserted") carrying a representative id, a member-id set, an actor, and a reason, written through the audit sink
  • When the tenant’s audit stream is read back
  • Then the event round-trips with its full asserted set, actor, and reason intact (reason round-trips "" ↔ NULL; an empty member_ids reads back as an empty list, not NULL)
  • And every template-specific and compaction_* column reads back as None / null, and a template_widened event in the same stream reads its alias_* columns back as None (§3.8 rule 6, per kind)
  • And given a stream carrying alias_asserted(A, {B}) followed by the matching alias_retracted for tenant T, when the querier derives T’s alias map at compile time (§3.7.1), then resolves_to(A) reflects exactly the folded state per RFC 0001 §6.7 (assert-then-retract → {A})
  • And a second tenant’s alias events contribute nothing to T’s derived map (CLAUDE.md §3.7; RFC 0001 scenario RFC0001.14 at the storage layer)

6. Testing strategy

  • RFC0005.1 — property test in crates/ourios-parquet/tests/roundtrip.rs using proptest to generate MinedRecords spanning every column variant; asserts byte-equality after a round trip through the writer and reader. Corpus integration test in the same file drives the H7.1 corpus through writer → reader and asserts the same property end-to-end.
  • RFC0005.2, RFC0005.3, RFC0005.4 — schema-evolution tests in crates/ourios-parquet/tests/evolution.rs. Each test builds a Parquet file with the parquet crate directly (not through the project’s writer), exercising a specific shape: missing-OPTIONAL, unknown-column, missing-REQUIRED. Asserts the §3.9 reader contract.
  • RFC0005.5 — integration test in crates/ourios-parquet/tests/partition.rs that drives the writer with a synthetic multi-tenant, multi-hour stream and asserts the bucket layout via filesystem inspection. The non-ASCII tenant id case is a sub-test.
  • RFC0005.6 — corpus integration test in crates/ourios-parquet/tests/sizing.rs. Generates ≥256 MiB of records, flushes through the writer, parses each emitted file’s Parquet footer, asserts row-group sizes inside the H4 range. Marked #[ignore] by default (slow); contributors run it manually via cargo test --ignored. Scheduling it on a CI cadence is an open question (§7) — the project’s CI workflow has no schedule trigger today, so the RFC does not commit to one.
  • RFC0005.7 — integration test in crates/ourios-parquet/tests/audit.rs that wires the audit sink to the writer’s audit path, triggers a widening through the miner, flushes, and reads back the audit file. Asserts the §3.7 column set.
  • RFC0005.8 — Parquet-metadata inspection test in crates/ourios-parquet/tests/encoding.rs. Drives 100+ unique bodies through the writer, opens the resulting file’s footer via the parquet crate’s column-chunk metadata, asserts the body column’s compression is ZSTD and its encodings list does not include PLAIN_DICTIONARY or RLE_DICTIONARY (the two distinct Parquet-metadata fields per RFC0005.8).
  • RFC0005.9 — unit test in crates/ourios-parquet/src/reader.rs with an in-memory Parquet file built directly from arrow arrays carrying a forged 99 in the type_tag list.
  • RFC0005.10 — unit test in crates/ourios-parquet/tests/schema_pin.rs that holds a const expected-column-list and compares against Schema::columns(). This is the “schema-as-spec” pin: adding a column to Schema without updating the expected list (and, by implication, this RFC) fails the test, mirroring the RFC0004.3 pattern.
  • RFC0005.11 — integration test in crates/ourios-parquet/tests/partition_validation.rs that builds Parquet files at deliberately mismatched partition paths (row says tenant_id = a, path segment says tenant_id=b) and asserts the reader’s hard-error path fires with the documented diagnostic. Sub-tests cover the four time-bucket parts (year/month/day/hour).
  • RFC0005.12 — round-trip test in crates/ourios-parquet/tests/ lands with the audit-schema code change: write a compaction audit event and a template_widened event through AuditWriter, read them back via AuditReader, and assert each kind’s columns are populated / null per §3.7 (the relaxed template columns non-null only for template kinds; compaction_* non-null only for compaction).
  • RFC0005.13 — integration test spanning crates/ourios-parquet (writer derivation + the §3.9 absent-column default) and crates/ourios-querier (the time-window filter): write a time_unix_nano = 0 record with observed_time_unix_nano set, assert the stored column, the partition path, the window hit, and the verbatim zero; then build a pre-amendment-shaped file (no effective_time_unix_nano column) with the parquet crate directly, per the RFC0005.2 pattern, and assert the window filter behaves as effective := time_unix_nano.
  • RFC0005.14 — lands with the issue-#148 implementation slice. Round-trip test in crates/ourios-parquet/tests/audit.rs per the RFC0005.12 pattern: write alias_asserted / alias_retracted and a template_widened event through AuditWriter, read back via AuditReader, assert each kind’s columns populated / null per §3.7 (including the "" ↔ NULL reason rule and the empty-vs-NULL alias_member_ids distinction). Derivation test in crates/ourios-querier: fold a written assert/retract stream into the tenant’s AliasMap per §3.7.1 and assert resolves_to over the result, with a second tenant’s events on disk to pin isolation. The unknown-kind tolerance rule is pinned by extending the existing forged-ordinal reader test (audit_reader.rs) from expect-error to expect-opaque-event.

Criterion benchmarks (in ourios-bench, Phase 3 territory) will measure A1 (compression ratio) and B1/B2 (predicate-pushdown latency) against the schema this RFC specifies; those numbers are normative for the maturity-stage move from green to validated.

7. Open questions

  • Compression codec. ZSTD-3 is the default per §3.5; ZSTD-22 trades CPU for ratio. The A1 measurement decides whether to add zstd_level as a tunable per RFC 0004. Defer until A1 numbers exist.
  • Bloom filter sizing. §3.6 names template_id as the one column with a bloom filter; the false-positive rate is a Parquet writer parameter (Arrow default is 1%). Lower FPR trades file size for query selectivity. Defer until B2 numbers exist.
  • Audit-event retention. Audit events have a different retention policy than log records (audits should outlive the data they audit, for forensics). The retention plumbing is post-MVP (no compaction = no expiry in MVP); the RFC notes the asymmetry but does not pin a policy.
  • Partition-discovery API on the reader. The reader has to enumerate files under a <bucket>/data/ prefix and decode the Hive partition values to apply predicate-pushdown. Whether this is in-crate (Reader::open_partition) or delegated to DataFusion’s ListingTable is a Phase 3 wiring decision; for the standalone reader tests the bench will use whichever is simplest.
  • Concurrent writers per partition. Two writers writing to the same tenant_id=…/hour=HH/ simultaneously is fine (UUIDv7 prevents filename collision), but readers that enumerate partitions during an active write may see partial files. The reader contract assumes a file is either complete or absent. The atomic-publish convention (write to a temp path, rename on close) is the writer’s responsibility; the reader does not need to do anything special. Defer the writer PR to nail this down.
  • Scheduled CI cadence for the slow tests. RFC0005.6 (row-group sizing) and any future criterion benchmarks are marked #[ignore] and rely on cargo test --ignored / manual invocation. Adding a GitHub Actions schedule: trigger (e.g. nightly at 03:00 UTC) so these run automatically is a follow-up workflow PR, not part of this RFC. The RFC notes the gap; the workflow PR will land alongside the Phase 3 ourios-bench benchmark implementation (docs/roadmap.md §4 Phase 3).

8. References

  • CLAUDE.md §1 (project charter), §2 (architectural pillars — Parquet, template miner, DataFusion), §3.2 (no unbounded cardinality in params), §3.5 (Parquet schema changes require a migration plan), §3.6 (object storage is the source of truth), §3.7 (multi-tenancy from day one), §5.1 (RFC process), §7 (target repository layout — ourios-parquet is the named crate).
  • RFC 0001 §6.1 (MinedRecord data model, OTLP-derived columns, body representation including the Ourios canonical body encoding rule), §6.4 (widening events that this RFC’s audit-event stream carries), §6.5 (OVERFLOW marker + forced body retention — the source of unbounded values in the body column), §6.6 (reconstruction — the consumer of the schema’s params / separators / lossy_flag columns), §6.7 (template versioning; the 2026-06-07 alias write path whose alias_asserted / alias_retracted events the §3.7 stream persists and whose projection semantics §3.7.1 folds), §9 (cross-RFC contracts pending — audit-event Parquet stream).
  • RFC 0002 (query DSL, drafted) — Phase 3 consumer of the reader.
  • RFC 0003 (OTLP receiver, drafted) — Phase 3 producer of records that feed this schema.
  • RFC 0004 (configuration policy) §3 (tunables-vs-invariants — this RFC’s encoding policy choices are not tunables; they are RFC-amendment territory).
  • docs/hazards.md H1 (silent template merges — audit-event stream is the operational signal), H4 (small-file problem — the row-group and file-size targets in §3.5), H5 (template schema evolution — the schema-evolution rules in §3.8).
  • docs/benchmarks.md A1 (compression ratio — gated on this RFC’s encoding policy), B1 (predicate-pushdown latency — gated on this RFC’s page index / partition layout), B2 (template-exact query latency — gated on this RFC’s bloom filter on template_id).
  • docs/roadmap.md §4 Phase 2 (the capability set this RFC opens), §5 (deliberately out of MVP — compaction, the post-MVP follow-up RFC named here).
  • Apache Parquet Format specification (file format, page index, bloom filter, LIST encoding) — project site https://parquet.apache.org/; the normative format spec lives in the repository at https://github.com/apache/parquet-format.
  • OpenTelemetry Logs Data Model — AnyValue, normative source at https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md.
  • OpenTelemetry Protocol (OTLP) specification — the proto3-JSON mapping (plus OTLP’s closed list of deviations) that the Ourios canonical body encoding for body_kind = Structured builds on lives at https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/otlp.md (see the “OTLP/HTTP” section). OTLP defines no canonical / byte-deterministic JSON form and requires no lossless translation; the byte-stable encoding is Ourios-local — see RFC 0001 §6.1.

RFC 0006 — Bench harness


rfc: 0006 title: Bench harness — A1 / C1 / C2 thesis-gate measurement status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-05-22 supersedes: — superseded-by: —

RFC 0006 — Bench harness: A1 / C1 / C2 thesis-gate measurement

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

1. Summary

Pins the contract for ourios-bench: a binary that drives the shipped ourios-miner + ourios-parquet pipeline against a corpus on disk, computes the three writer-side thesis-gate numbers (A1 compression, C1 reconstruction, C2 template-count convergence) per docs/benchmarks.md §2 / §4, and writes results into docs/benchmarks.md §9 in a diff-reviewable shape. The RFC fixes the methodology — what counts as a raw byte, what counts as a Parquet byte, when plateau is plateau, what equals what in reconstruction — before any code is written, because the difference between “the thesis holds” being a real claim and a vibe lives in those definitions. B1 and B2 (predicate-pushdown and template-exact query latency) are excluded: they need the DataFusion querier (ourios-querier, RFC 0007) and therefore landed in follow-up extensions once the querier was live — both are now measured authoritatively (docs/benchmarks.md §9.4; RFC 0007 is validated).

2. Motivation

2.1 The honesty contract collapses without measurement

CLAUDE.md §1 declares the project’s central claim and §2 names the three pillars that have to hold for the claim to be true. docs/benchmarks.md §7’s escalation rule is the load-bearing consequence: if two thesis-gates fail on any representative corpus, we pause implementation and revisit the pillars. That rule is a no-op as long as no thesis-gate has been measured. The §9 status line as of merge of RFC 0005 reads “no benchmark has been run; all targets are aspirational” — which is fine for the storage layer’s RFC, but cannot stay true through the rest of MVP. RFC 0006 is the gate that flips §9 from aspirational to measured.

2.2 Why bench-first, before the querier

docs/roadmap.md §4 Phase 3 names two crates: ourios-bench and ourios-querier. Either could go first. Three of the five thesis-gate goals (A1, C1, C2) need only the bench — the writer and reader shipped through PR-D…PR-G are everything those gates require on the storage side. The other two (B1, B2) need DataFusion plumbing on top. Bench-first means three thesis-gate signals land before any DataFusion code is written; querier-first defers all five signals until the querier is green.

The asymmetric value also runs the other direction: the methodology RFC is the kind of document that tends to surface gaps in the writer / reader contract while the storage code is still fresh, when those gaps are cheaper to fix. Writing it after the querier lands risks discovering A1-affecting writer bugs at the same time we’re debugging predicate pushdown.

2.3 Why an RFC and not just a PR

docs/rfcs/README.md requires an RFC for any new crate, which covers ourios-bench mechanically. The methodology section is the deeper reason: A1 in particular has subtle definitional choices (“what does bytes(raw_corpus) mean for a corpus that the miner wraps in OTLP envelopes?”, “do we include the audit-stream files in bytes(parquet)?”, “is zstd-alone the codec or the codec plus the comparable defaults the Drain paper uses?”) that are much easier to argue about in markdown than in code review. Pinning them in an RFC means the resulting numbers are not only reproducible but meaningful — the §9 Results section that grows out of this work cites the RFC by section, and changes to the methodology require an amendment.

2.4 Why this is one RFC, not three

A natural split would be RFC 0006 (bench crate shape) / RFC 0007 (A1 methodology) / RFC 0008 (C1+C2 methodology). All three are co-designed: the crate shape exists to compute the measurements, the A1 plain-text-vs-OTLP corpus decision affects which loader the crate exposes, and C1/C2 share the per-line ingest loop that A1 also runs to produce its Parquet output. Splitting them optimises for short documents and loses the cross-cutting constraints. The querier (and the B1/B2 methodology it carries) is a genuinely separate concern with no shared code path and lives in RFC 0007 (since shipped and validated).

3. Proposed design

3.1 Scope and what this RFC pins

This RFC pins:

  • The ourios-bench crate’s shape: a binary plus a small set of supporting modules (corpus loader, ingest harness, result writer).
  • The corpus input format for v1 — plain-text *.txt files under testdata/corpus/, one line per row, UTF-8, the same on-disk shape testdata/corpus/README.md documents and the existing H7.1 property test in crates/ourios-miner/tests/hazards.rs reads. The bench reuses the on-disk format, not the test’s OtlpLogRecord fixture defaults (see §3.3 for the bench-specific tenant / severity / scope envelope). Amendment (PR-K2, 2026-05-28): the OTLP-LogsData migration has landed — the loader also reads *.jsonl / *.json files in the OTel File Exporter format (one LogsData per line). The measurement formulas are unchanged; the §3.3 plain-text envelope defaults still apply to text input.
  • The A1 / C1 / C2 measurement formulas — what is divided by what, where the byte counts come from, what equality means.
  • The “hardware baseline annotation” rule: every result line carries the machine kind the measurement ran on so deltas across hardware classes don’t masquerade as code regressions.
  • The output format: a per-run JSON results file under benchmarks/results/<UTC-RFC3339-ms-colon-free>-<git-sha7>.json (filename colons replaced by -; see §3.6), and a human-readable summary appended to docs/benchmarks.md §9 under a date-stamped sub-heading.
  • The invocation surface: cargo run -p ourios-bench -- or just thesis-bench, with CLI flags pinning corpus selection, result-file output, and the optional “annotate-only mode” that runs measurements but does not write benchmarks.md. The recipe is not named just bench — that name is already taken by the criterion-micro-benchmark recipe in justfile; see §3.7.

This RFC does not pin:

  • B1 / B2 measurement — both need ourios-querier (RFC 0007, where they since landed; authoritative results in docs/benchmarks.md §9.4).
  • The OTLP-LogsData corpus migration (docs/roadmap.md §4’s “OTLP LogsData (canonical JSON or protobuf)” goal). Landed in PR-K2 (2026-05-28). The loader now reads *.jsonl / *.json files in the OTel File Exporter format alongside the plain-text *.txt path. As predicted, the measurement formulas were unchanged; only the loader’s parse step grew. The follow-up that swaps in the protobuf (*.binpb) LogsData decode (instead of JSON) remains out of scope for this RFC.
  • criterion micro-benchmarks for the miner / writer hot paths. criterion is the right tool for sub-measurements (e.g. per-line tokenize cost), but the thesis-gate harness is end-to-end. A follow-up may add a crates/ourios-bench/ benches/ directory; this RFC does not specify it.
  • A Validated-stage flip for any RFC — this RFC lands green (test stubs exist, measurements compile and run on the existing seed corpus), with hardware-and-corpus-specific validation happening in a follow-up benchmarking session.

3.2 Crate shape

crates/ourios-bench/
├── Cargo.toml
└── src/
    ├── main.rs        # CLI entry point, argument parsing
    ├── lib.rs         # public surface for integration tests
    ├── corpus.rs      # *.txt loader (mirrors ourios-miner's
    │                  # tests/hazards.rs but factored for reuse)
    ├── harness.rs     # ingest loop, per-line measurement
    │                  # callbacks (lines into miner, records
    │                  # into Parquet writer, samples C2)
    ├── a1.rs          # A1 compression-ratio computation
    ├── c1.rs          # C1 reconstruction-rate computation
    ├── c2.rs          # C2 template-count-convergence
    │                  # computation, including plateau detection
    └── report.rs      # JSON serialisation + benchmarks.md §9
                       # appender

lib.rs is non-empty so integration tests under tests/ can drive the bench without going through main.rs argument parsing. The binary’s main() is thin: parse args, configure harness, call harness.run(), hand the result to report.

No trait abstraction over Harness or Corpus until a second consumer exists. The crate is internal to the project; SemVer applies to it only via the report::ResultsFile shape under benchmarks/results/<...>.json.

3.3 Corpus format

For v1, the bench reads plain-text *.txt files under testdata/corpus/ per the format convention in testdata/corpus/README.md (one log line per row, UTF-8, empty rows skipped). Each non-empty line becomes one OtlpLogRecord with Body::String(line), a default tenant (bench-tenant), severity (9 / INFO), and scope (None / None); the in-memory shape matches what MinerCluster::ingest expects for body_kind = String records.

The bench reuses the same corpus files and one-line-per-record shape as the H7.1 loader in crates/ourios-miner/tests/hazards.rs, but intentionally differs on pipeline defaults: H7.1 uses tenant "corpus" and severity 0 (unspecified), while the bench uses "bench-tenant" and severity 9 (INFO). The divergence is deliberate — H7.1 exercises the miner’s body-reconstruction invariant where tenant/severity are irrelevant, whereas the bench exercises the full write path where a realistic severity aids coverage of the Parquet writer’s field encoding. Both loaders produce Body::String records from the same *.txt files; they are not code-shared because their purposes and default-filling strategies differ.

Time stamps for the synthesised records are deterministic: time_unix_nano starts at a fixed RFC 0005-friendly baseline (1_775_127_480_000_000_000, i.e. 2026-04-02T10:58:00 UTC, matching the existing test fixtures) and advances by a fixed 1 ms per line. The advancement is artificial; this RFC accepts the artificiality because A1 / C1 / C2 are time-insensitive (no gate measures throughput or query latency against a time range). The RFC 0007 measurement extension to B1/B2 revisited time-stamp synthesis as anticipated, since predicate-pushdown latency depends on the time-range distribution: the B1/B2 real-corpus arms window on the records’ real timestamps.

The default tenant means every record lands in the same partition. This is a simplification — the writer’s atomic publish, row-group rotation, and §3.9 row-vs-path contract have all been exercised on the multi-partition path through PR-E2 / PR-F / PR-G. A1 / C1 / C2 are tenant-distribution neutral. Multi-tenant bench scenarios land with future multi-tenant integration work.

Amendment (PR-K2, 2026-05-28): OTLP-LogsData corpus support has landed. The loader dispatches on file extension: *.txt → the plain-text path above; *.jsonl / *.json → OTLP/JSON Lines (one LogsData per line, the OTel File Exporter format), parsed via serde_json::from_str against the opentelemetry-proto types (the with-serde feature gives the OTLP/JSON spec mapping for free). Each wire LogRecord maps to one OtlpLogRecord per the RFC 0003 §6.6 shape — severity (clamped to OTLP’s 0..=24), scope, attributes, resource attributes (copied per record), trace context (length-validated [u8;16] / [u8;8]), body (StringValueBody::String, anything else → Body::Structured(AnyValue) per RFC 0003 §6.4). Both formats may coexist in the same corpus directory. Wire timestamps are honoured for OTLP records (file-static = run-reproducible); the §3.3 deterministic baseline still drives the text path. The follow-up that decodes the protobuf (*.binpb) LogsData form remains out of scope for this RFC.

3.4 Measurement methodology

The load-bearing section of this RFC. The §5 acceptance criteria assert each formula and the §9 status line cites this section by sub-heading.

3.4.1 A1 — Compression ratio

Per docs/benchmarks.md §2 / A1, the formula is:

ourios_ratio = bytes(raw_corpus) / bytes(ourios_output)
zstd_ratio   = bytes(raw_corpus) / bytes(zstd_corpus)
A1_delta     = ourios_ratio / zstd_ratio

Targets: A1_delta ≥ 3.0 on every corpus in benchmarks.md §1; ≥ 10.0 on well-templated services.

Pinned definitions:

  • bytes(raw_corpus): sum of std::fs::metadata(p).len() for every corpus file the loader consumed — recursively under the corpus directory. The loader dispatches on extension: *.txt (the §3.3 plain-text path), or *.jsonl / *.json (the §3.1 OTLP/JSON Lines path landed in PR-K2). *.binpb protobuf-encoded LogsData is reserved for a future follow-up and not counted today. No transformation: this is the byte count an operator measures with find testdata/corpus/ \( -iname '*.txt' -o -iname '*.jsonl' -o -iname '*.json' \) -exec stat --printf='%s\n' {} + | awk '{s+=$1}END{print s}' (or the platform equivalent). For OTLP/JSON corpora the byte count includes the envelope (camelCase keys, base64 bytes), so A1 ratios are not directly comparable across formats — a directory holding a mix of *.txt and *.jsonl produces one aggregate number that conflates both encodings. The §3.6 results JSON’s corpus.directory field lets consumers locate the corpus and inspect its contents to interpret the result; cleanly comparable runs need a corpus directory of one encoding only. A per-format byte breakdown on the results JSON is a future enhancement (see §9 open questions). bytes(zstd_corpus) (below) covers the same extension set so the §3.4.1 math invariant — both sides processing the same input — holds across formats.
  • bytes(ourios_output): sum of std::fs::metadata(p).len() for every *.parquet file under the bench’s output bucket directory, including the audit-event file series (audit/...). The audit stream is part of what Ourios stores about the corpus — excluding it would understate the on-disk footprint and inflate the ratio. The pre-rename *.parquet.tmp files are skipped (the writer’s atomic-publish contract per RFC 0005 §7 means an open *.parquet.tmp indicates an in-flight write, not a durable artefact).
  • bytes(zstd_corpus): sum of std::fs::metadata(p).len() for every *.zst file produced by running zstd -19 --no-progress against each consumed input file individually — same extension set as bytes(raw_corpus) above (*.txt + *.jsonl + *.json). The two byte counts must cover identical input files; broadening one without the other would break the §3.4.1 math invariant (zero bytes(zstd_corpus) on an OTLP-only corpus would produce zstd_ratio = 0 and an undefined A1 delta). Level 19 (not 3) matches the Drain paper’s published comparison and is the strictest competent byte codec; using ZSTD-3 would make Ourios’s A1 trivially pass and is dishonest. The --no-progress flag suppresses the progress bar so the bench is deterministic on reinvocation.
  • A1_delta is the ratio of ratios; it has no units. Reported to three significant figures (3.21×, 12.4×, etc.) and rounded down to that precision so reported numbers err pessimistic.

The bench logs bytes(raw_corpus), bytes(ourios_output), bytes(zstd_corpus), ourios_ratio, zstd_ratio, and A1_delta for each corpus directory it processes. The §9 table summarises by corpus name + hardware kind.

3.4.2 C1 — Bit-identical reconstruction rate

Per docs/benchmarks.md §4 / C1, the formula is:

C1 = count(records WHERE !lossy_flag AND reconstruct == bytes)
   / count(records WHERE !lossy_flag)

Target: C1 = 1.000 (100.000%) on every corpus. lossy_flag = true rows are excluded from both numerator and denominator — that’s the definition of “non-lossy reconstruction rate”. A non-lossy row that reconstructs wrong is a CLAUDE.md §3.3 violation and a blocker per §4 / benchmarks.md C1; the bench reports such rows as a hard failure (non-zero exit code) rather than a degraded gate.

Amendment (PR-K4, 2026-05-29): BodyKind::Structured rows are also excluded from the C1 denominator. Per RFC 0001 §6.4 / RFC 0003 §6.4, reconstruction for structured bodies is a storage-layer round-trip (decode the stored AnyValue bytes) — not a template + params reconstruction — so the template-based equality C1 measures doesn’t apply to them. Structured ≠ lossy (the two are independent axes; a structured record can be high-confidence). The harness symmetrically skips the templates_for() snapshot lookup for those records, because RFC 0001 §6.1 assigns them a sentinel template id outside the Drain tree (no leaf to find).

Pinned definitions:

  • reconstruct(record, template) is the function exposed by ourios_miner::reconstruct::reconstruct, signature fn reconstruct(record: &MinedRecord, template: &[OwnedToken]) -> Vec<u8> — same function RFC 0001 §6.6 specifies and the H7.1 property test in crates/ourios-miner/tests/hazards.rs already exercises at unit scale. The function takes the emitted record and the leaf’s template token slice at the record’s emit-time (template_id, template_version); template snapshots have to be captured separately because a later attach can widen the same leaf and rewrite the live template.
  • Template-snapshot capture mirrors the H7.1 pattern: after each MinerCluster::ingest, the harness walks cluster.templates_for(tenant) and records the current template tokens into a HashMap<(template_id, template_version), Vec<OwnedToken>> via or_insert_with (so the first observation of a (id, v) pair wins and later widenings produce (id, v+1) entries without clobbering). At C1 evaluation time, each record’s (template_id, template_version) looks up its emit-time-active snapshot. A record whose key is not in the map is a contract violation — the harness exits with non-zero before reporting C1.
  • bytes is the original line bytes the loader handed MinerCluster::ingest, captured by the harness alongside each MinedRecord. The bench MUST capture the input line before MinerCluster::ingest borrows or transforms it; the comparison happens against the exact bytes the miner saw.
  • Equality is byte-for-byte == between reconstruct(record, template) (a Vec<u8>) and line.as_bytes(). No trailing-newline normalisation, no case folding, no whitespace trimming.
  • Reported as a fraction with six decimal places (1.000000 / 0.999998). C1’s 100.000% target makes three-decimal precision insufficient — a single failing reconstruction out of 100 000 records is the difference between green and a blocker.

The bench also reports lossy_flag_ratio = count(lossy=true) / count(all) as a quality signal per benchmarks.md C1, with the ≤ 5% / ≤ 20% targets surfaced but not gating.

3.4.3 C2 — Template-count convergence

Per docs/benchmarks.md §4 / C2, the gate is “template count grows sub-linearly and plateaus within 2× of its steady-state value by 1 M lines”. The formula needs three things pinned: when to sample, what counts as plateau, and what counts as “steady-state value”.

The benchmarks.md C2 phrasing — “template count grows sub-linearly and plateaus within 2× of its steady-state value by 1 M lines” — operationalises to: at the 1 M-line mark, the template count is at least half of the count the curve eventually converges to. Since template count is monotonic non-decreasing (the miner does not unmerge templates), this is the cleanest formulation; if count(1M) ≥ SS / 2, the curve cannot have more than doubled between 1 M lines and end-of-corpus, i.e. it is within 2× of its steady-state value. The phrasing reading where SS is defined as max(samples) and the comparison is plateau_value ≤ 2 × max is tautological — plateau_value ≤ max by definition — and was rejected after the first copilot review of this RFC.

Pinned definitions:

  • Sample cadence: every N lines, where N = max(1, ceil(lines_in_corpus / 1024)). The cadence uses ceiling division so the curve never exceeds 1024 samples regardless of corpus size; a 1 M-line corpus samples every 977 lines, a 10 k-line corpus samples every 10 lines. Sampling indices: the curve records template count after processing line indices N-1, 2N-1, 3N-1, … (i.e. after every N-th line, zero-indexed). The final sample is always taken at total_lines - 1 (the last line), regardless of whether it falls on a cadence boundary. The sample count is therefore ceil(total_lines / N) — at most 1024 entries.
  • Steady-state value (SS): the template count at the last sample (line index = total_lines - 1; always included by the final-sample rule above). Operationally, “where the curve ended up”. Not the running max — see the rationale paragraph above.
  • Count at 1 M lines: the template count at the sample whose line index is closest to 999_999 (the millionth line, zero-indexed). When two samples are equidistant, the earlier one wins (floor tie-break). Defined only on corpora of ≥ 1_000_000 lines.
  • Convergence ratio: count_at_1m / SS, defined only when SS > 0. By monotonicity (count_at_1m ≤ SS) it is ≤ 1.0; it is 0.0 when no template has been minted as of the sample nearest the 1 M-line mark (count_at_1m == 0, SS > 0) — count_at_1m is that nearest sample, not the exact millionth line — so the defined range is [0.0, 1.0]. It is undefined (null, paired with a null count_at_1m) when SS == 0 — a ≥ 1 M corpus that mints no templates at all, a 0/0 ratio.
  • Pass condition (gate) — per service (amended for #444, maintainer-approved 2026-07-10): C2 is defined over “a corpus from a single stable service”, so on a multi-service corpus the gate is evaluated per service.name, not on the whole corpus. Each service’s ratio is count_at_1m / SS over that service’s lines, with count_at_1m taken at that service’s exact millionth line (not the whole-corpus nearest-sample; template creation is a globally-monotonic event attributed to the minting service, so per-service creations partition the whole-corpus template count exactly). A corpus passes iff every service with ≥ 1_000_000 lines has ratio ≥ 0.5 — with one exception: a service that mints zero templates over its ≥ 1 M lines (SS == 0, an undefined 0/0 ratio) passes trivially, since a flat-zero count is the strongest possible convergence (C2’s falsifier is linear growth; an all-NO_TEMPLATE service is a body-retention / parse-failure concern, caught by §3.1’s counters, not a convergence failure). It fails if any ≥ 1 M service has a defined ratio below 0.5; it abstains (c2.pass = null) when no service reaches 1 M lines. A single-service corpus — including the plain-text <unknown> bucket (no service.name) — is gated on that one service’s ratio, measured at its exact millionth line. That reproduces the pre-amendment whole-corpus verdict for every historical converged corpus (whose ratio sits far from the 0.5 boundary); it is not bit-identical to the whole-corpus convergence_ratio, which is sampled at the nearest curve point (cadence granularity) and is now only a diagnostic. Only multi-service OTLP corpora change verdict. Rationale: running one whole-corpus ratio over a multi-service capture (e.g. the OTel-Demo) is a category error — it conflates a noisy infra service (a broker emitting high-cardinality offset/path tokens) with clean application services, so the whole-corpus number fails even when every application service converges perfectly (v8 §9.12). The whole-corpus convergence_ratio is retained as a diagnostic (the by_service breakdown is the gate basis). Note: token-level polishing of high-cardinality infra logs is an OTel Collector concern (a transform/redaction processor upstream), not the miner’s — consistent with “format parsing is the Collector’s job”. Cardinality cap: the decomposition holds at most MAX_SERVICES = 1024 distinct service.name buckets (an O(services) memory guard mirroring §3.2); beyond that, further services fold into one <other> bucket and c2.services_truncated is set. A real OTLP capture carries tens of services, so the cap is not expected to bind; if it does, the folded <other> bucket mixes services and its per-service ratio is no longer strictly single-service — services_truncated flags that the run should be re-scoped (the truncation is surfaced, never silent).
  • Plateau-detection diagnostic (not a gate): the curve is “plateaued” at the sample where the trailing K = 64 samples all lie within ± 5% of the SS. The diagnostic is useful for understanding where the curve actually flattens (often well before 1 M lines), but it does not gate the RFC — the gate is the 2× rule above.

Reported as: template_count_at_1m_lines (integer; null for corpora < 1 M lines or a ≥ 1 M corpus with SS == 0), template_count_at_end (integer; this is SS), convergence_ratio (three-decimal float; null under the same two conditions). These two form a matched pair — both null or both set, never mixed — which the report layer relies on (report.rs errors on a mixed pair). pass (bool or null), corpus_at_least_1m (bool).

v1 records the convergence curve in the results JSON (as c2.convergence_curve, an array of {"lines": N, "template_count": M} objects at the sample cadence) but does not plot it. A future RFC may add a plot artefact so the §9 Results section can include visualisations.

3.5 Hardware baseline and annotation

docs/benchmarks.md §1 pins the hardware baseline: “commodity cloud VM, 8 vCPU, 32 GiB RAM, gp3-class SSD.” Every bench run captures the host’s --hardware-kind=<tag> CLI argument (required; defaults to unknown only when explicitly opted in via --allow-unknown-hardware) and writes it into the results JSON. The §9 Results table cites the hardware tag on every row; a comparison across rows with different tags is a delta between hardware and code, not code alone.

Hardware tags this RFC pins as known: baseline-8vcpu-32gib (the §1 reference), dev-laptop, ci-runner. New tags can be added without an RFC amendment — the value is operator discipline, not a closed vocabulary — but unknown tags require the explicit --allow-unknown-hardware opt-in so a forgotten --hardware-kind doesn’t silently land in §9 as unknown.

3.6 Result format

Each bench invocation writes one results JSON to:

benchmarks/results/<UTC-RFC3339-ms-colon-free>-<git-sha7>[-N].json

The name embeds the run’s millisecond-precision RFC3339 timestamp with the : separators replaced by - (so 2026-05-22T14:30:00.123Z becomes 2026-05-22T14-30-00.123Z). The colon substitution is required: : is illegal in filenames on Windows and awkward for shell / tooling elsewhere, so the on-disk name is colon-free even though the timestamp field inside the JSON keeps canonical RFC3339 (colons included). Two runs on the same commit in the same wall-clock second still produce distinct names via the millisecond component.

Even at millisecond precision two runs can theoretically collide on a fast machine. The writer creates each candidate with an atomic create_new (“create iff absent”) open and, on AlreadyExists, appends a numeric suffix (-1, -2, …) until it finds a free name — rather than re-deriving the timestamp. This closes the check-then-write race against a concurrent run and never clobbers an existing file; if the suffix budget is exhausted the write fails loudly rather than overwriting. The directory benchmarks/ will be created at the repo root by the implementation PR that lands the ourios-bench crate. That same PR adds a .gitignore entry ignoring benchmarks/results/ except for a .gitkeep and the specific runs the maintainer chooses to commit (the §9 Results section then cites those by file path).

The JSON shape is pinned by report::ResultsFile and looks like:

{
  "rfc": "RFC 0006",
  "rfc_version": "v1",
  "timestamp": "2026-05-22T14:30:00.123Z",
  "git_sha": "abc1234",
  "hardware_kind": "baseline-8vcpu-32gib",
  "corpus": {
    "directory": "testdata/corpus/",
    "total_lines": 1234567,
    "total_files": 2,
    "raw_bytes": 98765432
  },
  "ourios": {
    "data_parquet_bytes": 56789,
    "audit_parquet_bytes": 1024,
    "total_parquet_bytes": 57813
  },
  "zstd": {
    "level": 19,
    "compressed_bytes": 312345
  },
  "a1": {
    "ourios_ratio": 13.6,
    "zstd_ratio": 3.95,
    "delta": 3.44,
    "target_delta": 3.0,
    "pass": true
  },
  "c1": {
    "non_lossy_total": 12000,
    "non_lossy_reconstruct_ok": 12000,
    "rate": 1.000000,
    "lossy_flag_ratio": 0.0279,
    "pass": true
  },
  "c2": {
    "sample_cadence": 1206,
    "total_lines": 1234567,
    "template_count_at_1m_lines": 142,
    "template_count_at_end": 145,
    "convergence_ratio": 0.979,
    "convergence_curve": [
      {"lines": 1206, "template_count": 14},
      {"lines": 2412, "template_count": 27}
    ],
    "pass": true,
    "corpus_at_least_1m": true
  }
}

The temp-directory paths the bench actually uses (the Writer’s bucket root) are intentionally not in the JSON. They’re an implementation detail that differs across runs and would otherwise break the §5 RFC0006.7 reproducibility scenario. The byte counts are what downstream analysis cares about; the paths are debug-only and logged to stderr when --keep-parquet is passed. The field relationship: total_parquet_bytes = data_parquet_bytes + audit_parquet_bytes, and total_parquet_bytes is the value §3.4.1 calls bytes(ourios_output). data_parquet_bytes is the sum of *.parquet sizes under data/…; audit_parquet_bytes is the sum under audit/…. The split is recorded for diagnostic transparency (understanding how much of the footprint is audit overhead) but the A1 formula operates on the total.

Gate sections are nullable. The a1, c1, and c2 keys are always present at the top level but their values are null when the corresponding gate is skipped (via --gates per §3.7) or abstains (e.g. c2 on a corpus of < 1 M lines — see §3.4.3). The example above shows all three populated (the “all gates ran, all gates pass” case); a --gates c1 run produces "a1": null, "c2": null while "c1": { ... } carries the populated payload. Downstream analysis MUST handle the null case (rather than assuming the object shape) — the §5 RFC0006.6 scenario asserts the behaviour.

rfc_version is a literal "v1" and tracks RFC 0006 amendments; bumping it requires an RFC amendment, and downstream analysis tooling refuses unknown versions with a hard error. This is the bench’s own forward-compatibility policy — the results JSON is a closed schema, unlike RFC 0005 §3.9’s Parquet reader which ignores unknown columns and surfaces unknown ordinals as ParamType::Unknown.

A human-readable summary is appended to docs/benchmarks.md §9 as a sub-heading per run, with the same numbers in a markdown table. Repeated bench runs on the same (git-sha, hardware-kind) pair update the existing sub-heading rather than appending duplicates — the bench reads the §9 section, finds the matching heading, and rewrites it in place.

3.7 Invocation

The CLI has two output-path concepts and they are spelled differently to avoid the §3.4.1 “output bucket directory” ambiguity:

  • --results-dir is where the JSON results file from §3.6 lands. Default: benchmarks/results/.
  • --bucket-dir is the bucket_root passed to the ourios-parquet writer — the directory the writer’s data/ and audit/ partition trees grow under, and whose total byte size is bytes(ourios_output) in the §3.4.1 A1 formula. Default: a fresh temp dir under std::env::temp_dir() per invocation, cleaned up on exit unless --keep-parquet is passed.

CLI (crates/ourios-bench/src/main.rs):

ourios-bench [--corpus <path>]
             [--results-dir <path>]
             [--bucket-dir <path>]
             [--keep-parquet]
             [--hardware-kind <tag>]
             [--allow-unknown-hardware]
             [--update-benchmarks-md]
             [--gates a1,c1,c2]

Flags:

  • --corpus <path> (default testdata/corpus/): directory of corpus files the loader walks recursively. Files are dispatched on extension: *.txt (plain-text per §3.3) and *.jsonl / *.json (OTLP/JSON Lines per §3.1 — one LogsData per line, the OTel File Exporter format). Both formats may coexist in the same directory; any other extension is silently skipped.
  • --results-dir <path> (default benchmarks/results/): where the §3.6 JSON file lands.
  • --bucket-dir <path> (default: fresh temp dir): the Parquet writer’s bucket_root. Cleaned up on exit unless --keep-parquet is passed.
  • --keep-parquet (off by default): suppress the temp-dir cleanup so the Parquet partition tree is inspectable after the bench exits. Path is logged to stderr.
  • --hardware-kind <tag> (required unless --allow-unknown-hardware): the §3.5 annotation.
  • --update-benchmarks-md (off by default): append / rewrite the §9 sub-heading. CI runs without this flag; maintainers invoke with it to commit numbers.
  • --gates a1,c1,c2 (default all): comma-separated subset of gates to compute. Useful when iterating on a single measurement.

Adds a just thesis-bench recipe wrapping cargo run -p ourios-bench --release --. The recipe is not named just bench — the existing bench recipe in justfile already runs cargo bench (criterion micro-benchmarks; the suite is empty today, but the recipe is reserved for the follow-up that lands crates/ourios-bench/benches/). thesis-bench makes the gate-vs-microbench distinction greppable at the recipe level. The --release is normative — A1 on a debug-mode writer would understate compression because debug builds disable some arrow / parquet optimisations the release writer relies on.

CI cadence: not on every PR — too slow for the per-PR loop and hardware-dependent in ways that would generate noise. The bench runs on demand (PR comment /bench, future workflow) and on the nightly schedule that docs/rfcs/0005-parquet- storage.md §7’s open-question on slow-test CI cadence will formalise. RFC 0006 does not commit to a CI cadence — that’s the open question’s domain.

4. Alternatives considered

4.1 criterion instead of a custom harness

criterion is the standard Rust micro-benchmarking framework and CLAUDE.md §6.2 names it for the project’s hot-path benchmarks. Rejected for the thesis-gate harness: criterion is statistically tuned for sub-microsecond function-level measurements (per-iteration noise estimation, warmup loops, bootstrapped confidence intervals), which is the wrong tool for “ingest a 1 M-line corpus, write a Parquet partition, then divide two file-tree sizes.” The bench also runs criterion benchmarks under crates/ourios-bench/benches/ for the per-line miner cost and the per-batch writer cost — but that’s a follow-up PR after the thesis-gate harness lands, not the v1 shape.

4.2 Bench inside ourios-parquet as an [[example]]

A Cargo [[example]] under crates/ourios-parquet/examples/ could drive the writer + reader without a new crate. Rejected: the bench needs the miner and the writer plus a custom result-file writer; living under ourios-parquet would either add a ourios-miner dependency to the storage crate (architecturally wrong — storage has no business knowing about template mining) or grow into a binary that’s not really an “example” anymore. The dedicated crate matches the docs/roadmap.md §4 Phase 3 layout.

4.3 Quote A1 against the LogPAI corpora only

The Drain paper measures on LogPAI’s HDFS / BGL / Spark / Apache / OpenSSH / Windows corpora; we could pin A1 to the same corpora exclusively and call any other corpus a “tuning” measurement. Rejected: docs/benchmarks.md §1 already commits to “every corpus in §1”, including the self-collected archetypes. Restricting v1 to LogPAI would leave the self-collected work unmeasured and reintroduce the “we never ran the bench on the data that matters” gap §1 is designed to close. v1 measures on whatever corpora are committed; the seed corpus is the floor, and additions are additive.

4.4 ZSTD level 3 for the reference

ZSTD-3 is the codec the writer itself uses per RFC 0005 §3.5. Using ZSTD-3 also as the A1 reference would make ourios_ratio / zstd_ratio an apples-to-apples codec-vs-codec comparison instead of a structure-vs-codec one (both sides use the same compressor; Ourios’s win is purely the template-mining pillar). Rejected because:

  • The Drain paper compares against the strongest competent byte codec, and that’s ZSTD-19 / level-max. Using ZSTD-3 understates the codec’s reachable ratio and inflates Ourios’s A1 win.
  • CLAUDE.md §1’s central claim is “Parquet + template mining + DataFusion collapses [the layers]”; that claim is about the whole stack, not just the template-mining pillar. The reference should be the strongest alternative, not the same codec Ourios uses internally.

The downside — losing the codec-vs-codec isolation — is captured as an open question (§7). A future RFC may add A1' (prime, “codec-isolated”) as an additional tuning-goal measurement alongside the thesis-gate A1.

4.5 Defer the bench to after the corpus migration

The roadmap names “OTLP LogsData corpus” as the Phase 3 goal and one could argue the bench should not land until the corpus is in its target shape. Rejected: A1 / C1 / C2 are well-defined on plain-text input today (the seed corpus is plain text and the unit-scale H7.1 test already runs against it). Waiting on the OTLP migration to produce A1 / C1 / C2 numbers couples a mechanical loader change to a measurement deliverable for no real reason. The bench’s corpus.rs exposes the loader as an abstraction so the OTLP migration drops in without touching the harness or the formulas.

5. Acceptance criteria

Scenario RFC0006.1 — A1 formula is well-defined on the seed corpus

  • Given the bench is invoked with --corpus testdata/ corpus/, the writer ships with the §3.5 / §3.6 RFC 0005 encoding policy, and the zstd_safe Rust crate is linked (per the §7 resolution of the ZSTD-integration question)
  • When the bench runs the A1 measurement
  • Then bytes(raw_corpus) equals sum(std::fs::metadata(f).len()) over the consumed corpus files (*.txt, *.jsonl, *.json) in the corpus directory
  • And bytes(ourios_output) equals the sum of all *.parquet (not *.parquet.tmp) file sizes under the bench’s output bucket, including the audit/... partition
  • And bytes(zstd_corpus) equals the sum of std::fs::metadata(f).len() over the *.zst files produced by zstd -19 --no-progress on each consumed input (same extension set as bytes(raw_corpus))
  • And the reported delta equals ourios_ratio / zstd_ratio, rounded down to three significant figures

Scenario RFC0006.2 — C1 = 100% on the seed corpus, mismatch is a hard failure

  • Given the bench is invoked with the seed corpus committed under testdata/corpus/
  • When the bench runs the C1 measurement
  • Then non_lossy_reconstruct_ok / non_lossy_total = 1.000000 (six-decimal precision)
  • And the results JSON records c1.pass = true
  • And if any non-lossy row has reconstruct(record) != ingested_bytes, the bench writes the failing row’s template_id, template_version, expected bytes, and actual reconstruction to stderr and exits with non-zero, and the results JSON records c1.pass = false
  • And the bench writes the results JSON irrespective of --update-benchmarks-md — the JSON file always lands; only the docs/benchmarks.md §9 mutation is gated by the flag, so a failure run still leaves a machine-readable record on disk

Scenario RFC0006.3 — C2 gate (“within 2× of SS by 1 M lines”) on a stable corpus

  • Given a synthetic stable corpus of ≥ 1_000_000 lines whose template alphabet is bounded (constructed by the bench’s integration test; not committed to testdata/corpus/)
  • When the bench runs the C2 measurement
  • Then c2.corpus_at_least_1m = true
  • And template_count_at_1m_lines is the integer template count at the sample whose line index is closest to 999_999 (zero-indexed; per §3.4.3)
  • And template_count_at_end is the integer template count at the final sample (the §3.4.3 SS definition)
  • And convergence_ratio = template_count_at_1m_lines / template_count_at_end ≥ 0.5 — the “within 2× of SS” gate, made non-tautological by defining SS as the end-of-corpus value rather than the running max
  • And c2.pass = true
  • And the convergence curve in the results JSON has exactly ceil(total_lines / sample_cadence) samples (the sampling rule pinned in §3.4.3: indices N-1, 2N-1, 3N-1, … plus a guaranteed final sample at total_lines - 1)
  • And on a corpus of < 1_000_000 lines, c2.corpus_at_least_1m = false, c2.pass = null, and c2.template_count_at_1m_lines = null — the gate abstains rather than passing or failing

Scenario RFC0006.4 — Result file shape is stable and the §9 update is reversible

  • Given the bench has run and written its results JSON to benchmarks/results/<...>.json
  • When a downstream consumer (or a future RFC’s bench) reads the file
  • Then the JSON parses against report::ResultsFile with rfc_version = "v1"
  • And the file contains the §3.6 schema’s required keys (rfc, rfc_version, timestamp, git_sha, hardware_kind, corpus, ourios, zstd, a1, c1, c2)
  • And when --update-benchmarks-md is passed and the §9 section already contains a sub-heading for the same (git_sha, hardware_kind) pair, the bench rewrites that sub-heading in place — running the bench twice on the same commit / hardware does not duplicate §9 rows

Scenario RFC0006.5 — Hardware-kind annotation is required

  • Given the bench is invoked without a --hardware-kind flag and without --allow-unknown-hardware
  • When the bench parses CLI arguments
  • Then the bench exits with a usage error before any measurement runs
  • And if --allow-unknown-hardware is passed, the resulting JSON carries hardware_kind = "unknown" and stderr emits a warning naming the §1 baseline tag for reference

Scenario RFC0006.6 — --gates flag scopes the measurement

  • Given the bench is invoked with --gates c1
  • When the bench runs
  • Then only the C1 measurement executes; A1 and C2 are skipped
  • And the results JSON contains c1 populated and a1, c2 set to null
  • And the §9 update path (when --update-benchmarks-md is passed) leaves the existing A1 / C2 numbers for the (git_sha, hardware_kind) pair untouched

Scenario RFC0006.7 — Bench is reproducible across runs

  • Given the bench is invoked twice on the same git checkout and the same corpus, with no code or data changes in between
  • When the two runs complete
  • Then every measurement field of the results JSON is bit-identical across the two runs — specifically corpus.raw_bytes, corpus.total_lines, corpus.total_files, ourios.data_parquet_bytes, ourios.audit_parquet_bytes, ourios.total_parquet_bytes, zstd.compressed_bytes, a1.delta, c1.rate, c1.non_lossy_total, c1.non_lossy_reconstruct_ok, c2.template_count_at_end, and (when the corpus is ≥ 1 M lines) c2.template_count_at_1m_lines / c2.convergence_ratio
  • And the only fields that legitimately differ are timestamp (wall-clock) and the output JSON file’s path (derived from timestamp). The temp-dir bucket the writer used is not in the JSON per §3.6, so it cannot contribute to a spurious diff

6. Testing strategy

Per CLAUDE.md §6.2 / docs/verification.md §2:

  • RFC0006.1 — integration test in crates/ourios-bench/tests/a1.rs. Calls ourios_bench::run against a fixture corpus committed under crates/ourios-bench/tests/fixtures/, captures the resulting JSON, and asserts each formula leg (raw_bytes from fs::metadata, total_parquet_bytes from inspecting the output bucket, zstd_bytes from the zstd_safe crate per the §7 ZSTD-integration resolution).
  • RFC0006.2 — integration test in crates/ourios-bench/tests/c1.rs. Drives the bench against the seed corpus; asserts c1.rate == 1.0. A second sub-test injects a synthetic record whose reconstruct() disagrees with the input (built by hand, not by the miner) and asserts the bench exits with a non-zero code and emits the mismatch diagnostics to stderr.
  • RFC0006.3 — integration test in crates/ourios-bench/tests/c2.rs. Builds a synthetic corpus in memory (no committed testdata/) of 1.5 M lines with a known small template alphabet; asserts convergence_ratio ≥ 0.5 and the convergence curve has exactly total_lines / sample_cadence entries (rounded). A second sub-test feeds a non-plateauing corpus (every line introduces a new template structure) and asserts c2.pass = false.
  • RFC0006.4 — colocated unit test in crates/ourios-bench/src/report.rs. Serialises a hand-built ResultsFile, parses the JSON back, asserts field-by-field equality. A second sub-test exercises the in-place §9 update via a temp markdown file.
  • RFC0006.5 — colocated unit test in crates/ourios-bench/src/main.rs (#[cfg(test)] mod tests) for the CLI parser. Asserts the missing --hardware-kind flag without --allow-unknown-hardware produces a usage error before Harness::run is invoked.
  • RFC0006.6 — same test file as RFC0006.5; covers the --gates filtering.
  • RFC0006.7crates/ourios-bench/tests/ reproducibility.rs. Runs the bench twice against a fixed fixture corpus and asserts the relevant fields bit-equal.

A criterion bench under crates/ourios-bench/benches/ is deferred to a follow-up PR. The thesis-gate harness this RFC specifies is correctness-first; per-line miner microbenchmarks are a separate measurement category.

7. Open questions

  • zstd integration. Resolved 2026-05-25: the bench links the zstd_safe Rust crate. Already in the dep tree via parquet’s zstd feature, so the marginal build cost is zero. The decision turns on cross-platform reproducibility: shell-out requires zstd on PATH at runtime (not default on macOS or Windows, version varies across Linux distros), and version drift across hosts would mean the same Ourios commit produces different A1 numbers on different machines. With the crate, the compressor version is pinned by Cargo.lock and the bundled C library builds on every Tier 1 Rust platform — A1 is reproducible across Linux / macOS / Windows / CI runners without a host-side install step. The Drain-paper apples-to-apples concern is small in practice: zstd_safe wraps the same C library at the same compression level, so the resulting bytes are identical to what the CLI binary produces. (RFC0006.1 asserts the byte-count formula directly; if a future observer wants to spot-check against a CLI binary, the JSON results file records zstd.level = 19 so a reproduction pipeline is unambiguous.)
  • Convergence curve plotting. The results JSON carries the full sample series. Should the §9 sub-heading also render a tiny SVG / ASCII plot of the C2 curve, or is the curve only for downstream analysis? Defer until at least one real run exists.
  • CI cadence. When (or whether) the bench runs on a schedule: trigger is the RFC 0005 §7 open question on slow-test CI cadence. This RFC inherits the question; resolution is the workflow PR that lands the cadence.
  • Result-file retention policy. benchmarks/ results/*.json will be gitignored by default (§3.6); specific runs are committed when the maintainer cites them in §9. Open: should there be a benchmarks/results/baseline/ sub-directory whose contents are always committed, so regression detection has a stable reference even when the §9 markdown is hand-pruned?
  • Out-of-tree corpora. A --corpus <external-path> invocation against, say, a downloaded LogPAI corpus runs but the results JSON points to a path the repo doesn’t carry. Should the JSON record a content hash of the corpus directory (sha256 of the concatenated files) so future readers can verify they’re comparing against the same input? Probably yes; defer the mechanics until at least one out-of-tree corpus is actually being measured.

8. References

  • CLAUDE.md §1 (project charter), §2 (pillars), §3.3 (bit-identical reconstruction), §6.2 (testing discipline), §10 (docs/hazards.md reading rule).
  • docs/benchmarks.md §1 (corpora + methodology), §2 (A1), §4 (C1, C2), §7 (thesis-gate summary), §9 (Status).
  • docs/roadmap.md §4 Phase 3 (bench + querier scope), §5 (deferred capabilities).
  • docs/rfcs/README.md (RFC process and maturity model).
  • docs/rfcs/0001-template-miner.md §6.6 (reconstruct), §6.4 (audit-event contract that C2’s plateau exercises).
  • docs/rfcs/0005-parquet-storage.md §3.5 (row-group sizing the A1 measurement implicitly depends on), §3.6 (encoding policy that affects compressed bytes), §7 (open question on slow-test CI cadence inherited here).
  • docs/verification.md §2 (scenario-id greppability convention), §3 (maturity-stage gates).

RFC 0007 — Querier


rfc: 0007 title: Querier — DataFusion execution frontend for the logs DSL status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-06-01 supersedes: — superseded-by: —

RFC 0007 — Querier: DataFusion execution frontend for the logs DSL

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip; thesis-gates passed at validated.

Status note. validated (2026-06-12, per the maintainer’s authorization of the same date). The docs/verification.md §3 ladder requires for validated that “every thesis-gate in benchmarks.md §7 that the RFC’s pillars touch passes on representative corpora.” This RFC’s pillar is the query engine (pillar #3 — DataFusion); the gates it touches are B1 and B2, and both now pass authoritatively on the §1 hardware baseline (baseline-8vcpu-32gib), measured over ~1 GB+ corpora including a second corpus family — LogHub HDFS_v1, 11.2 M rows (docs/benchmarks.md §9.4): B1 at 34.2× / 25.4× against the ≥ 10× gate with exact row-count agreement vs the reference pipeline; B2’s windowed template-exact scan flat at 1 row group / 4.2–5.9 ms from 735 k to 11.2 M rows while the full-span variant grows with the corpus. A1’s authoritative FAIL does not gate this RFC: the ladder scopes validation to the gates the RFC’s pillars touch, and A1 belongs to the template-mining / compression pillar (measured under RFC 0006), where its escalation is handled. The prior validated-pending checklist resolves as: (1) authoritative baseline-8vcpu-32gib rerun — ✓ done (§9.4); (2) denser error band — still open, a non-blocking quality improvement (the §9.4 B1 bands remain 11 / 28 rows); (3) second corpus family for B2 — ✓ done (HDFS_v1 via the query-bench arm). Earlier history: the §5 acceptance criteria RFC0007.1–.5 went green via crates/ourios-querier/tests/{execution,boundary,forward_compat}.rs and the crates/ourios-querier/src/lib.rs no-leakage unit test (tests/acceptance.rs is a pointer to them); the first indicative ci-runner B1/B2 readings are §9.3. accepted follows on maintainer sign-off per the docs/rfcs/README.md ladder.

1. Summary

Introduces the ourios-querier crate (pillar #3 — DataFusion as the query engine). It takes a parsed logs-DSL query (RFC 0002), lowers it to a DataFusion LogicalPlan over the RFC 0005 Parquet data + audit files on object storage, executes it with aggressive predicate pushdown (row-group skipping via min/max statistics, bloom filters, page indexes), and returns results without ever exposing DataFusion or SQL to the caller (hazard CLAUDE.md §4.6). It is the home of the B1 (predicate-pushdown) and B2 (template-exact query latency) thesis gates that RFC 0006 §1 deferred. The crate is the read path; it depends on neither the WAL (RFC 0008) nor the receiver (RFC 0003) — it reads what the writer already produced.

2. Motivation

2.1 The thesis’s load-bearing half is unmeasured

CLAUDE.md §1 stakes Ourios on collapsing the inverted index, the compression layer, the storage tier, and the query engine into “one stack of off-the-shelf parts plus thin glue.” The compression and storage claims (A1/C1/C2) are now measured against a real OTLP corpus (RFC 0006, the corpus/otel-demo-v* series). The query claim — that template structure + Parquet statistics let us answer queries by skipping data rather than scanning it — has no code and no measurement. B1/B2 are blank. Until they aren’t, “viable log backend” is unproven on its central premise.

2.2 Why at this layer, and why now

docs/roadmap.md Phase 3 names ourios-querier alongside ourios-bench (shipped). RFC 0006 §1 explicitly routes B1/B2 here. The dependency it needs — ourios-parquet’s reader contract (RFC 0005 §3.9 reader contract) — already exists, so the querier can be built and benchmarked in parallel with the WAL/receiver ingest path. It is the highest-information work available: it converts the project’s biggest open question into a measurement.

2.3 Why an RFC and not just a crate

A new crate is an architectural commitment (CLAUDE.md §7), it realises pillar #3 (§5.1), and it owns hazard §4.6 (no DataFusion leakage to users). The DSL→plan→execution boundary and the B1/B2 acceptance criteria need pinning before code so the bench gates are testable contracts rather than retrofitted numbers.

3. Background — what the querier is and is not

3.1 Is

A library crate exposing a Querier that accepts an RFC 0002 query AST, compiles it to a DataFusion LogicalPlan, registers the RFC 0005 Parquet files as a partitioned ListingTable (or a custom TableProvider when partition pruning needs it), executes via DataFusion’s physical planner, and streams typed result rows back.

3.2 Is not

  • Not the DSL parser/surface — that is RFC 0002. The querier consumes the AST RFC 0002 produces.
  • Not a SQL endpoint. DataFusion’s SQL frontend, LogicalPlan types, and arrow/datafusion errors never cross the public API (hazard §4.6). The public surface speaks logs-DSL and Ourios result/error types.
  • Not the storage format. It reads the RFC 0005 contract; it does not define it.

4. Proposed design

4.1 Crate shape

crates/ourios-querier/, #![deny(unsafe_code)], workspace lints. Public surface (sketch — names provisional):

#![allow(unused)]
fn main() {
pub struct Querier { /* object-store handle, session ctx, config */ }
pub struct QueryRequest { tenant: TenantId, query: ParsedQuery, /* time bounds, limit */ }
pub struct QueryResult { /* typed rows + stats: rows, row_groups_scanned, row_groups_pruned, bytes_read */ }
pub enum QueryError { /* no datafusion/arrow types leaked */ }
impl Querier {
    pub async fn run(&self, req: QueryRequest) -> Result<QueryResult, QueryError>;
}
}

4.2 DSL → LogicalPlan lowering

RFC 0002 §5.5 fixes the compilation target as a DataFusion LogicalPlan for both syntax branches. The querier owns that lowering: predicates → Expr filters; template references → template_id equality/IN; time bounds + tenant → partition-key filters (Hive partitioning per RFC 0005). The lowering is the only place DataFusion types appear; they are an implementation detail behind run.

4.3 Predicate pushdown (the thesis mechanism)

Pushdown is scoped to exactly the columns RFC 0005 indexes. Its §3.3 query-consumer-absence rule fixes the Phase 3 B1/B2 pushdown keys as template_id, tenant_id, and time_unix_nano, and §3.6 deliberately gives params list values no page index and no bloom filter (per-row entropy too high). The querier therefore relies on:

  • Partition pruning: tenant_id and time partition keys filter whole directories before any file is opened.
  • Row-group skipping: min/max statistics on template_id, time_unix_nano, and severity let DataFusion drop row groups whose stats can’t satisfy the predicate.
  • Bloom filter / page index on template_id (RFC 0005 §3.6 writer policy) for high-selectivity template-exact equality (B2).
  • Param predicates are not row-group-prunable under the current RFC 0005 format — they apply as post-scan DataFusion filters over the rows the above pruning leaves. They benefit from template/time pruning narrowing the scan, but a param value alone skips no row groups; param-level pruning would need a future RFC 0005 §3.6 storage amendment (§8).
  • The querier configures the DataFusion session so the above are enabled, and surfaces row_groups_pruned / bytes_read in QueryResult stats so B1 can assert pruning actually happened.

4.4 No-leakage boundary (hazard §4.6)

A boundary test asserts the public API’s types are Ourios-owned: no datafusion::* / arrow::* / SQL strings in signatures or error Display. DataFusion is a pub(crate) dependency.

5. Acceptance criteria

Given/When/Then, ids greppable from tests. These realise the RFC 0006 B1/B2 gates as querier-level contracts.

  • RFC0007.1 — B1 predicate pushdown prunes row groups [thesis]

    • Given a corpus partitioned across many row groups where a target template_id lives in a known minority of them
    • When a template-exact query runs
    • Then the pruned fraction row_groups_pruned / (row_groups_scanned + row_groups_pruned) (both QueryResult stats fields) exceeds a floor (e.g. ≥ 80% on the bench corpus)
    • And bytes_read is sub-linear in corpus size for fixed result size.
  • RFC0007.2 — B2 template-exact latency scales with result, not corpus [thesis]

    • Given the same query against corpora of increasing size with the result-set size held ~constant
    • When each is executed
    • Then median latency is bounded by result size, not corpus size (the inverted-index-collapse claim, docs/benchmarks.md B2) — measured by criterion across the corpus/otel-demo-v* series.
  • RFC0007.3 — no DataFusion/SQL leakage [§4.6]

    • Given the public API
    • When a query errors or returns
    • Then no datafusion/arrow/SQL type appears in any public signature or error message (compile-/string-level boundary test).
  • RFC0007.4 — forward-compatible reads [§3.5]

    • Given Parquet files with unknown columns (future schema) or missing optional columns (old schema)
    • When queried
    • Then results honour RFC 0005 §3.9 reader-contract defaults without error.
  • RFC0007.5 — tenant isolation [§3.7]

    • Given multi-tenant data
    • When a query for tenant T runs
    • Then no row from another tenant can appear, enforced at the partition-prune layer (a query without a tenant is a usage error, not a cross-tenant scan).

6. Testing strategy

Mapped to CLAUDE.md §6.2:

  • Unit — DSL→LogicalPlan lowering (RFC0007.1/.4/.5 plan shape), colocated.
  • Boundary test — RFC0007.3 no-leakage (trybuild/string assertion).
  • Integration — run queries over fixture Parquet from the ourios-parquet writer; assert row_groups_pruned/bytes_read (RFC0007.1) and tenant isolation (RFC0007.5).
  • Bench (criterion) — RFC0007.2 latency-vs-corpus-size across corpus/otel-demo-v*; wired into ourios-bench as the B1/B2 gates, closing the RFC 0006 §1 deferral.
  • Property (proptest) — lowering total over the RFC 0002 AST (no panic; tenant + time bounds always present in the plan).

7. Alternatives considered

  • Expose DataFusion SQL directly (no logs DSL). Cheapest to build — register the tables, hand users SQL. Rejected: it violates hazard §4.6 (no DataFusion/SQL leakage), couples the user-facing query contract to an implementation dependency, and forfeits the logs-shaped ergonomics RFC 0002 exists to provide.
  • Write a bespoke vectorised execution engine. Maximum control over pushdown. Rejected: it contradicts pillar #3 (CLAUDE.md §2 — “we do not write a vectorised execution engine”) and the “off-the-shelf parts plus thin glue” thesis (§1). DataFusion already does row-group skipping from Parquet stats.
  • Lucene/Tantivy-style inverted index alongside Parquet. A second index structure for term lookups. Rejected for v1: the thesis is that template structure + Parquet statistics collapse the inverted index into the columnar store (§1) — adding a separate index pre-judges that the collapse fails, which is what B1/B2 are meant to test. Revisit only if B1/B2 fail.
  • Defer the crate until RFC 0002’s DSL branch is decided. Rejected: the execution layer (lowering target, pushdown, B1/B2 measurement) is branch-independent (RFC 0002 §5.5), and B1/B2 are the project’s largest unmeasured risk — building the branch-independent half now buys the thesis signal soonest. The parser integration landed once RFC 0002 §3 resolved (Branch B); see §8.

8. Open questions

  • RFC 0002 §3 resolved (Branch B, #143) and the parser integration landed (#145–#154; RFC 0002 is green). The execution layer here was branch-independent throughout, as planned.
  • ListingTable vs a custom TableProvider — does partition pruning over object storage need the custom provider, or does the listing table’s pruning suffice?
  • Param-predicate pushdown is out of scope under the current format (RFC 0005 §3.6 gives params no index/bloom). If param predicates ever need row-group pruning, that’s a future RFC 0005 §3.6 storage-format amendment (add index/ bloom to selected param columns — selectivity vs file-size cost), not a querier-side policy decision.
  • Streaming vs materialised results in QueryResult (large result sets); pagination surface.
  • Object-store caching / footer-cache policy for repeated queries — affects B2 measurement methodology.
  • Async runtime + concurrency model for the querier role of the server binary (RFC 0003 sibling).

9. References

  • CLAUDE.md §1 (thesis), §2 pillar #3 (DataFusion), §4.6 (DSL/no leakage hazard), §3.5 (schema evolution), §3.7 (multi-tenancy), §7 (new crate).
  • RFC 0002 — query DSL (the syntax this executes; §5.5 plan target).
  • RFC 0005 — Parquet storage (the reader contract this queries).
  • RFC 0006 — bench harness (defers B1/B2 here; the corpus series).
  • docs/benchmarks.md B1/B2 (the thesis-gate definitions).
  • docs/roadmap.md Phase 3.

RFC 0008 — Write-ahead log


rfc: 0008 title: Write-ahead log — durable buffer between OTLP receiver and Parquet writer status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-05-29 supersedes: — superseded-by: —

RFC 0008 — Write-ahead log

Status note. accepted (2026-06-14, maintainer sign-off — the terminal ladder status per docs/rfcs/README.md). Reached green 2026-06-13 (criteria below); validated is vacuous for the WAL (its pillar touches no thesis gate — see the closing paragraph), so the maintainer advances it directly from green to accepted. The docs/verification.md §3 / docs/rfcs/README.md ladder defines green as all §5 acceptance criteria pass. Every RFC0008 §5 arm (.1–.10) now has a live, passing test — no #[ignore]/unimplemented!() acceptance stubs remain under crates/ourios-wal/tests/ (the encode_audit_event free fn in lib.rs is still unimplemented!(), but that is the §9 AuditEvent serde-format deferral, not a §5 acceptance criterion): .1 wal-before-ack, .2 crash-recovery completeness (the real-SIGKILL CI gate), .3 recovery non-amplification, .4 torn-write heal, .5 corruption (all five reasons), .6 segment rotation, .7 checkpoint + durable sidecar, .8 batched-fsync group commit, .9 bounded wal_unflushed_bytes, .10 the startup recovery driver. Landed across #123/#126 (recovery), #185–#187 (snapshot restore), #188 (rotation + §6.9 cadence), #189 (this spec’s audit-deferral amendment), #190 (the .1/.3/.4/.5/.9 arms), and #191 (.8).

One deferral, not a gap. RFC0008.5’s corruption audit event is deferred to a system-scoped-audit follow-up (§9, 2026-06-13 amendment) — the audit stream is tenant-partitioned and WAL corruption has no tenant. The arm’s load-bearing halves (structured RecoveryError + halt-all-segments) are green and corruption stays loud (wal_corrupt_frames_total); only the durable queryable record is postponed.

validated is vacuous here; accepted is the maintainer’s call. The ladder’s validated stage gates on thesis-gates in benchmarks.md §7 (compression / query-latency / reconstruction). The WAL is a durability buffer — its pillar touches no thesis gate — so the validated condition (“every thesis-gate the RFC’s pillars touch passes”) is vacuously satisfied now that §5 is green. The WAL never self-promoted through the vacuous validated stage; the terminal accepted flip was the maintainer’s call per docs/rfcs/README.md, granted 2026-06-14.

How to read this document. Sections §§1–4 are the design contract — the what and the why. §5 lists the normative Given / When / Then scenarios — the contract. §6 is the precise specification the ourios-wal crate is implemented against. §7 records the alternatives we evaluated and rejected. §8 maps each §5 scenario to a test technique and a test file. §9 holds the open questions still up for debate.

1. Summary

Ourios introduces an ourios-wal crate that owns the on-disk write-ahead log between the OTLP receiver and the rest of the ingest pipeline. Every accepted OTLP batch lands as one length-prefixed, CRC-validated frame in an append-only segment file on local disk; the receiver acknowledges only after the batch is durably fsync’d, satisfying CLAUDE.md §3.4 WAL-before-ack. Segments rotate by size or time (whichever first); a recovery scanner replays surviving frames through the normal ingest pipeline on restart; and a checkpoint mechanism lets the Parquet writer signal which records are durably on object storage so the corresponding WAL segments can be deleted. Replication is explicitly out of scope — when it lands, it is in addition to the WAL, not instead of it (CLAUDE.md §3.4).

2. Motivation

2.1 The §3.4 invariant has no implementation

CLAUDE.md §3.4 pins WAL-before-ack as one of the non-negotiable invariants (“Ingester acknowledges an OTLP batch only after it has been durably written to the WAL. … No in-memory-only acks, ever.”). docs/hazards.md H3 carries the matching hazard. No crate currently implements it. RFC 0003 §6.5 calls the contract out as a hard dependency (“the receiver itself is post-MVP per roadmap.md §5; … cannot be enabled until ourios-wal lands, and there is no MVP code path that acks a network request before durability”), but leaves the WAL itself unspecified. This RFC is that specification.

2.2 The receiver is blocked on this

RFC 0003 (OTLP gRPC + HTTP receiver) is at drafted and will stay there until §5’s acceptance criteria can credibly assert the WAL-before-ack sequence — which requires the WAL to exist. Landing ourios-wal therefore unblocks RFC 0003’s progression to specified, then implementation, then a real “telemetrygen → ourios-receiver → measure” live-services test path. The bench’s PR-K2 file-loader path (RFC 0006 §3.1) remains the MVP route in the meantime; the WAL is what lets ingest happen over the wire.

2.3 Roadmap context

docs/roadmap.md §5 lists the WAL as post-MVP. This RFC does not move it into the MVP; it specifies the post-MVP implementation. The MVP corpus path (file loader → miner → Parquet writer) does not use the WAL — it bypasses the receiver entirely, which is precisely why the bench works today without the WAL.

3. Background — what we are and aren’t building

3.1 What a WAL is in this context

A write-ahead log is the canonical pattern from durable storage systems (PostgreSQL, RocksDB, LMDB, Kafka): every durability-relevant change is appended to a strictly-sequential log on stable storage before it is acknowledged or applied to the live data structures. On crash, the system replays the log to reach a consistent post-crash state. Two properties make WALs suitable for the §3.4 invariant:

  1. Sequential append + batched fsync amortises the disk sync cost across many small writes — orders of magnitude cheaper than per-record fsync, with the durability still bounded by the batch latency.
  2. Strict ordering lets recovery be a forward scan: read frames left-to-right and replay each. No log-side rewrite, no merge.

We are not building Raft / Paxos / Chubby. The WAL is a single-writer single-node component; replication is CLAUDE.md §3.4’s explicit non-goal at this layer.

3.2 What goes into the WAL

The WAL carries two frame kinds, distinguished by a 1-byte discriminator (§6.2.2):

  • FrameKind::OtlpBatch — payload is the OTLP protobuf bytes the wire delivered (ExportLogsServiceRequest), verbatim. Per-batch (not per-record) granularity matches the §3.4 ack boundary; one fsync gates one ack. The payload is the same bytes the receiver decoded, so recovery is “re-run the decode + fan-out pipeline” (§6.6) rather than “rehydrate per-record proto from a custom format.” Tenant fan-out (RFC 0003 §6.3) stays on the receiver’s side of the WAL.
  • FrameKind::AuditEvent — payload is a single ourios_core::audit::AuditEvent (the TemplateWidened / TemplateTypeExpanded / TemplateWideningRejectedDegenerate shape RFC 0001 §6.4 defines). RFC 0001 §6.7 and RFC 0005 §3.7 both pin “audit events route through the same WAL path as data records;” RFC 0005 §3.7 explicitly defers the contract to this RFC (“a contract that lands with the post-MVP ourios-wal crate; until then audit-event durability is in-memory and the corpus bench accepts that”). The frame- kind discriminator is how that contract is honoured. The exact AuditEvent byte encoding is §9 open (“AuditEvent has no serde derives today; the encoder lands alongside them”).

The receiver flow RFC 0003 §6.5 already pins for the OTLP-batch kind: decode batch → WAL append → fsync → ack on the critical path; tenant fan-out + mining + Parquet writes happen post-ack. The audit-event kind is appended during the post-ack mining work — under the §6.4 audit-ordering barrier: audit-event frames for record R must be append-ed and sync-ed before the data Parquet row group containing R becomes durable on object storage. RFC 0001 §6.4 spells this out as a normative requirement on this RFC (“a data record carrying template_version = V must not become durable before every audit event justifying the leaf’s progression to V is durable”); §6.4 below pins the WAL-side discipline that satisfies it.

3.3 What does not go into the WAL

  • Miner state. The Drain tree is reconstructed by replaying the WAL records through the miner; we do not snapshot it. Snapshotting becomes interesting if recovery time on a hot-template corpus exceeds the operator’s patience; that is §9 open question.
  • Parquet writer state. The Parquet writer’s open row groups are not durable; on crash the in-flight row group is lost and rebuilt from the WAL. Atomic-publish (RFC 0005 §3.7 / RFC0005.2) means closed row groups are durable and need no replay.
  • Query state. The querier is read-only against object storage; it has no recovery concern at this layer.

4. Background — existing Rust durability ecosystem

4.1 What we surveyed

Rust’s WAL crate landscape divides into two camps:

  • Heavy embedded databases (sled, redb, fjall, rocksdb via FFI). These give us a WAL for free but also a B-tree, MVCC, transaction model, and a persistent key-value abstraction — none of which the ingest path consumes. Carrying them adds dependency surface area (sled is still pre-1.0 with known durability bugs; rocksdb adds a C++ link and forecloses pure-Rust toolchains) for an API we do not use.
  • Focused WAL crates (okaywal, wal, vlog). Smaller surface, but each makes a different opinionated choice (multi-writer arbitration, async vs sync I/O, segment format) and the format becomes our wire-compat boundary the same way it would for a hand-rolled implementation.

4.2 Why hand-roll

The component we need is small (single writer, append-only, length-prefixed framing, CRC, batched fsync, segment rotation, linear recovery, checkpoint-driven truncation — call it 600 lines of careful Rust). The wire format is what we have to be careful about; the surrounding code is mechanical. A focused crate would impose its own format conventions on us at the same boundary, so the dependency saves implementation time but not design time. We hand-roll, keep the format under our direct control, and §7 records the focused-crate alternatives we considered.

5. Acceptance criteria

Scenario RFC0008.1 — WAL-before-ack [§3.4]

  • Given a fake receiver wired to a real Wal (opened with defaults) and a single OTLP ExportLogsServiceRequest batch ready to acknowledge
  • When the receiver runs its accept path
  • Then the 2xx (gRPC OK) response is emitted only after the Wal::sync call returns Ok(_) — measured by an AtomicBool set after sync returns Ok(_) (not inside sync; the probe inside sync would already be true mid-call, which would let an ack racing the sync trivially pass). The ack-emit path asserts the flag is true before sending the response, and the receiver’s pre-sync points (decode, tenant_derive, append) all assert the flag is false
  • And wal_unflushed_bytes is non-zero between append and sync, and zero after sync returns
  • And a fault injected at append (returns AppendError) suppresses both the sync and the ack (no record of the batch ever reaching disk or the client)
  • And a fault injected at sync (returns SyncError) suppresses the ack and surfaces the error to the receiver per §9’s “what does the receiver do when sync returns error?” open question — the test asserts that the ack does not fire, the specific receiver behaviour beyond that being out of this RFC

Scenario RFC0008.2 — Crash-recovery completeness [§3.4 / H3]

  • Given a cargo test harness that forks a child running a receiver wired to a real Wal, with the parent controlling the request rate and a deterministic record generator
  • When the parent sends SIGKILL between Wal::append and Wal::sync (first arm), and between Wal::sync and the simulated ack (second arm)
  • Then on restart, Wal::replay produces every frame the child had fsync’d before the kill (no fsync’d frame is lost — the H3 invariant)
  • And any frame whose append completed but sync did not falls into one of three buckets, per §6.6: replayed as a normal frame (complete + CRC-valid → the kernel post-mortem flush left it on disk; legitimate but un-acked at the client), surfaced via the §6.6 step 4 newest-segment torn-tail truncate (partial header / partial payload), or surfaced as RFC0008.5 corruption (complete frame whose CRC32-C doesn’t match — even on the newest segment, a CRC mismatch is corruption, not truncation). The test does not assert “exactly the fsync’d frames” — that would be stronger than SIGKILL semantics admit, since process death does not discard dirty page-cache data
  • And FrameKind::AuditEvent frames the miner had appended-and-fsync’d before the kill survive identically (per the RFC 0005 §3.7 / RFC0005.2 audit-durability contract this RFC implements via §6.4)
  • And this scenario’s test runs on every PR; failure blocks merge

Scenario RFC0008.3 — Crash-recovery non-amplification [H3]

  • Given a fixture corpus of N ∈ {1, 4, 16} segments each at the default wal_segment_size_bytes (128 MiB) of representative OtlpBatch payloads, captured by a criterion benchmark
  • When Wal::replay runs over the directory and the benchmark measures wall time
  • Then total wall time scales O(N) in segment count, within a ±20 % tolerance to allow for warm-cache vs cold-cache differences across the runs
  • And wal_syncs_total does not advance during replay (replay is read-only on closed segments; the §6.6 step 4 heal fsync only fires on a torn-tail newest segment, which the benchmark fixture deliberately omits)
  • And no per-record audit event is emitted by the recovery driver itself (the happy path emits none; the single RFC0008.5 corruption-path audit event is deferred per the 2026-06-13 §5 amendment, so today the happy path and the corrupt path alike emit zero)

Scenario RFC0008.4 — Torn last frame on the newest segment [H3]

  • Given a segment directory where the lexicographically- greatest segment file has a partial frame at its tail — either a partial 12 B frame header, or a valid header followed by a short payload (truncated at a random offset inside the payload bytes)
  • When Wal::replay walks that directory in order
  • Then the partial bytes are treated as clean truncation: the scan stops on this segment, no error is returned, no audit event is emitted
  • And §6.6 step 4 heals the segment: ftruncate(2) to the last valid frame boundary recorded during the scan, fdatasync the segment, fsync the parent directory — the truncated bytes are physically gone from disk
  • And the post-recovery Wal::append lands at the frame boundary preceding the torn write
  • And a partial header / payload on any older (non- newest) segment is not treated as truncation — it surfaces as RFC0008.5 corruption, because older segments are post-rotation and their final fsync completed; a torn tail there is file-system corruption rather than a legit rotate-pending-fsync. This newest-vs-older fork is the scenario’s central pin

Scenario RFC0008.5 — Corrupt frame [H3]

  • Given a segment with one of: a CRC32-C mismatch on a complete frame, an unknown kind byte (> 0x03 since the RFC 0046 amendment; > 0x02 before it), a non-zero _pad, an oversize len (> MAX_FRAME_BYTES), or a torn header/payload on a closed (non-newest) segment
  • When Wal::replay walks the segment containing the corruption
  • Then the scan emits a structured RecoveryError carrying the segment UUIDv7 + byte offset of the corrupt frame
  • And recovery stops scanning all segments — the high-water-mark logic depends on contiguous ordering and a corrupt frame invalidates everything past it (an operator must intervene before the receiver opens its listeners)
  • And the test exercises all five corruption sub-cases in separate arms (crates/ourios-wal/tests/corruption.rs)

Amendment 2026-06-13 (corruption audit event deferred). The original criterion also required a WalRecoveryCorruption { segment, offset, reason } audit event appended to the audit-event Parquet writer’s queue. That half is deferred to a follow-up (§9): the RFC 0005 audit stream is tenant-partitioned (CLAUDE.md §3.7) and WAL corruption is a system event with no tenant, so the durable forensic record needs a system-scoped-audit design that does not yet exist. Deferring it costs no safety — corruption is already loud: replay returns the structured RecoveryError and recovery halts, so the receiver refuses to open its listeners and an operator must intervene. Only the queryable record is postponed, not the failure response. RFC0008.5 is satisfied by the structured-error + halt-all-segments halves; wal_corrupt_frames_total (§6.8) already counts occurrences for the operator dashboard.

Scenario RFC0008.6 — Segment rotation

  • Given an open Wal at default wal_segment_size_bytes (128 MiB) and wal_segment_age_secs (600 s)
  • When an append would push the current segment past the size cap (size-cap arm), OR wal_segment_age_secs elapses since the current segment’s header was written (time-cap arm), whichever fires first per §6.5
  • Then the current segment is closed cleanly: the final fdatasync on the segment + fsync on the parent directory complete before any frame is appended to the new segment
  • And a fresh segment is opened with a new UUIDv7 filename, the 24 B segment header is written, and the subsequent append lands in the new segment — not the old one
  • And no frame is dropped or duplicated across the rotation boundary (asserted by counting frames before / after rotation in the test)
  • And a rotation whose final fsync returns an error surfaces as a hard AppendError: subsequent append calls return the same error, the receiver refuses to accept new batches, and the operator must intervene

Scenario RFC0008.7 — Checkpoint-driven truncation + durable sidecar

  • Given a Wal that has accumulated K segments and a simulated Parquet writer that has durably committed records up to WalOffset X
  • When Wal::checkpoint(X) runs followed by a housekeeping pass
  • Then the <wal_root>/CHECKPOINT sidecar is atomically written (CHECKPOINT.tmpfsyncrename → parent- dir fsync), containing the 32 B record {magic = "OWCK", version = 1, flags = 0, segment_uuid (16 B), byte (8 B LE)}
  • And every segment whose highest frame offset is ≤ X is unlink’d; segments straddling X are kept
  • And wal_disk_bytes drops by the sum of the deleted segments’ sizes

Crash-between-checkpoint-and-housekeeping arm:

  • Given the same setup
  • When SIGKILL fires between Wal::checkpoint(X) returning and the housekeeping pass starting
  • Then the CHECKPOINT sidecar survives the crash (the atomic-write + fsync above is what guarantees this)
  • And on restart, Wal::last_checkpoint() returns Some(X), and the recovery driver suppresses every frame at append-offset ≤ X on its Parquet path — records already published are not re-fed and not duplicated on the data side (replay is at-least-once; dedup is explicitly out of scope, so this suppression is the only mechanism that prevents the dup). replay itself still delivers those frames — the miner may need them when its snapshot lags (2026-06-12 amendment; suppression is per consumer, see §6.6)

Surviving-segments / no-global-counter arm:

  • Given a checkpoint has advanced past several older segments and housekeeping has unlinked them
  • When a fresh Wal::open is followed by a new Wal::checkpoint(Y) where Y > X
  • Then the operation proceeds against the surviving UUID-named files without reconstructing a global offset counter — the (segment_uuid: UUIDv7, byte) WalOffset representation makes this trivially well-defined

Retain-floor arm (2026-06-12 amendment):

  • Given a checkpoint at X and a retain floor S < X (a miner snapshot lagging the Parquet writer)
  • When Wal::housekeeping(Some(S)) runs
  • Then only segments whose highest frame offset is ≤ both X and S are unlinked — segments holding frames in (S, X] survive, because the miner snapshot has not captured them yet and truncating them would leave a hole in the restored tree (template drift, hazard #5)
  • And a later pass with an advanced floor S' ≥ X reclaims them — the floor delays truncation, never cancels it

Scenario RFC0008.8 — Batched-fsync knob

  • Given the group-commit CommitCoordinator over a Wal, with wal_batch_window_ms set in turn to {10, 100, 1000}
  • When a batch of commits is fired together under a paused virtual clock — so the only time that elapses is the coordinator’s own window timer; the real fsync runs but does not advance the virtual clock
  • Then each commit’s ack latency equals the configured window exactly (deterministic, no scheduler/instrumentation jitter) — the window is the dominant contributor, not per-record fsync cost (which would ack at ≈ 0 independent of the window). This virtual-clock formulation supersedes the original wall-clock-P99-within-±30 % one, which was non-deterministic on shared and instrumented CI runners.
  • And wal_syncs_total advances at a rate ≈ (steady-state arrival-rate / per-window batch size) rather than per-record (so appends_per_sync is well above 1 in every setting)
  • And the §3.4 invariant holds across all three: no ack precedes the Wal::sync whose returned offset covers the corresponding frame

Scenario RFC0008.9 — wal_unflushed_bytes is bounded [H3 detection]

  • Given a Wal under a randomised arrival pattern (proptest-driven mix of small frames + max-sized 16 MiB frames + small frames, varying interleavings)
  • When the test samples wal_unflushed_bytes at every append and sync boundary across the run
  • Then the metric never exceeds 2 × wal_segment_size_bytes at any sample, regardless of arrival rate
  • And the §6.9 tunables-table lower bound on wal_segment_size_bytes (≥ MAX_FRAME_BYTES + segment_header + frame_header, validated as ≥ 17 MiB) is what makes the bound achievable: a max-sized frame always fits inside a single segment, so the in-flight batch can never straddle three segments at once
  • And a configuration with wal_segment_size_bytes below the lower bound is rejected at Wal::open time (OpenError::InvalidConfig) — the validation runs on every §6.9 tunable, not just this one

Scenario RFC0008.10 — Startup recovery driver: per-consumer horizons (2026-06-12 amendment)

  • Given a WAL whose frames span a CHECKPOINT at X and a durable miner snapshot whose recorded high-water mark is S ≥ X (the steady-state ordering: snapshots are taken at segment rotation, the Parquet checkpoint lags)
  • When the ingester starts
  • Then recovery runs to completion before the network listeners open (no live append interleaves with replay)
  • And suppression is per consumer, by each delivered frame’s offset: the Parquet path consumes only frames > X (frames ≤ X are already on object storage), the miner only frames > S (the restored snapshot already covers (X, S], so re-feeding them would double-apply)
  • And the post-recovery miner tree state is equal to a control tree built by ingesting the same records from scratch (the RFC 0001 §3.5.3 equivalence, observed through this driver)

Lagging-snapshot catch-up arm (S < X):

  • Given the same WAL but a snapshot whose mark S is below the checkpoint X, with the (S, X] frames retained on disk by the §6.7 floor
  • When the ingester starts
  • Then the miner consumes the retained (S, X] frames (closing its state gap — the floor’s payoff) while the Parquet path suppresses them (already published), and both consume frames > X
  • And the post-recovery tree again equals the from-scratch control

Snapshot-cadence arm:

  • Given live ingest crossing a segment-rotation boundary
  • When the rotation completes
  • Then a per-tenant snapshot write is triggered, recording the rotation-point high-water mark (RFC 0001 §6.9 cadence), and the housekeeping retain floor advances to the new snapshot’s mark once the write is durable

6. Proposed design

6.1 Overall shape

Amendment 2026-06-12 (snapshot-restore support). Four changes land together with RFC 0001 §6.9’s v2 restore (the same-day amendment there is the other half of this design): FrameSink::consume now receives the frame’s WalOffset, and replay delivers every well-formed surviving frame — suppression moves out of replay and into the recovery driver as per-consumer replay horizons (the Parquet path consumes frames above the checkpoint, the miner frames above its snapshot’s high-water mark — §6.6; an in-replay skip would make a lagging snapshot’s retained frames undeliverable); the checkpoint offset is exposed to the driver as last_checkpoint; the housekeeping pass becomes an explicit API taking an optional retain floor so truncation never outruns the miner snapshot (§6.7); and replay takes &mut self, matching the landed implementation (§6.6 step 4 heals the newest segment in place).

ourios-wal is a single Rust crate exposing one struct Wal whose public API is:

impl Wal {
    pub fn open(config: WalConfig) -> Result<Self, OpenError>;
    pub fn append(&mut self, kind: FrameKind, payload: &[u8])
        -> Result<WalOffset, AppendError>;
    pub fn sync(&mut self) -> Result<WalOffset, SyncError>;
    pub fn checkpoint(&mut self, durable_to: WalOffset)
        -> Result<(), CheckpointError>;
    pub fn housekeeping(&mut self, retain_floor: Option<WalOffset>)
        -> Result<(), HousekeepingError>;
    pub fn last_checkpoint(&self) -> Option<WalOffset>;
    pub fn replay(&mut self, sink: &mut impl FrameSink)
        -> Result<(), RecoveryError>;
    pub fn metrics(&self) -> WalMetrics;
}

pub enum FrameKind {
    OtlpBatch  = 0x01,
    AuditEvent = 0x02,
    // RFC 0046 amendment (2026-08-17): the acknowledged export
    // prefixed by its tenant — `u16 LE len ‖ tenant ‖ protobuf`.
    TenantOtlpBatch = 0x03,
}

/// Opaque, totally-ordered position of a frame in the
/// WAL. Internally `(segment_uuid: Uuid /* UUIDv7 */,
/// byte_offset_in_segment: u64)`; ordering is
/// lexicographic on the pair, which means **UUIDv7's
/// chronological sort gives global monotonicity** even
/// after housekeeping deletes older segments. A pure
/// `u64` representation would be ambiguous after deletion
/// (no global offset survives reconstruction from the
/// UUID-named files), so the pair is the durable form.
pub struct WalOffset { segment: Uuid, byte: u64 }

pub trait FrameSink {
    fn consume(&mut self, offset: WalOffset, kind: FrameKind,
        payload: &[u8]) -> Result<(), RecoveryError>;
}

Semantics:

  • append writes a frame to the current segment and returns the post-append offset. The frame is not yet durable; the caller (the receiver) accumulates appends in a micro-batch and calls sync at the §3.4 batch boundary.
  • sync fsyncs the current segment and returns the highest offset that is now durable. The receiver gates its acks on this returning successfully.
  • checkpoint records “records ≤ durable_to are on object storage; segments wholly below this offset may be reclaimed.” Called by the Parquet writer’s atomic-publish callback. Reclaim happens on the housekeeping pass, not in-line.
  • housekeeping reclaims disk: unlinks every segment whose highest frame offset is ≤ the checkpoint and, when retain_floor is Some(floor), ≤ floor (§6.7). The caller (the ingester) runs it periodically — every wal_housekeeping_secs — passing the latest durable miner snapshot’s high-water mark as the floor; None means no snapshot consumer exists and the checkpoint alone governs.
  • last_checkpoint exposes the CHECKPOINT sidecar’s offset (None pre-first-checkpoint). The recovery driver reads it once at startup as the Parquet-side suppression horizon (§6.6).
  • replay is recovery: walk every surviving segment in chronological order, hand each well-formed frame — including frames at or below the checkpoint that a straddling or floor-retained segment holds — to sink along with its WalOffset. The offset lets the recovery driver suppress per consumer (Parquet above the checkpoint, miner above its snapshot mark — §6.6); replay itself filters nothing, because an in-replay skip would make a lagging snapshot’s retained frames undeliverable. Used by the ingester at startup before opening network listeners. Returns when the scan completes; the caller then begins serving live traffic.

The crate ships no executable. The receiver and the recovery driver both live in ourios-ingester (RFC 0003).

6.2 Segment layout

Segments live under <wal_root>/<UUIDv7>.wal, where wal_root is the local-disk path configured by the operator. UUIDv7 is the same sortable-by-creation identifier RFC 0005 §3.4 already uses for Parquet files; listing the directory in sorted order yields chronological order, which the recovery scan relies on.

A segment is append-only: the writer holds it open until rotation; readers (recovery) open snapshots read-only — except the §6.6 step 4 heal path, which reopens the newest segment writable to ftruncate(2) a torn tail back to the last valid frame boundary, then closes and reopens read-only before any further reads. The writable window is narrow (one ftruncate + fdatasync + parent-dir fsync) and only ever the newest segment. The segment file format is a header followed by zero or more frames:

| segment-header (24 B)            |
| frame-0 | frame-1 | … | frame-N  |

6.2.1 Segment header

| magic: 4 B = b"OWAL"             |
| version: u16 = 1                 |
| flags: u16 = 0 (reserved)        |
| segment-uuid: 16 B (UUIDv7)      |  → 24 B total

The magic + version pair lets the recovery scanner reject foreign files (e.g. a sibling *.lock) early and pins the format version for future migrations. segment-uuid is the same UUID that appears in the filename; carrying it inside the file too means a mv that mangles the name doesn’t make the file unreadable.

6.2.2 Frame format

| len:   u32_le   (payload length, excluding header + CRC)              |
| kind:  u8       (0x01 = OtlpBatch, 0x02 = AuditEvent, 0x03 = TenantOtlpBatch; reserved >0x03) |
| _pad:  [u8; 3]  (reserved, MUST be zero, validated on read)           |
| crc32: u32_le   (CRC32-C over kind || pad || payload, Castagnoli)     |
| payload: [u8; len]                                                    |

Per-frame header = 12 B (4 + 1 + 3 + 4). len MUST be ≤ a configured maximum (default MAX_FRAME_BYTES = 16 MiB); larger appends are rejected at Wal::append time before any bytes are written, so a malformed call can’t grow the segment past the cap. Unknown kind values surface as a structured corruption error per RFC0008.5 — the reserved range is how the format admits future frame kinds without a version bump.

CRC choice is CRC32-C (Castagnoli, polynomial 0x1EDC6F41) — the same polynomial the Parquet writer already uses on row groups, so we share one implementation and one SIMD-acceleration path on modern x86 / aarch64. The CRC covers kind || _pad || payload (not the len header, which is implicitly validated by the read returning the right number of bytes; not its own bytes). This is the same shape Kafka uses for record-batch CRCs.

6.2.3 Payload encoding

Payload encoding is keyed on kind:

  • FrameKind::OtlpBatch (0x01, pre-RFC 0046): payload is the OTLP ExportLogsServiceRequest protobuf bytes the receiver decoded — verbatim, no re-encoding — with no tenant; replay used to re-derive one from Resource.attributes (RFC 0003 §6.3). Since RFC 0046 nothing derives a tenant, so the current recovery driver refuses 0x01 frames as unsupported for replay (a SinkRejected naming the offset and the remedy — drain or delete the WAL under the previous version), never as corruption: the kind byte stays valid on the wire.
  • FrameKind::TenantOtlpBatch (0x03, RFC 0046 §3.3): payload is u16 LE tenant byte length ‖ tenant bytes (UTF-8, 1..=256) ‖ ExportLogsServiceRequest protobuf bytes. The tenant is the out-of-band selector the export was acknowledged under; replay validates the prefix (length, bound, UTF-8) before the protobuf, then materialises every record under that tenant. A CRC-valid frame whose prefix is malformed is a SinkRejected invalid payload, not RFC0008.5 corruption. This is the only kind the receiver writes.
  • FrameKind::AuditEvent: payload is one serialised ourios_core::audit::AuditEvent. The exact serde format is a §9 open question — AuditEvent has no serde derives today (Debug, Clone, PartialEq, Eq only), and the encoder lands alongside them. Strong candidates are bincode (compact, fast, Rust-native) and serde_json (human-debuggable, already in the dep tree). The choice doesn’t affect this RFC’s frame layout; it lives entirely inside the kind = 0x02 payload bytes.

A future per-tenant WAL split would be additive (a new segment-selection layer above the same frame format), as would future frame kinds (the reserved kind range admits them without a version bump).

6.3 fsync policy

Wal::sync calls fdatasync(2) on the current segment file descriptor (or platform equivalent — FlushFileBuffers on Windows; fcntl(F_FULLFSYNC) on macOS when the operator opts into “full” durability). On a sync that follows a segment rotation (a new segment was opened since the last sync, or an older segment was unlinked by housekeeping), it also calls fsync(2) on the parent directory file descriptor — fdatasync is undefined behaviour on directories under POSIX, and fdatasync on the segment file alone does not flush the directory metadata that makes the new entry (or the unlink) durable. The two calls are distinct: fdatasync for the file’s payload + size, fsync for the directory’s entry. The implementation does not call fsync per append; the receiver is responsible for batching appends across the configured window and calling sync once per batch.

Two knobs are exposed at this layer (per CLAUDE.md §3.4 / H3); both are classified in §6.9’s WAL-tunables table:

  • wal_batch_window_ms (default 100): the receiver-side upper bound on time from first append to corresponding sync. The receiver implements this; the WAL itself is policy-free at this layer.
  • wal_segment_size_bytes (default 128 MiB): segment rotation cap (§6.5). The receiver’s sync MUST also fire whenever this cap is reached on the current segment, even if wal_batch_window_ms hasn’t elapsed — the “or segment fills, whichever first” clause.

6.4 Audit-event ordering barrier

RFC 0001 §6.4 carries the normative requirement (“WAL durability ordering of audit events”) this RFC has to satisfy: a data record carrying template_version = V MUST NOT become durable before every audit event justifying the leaf’s progression to V is durable. Crash recovery may observe [event_1, …, event_k, data_record] or any prefix thereof, but never a data record without the events that caused its version stamp. Without this, replay bumps template_version fewer times than the in-memory leaf did and surviving data records reference a version the audit stream cannot substantiate.

The WAL itself does not enforce the barrier — it provides the primitives (append, sync, the durable checkpoint of §6.7) that the consumer composes into the discipline. The consumer (the miner-side writer in ourios-ingester) is required to follow this sequence for every data record R that emits zero or more audit events E₁, …, Eₖ:

  1. Append all audit-event frames for R first: wal.append(AuditEvent, encode(E₁)) … wal.append(AuditEvent, encode(Eₖ)).
  2. Track the WAL offset returned by the last audit-event append as R’s required_audit_offset — the audit-WAL position that must be durable before R can be published. If R emits zero audit events (k=0), it has no required_audit_offset and does not contribute to the row group’s max_required_audit_offset in step 3 (max over the empty set is MIN, so the gate in step 4 is trivially satisfied for an all-zero-audit row group — common steady-state).
  3. The row group accumulating R’s MinedRecord carries a running max_required_audit_offset across its contained records (the maximum of every record’s required_audit_offset).
  4. The Parquet writer’s atomic-publish gate compares the row group’s max_required_audit_offset against the WAL’s last_synced_offset (the largest offset for which Wal::sync has returned). The publish proceeds only when max_required_audit_offset ≤ last_synced_offset; otherwise the row group is held until the next Wal::sync advances last_synced_offset past it.

The comparison is WAL offset vs WAL offset — same units. An earlier draft compared template_version (a small per-leaf integer) against an audit_durable_to position; that was a unit mismatch an implementer could honour incorrectly. The barrier is “is the durable WAL position at-or-past what this row group needs,” not “is the version label high enough.”

This composes with the OtlpBatch frames the receiver appends pre-ack: data records reach the miner via post-ack mining of an already-fsync’d OtlpBatch frame, so the data record’s ingest is already durable; the §6.4 barrier governs the second-stage publish of its post-mining representation to data Parquet. The §3.4 invariant (no ack without WAL fsync) and the §6.4 invariant (no data publish without audit-event WAL fsync) operate on different boundaries and don’t conflict.

Because audit events are emitted in the miner’s hot path, the per-record cost of “step 1 + step 3” is one append call (cheap) plus a shared fsync amortised across many records via the §6.3 batched window. The barrier therefore does not add a per-record fsync.

6.5 Segment rotation

A rotation closes the current segment, fsyncs it (the last fsync that segment ever receives), opens a new segment with a fresh UUIDv7, writes the new segment’s header, and from that point all subsequent appends land in the new segment. The critical-path cost of a rotation is one extra fsync of the parent directory so the new segment’s directory entry is durable before any frame lands in it.

Two triggers:

  • Size cap: the current segment’s file size (header + appended frames + the frame about to be written) exceeds wal_segment_size_bytes. Computed before the write so the rotation happens before the next frame’s bytes land.
  • Time cap: the current segment’s age (since its header was written) exceeds wal_segment_age_secs (default 600 = 10 min). Bounds the recovery window — a torn-write worst case touches at most one segment-age window of data.

Rotations are silent (no audit event in the steady state). A rotation that fails fsync is a hard error: the receiver must refuse further appends until an operator intervenes, because continuing would risk a frame landing in a segment whose directory entry is not durable.

6.6 Crash recovery

Wal::replay is invoked at startup, before the receiver opens its network listeners:

  1. Read <wal_root>/CHECKPOINT (per §6.7) into cp: Option<WalOffset>. A present sidecar yields Some(parsed); an absent sidecar yields None (first-run / pre-checkpoint). A sidecar that is present but invalid — wrong magic, unknown version, non-zero flags, or a size other than 32 B — is a structured corruption error that aborts recovery (the same posture as a corrupt closed segment): silently treating it as None would drop the Parquet suppression horizon and re-feed every already-published record to the data side. The operator restores or removes the sidecar knowingly; removal is an explicit acceptance of at-least-once re-publish. cp = 0 would be a synthetic offset undefined for the (segment_uuid, byte) pair, so absence is modelled as an option rather than a zero value. cp is exposed to the recovery driver as Wal::last_checkpoint() (§6.1) — it is the driver’s Parquet-side suppression horizon, not a delivery filter inside replay (2026-06-12 amendment; see step 3).
  2. List every *.wal file under wal_root. Sort lexicographically (= chronologically, per UUIDv7). The last segment in this order is the newest (the one that was open for appends at crash time); every other segment is older / closed (its rotation fsync completed before the next segment began).
  3. For each segment in order:
    1. Open read-only, verify the header (magic + version).
    2. Walk frames left-to-right. For each frame:
      • Read the 12 B frame header (len + kind + _pad
        • crc32). If EOF here:
        • Newest segment: clean-truncated tail; stop scanning this segment (RFC0008.4).
        • Older segment: structured corruption error — a closed segment cannot legitimately have a torn tail (rotation fsync’d it before the next segment started), so EOF mid-header is corruption. Emit audit event naming segment + offset; stop scanning all segments (no records past a corrupt point in the log are safe to replay, because the high-water-mark logic depends on contiguous ordering).
      • If len > MAX_FRAME_BYTES, kind is unknown (>0x02), or _pad is non-zero: structured corruption error → same all-segments-stop path.
      • Read len payload bytes. Short read here is the same newest-vs-older fork: a partial payload on the newest segment is RFC0008.4 clean truncation (rotate-pending-fsync at crash time); on any older segment it’s RFC0008.5 corruption (a closed segment with a partial payload means the segment’s final fsync was lost — file-system corruption).
      • Recompute CRC32-C over kind || _pad || payload. Mismatch → corruption (all-segments-stop).
      • Hand (offset, kind, payload) to sink.consume(offset, kind, payload)every well-formed surviving frame is delivered, including frames at or below cp that a straddling segment retains (2026-06-12 amendment; the pre-amendment design skipped them inside replay, which made the §6.7 retain floor useless — a lagging snapshot’s (S, cp] frames were kept on disk but never deliverable). Suppression is per consumer, in the driver: the Parquet path consumes only frames above cp (frames ≤ cp are already durably published — re-feeding them would duplicate on the data side, since replay is at-least-once and dedup is out of scope), and the miner consumes only frames above its restored snapshot’s high-water mark S (frames ≤ S are already folded into the snapshot — re-feeding them would double-apply, RFC 0001 §6.9 v2). The two horizons are independent and either ordering of cp and S is handled by the same rule: with S ≥ cp the miner consumes a suffix of what Parquet consumes; with S < cp (lagging snapshot) the miner additionally consumes the retained (S, cp] frames that Parquet suppresses. The sink is the recovery driver in ourios-ingester; for OtlpBatch it decodes the bytes as ExportLogsServiceRequest and runs the same tenant-fan-out + miner-ingest pipeline the live receiver does (gated per consumer as above); for AuditEvent it deserialises and reinjects into the audit-event Parquet writer’s queue, gated on the Parquet horizon.
  4. Heal the newest segment. If the newest segment’s scan stopped on a torn tail (RFC0008.4 clean truncation), ftruncate(2) the segment file to the last valid frame boundary recorded during the scan, then fdatasync the segment file and fsync the parent directory. Without this step the torn bytes persist and a second crash after a new segment opens would see them on a now-older segment, where RFC0008.5 corruption handling would reject them — contradicting RFC0008.4’s “next append starts at the frame boundary preceding the torn write.” Truncation runs only on the newest segment and only on the torn-tail path; a clean-tail (EOF aligned on a frame boundary) needs no work.
  5. After the last segment’s last frame, append-mode resumes on a new segment.

Recovery is single-threaded and synchronous. The operator-visible cost is “ingester is unavailable for the duration of recovery.” RFC0008.3 constrains recovery to O(N) wall time on N segments; the per-frame work is dominated by the miner’s tokenize cost, which is already corpus-bench- governed.

Replay is at-least-once: a batch that was WAL-fsync’d but not yet acked at crash time will be replayed on restart and re-sent through the pipeline. The client may also retry on its end. Dedup is intentionally not in scope here — RFC 0003 §9 carries it as an open question for a future amendment (likely an idempotency key on the batch envelope, persisted in the frame and checked at replay time).

6.7 Checkpoint-driven truncation

The WAL does not truncate itself based on age, count, or total bytes. It truncates only when the Parquet writer explicitly signals that records up to some WAL offset are durably on object storage:

parquet_writer.on_atomic_publish(|durable_to: WalOffset| {
    wal.checkpoint(durable_to);
});

checkpoint persists the offset to a small sidecar file — <wal_root>/CHECKPOINT — atomically (write to CHECKPOINT.tmp, fsync, rename, fsync parent dir). The file format is fixed: 4 B magic b"OWCK", 2 B version = 1, 2 B flags (reserved, zero), 16 B segment UUIDv7, 8 B little-endian byte-in-segment — 32 B total, matching the (segment, byte) WalOffset pair (§6.1). Storing the segment UUIDv7 rather than a synthetic global counter means the checkpoint survives housekeeping deletion of older segments: a deleted segment’s UUID is below any surviving segment’s UUID by definition of the rotation order, so “segments wholly below cp” is well-defined on the remaining files alone. Durability is required, not advisory: if the process crashes after checkpoint(X) but before the housekeeping pass unlinks segments wholly below X, the recovery driver must still know X so its Parquet path suppresses records already published (replay delivers every surviving frame per §6.6; replay is at-least-once and dedup is explicitly out of scope, so the driver’s suppression is the only thing standing between a surviving frame ≤ X and a data-side duplicate). On startup, the CHECKPOINT sidecar is read into Option<WalOffset> per §6.6 step 1 — present sidecar → Some(parsed); absent sidecar → None (first-run / pre-checkpoint) — and exposed to the recovery driver as last_checkpoint. When Some(offset), the driver’s Parquet path suppresses every frame at or below the offset; when None, no Parquet-side suppression applies (replay itself always delivers every surviving frame — §6.6, 2026-06-12 amendment). cp = 0 would be a synthetic offset undefined for the (segment_uuid, byte) pair, so the option semantics is the only well-defined absence representation.

A periodic housekeeping pass (every wal_housekeeping_secs, default 60; the timer lives in the ingester, the pass is the explicit Wal::housekeeping(retain_floor) API of §6.1) walks the segment directory and unlinks any segment whose highest frame offset is ≤ the checkpoint mark and, when a retain floor is supplied, ≤ the floor — i.e. truncation reclaims only segments wholly below min(checkpoint, floor). Segments that straddle either mark are kept until a later pass advances past them (no per-frame deletion; we delete whole segments).

The retain floor (2026-06-12 amendment). The checkpoint guarantees the data side: frames ≤ checkpoint are durably on object storage. It says nothing about the miner state derived from those frames — that lives in the per-tenant snapshot (RFC 0001 §6.9), whose recorded high-water mark S advances on its own cadence (per segment rotation). If a snapshot write fails or lags so that S < checkpoint, truncating up to the checkpoint would destroy the only remaining source of the miner state in (S, checkpoint]: the data is safe in Parquet, but the restored tree would re-mint template_ids for templates first seen in the gap — exactly the hazard #5 template-drift failure. The ingester therefore passes the latest durable snapshot’s high-water mark as retain_floor, and housekeeping never unlinks a frame the snapshot has not captured. None (no snapshot consumer configured) reduces to the checkpoint-only rule. The cost is bounded: the WAL retains at most the segments appended since the last successful snapshot — in the steady state (snapshot per rotation) the floor leads the checkpoint, and the rule is vacuous. The CHECKPOINT sidecar itself always records the true Parquet horizon, never the min — it is the recovery driver’s Parquet-side suppression horizon (read via last_checkpoint, §6.6), and capping it at the floor would re-feed already-published records to Parquet. The floor governs disk reclamation only; the retained (floor, checkpoint] frames stay deliverable through replay precisely so the miner can catch up from them (§6.6).

If the Parquet writer never checkpoints (e.g. a crash before the first row group commits), the WAL grows. The operator- visible signal is wal_disk_bytes rising monotonically; the H3 escalation rule applies. A future RFC may add a fallback “truncate by hard cap” with explicit data loss, but the default is “WAL grows until Parquet catches up” — losing durability silently is worse than running out of disk loudly.

6.8 Metrics

WalMetrics exposes:

  • wal_appends_total: u64 — frames appended since startup.
  • wal_syncs_total: u64fdatasync calls.
  • wal_unflushed_bytes: u64 — bytes appended but not yet fsync’d (the H3 detection metric, bounded by RFC0008.9).
  • wal_disk_bytes: u64 — sum of file sizes under wal_root (operator dashboard).
  • wal_segment_count: u32 — current segment count.
  • wal_sync_seconds: histogram — per-sync latency.
  • wal_checkpoint_segment: String (UUIDv7 of the segment in the last checkpoint arg) + wal_checkpoint_byte: u64 (byte-in-segment of the same arg). Two fields rather than one because WalOffset is now a (segment, byte) pair (§6.1) and a single u64 would lose the segment identity needed to interpret the value once housekeeping deletes older segments. None checkpoint (pre-first-checkpoint startup) is rendered as the empty string + 0; the ingester logs the transition from “no checkpoint” to “have checkpoint” so operators can tell the difference.
  • wal_recovery_seconds: Gauge (set once at startup).
  • wal_corrupt_frames_total: u64 — RFC0008.5 surface.

Per docs/roadmap.md §5’s maintainer note (2026-05-19, updated 2026-06-03: “instrument through the OpenTelemetry metrics API (meters) and export via the OTel SDK’s OTLP metric exporter (push), not the legacy prometheus client crate and not a scrape endpoint; any Prometheus compatibility is a downstream collector concern”), these are OTel metrics exported over OTLP; the CLAUDE.md §6.3 “every subsystem exposes Prometheus metrics” line predates that direction and the note flagged it for a follow-up amendment. RFC 0001 §6.8 (2026-06-03 amendment) is the normative reference for the export architecture.

6.9 WAL-tunables classification

Per RFC 0004 §3.1, every operator-visible knob is exactly one of Tunable or Invariant, with a startup- validated range and the §3 invariant it lives inside. Same schema as RFC 0004 §3.2’s miner table:

KnobClassDefaultValidated rangeInside invariant
wal_batch_window_msTunable100 (CLAUDE.md §3.4)0..=10_000§3.4 — bounds ack-latency vs durability tradeoff; 0 means per-append sync (allowed but discouraged)
wal_segment_size_bytesTunable128 MiB (matches Parquet row-group class, §6.5)MAX_FRAME_BYTES + segment_header (24 B) + frame_header (12 B) ..= 2 GiB (numerically ≥ 16 MiB + 36 B, validated as ≥ 17 MiB for headroom and round-numbering)§3.4 — bounds the segment-fill arm of the “or … whichever first” sync trigger. The lower bound MUST accommodate a single max-sized frame plus headers (a max frame that wouldn’t fit in any segment would force unbounded wal_unflushed_bytes, violating RFC0008.9)
wal_segment_age_secsTunable600 (10 min, §6.5)1..=86_400§3.4 — bounds recovery window
wal_housekeeping_secsTunable60 (§6.7)1..=3_600§3.6 — bounds local-disk pressure on a slow-publishing Parquet writer
wal_macos_full_fsyncTunablefalse (off — opt-in to the slower-but-stronger F_FULLFSYNC; macOS-only knob, ignored on other platforms)bool§3.4 — operator-visible durability choice on macOS dev boxes (the §9 open question pins the default)
MAX_FRAME_BYTESInvariant16 MiBn/a (compile-time)§3.4 / format compat — a tunable MAX_FRAME_BYTES would let a single batch grow past a segment-recoverable size and would make file-format compatibility per-deployment
Frame-header layout (§6.2.2)Invariantper §6.2n/aformat compat — the on-disk shape is the format-version contract
Segment-header layout (§6.2.1)Invariantper §6.2n/aformat compat — same
CRC32-C polynomialInvariant0x1EDC6F41 (Castagnoli)n/aformat compat
CHECKPOINT sidecar formatInvariantper §6.7n/aformat compat

Per RFC 0004 §3.1’s per-tenant override rule, all Tunables above are process-global at v1 — there is no per-tenant override surface for the WAL because the WAL is a single workspace-wide log (§7.4). A future per-tenant WAL split would inherit RFC 0004’s per-tenant override mechanism.

6.10 Out of scope for this RFC

  • Replication. Per CLAUDE.md §3.4. When it lands it joins a separate RFC and operates over the WAL, not instead of it.
  • Multi-writer arbitration. The ingester is a single process per node; no file lock dance here. A future multi-process design would need a lockfile + a coordinator, both deferred.
  • Snapshotting miner state. Replay rebuilds the miner; if recovery time becomes the operator complaint, a snapshot RFC is the response. §9 carries this.
  • Batch dedup on replay. RFC 0003 §9 owns the idempotency-key question; this RFC inherits whatever that RFC pins.
  • Distributed transactions across WAL + object storage. Atomic-publish (RFC 0005 §3.7 / RFC0005.2) is what we have; this RFC does not promise stronger ordering.

7. Alternatives considered

7.1 Use sled / redb / fjall / RocksDB

These give a WAL as a side effect of a B-tree / KV store. The ingest path consumes none of the surrounding API: we don’t iterate keys, we don’t query by prefix, we don’t transact across rows. Adopting one buys us a WAL implementation at the cost of a much larger dependency, a non-trivial migration if the upstream goes through a format break (sled has done this multiple times pre-1.0), and a wire format we don’t control. The hand-rolled crate is ~600 lines we can audit; the alternatives are tens of thousands of lines of code we don’t need.

7.2 Use okaywal / a focused-WAL crate

Closer fit (focused, small, hand-roll-equivalent). The remaining objection is that the format is what we care about most — recovery semantics, frame layout, CRC choice, checkpoint contract — and a third-party crate’s format is the same wire-compat boundary as our own. We do not save the design work, only the implementation work. For ~600 lines of careful Rust the dependency-discipline default (CLAUDE.md §10 “When in doubt, …”) is to own it. Worth revisiting if okaywal (or a peer) stabilises on a format that matches §6.2 closely enough that we can adopt them as a drop-in.

7.3 Per-record framing (not per-batch)

Per-record gives finer recovery granularity (“the last K records before crash were lost” vs “the last batch was lost”) and bypasses one decoder pass at replay (records are already split). Rejected because:

  • Per-record framing forces the receiver to re-encode each record from the decoded OtlpLogRecord back into a durable form, doubling the encode work on the hot path.
  • Batches are the ack boundary already (RFC 0003 §6.5); a per-record WAL would have to track batch-boundary metadata anyway to know which records to ack together.
  • Replay-as-receive is a strong simplicity win: one decoder, one fan-out, one mining pipeline. Per-record framing forks the code.

7.4 Per-tenant segments

A segment per (tenant_id, time-bucket) would let recovery parallelise by tenant and would shrink the truncation granularity (a slow-flushing tenant doesn’t pin the whole WAL). Rejected for v1 because tenant derivation (RFC 0003 §6.3) happens inside the batch — the WAL would need to peek inside ExportLogsServiceRequest to route, which re-introduces decode work on the critical path. The shared log keeps the WAL stateless about tenants; per-tenant segmentation is a future optimisation that the §6.2 format admits additively.

7.5 fsync per append

The simplest possible policy: every append fsyncs. Rejected because the latency cost is operator-hostile on the steady- state path (a single batch with 100 frames → 100 fsyncs, each ~10 ms on commodity SSDs). The batched-window approach preserves the §3.4 invariant (no ack before fsync) while amortising the cost across the configured window.

7.6 Direct I/O (O_DIRECT)

Skips the page cache for a known cost (fsync always flushes) in exchange for a known benefit (no kernel read-modify-write on small appends). Rejected for v1: the implementation surface is non-trivial (aligned buffers, sector-aligned writes), the benefit is operator-perceptible only at multi-thousand-batch-per-second sustained rates, and the H3 mitigation already gives us a tunable knob. Worth revisiting if production deployment shows fsync amplification as the dominant ack-latency contributor.

7.7 OTLP-protobuf vs bincode / flatbuffers

bincode would be slightly faster to encode and would shed the proto3 overhead, but the receiver has the protobuf bytes in hand for free (the wire decoded into them) — using them verbatim is zero work, while re-encoding into bincode would be a second pass. flatbuffers would require schema duplication we don’t currently maintain. The OTLP wire form also keeps the WAL self-describing to anyone with the opentelemetry-proto headers; a custom format would be ours-only.

8. Testing strategy

Per CLAUDE.md §6.2 (docs/verification.md §2), each §5 scenario maps to a named test:

  • RFC0008.1 — integration test in crates/ourios-wal/tests/wal_before_ack.rs. Drives a fake receiver against a real Wal; asserts the sync-returned offset is the gate.

  • RFC0008.2 — CI crash-recovery test (the H3 normative requirement). A test harness forks a child that appends N frames; the parent sends SIGKILL between append and sync and (in a second arm) between sync and the simulated ack. The assertion has two halves:

    1. No fsync’d frame is lost. Every frame the child fsync’d before the kill is recovered on restart.
    2. Any un-fsync’d frame is handled safely. Process death does not discard dirty page-cache data, so a complete-but-unsynced frame may still be on disk after restart (the kernel flushed it post-mortem). Recovery MUST sort it into one of the three §5 / §6.6 buckets: replayed as a normal frame (complete + CRC valid → legitimate, just unacked at the client); surfaced via the newest-segment torn-tail truncate (partial header / partial payload only — §6.6 step 4); or surfaced as RFC0008.5 corruption (complete frame with CRC mismatch, even on the newest segment — CRC mismatch is never truncation). The test does not assert “exactly the fsync’d frames” — that would be stronger than what SIGKILL semantics admit.

    Runs on every PR; failure blocks merge.

  • RFC0008.3criterion benchmark in crates/ourios-wal/benches/recovery.rs. Generates 1 / 4 / 16 segments of representative size, measures recovery wall time, asserts O(N) growth within a tolerance.

  • RFC0008.4 — property test in crates/ourios-wal/tests/torn_writes.rs. Generates a segment, truncates at a random offset inside the last frame, asserts replay succeeds and the recovered frame count equals the count before the torn frame.

  • RFC0008.5 — property test (crates/ourios-wal/tests/corruption.rs). Five arms, one per §5 RFC0008.5 sub-case, matching the §6.6 corruption branches:

    1. Payload bit-flip (CRC mismatch) on a closed segment — flip a random bit in a random frame, assert replay emits the structured corruption error and stops scanning all segments.
    2. Unknown kind — generate a segment containing a frame whose kind byte is anything other than 0x01 0x02 or 0x03 (i.e. inside §6.2.2’s reserved 0x04..=0xFF range — the RFC 0046 amendment added 0x03 = TenantOtlpBatch, exercising exactly the “future kinds without a version bump” clause); assert structured corruption.
    3. Non-zero _pad — generate a frame whose 3 B reserved _pad carries a non-zero byte; assert structured corruption (the §6.2.2 contract says _pad MUST be zero and is validated on read).
    4. Oversize len — generate a segment whose frame header declares len > MAX_FRAME_BYTES; assert structured corruption (the read must reject the header before attempting to read len bytes that could exceed the segment size).
    5. Torn header / payload on a closed segment — truncate a closed segment mid-header or mid-payload; assert RFC0008.5 corruption, not RFC0008.4 clean- truncation (the newest-vs-older pin §6.6 introduced).

    Each arm asserts (a) the structured error names the segment UUIDv7 + byte offset and (c) recovery stops scanning all segments (no records past the corrupt point are replayed). The original (b) — the WalRecoveryCorruption audit event — is deferred per the 2026-06-13 §5 amendment (system-scoped audit, §9); wal_corrupt_frames_total carries the operator-visible signal in the interim.

  • RFC0008.6 — integration test (crates/ourios-wal/tests/rotation.rs). Drives appends past the size cap and time cap; asserts no frame is dropped or duplicated and the segment-file count grows by one.

  • RFC0008.7 — integration test (crates/ourios-wal/tests/checkpoint.rs). Four arms:

    1. Normal-flow truncationcheckpoint(X) followed by a housekeeping pass unlinks segments wholly below X and keeps segments straddling it.
    2. Crash between checkpoint(X) and housekeeping — fork a child that calls checkpoint(X), SIGKILL the child before housekeeping runs; restart, assert the CHECKPOINT sidecar survives the crash: last_checkpoint() returns Some(X), so the driver’s Parquet-side suppression of records ≤ X holds (they aren’t re-fed and duplicated on the data side), while replay still delivers them with their offsets. Without this arm a passing test could still hide a non-durable checkpoint (the §6.7 sidecar requirement).
    3. Surviving-segments offset reconstructioncheckpoint(X) advances past several older segments, housekeeping deletes them, then a fresh Wal::open plus a new checkpoint(Y > X) proceeds against the surviving UUID-named files without needing a global offset counter to be reconstructed — verifying the §6.1 WalOffset = (segment, byte) representation is enough.
    4. Retain floor — with checkpoint(X) and a floor S < X, housekeeping(Some(S)) keeps every segment holding frames in (S, X]; advancing the floor to S' ≥ X on a later pass reclaims them.
  • RFC0008.8 — integration test that drives the wal_batch_window_ms knob at {10, 100, 1000} ms under a paused virtual clock and asserts ack latency equals the configured window exactly (deterministic — the window dominates, not per-record fsync).

  • RFC0008.9 — property test on the metric: wal_unflushed_bytes is monitored over a randomized arrival pattern and asserted bounded by 2 × wal_segment_size_bytes.

  • RFC0008.10 — integration test in ourios-ingester / ourios-server (the driver lives there per §6.1, like the RFC 0001 criteria hosted in ourios-querier). Builds a WAL

    • snapshot fixture with S ≥ X, starts the server, and asserts: recovery completes before the listeners accept; per-consumer delivery counts (Parquet sees all frames

    X, the miner only frames > S); and tree-state equality against a from-scratch control. The snapshot-cadence arm drives appends across a rotation and asserts a snapshot artefact appears recording the rotation-point high-water mark.

The crash-recovery test (RFC0008.2) is the single most important test in this RFC and is also the H3 CI gate. It runs on every PR regardless of which files changed — H3’s “any failure is critical” applies.

9. Open questions

  • System-scoped audit for WalRecoveryCorruption (deferred from RFC0008.5, 2026-06-13). RFC0008.5’s durable forensic record — a WalRecoveryCorruption { segment, offset, reason } audit event — was deferred because the RFC 0005 audit stream is tenant-partitioned (CLAUDE.md §3.7) and WAL corruption is a system event with no tenant. The follow-up needs a system-scoped audit partition (a reserved tenant id, or a sibling audit/system/ tree) decided in an RFC 0005 amendment; once it exists, the recovery driver appends the event post-scan (recovery is single-threaded, so the queue drains after the halt). Until then the structured RecoveryError halt + wal_corrupt_frames_total are the operator-facing signal. The same system-scoped partition would host other infrastructure events (rotation-fsync failure, checkpoint-write failure) that today only surface as metrics + the quiesce/error response.
  • AuditEvent serde format. AuditEvent has no serde derives today (Debug, Clone, PartialEq, Eq only), so the FrameKind::AuditEvent payload encoding is intentionally deferred to the implementation PR — this RFC at specified pins the frame layout and the ordering contract (§6.4), not the inner encoding. The encoder lands alongside the serde derives in PR-M2; the candidates are bincode (compact, fast, Rust-native — picks up cross-version compatibility concerns later if the struct evolves) and serde_json (human-debuggable, already in the dep tree, larger on disk). Either lives entirely inside the kind = 0x02 payload bytes and doesn’t affect the frame layout this RFC pins. The choice should be consistent with whatever encoding the audit-event Parquet writer (RFC 0005 §3.7) uses on the read path.
  • Idempotency key on the batch envelope. RFC 0003 §9 carries the at-least-once duplicate problem; the WAL frame can embed a key for replay-time dedup. Sequencing: whichever RFC moves first proposes the key shape, the other amends to consume it.
  • macOS F_FULLFSYNC policy. macOS fsync doesn’t flush the on-disk cache without F_FULLFSYNC. Default to the stronger primitive (slower, correct) or the weaker one (faster, dependent on power-loss assumptions)? Most Ourios deployments are Linux, so the default doesn’t bite the common case, but the macOS dev-laptop bench path benefits from the weaker default. Lean toward an explicit wal_macos_full_fsync = true knob with a documented trade-off.
  • Recovery-time snapshot of miner state. If replay on a hot-template multi-tenant corpus exceeds the operator’s restart-window budget, a snapshot of the miner’s Drain trees becomes worth specifying. Currently deferred with no signal that it’s needed. RESOLVED 2026-06-12: specified in RFC 0001 §6.9 (format + v1 landed 2026-06-10; the v2 restore-and-resume amendment is the counterpart of this RFC’s same-day §6.1/§6.7 amendment — offset-carrying sink, retain floor, RFC0008.10 driver).
  • Cross-segment frame straddling. The §6.2 format forbids it (a frame lives in exactly one segment); the rotation rule pre-checks size before appending. Confirm the receiver-side append loop respects this; one test in §8 already covers it but the open question is whether the knob should be tunable (very large batches near the rotation point) or fixed (always rotate ahead of the oversize append).
  • What does the receiver do when sync returns error? Refuse acks for that batch is the obvious answer; less obvious is whether the receiver tears down its network listener (force the orchestrator to restart it) or holds incoming requests until the error clears. RFC 0003 §9 is the natural owner of this once the WAL contract is specified.
  • Multi-WAL on a single node. Could a single ingester run two Wals (e.g. one per fast-tier disk for IO parallelism)? §6 currently pins a single WAL per process; a multi-WAL design would need a coordinator and a cross-WAL ordering rule. Deferred.

10. References

  • CLAUDE.md §3.4 WAL-before-ack — the load-bearing invariant.
  • CLAUDE.md §3.6 Object storage is the source of truth — the WAL is local-disk cache + durability, not the persistent record.
  • CLAUDE.md §10 When in doubt, … — informs the hand-roll vs adopt-a-crate choice in §4.2 / §7.1.
  • docs/hazards.md H3 WAL durability vs. latency — the mitigation, detection, and escalation language this RFC promises to honour.
  • RFC 0003 §6.5 WAL-before-ack sequencing — the receiver contract this RFC is the other side of. RFC 0003 cannot progress past drafted until this RFC reaches specified (because RFC 0003’s §5 scenarios cite the WAL).
  • RFC 0005 §3.4 Partition layout on disk — provides the UUIDv7 convention §6.2 reuses.
  • RFC 0005 §3.7 / RFC0005.2 Atomic publish — the per-row-group publish primitive §6.4 (audit-event ordering barrier) and §6.7 (checkpoint-driven truncation) compose against.
  • RFC 0005 §3.3 — the canonical-JSON encoding for attributes / structured bodies, which the replay pipeline consumes downstream of the receiver.
  • RFC 0001 §6.1 — OtlpLogRecord shape, the in-memory form the recovery sink fans out to.
  • RFC 0001 §6.4 / §6.7 — AuditEvent schema and the audit-stream-through-WAL contract this RFC implements via FrameKind::AuditEvent.
  • RFC 0005 §3.7 — audit-event Parquet schema; explicitly defers the audit-WAL contract to this RFC (“a contract that lands with the post-MVP ourios-wal crate; until then audit-event durability is in-memory and the corpus bench accepts that”).
  • docs/roadmap.md §5 — the maintainer note (2026-05-19, updated 2026-06-03) pinning OTel metrics (via meters) + OTLP export (not direct Prometheus, not a scrape endpoint) as the metrics surface; the §6.8 instrumentation cites it.
  • PostgreSQL WAL design notes — informed the segment-rotation + checkpoint-truncation pattern.
  • LMDB B+tree on a memory-mapped file — informed the “tiny, owned, audited” approach in §4.2.
  • Kafka log segment format — the per-frame length-prefixed CRC-checked layout in §6.2.2 is the conventional one and follows Kafka closely.

RFC 0009 — Compaction


rfc: 0009 title: Background compaction — small-file consolidation status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-06-02 supersedes: — superseded-by: —

RFC 0009 — Background compaction: small-file consolidation

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip; thesis-gates passed at validated.

Status note. validated (2026-06-15) — the RFC0009.7 D2/D3/B2-post benches were measured authoritatively on baseline-8vcpu-32gib (benchmarks.md §9.7, git 4d52288): D3 PASS (a band-scale compaction output lands at 456.7 MiB, IN the 256 MiB–2 GiB H4 band, 0% under 128 MiB); D2 compaction throughput 166.8 MiB/s single-partition (≫ any per-partition seal rate → backlog drains); B2-post query latency 12.78 ms → 2.10 ms (≈6.1×) as 32 files / row groups collapse to 1. The sustained-ingest soak (D2’s literal one-hour window at D1’s rate) and D1 itself remain unrun — the throughput is the RFC0009.7 D2 measure, not that soak. The prior green status (flipped earlier the same day) rested on every RFC0009 §5 acceptance criterion having a live, passing test: .1 small-file count collapses (rfc0009_1_*), .2 row conservation (compaction_conserves_every_row proptest), .3 atomic publish / no torn read (atomic_publish_… + the ourios-querier rfc0009_3_* manifest tests), .4 crash safety (rfc0009_4_*gc_orphans reclaims orphans, reads stay at a clean generation), .5 tenant/partition isolation (rfc0009_5_* — a mis-partitioned input aborts on the §3.9 row-vs-path check), .6 union-schema merge across an amendment (rfc0009_6_*). The atomic-publish protocol (§3.4) is the per-partition manifest + atomic generation swap; the querier resolves live files reader-first (glob-fallback when absent); the runner lives in ourios-ingester (run_sweep/Compactor) with the §3.6 OTel metrics + audit event.

RFC0009.7 — measured. The D2/D3 criterion benches (compaction throughput, small-file size band) + the post-compaction B2 re-run live in ourios-bench’s compaction bench — CI-indicative via compaction-bench.yml, authoritative on baseline-8vcpu-32gib in §9.7. Its structural half (file count falls under compaction, every row conserved) is also pinned deterministically by RFC0009.1 / compaction_conserves_every_row.

Open follow-ups (§7, post-validated): the full D2 sustained-ingest soak (backlog-returns-to-zero in a one-hour window at D1’s rate) + a measured D1; late-arriving data re-flagging an already-compacted partition (the manifest is authoritative, so a new write into such a partition must be picked up by the candidate scan / folded into the manifest — confirm plan_candidates covers it); the S3 atomic-swap primitive + single-writer lease for object storage (local FS uses rename); and the RFC 0004 cadence defaults.

1. Summary

Ourios’s writers land many small Parquet files per tenant per hour (one per writer flush / time-rotated partition; docs/hazards.md H4). This RFC introduces a background, per-tenant, per-partition compaction pass that consolidates the small *.parquet files of a sealed partition into one (or few) files inside the RFC 0005 §3.5 size targets, without changing a single stored row and without any query ever seeing a row twice or missing one. Compaction reuses the existing ourios-parquet Reader/Writer and the atomic-publish convention (write to *.parquet.tmp, commit by rename); the live set of a partition is named by a small per-partition manifest so the commit is a single atomic object swap. It is the mitigation for hazard H4 and the lever the RFC 0007 §6 / PR #92 B2 bench identified: query latency there is dominated by per-file footer reads, not data scanning, so fewer/larger files is the next query-latency win.

2. Motivation

2.1 The small-file problem is now measured, not theoretical

docs/hazards.md H4 predicted it; the B2 latency bench (crates/ourios-bench/benches/b2.rs, RFC 0007 §6, landed in PR #92) measured it. With result size held constant while the corpus grows 1×/10×/50×, query latency grew sub-linearly but not flat (~0.95 ms → ~1.55 ms → ~4.36 ms). The structural B2 test (rfc0007_2_template_exact_work_scales_with_result_not_corpus) proves the scanned row groups and bytes stay flat; the residual wall-clock growth is per-file footer/metadata reads, because file count scales with corpus. Compaction is the direct lever on that residual: collapse N small files’ N footer reads into one.

2.2 RFC 0005 explicitly deferred it here

RFC 0005 §3.5 says the writer’s job is to “land at the bottom of [the file-size] range or below on its own … compaction is deferred,” and §4.5 parks background compaction as “a post-MVP RFC.” Two writer behaviours guarantee small files even at steady state and so require a sweeper: (a) time-rotated partitions — an hour partition that receives a trickle of late or low-volume traffic produces a sub-128 MiB file; (b) end-of-day audit files (RFC 0005 §3.4) are inherently small. H4’s detection threshold (“fewer than 5 % of files below 128 MiB at steady state”) cannot be met by writer sizing alone.

2.3 Why at this layer

Compaction is a write-path / storage concern, not a query-path one: the querier (RFC 0007, hazard §4.6) must stay a pure reader, and the WAL→Parquet flush sizing (RFC 0005/0008) is a separate mechanism (it sizes files as they are first written; this RFC re-consolidates files already published). Doing it as a background pass keeps it off the ack-latency hot path (WAL-before-ack, CLAUDE.md §3.4 is untouched).

3. Proposed design

3.1 Scope

In scope. Background consolidation of the published, committed *.parquet data files of a single sealed partition (data/tenant_id=<enc>/year=YYYY/month=MM/day=DD/hour=HH/, the RFC 0005 §3.4 Hive layout) into one or a few files meeting RFC 0005 §3.5 size targets, preserving every stored row exactly. The same mechanism applies to the audit-event series (audit/…, day-granular).

Out of scope. WAL→Parquet flush sizing (RFC 0005/0008); retention/expiry/TTL (no compaction-driven deletion of data — only of inputs it has just rewritten); cross-partition or cross-tenant merges; re-mining or re-templating (compaction copies rows, it does not touch the miner); query-side caching.

3.2 Where it runs

A background task hosted in the ingester role (it already owns the write path, the bucket credentials, and per-tenant context), with its own bounded concurrency knob so it never starves ingest. The compaction logic itself is a new compaction module in ourios-parquet (it is Parquet-file manipulation — read many via Reader, write one via Writer); no new crate. Driving it from a dedicated compactor role is a deployment-scaling evolution captured in §4, not an MVP requirement.

3.3 What is eligible: sealed partitions

Compaction only ever touches a sealed partition — one no longer receiving writes — so it never races an active writer for the same input set. A data partition (…/hour=HH/) is sealed once wall-clock time passes the end of its hour plus a compaction_grace margin (default 15 min, tunable per RFC 0004) that absorbs late-arriving records. A sealed partition is a candidate when it has more than compaction_min_files files (default 4) or holds files below 128 MiB. This is a partition-local trigger heuristic — distinct from H4’s tenant-level detection metric (the per-tenant file-size histogram / “fewer than 5 % of files below 128 MiB at steady state”, §3.6), which is the cluster signal compaction’s job is to keep satisfied. Late data that arrives after a partition is compacted lands as a new small file and re-flags the partition as a candidate — compaction is idempotent and re-runnable (§3.5).

3.4 The atomic-publish protocol — per-partition manifest

A query (RFC 0007) plans over a partition by enumerating its committed *.parquet files. If compaction publishes the consolidated file before deleting its inputs, a concurrent query double-counts; if it deletes inputs first, a query misses rows. Object storage (the source of truth, CLAUDE.md §3.6) offers no atomic multi-object operation, so a glob-the-directory reader cannot be made correct under compaction.

The commit mechanism is a per-partition manifest. Each partition carries a small manifest.json naming the live set of data files (UUIDv7 names) plus a monotonically increasing generation number. The read path (RFC 0007) resolves a partition’s files through the manifest, not a raw glob; absence of a manifest means “glob all *.parquet” — so pre-compaction partitions and the current querier keep working, and the reader gains manifest support before any compactor writes one (the reader-first sequencing in §7). Compaction:

  1. reads the live set, writes the consolidated *.parquet.tmp;
  2. renames it to its committed *.parquet name (still not referenced by any manifest, so invisible to queries);
  3. writes manifest.json.tmp naming only the new file at generation + 1, and atomically swaps it into place (single- object rename / conditional put) — this is the commit point;
  4. lazily deletes the now-orphaned input files (a crash here leaves harmless orphans that a GC sweep reclaims; correctness already committed at step 3).

A query reads a consistent generation: either the pre-compaction set or the post-compaction set, never a mix (RFC0009.3). This is the Iceberg/Delta “atomic metadata swap” idea reduced to one flat file per partition — deliberately not a full table format; the generation- subdirectory and glob-the-directory-reader alternatives were weighed and rejected in §4.

sequenceDiagram
    participant C as Compactor (ingester)
    participant FS as Object store (partition)
    participant Q as Querier
    Note over FS: manifest@gen=N → {a,b,c}.parquet
    C->>FS: read live set {a,b,c}
    C->>FS: write compacted.parquet.tmp → rename compacted.parquet
    Q-->>FS: plan @gen=N (sees {a,b,c}) ✓ no torn read
    C->>FS: atomic swap manifest@gen=N+1 → {compacted}
    Q-->>FS: plan @gen=N+1 (sees {compacted}) ✓
    C->>FS: GC orphaned {a,b,c} (lazy, post-commit)

This protocol is an interaction with RFC 0007 (the querier must read through the manifest) and a small extension to RFC 0005 (the manifest is a new per-partition artifact — additive, optional, back-compatible). Both are recorded as resolved decisions in §7.

3.5 Correctness, idempotency, crash safety

  • Row conservation. Compaction preserves every row value exactly — including the raw body bytes — but does not promise byte-identical Parquet files: the physical encoding may differ (row groups re-packed to the §3.5 sizes, compression re-applied, rows possibly reordered within the partition). The logical guarantees hold: same RFC 0005 schema, same partition ⇒ row-vs- path validation §3.9 still holds; bit-identical body reconstruction §3.3 is preserved because rows are copied, never re-mined. Total row count and per-template_id counts are invariant across a compaction (RFC0009.2).
  • Idempotency. Re-running compaction on a partition with a single already-large file is a no-op (not a candidate per §3.3).
  • Crash safety. The only commit point is the atomic manifest swap (step 3). A crash before it leaves the prior generation authoritative — no acknowledged data lost (mirrors the WAL crash-recovery discipline, CLAUDE.md §3.4). Temp files and post-commit orphans are reclaimed by an idempotent GC sweep.
  • Heterogeneous input schemas. Inputs spanning a schema amendment (some files with an added OPTIONAL column) merge to the union schema and stay readable per RFC 0005 §3.9 (the same forward-compatible read RFC0007.4 already tests).

3.6 Audit + observability

Audit event

Every committed compaction emits an audit event to the RFC 0005 §3.7 audit stream — the “nothing happens silently to stored data” stance applied to file lifecycle (CLAUDE.md §3.1), the same way a template merge is audited. The event records the partition, the input file set, the consolidated output file, the row count (which must be conserved, RFC0009.2), and the committed manifest generation.

Open question (§7): the existing audit schema (RFC 0005 §3.7) is shaped for template events — event_kind is a bounded ordinal mapping with no compaction member, and the template-specific columns (old_template, positions_widened, …) are non-nullable. A compaction event can reuse the common envelope (tenant_id, timestamp, event_kind / event_type, reason) but (a) needs a new compaction member in the event_kind mapping and (b) has no applicable value for the non-nullable template columns, nor a place for the file set / generation. This is an implementation detail to settle when the compaction audit-emit code lands — not a design blocker, so it does not gate red (tracked in §7). The two routes:

  • structured reason — carry the file set / generation as a structured reason payload. Avoids new columns, but still needs the event_kind member and forces placeholder values into the non-nullable template columns (or making them nullable, which is itself a schema change), so “no schema change” is not quite free.
  • additive OPTIONAL columns — add OPTIONAL compaction columns and relax the template columns to OPTIONAL (an RFC 0005 §3.8 additive, back-compatible amendment); old readers ignore unknown columns per RFC 0005 §3.9.

The non-nullability tilts this toward the additive route; settle it against RFC 0005 §3.7 when that code lands, not here.

Metrics (OpenTelemetry semantic conventions)

Instrumented as OpenTelemetry meters and exported via the OTel SDK’s OTLP metric exporter (push over OTLP to a collector / endpoint) — the OTel SDK pipeline end-to-end. No prometheus client crate and no Prometheus scrape endpoint (maintainer direction, 2026-06-03, superseding the earlier opentelemetry-prometheus exporter note in roadmap §5; any Prometheus compatibility is a downstream collector concern, not Ourios’s). The names below follow the OTel metric-naming guidelines — dotted/namespaced, no _total/unit suffixes, UCUM units (including UCUM curly-brace annotations such as {sweep} / {file} for dimensionless counts, which annotate the unit 1), dimensions as attributes — and are exported verbatim over OTLP (no exporter-side name mangling).

MetricInstrumentUnitAttributesSource
ourios.compaction.sweepsCounter{sweep}ourios.compaction.resultRFC 0009 §3.2
ourios.compaction.partitionsCounter{partition}partitions consolidated
ourios.compaction.filesCounter{file}input files merged away (H4)
ourios.compaction.rowsCounter{row}rows rewritten (RFC0009.2)
ourios.compaction.ioCounterByourios.io.directionbytes read / written
ourios.compaction.durationHistogramsourios.compaction.resultsweep wall-clock
ourios.compaction.orphan.filesCounter{file}inputs left un-GC’d (gc_failures)
ourios.compaction.backlogUpDownCounter{partition}ourios.tenantsealed-but-uncompacted (lag)
ourios.storage.parquet.file.sizeHistogramByourios.tenantH4 detector — alert when > 5 % of files < 128 MiB

Attributes (namespaced per the conventions):

  • ourios.tenant (string) — tenant id. Cardinality is bounded by the tenant count; on the per-file-size histogram it is the dimension H4 detection needs (“per-tenant file-size histogram”).
  • ourios.io.direction (string, read | write) — mirrors disk.io.direction; one io counter with a direction attribute rather than two _in/_out metrics.
  • ourios.compaction.result (string, committed | noop | error) — sweep / partition outcome (noop = candidate that consolidated nothing; error = a partition skipped per the resilient sweep).

The H4 “file-count grows sub-linearly with bytes” signal is a derived alert over ourios.storage.parquet.file.size (count) and ingested bytes, not a base metric.

Validation gate. This set is the OpenTelemetry semantic- conventions registry at semconv/registry/, validated by weaver registry check in CI (the semconv job, a required check) — so the names/units/attributes stay spec-adherent and can’t drift. Compaction is the first place these conventions are pinned; RFC 0001 §6.8’s Prometheus-style names get the same OTel-source treatment in its own amendment (roadmap §5).

Code generation. Instrumentation does not hand-type these names: weaver registry generate renders the registry into a dependency- free leaf crate ourios-semconv (const &str per metric / attribute, mirroring upstream opentelemetry-semantic-conventions), which every instrumented crate depends on. The generator template lives at templates/registry/rust/; regenerate with the same command CI runs (--future matches weaver registry check --future):

weaver registry generate rust crates/ourios-semconv/src \
    -t templates -r semconv/registry --future
cargo fmt -p ourios-semconv

The same semconv CI job regenerates and fails on any diff (it also catches new untracked files), so the constants cannot drift from the registry. This new leaf crate extends the CLAUDE.md §7 layout; the commitment is blessed here, the same way ourios-telemetry was blessed in RFC 0001 §6.8.

4. Alternatives considered

  • No compaction (rely on writer flush sizing). Rejected: §2.2 — time-rotated low-volume partitions and end-of-day audit files are small by construction, so H4’s <5 % threshold is unmeetable without a sweeper, and PR #92 measured the latency cost.
  • Glob-the-directory reader, delete-after-publish (no manifest). Rejected: object storage has no atomic multi-object op, so there is always a window where a query double-counts (publish-then-delete) or misses rows (delete-then-publish). §3.4.
  • Full table format (Apache Iceberg / Delta Lake). Rejected for now: Pillar 1 commits Ourios to plain Parquet end-to-end (RFC 0005 §4.6 rejects even a second file format); a full manifest-of- manifests, snapshot log, and schema-registry is far more machinery than one flat per-partition manifest needs. The atomic-swap idea is borrowed from them (§3.4); the bookkeeping is not.
  • Compaction in the querier. Rejected: the querier is a pure reader (hazard §4.6); a read path that mutates storage breaks that contract and the multi-reader model.
  • Dedicated compactor role/binary. A viable evolution for isolating compaction CPU/IO from ingest at scale; deferred — the MVP hosts it as a bounded background task in the ingester (§3.2), and the role split is a later, non-breaking change.
  • Generation subdirectories instead of a manifest (…/gen=K/, querier reads the highest). Rejected: it leaks generation into the partition path (a second pruning axis the querier must learn) and complicates partition discovery; the flat manifest (§3.4) keeps the path stable and confines the change to one optional per-partition file. (This was the leading alternative; the manifest won on read-path simplicity.)

5. Acceptance criteria

Given / When / Then / And; ids greppable from tests. These realise hazard H4 and the affected invariants.

  • RFC0009.1 — small-file count falls below the H4 threshold [H4 detection]

    • Given a sealed partition with many sub-128 MiB files
    • When compaction runs to completion
    • Then the partition holds files inside the RFC 0005 §3.5 size range, and at steady state fewer than 5 % of a tenant’s files are below 128 MiB.
  • RFC0009.2 — row conservation [§3.3 / data integrity]

    • Given any set of input files in a partition
    • When they are compacted
    • Then the multiset of stored rows is identical (total row count and per-template_id counts unchanged), and each row still reconstructs bit-identically (RFC 0005 §3.3).
  • RFC0009.3 — query atomicity (no double-count, no miss) [H4 / RFC0007]

    • Given a query planned concurrently with a compaction of the same partition
    • When it executes
    • Then it observes exactly one generation’s file set — every row exactly once — never a torn mix of pre- and post-compaction files.
  • RFC0009.4 — crash safety [§3.4 discipline]

    • Given a compactor killed at any point
    • When the system recovers
    • Then no acknowledged row is lost: the partition reads as either the pre- or post-compaction generation, and orphaned temp/input files are reclaimable.
  • RFC0009.5 — tenant + partition isolation [§3.7]

    • Given multi-tenant data
    • When compaction runs
    • Then it never merges files across tenants or across partition keys; a compacted file’s rows all share the partition’s tenant_id and time bucket (RFC 0005 §3.9 row-vs-path holds).
  • RFC0009.6 — forward-compatible merge [§3.5 / RFC0007.4]

    • Given inputs spanning a schema amendment (some with an added OPTIONAL column)
    • When compacted
    • Then the output carries the union schema and reads without error per RFC 0005 §3.9.
  • RFC0009.7 — file count sub-linear in bytes [H4 / benchmarks D3]

    • Given sustained ingest with compaction running
    • When bytes ingested grow
    • Then file count grows sub-linearly, and template-exact query latency (RFC 0007 §6 B2 bench) does not grow proportionally to the pre-compaction file count.

6. Testing strategy

Mapped to CLAUDE.md §6.2:

  • Property (proptest) — RFC0009.2: over arbitrary input file sets (varied templates, row counts, schemas), compaction preserves the row multiset and per-template counts. The reconstruction property test (RFC 0005 §3.3) runs on compacted output too.
  • Integration — RFC0009.1/.5/.6: build a multi-file partition via the ourios-parquet writer, compact, assert file-size/count and that a Querier returns identical results before and after.
  • Concurrency — RFC0009.3: interleave a query with a compaction commit (drive the manifest swap mid-plan) and assert the row count is exactly correct for one generation.
  • Crash recovery — RFC0009.4: SIGKILL the compactor before and after the manifest swap; assert recovery loses no rows and GC reclaims orphans (the WAL crash-recovery test is the template).
  • Corpus — RFC0009.1: file-size histogram on the otel-demo corpora before/after compaction.
  • Bench (criterion) — RFC0009.7 / benchmarks D2 (compaction throughput) + D3 (file count under load); re-run the RFC 0007 §6 B2 latency bench post-compaction to show the per-file footer-read residual (§2.1) shrinks.

7. Open questions

Resolved at specified (the design forks; recorded here so the history is legible):

  • Manifest vs. generation-subdirectories vs. glob-the-directory reader. Decided: a per-partition manifest.json with an atomic generation swap (§3.4); the generation-subdirectory and glob-the-directory-reader alternatives are rejected in §4.
  • RFC 0007 read-path change. Decided: the querier resolves a partition’s files through the manifest, glob-fallback when absent. Sequenced reader-first — the RFC 0007 amendment + querier PR (reader tolerates a manifest) lands before any compactor writes one, so no flag day.
  • RFC 0005 artifact ownership. Decided: the manifest is specified here in RFC 0009 and is additive, optional, and back-compatible to the RFC 0005 layout (absent ⇒ glob), so it needs no breaking RFC 0005 amendment; RFC 0005 §3.4 is cross- referenced, not rewritten.

Open (implementation details; none block red):

  • Manifest serialization + atomic-swap primitive. Local FS: rename is atomic. S3: needs conditional-put / versioned-put or a single-writer lease. Which object-store abstraction (and does object_store give us the primitive portably)?
  • Single-writer-per-partition. Lease, or rely on the ingester being the sole writer by construction? (Interacts with the eventual horizontally-scaled ingester.)
  • Late-arriving data into a compacted partition. Direction decided: a new small file re-flags the partition as a candidate (§3.3), not re-opening the compacted file; confirm the compaction_grace default.
  • Cadence + concurrency defaults (RFC 0004): scan interval, compaction_min_files, compaction_grace, max concurrent partitions.
  • Audit partition compaction (day-granular) — same protocol, or simpler given lower volume?
  • Retention/expiry interplay — explicitly deferred; note the seam so a later TTL RFC composes with the manifest.
  • Audit-event shape (§3.6). Carry the compaction file set / generation in a structured reason payload vs. OPTIONAL audit columns (RFC 0005 §3.8 additive amendment). Per §3.6 the template columns are non-nullable and event_kind has no compaction member, so the structured-reason route is not schema-free either; leaning the additive OPTIONAL route.
  • Metric semconv validation (§3.6). Run the §3.6 metric names/units/attributes through the OpenTelemetry semantic-conventions check (OTel assistant / weaver / rego policy packages) and fix any divergence before instrumentation lands.

8. References

  • docs/hazards.md H4 (small-file problem) — the hazard this mitigates; CLAUDE.md §4 hazard 4.
  • RFC 0005 §3.4 (partitioning + atomic publish), §3.5 (size targets), §3.9 (reader contract / forward-compat), §4.5 (compaction deferral), §3.7 (audit stream).
  • RFC 0007 §6 + crates/ourios-bench/benches/b2.rs (PR #92) — the B2 latency finding that quantifies the small-file cost; RFC 0007 §4.6 (querier stays a pure reader); RFC0007.4 (forward-compatible reads).
  • RFC 0008 (WAL) — crash-recovery discipline (CLAUDE.md §3.4) the compactor’s commit protocol mirrors.
  • RFC 0004 (configuration policy) — where the cadence/grace/concurrency knobs live.
  • docs/benchmarks.md D2 (WAL→Parquet compaction keeps up), D3 (small-file count under sustained load).
  • Apache Iceberg / Delta Lake atomic metadata-swap commit — design inspiration for §3.4 (the idea, not the machinery).

RFC 0010 — Audit-stream queries & template drift


rfc: 0010 title: Audit-stream queries & template drift status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-06-09 supersedes: — superseded-by: —

RFC 0010 — Audit-stream queries & template drift

Status: accepted (2026-08-25, maintainer sign-off). Terminal. This RFC’s §9 header gated accepted on resolving its open questions; all four are now resolved (not waived) — each was embodied by the green implementation and is confirmed inline with the code and test evidence. No thesis-gate applies beyond the ones already standing in docs/benchmarks.md §7.

Status note. green (2026-06-15) — all eight §5 acceptance scenarios (RFC0010.1–.8) have live, passing tests in crates/ourios-querier/tests/it/drift.rs: drift returns drifted templates with counts (.1), half-open [from, to) window (.2), event_type scoping excludes non-widenings (.3), tenant isolation (.4), empty-is-empty-not-error (.5), widening_count desc / template_id asc ordering (.6), aggregate version/time bounds (.7), no DataFusion/SQL leakage (.8). The dedicated drift surface (a contained query head, not the general aggregation pipeline) was maintainer-confirmed 2026-06-09; this RFC fills the audit-stream query gap RFC 0002 §6.3 deferred, encapsulating the fixed aggregation RFC 0001 §6.7 wrote out as SQL “for spec clarity”.

RFC0010.1 discharges RFC 0001 H5.3. That hazard test was relocated out of crates/ourios-miner/tests/hazards.rs (now a relocation pointer there) to crates/ourios-querier/tests/drift.rs::h5_3_drift_query_returns_templates_that_gained_a_version, where the drift surface lives — it is no longer #[ignore] / todo!().

This RFC extends RFC 0002 (it does not reopen or renumber it; RFC 0002 stays green) and reads the RFC 0005 audit schema (it does not redefine it). Hazard 6 (CLAUDE.md §4 — no DataFusion/SQL leakage) constrains the surface: drift is exposed through the DSL, never as raw SQL.

Open for accepted (non-gating for green, per §9): the verb-head fork is resolved; the remaining §9 items (range-clause vs stage token, mandatory vs default window, tie-break stability, cross-kind version aggregation) are maintainer confirmations. General audit aggregation stays out of scope (§3.2).

1. Summary

Ourios records every template structural change as a durable audit event (RFC 0001 §6.4, persisted by RFC 0005’s audit/ Parquet series). RFC 0001 §6.7 specifies the operator-facing drift query — “templates that gained a new version in the window [t1, t2)” — but writes it out as SQL “for spec clarity”, explicitly deferring the user-visible form to “the RFC 0002 DSL, not raw SQL”. RFC 0002 in turn deferred the audit-stream query surface as “a future capability” (§6.3). This RFC is that future capability. It specifies a single, contained DSL query head — drift — that scans the per-tenant audit stream over a time window, aggregates the widening/type-expansion events per template, and returns one drift row per affected template. It deliberately does not require RFC 0002’s deferred general count / aggregation pipeline: drift is the one fixed aggregation §6.7 names, so it ships as a closed form rather than as a worked example of a general audit-stream aggregation engine.

2. Motivation

2.1 The gap, precisely

Three existing RFCs (RFC 0001 specified, RFC 0002 green, RFC 0005 drafted) leave a single hole between them:

  • RFC 0001 §6.7 (“Drift detection as a first-class query”) gives the exact semantics as SQL over a template_audit relation, then says: “SQL shown for spec clarity; the user-visible form is the RFC 0002 DSL, not raw SQL — see hazard H6.” The user-visible form was never specified.
  • RFC 0002 §6.3 lists the drift question and resolves the membership half (resolves_to(X) answers “what is template X aliased to”), but explicitly defers the windowed “did this template gain a version” half: “drift is an audit-stream property, not a column in the RFC 0005 data files, so it needs an audit-stream query path — a future capability, not a row predicate in this grammar.”
  • RFC 0005 §3.4 / §3.7 already persists the audit events to a queryable audit/ Parquet series with the columns the §6.7 query reads (event_type, template_id, old_version, new_version, timestamp, …), and ourios-parquet ships ParquetAuditSink / AuditReader / audit_schema(). The data is on disk and readable; nothing turns it into an operator query.

The miner’s RFC 0001 scenario H5.3 is the visible symptom: it is a red-gate #[ignore] / todo!("RFC 0001 §6.7") stub (crates/ourios-miner/tests/hazards.rs) precisely because there is no specified query path to assert against. RFC 0001 §9 records the same as a pending cross-RFC contract (“the DSL surface required to expose drift detection”).

2.2 Why at this layer, and why now

Drift detection is hazard H5 (docs/hazards.md H5, “Template schema evolution across deploys”): a sudden cluster of template_widened events correlated with a deploy is the H5 detection signal, and the §3.5 invariant (“Parquet schema changes require a migration plan”) is the data-side sibling. The audit stream is the only place that signal lives — it is not a column on the data rows. So the query must be an audit-stream query, and it belongs to the querier (RFC 0007, pillar #3) because that is where a compiled query becomes a partition-pruned DataFusion scan.

Now, because the three dependencies are in place: RFC 0001 §6.7 fixed the semantics, RFC 0005 persists the events with predicate-pushable columns, and RFC 0007 is green (the execution layer exists). The only missing piece is the surface and the aggregation that binds them — small enough to ship as a closed form without reopening RFC 0002’s broader deferred work.

3. Scope

3.1 In scope

  • A first-class DSL query head, drift, over the per-tenant RFC 0005 audit/ stream (§6.1, §6.2).
  • The fixed aggregation of RFC 0001 §6.7 (§6.3), its result row shape (§6.4), and its tenancy + window semantics (§6.5).
  • Compilation to a DataFusion plan over the RFC 0005 audit files, executed by RFC 0007 (§6.6).
  • The §5 acceptance criteria, including the H5.3 flip.

3.2 Out of scope (stated explicitly)

  • General audit-stream aggregation. Arbitrary GROUP BY / count / sum over audit events — i.e. RFC 0002’s deferred count / aggregation-stage pipeline applied to a generic audit source — stays deferred. drift is one closed query, not a general engine (§8 alternative A; the dedicated form may later be re-expressed on top of that engine without a surface change).
  • The rejected_degenerate event. drift counts widenings and type-expansions only, exactly as RFC 0001 §6.7’s event_type IN ('template_widened', 'template_type_expanded') filter specifies. template_widening_rejected_degenerate (RFC 0005 ordinal 2) records a non-change and must not count toward widening_count (RFC0010.3). The compaction event (RFC 0005 ordinal 3) is likewise not a template change and is excluded by the same event_type filter.
  • Alias / resolves_to membership. “Is template X aliased to Y” is the cross-alias axis already served by RFC 0002 resolves_to over the RFC 0001 §6.7 alias map. drift is the orthogonal cross-version axis (“did leaf X gain a version in [t1, t2)”); see §8 alternative B.
  • Raw SQL / DataFusion passthrough. Rejected per hazard H6 (docs/hazards.md H6): the DataFusion SQL surface is never exposed. drift is DSL-only (§8 alternative C).
  • The compaction-event query surface. RFC 0005 routes compaction events through the same audit stream; querying those is a separate future need, not folded in here.

4. Background: what is already on disk

This RFC reads, and does not redefine, the RFC 0005 audit schema. The relevant facts, cited so the design below is unambiguous:

  • Partition layout (RFC 0005 §3.4). Audit files live at <bucket>/audit/tenant_id=<tenant_id>/year=YYYY/month=MM/day=DD/<flush_uuid>.parquet — a parallel series to the data/ logs, keyed by tenant_id then a day-granularity time bucket derived from timestamp. tenant_id is a row-level REQUIRED column and the leading Hive partition key, so a per-tenant scan is a partition prune, not a post-filter (§6.5).
  • Columns (RFC 0005 §3.7). event_type (REQUIRED STRING, the predicate-pushdown surface RFC 0005 names “for the RFC 0001 §6.7 drift query”), event_kind (REQUIRED, Arrow UInt8 / INTEGER(8, unsigned) ordinal), template_id (UInt64, OPTIONAL but required-by-convention for the template kinds), old_version / new_version (UInt32, OPTIONAL — relaxed for the compaction kind, required-by-convention for the template kinds), timestamp (TIMESTAMP(NANOS, UTC), REQUIRED), and the template-detail columns (old_template, new_template, positions_widened, slots_expanded, triggering_line_*, reason). Drift reads only tenant_id, event_type, template_id, old_version, new_version, and timestamp.
  • Event-kind mapping (RFC 0005 §3.7). 0 → template_widened, 1 → template_type_expanded, 2 → template_widening_rejected_degenerate, 3 → compaction. Drift’s event_type filter selects ordinals 0 and 1.
  • Reader (ourios-parquet). AuditReader::open_partition is the production query path; audit_schema() is the canonical Arrow schema. This RFC’s compile target (§6.6) consumes that reader.

5. Acceptance criteria

Normative scenarios, in the docs/rfcs/README.md Required-sections acceptance-criteria format (Given / When / Then / And). Each carries a greppable id referenced from the test code. Where a scenario discharges a sibling RFC’s criterion, both ids are listed so the mapping stays greppable from either side.

  • RFC0010.1 — Drift query returns templates that gained a version in the window (discharges RFC 0001 H5.3) [RFC 0001 §6.7], hazard H5

    • Given a tenant’s audit stream containing template_widened and/or template_type_expanded events for template A and for template B, all with timestamp inside [t1, t2)
    • When the drift query drift from <t1> to <t2> runs in that tenant’s context
    • Then the result contains exactly one row for A and one row for B
    • And each row’s widening_count equals the number of that template’s qualifying events in [t1, t2)
    • And this is the criterion that flips the RFC 0001 H5.3 stub (crates/ourios-miner/tests/hazards.rs::h5_3_drift_query_returns_templates_that_gained_a_version), which is owned by RFC 0001 and satisfied here.
  • RFC0010.2 — Window boundary excludes out-of-window events [§6.5]

    • Given a qualifying event whose timestamp is strictly before t1, a second strictly after the window’s upper bound, and a third exactly on each boundary
    • When the drift query over [t1, t2) runs
    • Then the out-of-window events do not contribute to any widening_count
    • And boundary inclusion is half-open [from, to) — the lower bound from is included, the upper bound to is excluded — so a template with only a boundary event is present iff that boundary is the included (lower) one.
  • RFC0010.3 — event_type scoping excludes non-widenings [RFC 0001 §6.7], §3 (out of scope)

    • Given a template C with only template_widening_rejected_degenerate and/or compaction events in [t1, t2) (and no template_widened / template_type_expanded)
    • When the drift query over [t1, t2) runs
    • Then template C does not appear in the result
    • And for a template D with both qualifying and rejected_degenerate events, widening_count counts only the qualifying ones.
  • RFC0010.4 — Tenant isolation [CLAUDE.md §3.7], RFC0007.5

    • Given qualifying audit events for tenant X and qualifying audit events for tenant Y in the same window
    • When the drift query runs in tenant X’s context
    • Then no row attributable to tenant Y’s events appears, enforced at the partition-prune layer (the tenant_id Hive key), and a drift query without a tenant is a usage error, not a cross-tenant scan.
  • RFC0010.5 — Empty result is empty, not an error [§6.4]

    • Given a tenant with no qualifying events in [t1, t2) (no audit files for the window, or only excluded event types)
    • When the drift query over [t1, t2) runs
    • Then it returns an empty result set, not an error.
  • RFC0010.6 — Result ordering is widening_count descending [RFC 0001 §6.7]

    • Given templates whose qualifying-event counts in [t1, t2) differ
    • When the drift query over [t1, t2) runs
    • Then rows are ordered by widening_count descending, matching RFC 0001 §6.7’s ORDER BY widening_count DESC
    • And the tie-break among equal counts is deterministic (ascending template_id) so the result is stable for golden-test pinning.
  • RFC0010.7 — Aggregate version/time bounds per template [RFC 0001 §6.7]

    • Given template A with qualifying events spanning versions v_lo … v_hi and timestamps ts_lo … ts_hi inside [t1, t2)
    • When the drift query over [t1, t2) runs
    • Then A’s row carries min_old_version = v_lo, max_new_version = v_hi, first_seen = ts_lo, last_seen = ts_hi, matching RFC 0001 §6.7’s MIN(old_version), MAX(new_version), MIN(timestamp), MAX(timestamp).
  • RFC0010.8 — No DataFusion/SQL leakage [H6], RFC0007.3

    • Given the public drift surface (string DSL and structured form)
    • When a malformed or SQL-shaped drift query is submitted
    • Then neither the accepted grammar nor any error Display exposes DataFusion or SQL types/identifiers; the drift head is DSL-only, as resolves_to and render are (RFC 0002 §6.5).

6. Proposed design

6.1 A dedicated drift query head, not a general pipeline

RFC 0002’s pipeline is predicate { | stage } over the data/ log table (from logs is implicit; RFC 0002 §6.5). Drift is structurally different in three ways that make it a poor fit for that pipeline as-is:

  1. Different source. It scans audit/, a different Parquet series with a different schema, not data/.
  2. A fixed aggregation. RFC 0001 §6.7 fully specifies the projection, grouping, and ordering. There is exactly one drift query shape.
  3. No log predicate vocabulary. service, severity, body, template_id predicates etc. (RFC 0002 §7 nonsev_field) are log-record fields; they have no meaning over audit rows.

So rather than (a) adding a general audit source plus the deferred aggregation stages and then expressing drift as one instance, this RFC introduces a closed query head:

drift_query = "drift" , "from" , time , "to" , time ;

time is the exact RFC 0002 §7 time productionnow, a signed relative duration (-1h, -7d, …), or an RFC 3339 timestamp — reused verbatim, so the window vocabulary an operator already knows from range(...) carries over. The head is a top-level alternative to RFC 0002’s predicate { | stage } query, not a stage within it; a drift query is its own well-formed query, and it admits no further | stages (the projection, grouping, and ordering are fixed by §6.3, so there is nothing to compose).

Why this surface (drift from <t1> to <t2>) over audit | drift(...). RFC 0002’s pipeline reads as source | transform | …. A bare drift verb head reads as a single declarative question — “drift, from t1 to t2” — which matches how the operator thinks (“show me drift since the deploy”) and keeps the fixed, non-composable nature of the query honest: there is no audit source to further filter or aggregate, because the only audit-stream question this RFC answers is drift. An audit | drift(...) form would imply a general audit source and a composable drift stage, which is precisely the broader engine this RFC declines to build (§8 alternative A); choosing the verb head avoids promising composition the grammar does not deliver. The cost is one more top-level query shape in the grammar; that is paid once and is cheaper than a misleading pipeline.

The structured surface (RFC 0002 §6.4, the MCP/agent contract) carries the same query as a tagged object:

{ "drift": { "from": "-7d", "to": "now" } }

from / to are RFC 0002 §7 lexical time strings (relative duration, "now", or RFC 3339), exactly as the structured surface already carries durations and timestamps. As with the string head, no predicate or stages keys are accepted alongside drift — it is a distinct top-level object, validated by its own published JSON Schema fragment (versioned with the parser, snapshot-tested like the RFC 0002 §7 grammar).

6.2 Example queries

drift from -7d to now

“Which templates gained a version in the last seven days?” — the post-deploy H5 check.

drift from 2026-06-01T00:00:00Z to 2026-06-02T00:00:00Z

Drift confined to a single UTC day (an absolute window straddling one deploy), the form an operator pins in a Perses panel.

6.3 Semantics — RFC 0001 §6.7, verbatim

The drift query is the closed form of RFC 0001 §6.7’s specification. Over the executing tenant’s audit stream:

  1. Filter to event_type IN ('template_widened', 'template_type_expanded') (RFC 0005 ordinals 0 and 1) and timestamp in the window (§6.5).
  2. Group by template_id.
  3. Project per group:
    • widening_count = COUNT(*)
    • min_old_version = MIN(old_version)
    • max_new_version = MAX(new_version)
    • first_seen = MIN(timestamp)
    • last_seen = MAX(timestamp)
  4. Order by widening_count descending, then template_id ascending (the deterministic tie-break of §5 RFC0010.6; RFC 0001 §6.7 leaves ties unspecified, this RFC pins them for stable golden tests).

Equivalent to RFC 0001 §6.7’s SQL, restated here only to anchor the column names this RFC’s result shape uses:

SELECT template_id,
       COUNT(*)          AS widening_count,
       MIN(old_version)  AS min_old_version,
       MAX(new_version)  AS max_new_version,
       MIN(timestamp)    AS first_seen,
       MAX(timestamp)    AS last_seen
FROM   audit                       -- the per-tenant RFC 0005 audit/ stream
WHERE  event_type IN ('template_widened', 'template_type_expanded')
  AND  timestamp >= $t1 AND timestamp < $t2
GROUP  BY template_id
ORDER  BY widening_count DESC, template_id ASC

(SQL is shown for spec clarity only, mirroring RFC 0001 §6.7; the user-visible form is the §6.1 drift head, never raw SQL — hazard H6 / §8 alternative C.)

6.4 Result shape

A drift query returns a typed result set of drift rows, distinct from the log result rows RFC 0007 returns. One row per affected template:

#![allow(unused)]
fn main() {
pub struct DriftRow {
    pub template_id: u64,
    pub widening_count: u64,
    pub min_old_version: u32,
    pub max_new_version: u32,
    pub first_seen: SystemTime,
    pub last_seen: SystemTime,
}
}

The columns map one-to-one onto the §6.3 projection. The carrier follows the RFC 0007 QueryResult shape (typed rows plus scan stats: row_groups_scanned / row_groups_pruned / bytes_read). An empty result is an empty row set (RFC0010.5), never an error. The drift result is its own variant so it cannot be confused with a log-row result; making the two result shapes distinct keeps invalid mixes unrepresentable.

6.5 Tenancy and window semantics

  • Tenant scoping (CLAUDE.md §3.7, RFC0010.4). The tenant is supplied by the executing context, exactly as for RFC 0002/RFC 0007 log queries — never expressed in the query text. It compiles to a tenant_id partition-key filter over the audit/tenant_id=… Hive layout (RFC 0005 §3.4), so isolation is a partition prune, not a post-scan filter (RFC 0007 §6.5). A drift query with no tenant is a usage error, not a cross-tenant scan.
  • Window boundaries (RFC0010.2). drift defines its window as half-open [from, to) — lower bound included, upper bound excluded. (RFC 0002’s range(from, to) stage does not pin its boundary semantics today; RFC 0010 defines half-open for the drift window independently.) from/to reuse the RFC 0002 §7 time grammar (relative durations resolve against query-evaluation now; RFC 3339 timestamps are absolute).
  • Window → partition prune. The window’s resolved [t1, t2) bounds drive year/month/day partition pruning over the audit layout (day-granularity per RFC 0005 §3.4), then an exact timestamp predicate trims the boundary days. This is the RFC 0007 partition-prune model applied to the audit series.

6.6 Compilation and execution

flowchart LR
  Q["drift from t1 to t2<br/>(string DSL / structured)"] --> P[parser / validator]
  P --> IR["drift query IR<br/>{ window: [t1, t2) }"]
  IR --> C["compiler<br/>(no SQL leakage — H6)"]
  C --> LP["DataFusion plan over the<br/>RFC 0005 audit/ series<br/>(Filter → Aggregate → Sort)"]
  LP --> X["RFC 0007 execution<br/>(AuditReader, partition prune)"]
  X --> R["DriftRow result set<br/>+ scan stats"]

The drift head parses (string surface) or validates (structured surface) to a small drift IR carrying only the resolved window; both surfaces lower to the same IR (the RFC 0002 §6.4 “two front-ends, one core” discipline). The compiler lowers the IR to a DataFusion plan over the RFC 0005 audit/ files — registered through the ourios-parquet AuditReader / audit_schema() surface — as Filter (event-type + tenant + window) → Aggregate (group by template_id, the §6.3 aggregates) → Sort (§6.3 ordering). Lowering is the only place DataFusion types appear; they never reach the caller (RFC0010.8 / RFC 0007 §6.5). Execution is RFC 0007’s: partition pruning on tenant_id and the time keys, event_type predicate pushdown (RFC 0005 §3.7 names event_type as the pushdown surface for exactly this query), scan stats surfaced on the result.

The querier today scans only the data/ series and rejects Count / Agg stages (crates/ourios-querier/src/compile.rs). This RFC adds the audit source and the one closed aggregation the drift head needs — it does not unblock the general Count / Agg stages over data/, which remain the RFC 0002 deferred work (§8 alternative A).

7. Testing strategy

Mapping to CLAUDE.md §6.2 and docs/verification.md (red→green two-loop: #[ignore]’d stubs first, implementations second). Every §5 scenario is a test; ids are greppable from the test code.

  • The H5.3 flip (RFC0010.1). The existing crates/ourios-miner/tests/hazards.rs::h5_3_drift_query_returns_templates_that_gained_a_version stub is replaced by a real test driving the drift query over a seeded audit stream and asserting templates A, B and their counts. Because the query path lives in ourios-querier, the integration test seeds an audit partition via the ourios-parquet ParquetAuditSink and runs the compiled drift query; the miner-side hazards.rs test asserts the same end-to-end behaviour through the public surface (so RFC 0001’s H5.3 and this RFC’s RFC0010.1 reference one mechanism).
  • Unit / parse tests. Positive and negative parse tests for the drift_query production and the structured { "drift": … } object, including rejection of trailing | stages and of a predicate/stages sibling key (the closed-form constraint, §6.1).
  • Boundary tests (RFC0010.2). Events placed before, on, and after each window bound; assert the half-open [from, to) inclusion this RFC defines (§6.5) — lower bound included, upper bound excluded.
  • Scoping tests (RFC0010.3). rejected_degenerate and compaction events seeded alongside qualifying ones; assert exclusion and correct widening_count.
  • Tenant-isolation test (RFC0010.4). Two tenants’ audit partitions; assert tenant X’s drift never sees tenant Y, and the no-tenant usage error — mirrors RFC0007.5.
  • Empty-result test (RFC0010.5). No qualifying events / no audit files; assert empty, not error.
  • Ordering / aggregate golden tests (RFC0010.6, RFC0010.7). A seeded multi-template audit set with a pinned expected DriftRow ordering and per-row version/time bounds (golden, like RFC 0002’s compilation goldens and RFC 0007’s end-to-end pins).
  • No-leakage test (RFC0010.8). Compile + error-Display string test that no DataFusion/SQL identifier escapes the drift surface; the same technique as RFC0002.3 / RFC0007.3.

8. Alternatives considered

  • A. General audit-stream aggregation pipeline. Implement RFC 0002’s deferred count / agg / group by stages plus a generic audit source, then express drift as a normal aggregation query (audit | range(...) | count by template_id | sort count desc). More general and reusable — it would answer many audit questions, not just drift — but it is a substantially larger surface, it reopens RFC 0002’s deliberately-deferred aggregation work (and its grammar/versioning contract), and it over-delivers for the one fixed query H5.3 needs. The dedicated head ships the H5 signal now with a closed, testable surface. It is a fair trade only because drift is the single audit question on the table; the dedicated drift head can later be re-expressed on top of the general engine (same DriftRow output, same drift surface) without a user-visible change, so choosing it now does not foreclose the general path — it sequences it after a proven need. Hence A is the rejected-for-now primary alternative, not a dead end.
  • B. resolves_to only. RFC 0002 already ships resolves_to(X), which answers “what template_ids are aliased to X” (cross-alias membership; RFC 0001 §6.7). One might argue drift is already covered. It is not: resolves_to is a membership test on the operator-asserted alias map, answering “are these the same template”, whereas drift is the windowed rate-of-change signal “did leaf X gain a version in [t1, t2)” — the cross-version axis RFC 0001 §6.7 keeps explicitly disjoint from the alias axis. The two are orthogonal; resolves_to cannot express a time window or count events, so it cannot answer H5.
  • C. Raw SQL / DataFusion passthrough. Expose the §6.3 SQL (or a general SQL endpoint) directly. Zero surface-design cost. Rejected per hazard H6 (docs/hazards.md H6, “do not leak DataFusion specifics through to users”) and RFC 0002 §10’s standing rejection of a SQL default: it binds the user surface to DataFusion and reopens the cross-tenant / unbounded-scan risks the DSL exists to contain.
  • D. A template_drift boolean column on the data rows. Materialise a per-row “this template drifted” flag so drift becomes a data-file predicate. Rejected: drift is a property of the audit timeline, not of any single log row; the flag would be window-relative (drift is always “in [t1, t2)”), so it cannot be precomputed at write time, and it would bloat every data row for a low-frequency query. RFC 0002 §6.3 already rules this out (“not a column in the RFC 0005 data files”).

9. Open questions

Must be resolved before accepted; none block specified.

  • Surface fork — verb head confirmed (maintainer, 2026-06-09). §6.1’s drift from <t1> to <t2> top-level verb head (over audit | drift(...), a stage on a general audit source) is the intended surface — it forecloses composing further stages onto a drift query by design, and is NOT the general aggregation pipeline (RFC 0002’s deferred work).
  • Re-use of range vs a head-local window. Resolved (maintainer, 2026-08-25): the dedicated clause. The shipped grammar carries from/to on the drift head itself (DriftQuery { from: Time, to: Time } in dsl/ir.rs — “no | stage to compose”), and range stays a log-pipeline stage token. Green under the full RFC0010 §5 suite.
  • Default window. Resolved (maintainer, 2026-08-25): mandatory, as specced. DriftQuery.from/.to are non-optional IR fields — a drift query cannot be expressed without its window. The rationale stands: drift questions are deploy-relative, so a tenant-default window is rarely what an operator means.
  • Tie-break stability. Resolved (maintainer, 2026-08-25): ascending template_id. Implemented as widening_count DESC, template_id ASC (drift.rs §6.6) and pinned by the §5 ordering test — a deterministic, pagination-stable order; last_seen DESC was rejected because recency is already a visible column and would make equal-count ordering non-deterministic across reruns.
  • old_version / new_version on type-expansion rows. Resolved (maintainer, 2026-08-25): both kinds aggregate. TemplateChange::TypeExpanded carries old_version / new_version exactly as widenings do (ourios-core/audit.rs), and the drift aggregate groups both event kinds — RFC 0001 §6.7’s SQL semantics, asserted green by the §5 suite.

10. References

  • RFC 0001 §6.7 — “Drift detection as a first-class query” (the SQL semantics this RFC closes over) and §6.4 (the audit event model); RFC 0001 scenario H5.3 (the red-gate stub this RFC discharges) and §9 (the pending DSL-surface contract).
  • RFC 0002 — the base DSL this RFC extends (predicate { | stage }, the §6.4 two-surface model, the §7 time production reused here, the §6.5 compilation discipline). RFC 0002 §6.3 explicitly deferred the audit-stream query path; that deferral is resolved by this RFC. RFC 0002 stays green and is not edited here.
  • RFC 0005 §3.4 / §3.7 — the audit/ partition layout and audit-event schema this RFC reads (event_type, template_id, old_version, new_version, timestamp; the event-kind mapping table). This RFC does not redefine the schema.
  • RFC 0007 — the querier execution layer (DataFusion, partition prune, scan stats) that runs the compiled drift query; criteria RFC0007.3 (no-leakage) and RFC0007.5 (tenant isolation) are the siblings of RFC0010.8 and RFC0010.4.
  • CLAUDE.md §3.5 (schema migration), §3.7 (multi-tenancy); docs/hazards.md H5 (schema evolution / drift) + H6 (no DataFusion/SQL leakage).
  • ourios-parquet ParquetAuditSink / AuditReader / audit_schema() (the persisted, readable audit surface this RFC queries).

RFC 0011 — A1 re-scope


rfc: 0011 title: A1 re-scope — template-mining compression is logical (query-pruning), not byte-level status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-06-13 supersedes: — superseded-by: —

RFC 0011 — A1 re-scope

Status note. accepted (2026-06-14, maintainer sign-off). A tuning RFC, so it advances directly to the terminal status once its §5 criteria are enacted: RFC0011.1 (A1 is diagnostic, not gating), RFC0011.2 (the miner’s thesis gates are C1 + C2), and RFC0011.3 (the A1 diagnostic is still recorded) are all in force — the docs/benchmarks.md §7 gate table marks A1 diagnostic, RFC 0001’s validated is judged on C1/C2, and §9.5/§9.6 record the A1 readings. Accepting ratifies the re-scope that RFC 0001’s validated/accepted (also 2026-06-14) rests on.

How to read this document. This is a tuning RFC spawned by the docs/benchmarks.md §7 escalation path: a thesis gate (A1) failed and the failure analysis is in, so the gate is reconciled with the evidence rather than left to block indefinitely. §§1–4 are the design contract; §5 is the acceptance criteria (what this RFC must enact); §6 records the measurements. It amends docs/benchmarks.md (the A1 gate’s role) and the thesis-gate set RFC 0001’s validated stage is judged against.

1. Summary

The A1 thesis gate — “Ourios on-disk bytes ≥ 3× smaller than zstd-19 over the raw corpus” — is refuted by measurement on every corpus class tested, including the maximally-templated one, and fails worse the more templated the corpus is. A1 is therefore demoted from a gating thesis criterion to a recorded diagnostic. Template mining’s compression value is realised as query pruning (B1/B2 — row-group skipping, RFC 0007, already validated), reconstruction fidelity (C1), and template-count convergence (C2) — not as on-disk bytes versus a byte codec. RFC 0001’s (template-miner pillar) validated stage is accordingly judged against C1 + C2, both of which pass on a representative ≥ 1 M-line corpus (§6).

2. Motivation

2.1 The measurement

A1 had only ever been measured on the OTel-Demo corpus class (benchmarks.md §9.1/§9.4), where it failed (best 0.829× vs the 3.0× target). The standing analysis attributed this to two structural causes — the demo logs are locally repetitive (so zstd-19 over the concatenated stream captures the redundancy at any size) and columnar Parquet carries a framing premium (per-column/page-index/bloom/row-group overhead) that is the price of queryability. But OTel-Demo is not the corpus where template mining should look best. The decisive test is a maximally-templated corpus: a handful of templates over millions of lines. LogHub HDFS_v1 (11.2 M lines, 1.58 GB) is exactly that.

A1 on HDFS_v1 (§6): ourios 8.300× vs zstd-19 16.000× → delta 0.516× → FAILworse than OTel-Demo, not better.

2.2 Why the best case for template mining is the best case for zstd

The result is not a defect; it is structural and was predictable in hindsight. The more templated (repetitive) a corpus, the more completely a whole-stream byte codec captures its redundancy: zstd-19 over the concatenated HDFS log hits 16×. Template mining collapses the repetitive template text, but the variable bits it extracts — HDFS block IDs, timestamps, IPs — are high-cardinality columns that do not compress to the same degree, and the columnar layout adds framing the single zstd window does not pay. Net: ourios’s 8.3× cannot beat the 16× a byte codec already extracts from the same redundancy. The corpus that most rewards template mining most rewards the byte codec it is measured against, so the ≥ 3× over zstd framing cannot hold on any realistic log corpus.

2.3 What template mining actually buys

The thesis (CLAUDE.md §2 pillar #2) is sound; A1 measured the wrong quantity. Template mining’s “50–200×” is a logical reduction — each line becomes (template_id, params), so a selective query reads a handful of row groups instead of scanning the corpus. That value is captured by B1 (predicate-pushdown latency, ≥ 10×) and B2 (template-exact queries scale with result size, not corpus size) — both pass authoritatively (RFC 0007, validated; benchmarks.md §9.4, incl. HDFS_v1 at 11.2 M rows). The miner’s own correctness is C1 (bit-identical reconstruction or flagged-lossy) and C2 (sub-linear template growth) — both pass on HDFS_v1 (§6). On-disk bytes versus a byte codec is a diagnostic (it tells operators the queryability premium), not a thesis claim.

3. Proposed design

  1. A1 is reclassified diagnostic, not gating. The measurement (ourios ratio, zstd-19 ratio, delta) is still computed and recorded in the benchmarks.md §9 series — it characterises the columnar queryability premium and guards against regression in the codec path — but a delta < 3.0× no longer blocks any RFC’s validated stage. benchmarks.md §7’s gate table marks A1 diagnostic; the §3.4 target text is retained as the diagnostic’s reference line, annotated that it is informational.
  2. The template-miner pillar’s gating thesis criteria are C1 + C2. RFC 0001 (green) reaches validated when C1 and C2 pass on a representative (≥ 1 M-line, benchmarks.md §8) corpus — which they do on HDFS_v1 (§6). The query-pillar gates B1/B2 remain RFC 0007’s, and are already validated.
  3. No change to the codec or the writer. The production ZSTD-3 default stands (the codec gain is small and saturates by level 9, and the residual gap is structural — benchmarks.md §9.1). This RFC changes only what A1 means for the maturity ladder, not any byte on disk.
  4. CLAUDE.md §2 wording is flagged, not changed here. Pillar #2’s “50–200× compression … before any byte-level codec runs” reads as an on-disk-bytes claim; it is precise only as a logical reduction. A one-line clarification is recommended but CLAUDE.md is load-bearing and changes require a meta: RFC + maintainer approval (its own footer), so it is an explicit follow-up (§7), not enacted here.

4. Alternatives considered

  • Keep A1 as a hard ≥ 3× gate. Rejected: it fails on every corpus class including the maximally-favourable one, so it would block RFC 0001’s validated permanently on a criterion the data shows is mis-framed — penalising the project for a measurement that never reflected the thesis.
  • Optimise ourios’s on-disk size to beat zstd-19. Rejected as futile and counter-productive: the ~17 %–2× gap is the columnar framing (page indexes, per-column chunks, bloom filters, row-group metadata) that enables row-group skipping — i.e. it is the price of B1/B2. Shrinking it would trade away the thesis’s actual value to win a metric that doesn’t matter.
  • Drop A1 entirely. Rejected: the ourios-vs-zstd ratio is a useful operator-facing diagnostic (bytes-per-line, the queryability premium) and a regression guard on the codec path. Demote, don’t delete.
  • Redefine A1 to measure the logical reduction (lines → template rows). Considered; deferred. The logical reduction is already what B2 operationalises (result-size-not-corpus-size scaling) and what C2 tracks (template plateau); a third metric restating it adds little. If a standalone “logical compression ratio” proves useful to operators it can be added later as another diagnostic.

5. Acceptance criteria

Scenario RFC0011.1 — A1 is diagnostic, not gating.

  • Given the benchmarks.md §7 thesis-gate table and the §3.4 A1 definition
  • When this RFC is enacted
  • Then A1 is labelled diagnostic (not gating) in the §7 table with a pointer to this RFC, and the §3.4 target is annotated informational
  • And a delta < 3.0× no longer appears in any RFC’s validated blocking set

Scenario RFC0011.2 — the miner pillar’s thesis gates are C1 + C2, and they pass on a representative corpus.

  • Given RFC 0001 (green) and a representative ≥ 1 M-line corpus (benchmarks.md §8)
  • When C1 (reconstruction) and C2 (convergence) are measured on it
  • Then both pass — C1 = 1.000000 bit-identical on non-lossy rows, C2 sub-linear with the formal gate applying (not abstaining) at ≥ 1 M lines — recorded in the §9 series
  • And RFC 0001’s validated stage is judged against C1 + C2 (with B1/B2 the query pillar’s, RFC 0007); A1 does not gate it

Scenario RFC0011.3 — the diagnostic is still recorded.

  • Given a bench run with the A1 gate selected
  • When the harness finalises
  • Then the ourios ratio, zstd-19 ratio, and delta are still computed and written to the §9 results, flagged diagnostic — so the queryability premium stays visible and codec regressions surface

6. Measurements (2026-06-13, local — hardware_kind = "unknown")

Run via ourios-bench --gates … --parquet-zstd-level 19 --allow-unknown-hardware on LogHub HDFS_v1 (Zenodo record 8196385, md5 76a24b4d…; 11,175,629 lines, 1,577,982,906 raw bytes; fetched at bench time, never redistributed — query-bench.yml). Local hardware, so these are diagnostic, not the authoritative baseline-8vcpu-32gib numbers; A1’s verdict is corpus-structural and hardware-independent (compressed bytes are deterministic), and C1/C2 are ratios, so the finding stands regardless of the runner. The authoritative representative-corpus rerun for the actual RFC 0001 validated flip is a maintainer-gated GH Actions / baseline step.

gateresultverdict
A1ourios 8.300× vs zstd-19 16.000× → delta 0.516× (raw 1.578 GB → ourios 189.98 MB, zstd-19 98.21 MB)FAIL (now diagnostic)
C11.000000 reconstruction — 11,175,578 / 11,175,578 non-lossy rows bit-identical; lossy ratio 4.6e-06 (51 rows)PASS
C2end template count 40 at 11.2 M lines (33 at 1 M); ratio 0.825 — sub-linear, formal gate applies (≥ 1 M)PASS

For comparison, A1 on the OTel-Demo class (benchmarks.md §9.1/§9.4) was 0.829× best — so the maximally-templated corpus fails A1 harder, confirming §2.2.

7. Open questions

  • CLAUDE.md §2 pillar #2 wording. “50–200× compression … before any byte-level codec runs” should be clarified to “a 50–200× logical reduction (lines → (template_id, params)), realised as query pruning — not an on-disk-bytes win over a byte codec.” Requires a meta: RFC per CLAUDE.md’s footer; recommended follow-up.
  • Authoritative representative rerun. C1/C2 here are on local hardware. The validated flip for RFC 0001 should cite a baseline-8vcpu-32gib (or equivalent) representative run; the verdicts are not expected to change (deterministic ratios), but the record should be authoritative.

8. References

  • docs/benchmarks.md §3.4 (A1 definition), §7 (gate table + escalation), §9.1/§9.4 (prior A1), §8 (representative-corpus minimum).
  • RFC 0001 §5 (C1/C2 among the miner’s acceptance criteria), CLAUDE.md §2 pillar #2, §3.3 (reconstruction).
  • RFC 0007 (validated) — B1/B2, the query pillar.

RFC 0012 — meta: CLAUDE.md §2 pillar-#2 wording


rfc: 0012 title: “meta: CLAUDE.md §2 pillar-#2 wording — template mining’s 50–200× is a logical reduction, not on-disk bytes” status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-06-14 supersedes: — superseded-by: —

RFC 0012 — meta: CLAUDE.md §2 pillar-#2 wording

Status note. accepted (2026-06-14, maintainer-approved + enacted.) The CLAUDE.md §2 pillar-#2 reword (§3.1) and all three coupled reconciliations (§3.2 — benchmarks.md §2, README.md, RFC 0001 §1) were applied in the enacting PR; RFC 0001’s accepted prose took the recommended factual reword (with the RFC 0011 pointer). The §7 footer changelog line + Last updated bump landed in the same diff. This meta-RFC required majority maintainer approval per CLAUDE.md’s footer; that gate is satisfied.

This is a meta: RFC. It proposes a change to CLAUDE.md, which its own footer declares load-bearing: “This document is load-bearing; further changes require a meta: RFC and majority maintainer approval.” Per CLAUDE.md §8.5 (cache discipline) the edit is not made in the drafting session — this RFC specifies the exact change; a maintainer enacts it after approval. Precedent: the §6.2 “tests are specifications” bullet, added via an informal meta: RFC waiver (commit b50067d, 2026-05-13). This RFC follows the same path, written out in full rather than as an informal waiver.

1. Summary

CLAUDE.md §2 pillar #2 currently reads: “Log lines collapse to (template_id, params) at ingest time. This is where the 50–200× compression comes from — before any byte-level codec runs.” That phrasing reads as an on-disk-bytes claim: that template mining alone yields 50–200× smaller files than the raw corpus, ahead of (and independent of) a byte codec. RFC 0011 (accepted) established by measurement that this is false — a whole-stream byte codec (zstd) captures the same redundancy, so on disk Ourios does not beat zstd (the A1 gate is refuted and demoted to a diagnostic). Template mining’s 50–200× is a logical reduction (each line becomes one row keyed by a small, stable template_id), and its value is realised as query pruning — the benchmark gates B1/B2 — not as fewer on-disk bytes than a codec. This RFC amends pillar #2 to say so, so the project’s canonical thesis statement matches its measured reality, and reconciles the coupled echoes of the same framing elsewhere (benchmarks.md §2, README.md, and RFC 0001’s summary).

2. Motivation

2.1 The pillar statement is now contradicted by an accepted RFC

CLAUDE.md §2 is the project’s load-bearing thesis: changing a pillar “is an RFC-level decision.” Pillar #2’s “this is where the 50–200× compression comes from — before any byte-level codec runs” asserts that the byte-savings come from template mining, ahead of the codec. RFC 0011 (accepted 2026-06-14) measured the opposite on every corpus class, including the maximally-templated LogHub HDFS_v1 (ourios 8.3× vs zstd-19 16× → A1 delta 0.516×, benchmarks.md §9.5/§9.6). The headline number in the most load-bearing document in the repo is therefore inaccurate as written. benchmarks.md opens by calling itself “an honesty contract with ourselves”; the same standard applies to the pillar it tests against.

2.2 The number is not wrong — its referent is

The 50–200× figure is real and worth keeping: it is the logical collapse of N near-identical log lines to a handful of (template_id, params) rows. That reduction is exactly what lets a selective query read a few row groups instead of scanning the corpus (pillar #1’s footer-skip), which the thesis gates B1 (predicate-pushdown latency, PASS — 34.2× / 25.4×, benchmarks.md §9.4) and B2 (result-size-not-corpus-size scaling, PASS) measure and confirm. RFC 0011 §2.3 spells this out. So the fix is a referent correction — “logical reduction → query pruning,” not “on-disk bytes → before the codec” — not a retraction of the claim.

2.3 Why fix the wording at all

An inaccurate load-bearing claim quietly licenses bad decisions: someone could “optimise” Ourios’s on-disk size to chase the 50–200×-vs-codec framing, trading away the columnar framing (page indexes, bloom filters, row-group metadata) that is the value (it enables the row-group skipping B1/B2 measure) — exactly the alternative RFC 0011 §4 rejected as counter-productive. Pinning the pillar to the logical-reduction framing forecloses that.

3. Proposed design

3.1 The CLAUDE.md §2 pillar-#2 change

Replace the current pillar #2 (CLAUDE.md §2, the “Drain-derived online template mining” item):

  1. Drain-derived online template mining. Log lines collapse to (template_id, params) at ingest time. This is where the 50–200× compression comes from — before any byte-level codec runs. Correctness of this layer is the single biggest engineering risk in the project.

with:

  1. Drain-derived online template mining. Log lines collapse to (template_id, params) at ingest time — a logical 50–200× reduction (many near-identical lines become rows keyed by one small, stable template_id). That reduction is what lets a selective query read a handful of row groups instead of scanning the corpus, so the payoff is query pruning (pillar #1’s footer-skip; benchmark gates B1/B2), not fewer on-disk bytes than a byte codec — RFC 0011 showed a whole-stream codec captures the same redundancy, so the on-disk-compression-vs-zstd ratio (A1) is a recorded diagnostic, not a gate. Correctness of this layer is the single biggest engineering risk in the project.

The final sentence (the “single biggest engineering risk” line) is preserved verbatim — it is load-bearing in its own right and unaffected.

3.2 The coupled documentation reconciliations

The same on-disk/byte-level framing echoes in three other docs; all are reconciled in the same enactment so the docs stay consistent (none is load-bearing in the CLAUDE.md sense, so they ride normal doc PRs). The authoritative list is whatever the RFC0012.2 framing-grep (§5) surfaces — as of drafting, the phrase “before any byte-level codec” / “over a competent byte codec” appears in exactly these (plus RFC 0011 and this RFC, which quote it to describe the change):

  1. benchmarks.md §2 — the A1 “Why this bar” bullet paraphrases the pillar as the project’s headline claim (§2, CLAUDE.md) is “50–200× over raw, ≥ 5× over a competent byte codec.” That paraphrase (a) attaches a “≥ 5× over a competent byte codec” multiplier the pillar never literally stated and (b) is the byte-vs-codec framing RFC 0011 demoted. Reword to the logical-reduction / diagnostic framing.
  2. README.md — the “Drain-derived online template miner” bullet says lines collapse to (template_id, params) “before any byte-level codec runs.” Same fix: it is the logical reduction, before the codec in the pipeline but not a bytes-vs-codec claim.
  3. docs/rfcs/0001-template-miner.md §1 — its summary states “The compression target is 50–200× over raw bytes before any byte-level codec runs.” Same framing. RFC 0001 is accepted, but this is a factual thesis-statement correction (not a change to its design or §5 acceptance criteria), so reconcile it to the logical-reduction framing with a one-line note pointing at RFC 0011. (If the maintainer prefers to leave an accepted RFC’s prose untouched, the alternative is a dated editorial note rather than a reword — maintainer’s call at enactment.)

Only the framing is reconciled; bare mentions of the 50–200× figure as a logical reduction (e.g. docs/roadmap.md, other RFCs) are correct and are left alone.

3.3 What does not change

  • No code, schema, or on-disk format. This is a documentation-wording RFC.
  • The production codec default (ZSTD-3) and the A1 diagnostic itself (RFC 0011) are untouched.
  • CLAUDE.md §1’s thesis sentence (“collapses the inverted index, the compression layer, the storage tier, and the query engine into one stack”) is left as-is — it describes the stack collapsing layers, not template mining as the byte-compressor; see §7 for the open question on whether it also wants a touch.

4. Alternatives considered

  • Leave the wording. Rejected: an accepted RFC (0011) contradicts a load-bearing pillar; leaving it is the silent-inaccuracy failure mode the project’s honesty contract exists to prevent.
  • Delete the 50–200× number. Rejected: the logical reduction is real, is the thesis’s actual mechanism, and is worth stating — only its referent (logical, not on-disk-bytes) needs fixing.
  • Reword more aggressively (drop the figure, restate the whole pillar around query pruning). Rejected as over-reach for a wording fix: the minimal precise change keeps the pillar recognisable and the diff reviewable.
  • Fold this into RFC 0011. Rejected: RFC 0011 is accepted and explicitly deferred the CLAUDE.md edit to a meta: RFC (its §3 item 4 / §7), because CLAUDE.md changes need the footer’s majority-approval gate that a thesis-gate tuning RFC does not.

5. Acceptance criteria

Scenario RFC0012.1 — pillar #2 states the logical-reduction framing.

  • Given CLAUDE.md §2 pillar #2
  • When this RFC is enacted (post-approval)
  • Then pillar #2 reads per §3.1: the 50–200× is described as a logical reduction whose payoff is query pruning (B1/B2), and the on-disk-vs-zstd ratio is named a diagnostic (A1, RFC 0011), not a gate
  • And the “single biggest engineering risk” sentence is preserved verbatim

Scenario RFC0012.2 — no on-disk-bytes framing of the 50–200× remains.

  • Given the repo docs (CLAUDE.md, README.md, docs/benchmarks.md, docs/rfcs/0001-template-miner.md)
  • When this RFC is enacted
  • Then no passage frames template mining’s 50–200× as on-disk bytes beaten “before any byte-level codec runs” or as “≥ N× over a byte codec” — all coupled echoes (§3.2: benchmarks.md §2, README.md, RFC 0001 §1) are reconciled
  • And a repo-wide grep for the framing phrasesbefore any byte-level codec and over a competent byte codec — returns only RFC 0011 / this RFC (which quote the old wording to describe the change). The check is on the framing, not on the 50–200× figure itself: mentions of that figure as a logical reduction (e.g. docs/roadmap.md, docs/rfcs/0005-parquet-storage.md) are correct and expected to remain.

Scenario RFC0012.3 — consistency with the accepted A1 re-scope.

  • Given RFC 0011 (accepted), benchmarks.md §7’s gate table (A1 = diagnostic), and the amended pillar #2
  • When a reader cross-checks the thesis statement against the benchmark gates
  • Then the three agree: template mining’s value is logical / query-pruning (B1/B2 gate it), A1 is a diagnostic, and C1/C2 are the miner pillar’s gates (RFC 0001 accepted, RFC 0011)

6. Testing strategy

There is no code test: this RFC changes prose in two living documents. The acceptance criteria (§5) are doc-state assertions, verified by review + the grep in Scenario RFC0012.2, exactly as RFC 0011’s RFC0011.1–.3 were. Two notes:

  • Unlike new OTel names (semconv weaver registry generate no-diff CI) or RFC acceptance scenarios (greppable test ids), CLAUDE.md carries no automated consistency gate — the gate is the footer’s majority maintainer approval on the enacting PR. That human gate is this RFC’s “test.”
  • The enacting PR’s diff is the artefact: reviewers confirm the §3.1 text landed verbatim and §3.2’s benchmarks.md reconciliation rode along.

7. Open questions

  • Maintainer approval (majority). CLAUDE.md’s footer requires it for any change; this RFC cannot be enacted without it.
  • Does CLAUDE.md §1’s thesis sentence want a parallel touch? Resolved 2026-06-14 — yes (maintainer). Both the §1 thesis sentence and README.md’s parallel one now carry a one-clause clarification that the “compression” collapsed is Parquet’s byte codec plus the miner’s logical reduction (query pruning), not bytes that beat a codec. The load-bearing sentence itself is kept; only the clarifying clause was added.
  • Footer changelog line. CLAUDE.md’s footer records each meta change with its commit range and rationale; the enacting PR should add the 2026-06-14 line (and bump “Last updated”) in the same diff.

8. References

  • RFC 0011 — A1 re-scope (accepted): the measurement and the diagnostic-not-gating decision this RFC propagates to the pillar wording. Its §3 item 4 / §7 explicitly deferred this CLAUDE.md edit to a meta: RFC.
  • docs/benchmarks.md §2 (A1 + “Why this bar”), §7 (gate table: A1 diagnostic), §9.4/§9.5/§9.6 (the A1/B1/B2/C1/C2 readings).
  • CLAUDE.md §2 (the pillars), §8.5 (cache discipline — why the edit is not made in-session), and the footer (the meta: RFC + majority- approval rule; precedent b50067d).
  • RFC 0001 (accepted) and RFC 0007 (validated) — the miner and querier pillars whose gates (C1/C2 and B1/B2) carry the value the amended wording points at.

RFC 0013 — Object storage (S3-compatible)


rfc: 0013 title: Object-storage backend (S3-compatible) for the Parquet store status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-06-15 supersedes: — superseded-by: —

RFC 0013 — Object-storage backend (S3-compatible) for the Parquet store

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

Status note. green (2026-06-17; red 2026-06-15). The first shipping-milestone spine: the writer/reader/compactor/audit-sink addressed a single local filesystem bucket_root: &Path, but CLAUDE.md §3.6 declares object storage the source of truth. This RFC abstracts the storage seam behind the Apache object_store crate (already in our tree via DataFusion) so the RFC 0005 data + audit Parquet and the RFC 0009 manifest live on an S3-compatible bucket in production and on local disk in dev/test — without changing the on-disk layout or a single stored row.

All eight §5 scenarios pass. The S3-backed scenarios (RFC0013.1/.3/.4/.7) run in the s3-integration CI job against LocalStack (testcontainers); the local-backend and tenant scenarios (.2/.5) and the reader forward-compat scenario (.8, via the colocated RFC 0005 reader tests) run in the default cargo test; and RFC0013.6 (WAL stays local) is greened end to end through the served binary in ourios-server (tests/rfc0013_6_wal_stays_local.rs), which wires the Store into the RFC 0014 data write path and asserts only Parquet/manifest objects reach the store while the WAL *.wal segments stay on local disk. The crate-shape open question resolved to a module, not a new crate (§3.7).

Landed across green: the S3 backend (object_store aws feature) + conditional-PUT atomic publish (Manifest::publish_cas, RFC0013.3/.4); the writer/reader/compaction/audit consumers migrated from bucket_root: &Path onto Store; and the §7 questions (conditional-PUT portability, credentials via the object_store chain, endpoint override) decided. Deferred to their own follow-ups (not RFC0013 acceptance): a single-writer lease, the multipart-upload threshold, and a read cache.

1. Summary

Ourios’s storage layer addresses a single local-filesystem bucket_root: &Path, threaded through ourios-parquet (writer, reader, compaction, manifest, audit sink) and ourios-server. CLAUDE.md §3.6 makes object storage — “Parquet on S3” — the source of truth, with local disk only a cache and the WAL horizon. This RFC introduces an object-storage backend behind that seam by adopting the Apache Arrow object_store crate (one trait over LocalFileSystem and AmazonS3/S3-compatible stores), so the same code path targets local disk for dev/test/CI and an S3-compatible bucket in production. It pins how the RFC 0009 atomic-publish (manifest generation swap) maps onto object stores that lack POSIX rename (discharging RFC 0009 §7’s deferred S3 atomic-swap + single-writer lease). It changes where bytes live, never the RFC 0005 schema or the Parquet bytes themselves.

2. Motivation

2.1 §3.6 is currently unmet — and it gates deployment

CLAUDE.md §3.6: “Local disk is cache and WAL. Parquet on S3 is the truth. Never design a feature that requires local disk to be durable beyond the WAL horizon.” Today the store is local-filesystem only — no first-party object_store/S3 usage in our Rust source (it is present only transitively, via DataFusion; §2.2); every consumer takes a &Path bucket root. That is fine for the thesis-proving MVP (which ran on single hosts), but it makes Ourios undeployable to a cluster: pods are ephemeral, and acknowledged data must outlive any one node. Durable shared object storage is the spine of the first shipping milestone; nothing else (container image, Helm chart) matters if the data evaporates with the pod.

2.2 Why at this layer, and why object_store

The seam is narrow and already uniform: a bucket_root: &Path (plus the per-partition Hive key layout from RFC 0005 §3.4) threaded through the writer, reader, compact_partition, the manifest, and the audit sink. Abstracting it once behind a backend handle leaves every consumer’s logic unchanged. The natural abstraction is the Apache Arrow object_store crate — and it is already in our dependency tree (v0.13.2) transitively via DataFusion, our query engine (pillar #3). DataFusion’s own table providers read through object_store, so adopting it also aligns the read path: the querier can register the same ObjectStore with DataFusion instead of handing it local file paths. One abstraction, used end to end, that we already ship.

2.3 Why now

The thesis is proven (B1/B2/C1/C2 pass on baseline; benchmarks.md §9.4/§9.6) and the RFC ladder is green-or-beyond. The next milestone is deployability, and this is its load-bearing, architectural-pillar-level prerequisite — hence an RFC (CLAUDE.md §5.1) rather than a PR.

3. Proposed design

3.1 Scope

In scope: the read and write of the RFC 0005 data + audit Parquet series and the RFC 0009 manifest.json through an object-storage backend, with two concrete backends — LocalFileSystem (dev/test/CI; preserves today’s behaviour) and AmazonS3 (production; covers S3-compatible stores — MinIO, Cloudflare R2, etc. — via an endpoint override). Out of scope: the WAL (stays local — it is the §3.4 durability horizon, §3.5 below); any change to the RFC 0005 on-disk schema or Parquet encoding; a table format (Iceberg/Delta — rejected in RFC 0005 §4.1); a local read cache (a future perf concern, §7).

3.2 The object_store abstraction

Adopt object_store::ObjectStore (async put/get/list/delete over an object_store::path::Path — a UTF-8, /-delimited key). The RFC 0005 Hive layout (data/tenant_id=…/year=…/…/<uuid>.parquet, audit/tenant_id=…/…) maps directly onto object keys under a configured prefix — no layout change. A thin Store handle wraps an Arc<dyn ObjectStore> + a key prefix and is threaded where bucket_root: &Path is today.

flowchart LR
    subgraph consumers [ourios-parquet / ourios-server consumers]
        W[Writer] & R[Reader] & C[compact_partition] & A[ParquetAuditSink]
    end
    consumers --> S[Store handle\nan object_store handle + key prefix]
    S --> L[LocalFileSystem\ndev / test / CI]
    S --> O[AmazonS3 / S3-compatible\nproduction]
    Q[ourios-querier] -. registers same store .-> DF[DataFusion] --> S

3.3 The seam migration

Replace bucket_root: &Path with the Store handle across the writer, reader, compact_partition, manifest, and audit sink. Writes go to a temporary key and become live via the §3.4 publish; reads are list + get by key. The change is mechanical and consumer-logic-preserving — the existing RFC 0005/0009 tests re-run against the LocalFileSystem backend to prove no behavioural regression (RFC0013.2).

3.4 Atomic publish without POSIX rename

The hard part. RFC 0009’s atomic publish and the writer’s .parquet.tmp→final both rely on POSIX rename, which object stores do not provide. Map the manifest generation swap onto object stores via conditional PUT: S3 now supports If-None-Match (create-if-absent) and If-Match (compare-and-swap on ETag), surfaced by object_store as PutMode::Create / PutMode::Update{ETag}. A new manifest generation is written with a precondition on the current generation, giving single-writer-wins semantics — exactly the property compact_partition needs so a query never double-counts or misses a row. Data/audit objects are written to a _tmp/ key and made live solely by the manifest swap (no rename). The RFC 0009 §7 single-writer lease (so two compactors don’t race a partition) is realised by the same conditional-PUT contention or a dedicated lease object (§7 open question).

3.5 What stays local

The WAL stays on local disk: it is the §3.4 WAL-before-ack durability horizon, and §3.6 explicitly permits local disk up to that horizon. Recovery (RFC 0008) replays the local WAL into the object store on startup. No feature introduced here requires local disk to be durable beyond the WAL horizon, so the §3.6 invariant holds.

3.6 Schema / compatibility

No change to the RFC 0005 schema, the Parquet bytes, the partition layout, or the reader’s §3.9 forward-compat contract. This RFC is purely about where the bytes are stored; an operator’s existing data semantics are untouched.

3.7 Crate shape — resolved: a module in ourios-parquet

The backend is a store module in ourios-parquet (not a new crate), exposing a Store type. Resolved at red against the less-committing option (CLAUDE.md §7: a new crate is an architectural commitment): the dependency graph confirms it — ourios-querier, -ingester, and -server already depend on ourios-parquet, so the type is visible to every storage consumer without a new crate. If a future consumer needs the store without the Parquet writer/reader, extracting an ourios-store crate is a mechanical follow-up. Configuration (endpoint, region, bucket, prefix, credentials) flows through RFC 0004.

4. Alternatives considered

  • Hand-rolled aws-sdk-s3 client. Direct control, but reimplements what object_store already gives — multi-backend, retries, multipart, and the local/test backend — and diverges from DataFusion’s own storage layer. Rejected: more code, less reuse, two storage abstractions in one tree.
  • Network filesystem (EFS/NFS) mounted into pods, keep &Path. Avoids the S3 work, but violates the §3.6 “S3 is the truth” pillar, inherits NFS’s dicey rename/close-to-open consistency, and is operationally worse and costlier than object storage at log volumes. Rejected.
  • An object-store-as-filesystem shim (s3fs/goofys). Keeps the &Path code, but inherits non-atomic rename and read-after-write pitfalls — the exact correctness hazards §3.4 must avoid. Rejected.
  • A table format (Iceberg/Delta) on object storage. Already rejected in RFC 0005 §4.1 (the manifest in RFC 0009 is the minimal piece we actually need). Out of scope; not reopened here.

5. Acceptance criteria

Normative scenarios in the docs/rfcs/README.md Given/When/Then/And format; each id is referenced from the test code. Refined at specified once the backend trait shape (§7) is fixed, but the scenarios below are the binding contract.

RFC0013.1 — Round-trip through the S3 backend

  • Given a MinedRecord batch covering every RFC 0005 §3.2 column
  • When it is written and then read back through the AmazonS3 backend (a MinIO / localstack container via the testcontainers crate)
  • Then the recovered rows and Parquet bytes equal those from the LocalFileSystem backend, byte for byte.

RFC0013.2 — Local backend regresses nothing

  • Given the existing RFC 0005 and RFC 0009 acceptance suites
  • When they run against the LocalFileSystem backend after the seam refactor
  • Then every one passes unchanged (the abstraction is behaviour- preserving for the local case).

RFC0013.3 — Atomic publish under contention

  • Given two compact_partition runs racing on one partition’s manifest
  • When both attempt to publish a new generation
  • Then exactly one wins; no query observes a torn, doubled, or missing row; and the loser either retries against the new generation or no-ops.

RFC0013.4 — Manifest swap needs no rename

  • Given an object store with no POSIX rename
  • When a generation is published
  • Then it uses conditional PUT — create-if-absent (If-None-Match) and compare-and-swap (If-Match) — with no rename dependency anywhere on the publish path.

RFC0013.5 — Tenant isolation across the key prefix

  • Given data + audit objects for tenants X and Y under the configured prefix
  • When an operation runs in tenant X’s context
  • Then it addresses only X’s key sub-prefix; no read or write touches Y’s keys (CLAUDE.md §3.7).

RFC0013.6 — WAL stays local

  • Given a server configured with an object-storage backend
  • When it ingests and acknowledges a batch
  • Then only the RFC 0005 data/audit Parquet and the RFC 0009 manifest reach the object store; the WAL remains on local disk (the §3.4 durability horizon is unchanged).

RFC0013.7 — S3-compatible endpoints via override

  • Given an S3-compatible store (e.g. MinIO) configured through RFC 0004 with an endpoint override
  • When the backend reads and writes
  • Then it works against that endpoint exactly as against AWS S3.

RFC0013.8 — Reader forward-compat over the object store

  • Given objects written by an older/newer schema (absent / unknown columns, per RFC 0005 §3.9)
  • When the current reader reads them through the object-storage backend
  • Then the §3.9 contract holds (absent columns default, unknown columns ignored) — no error.

6. Testing strategy

Mapped to CLAUDE.md §6.2. The LocalFileSystem backend keeps the current fast, corpus-free unit/proptest path; the AmazonS3 backend is exercised against a MinIO/localstack container (testcontainers, which supports any OCI runtime — Docker on the CI runner, nerdctl/containerd or Podman locally). Per scenario:

  • RFC0013.1 / .7 / .8 (S3 round-trip / S3-compatible endpoint / reader forward-compat over the store) — integration tests against the S3 container, reusing the RFC 0005 round-trip and §3.9 fixtures behind the backend trait.
  • RFC0013.2 (local backend regresses nothing) — the existing RFC 0005 + RFC 0009 suites re-run against LocalFileSystem via a parametrised harness (one suite, two backends).
  • RFC0013.3 (atomic publish under contention) — a proptest / concurrency test driving N racing publishers at the S3 container, asserting exactly-one-wins and no torn / doubled / missing rows.
  • RFC0013.4 (manifest swap, no rename) — integration test asserting the publish path uses conditional PUT (PutMode::Create / PutMode::Update{ETag}) and never a rename.
  • RFC0013.5 (tenant isolation) — integration test interleaving two tenants’ objects under the prefix; asserts no cross-prefix access.
  • RFC0013.6 (WAL stays local) — after ingest + ack, assert WAL frames are on local disk and only data/audit/manifest objects reached the store.

The S3 integration lane is #[ignore] / feature-gated, so the default cargo test stays container-free; CI runs it explicitly.

7. Open questions

  • Crate shape — resolved at red: a store module in ourios-parquet (no new crate; dep-graph-confirmed, §3.7).
  • Single-writer lease — is conditional-PUT contention on the manifest sufficient, or is a separate lease object needed (RFC 0009 §7)?
  • Conditional-PUT portability — do If-None-Match/If-Match cover the compare-and-swap on every targeted store (S3, R2, MinIO, GCS via object_store)? What is the fallback where a store lacks it?
  • Multipart threshold — RFC 0009 outputs are 256 MiB–2 GiB; pick the multipart-upload threshold.
  • Credentials — IAM role (IRSA on k8s) vs static keys via RFC 0004 / k8s Secrets.
  • Local read cache for hot Parquet — defer (perf, not correctness)?
  • Migration — is a local-FS-store → object-store copy tool needed, or is it N/A pre-release (no production data yet)?

8. References

  • CLAUDE.md §3.6 (object storage is the source of truth), §3.4 (WAL-before-ack / durability horizon), §3.7 (multi-tenancy partitioning), §5.1 (RFC-required for a pillar), §7 (new-crate commitment).
  • RFC 0005 — the on-disk Parquet contract being relocated (unchanged).
  • RFC 0009 — the atomic-publish manifest; §7 deferred the S3 atomic-swap + single-writer lease, discharged here.
  • RFC 0004 — configuration policy (endpoint, region, bucket, prefix, creds).
  • object_store — the Apache Arrow object-storage abstraction, already a transitive dependency via DataFusion.
  • S3 conditional writes — If-None-Match / If-Match precondition PUTs.

RFC 0014 — Ingest write path (record sink & flush)


rfc: 0014 title: Ingest write path — record sink and flush policy status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-06-17 supersedes: — superseded-by: —

RFC 0014 — Ingest write path: record sink and flush policy

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

Status note. green (2026-06-17; red/specified same day). The conspicuous gap in the ingest stack is closed: the miner (RFC 0001) emits each mined MinedRecord into a RecordSink, and production formerly wired NoOpRecordSink — the records were dropped. Every other layer was built and tested (OTLP → WAL → miner; Parquet writer/reader; compaction; the RFC 0013 object-storage seam with a buffer-and-put Writer), but nothing carries a mined record to a Parquet object on the store. This RFC specifies the missing piece: a buffering RecordSink and the flush policy that governs when buffered records become a Parquet object — a CLAUDE.md §4 (small-file) / §3.4 (WAL-durability) / §3.7 (multi-tenancy) decision that no existing RFC covers.

Scope is deliberately narrow: the flush policy + the sink. Wiring the server to construct/inject a Store (RFC 0004 config, local vs S3) and migrating compaction’s manifest publish to Manifest::publish_cas on S3 (RFC0013.3/.4) are follow-on work, tracked as open questions, not part of this RFC’s acceptance.

specified finalizes the §5 acceptance criteria (RFC0014.1–.6, greppable + testable) and §6 testing strategy, and settles the two criteria-shaping design questions: rotation force-flushes every partition (§3.2, RFC0014.3), and the memory ceiling is hardemit blocks rather than exceed it (§3.4, RFC0014.4). The remaining §7 questions (defaults, early-flush victim, rotation-hook surface, size estimation) are tuning / implementation detail, decided across the red/green PRs.

red landed the six #[ignore]d acceptance stubs (RFC0014.1–.6) in crates/ourios-ingester/tests/rfc0014_ingest_write_path.rs. green built the buffering ParquetRecordSink (the hybrid size/age/rotation flush policy + the hard ceiling), then wired it into the miner in place of NoOpRecordSink via a SharedParquetSink the server constructs and the pipeline drives (flush_all on rotation, flush_aged on a batch-window age sweep, a drain on graceful shutdown). All six §5 scenarios pass: RFC0014.1–.4 and .6 drive the sink directly against a LocalFileSystem-backed Store; RFC0014.5 (no acknowledged-data loss) is a real-process SIGKILL crash test (tests/rfc0014_5_crash_no_loss.rs) that extends the RFC 0008 harness — after a crash with a non-empty buffer, WAL replay re-mines every un-flushed acknowledged record into a fresh sink, which flushes them to the store.

No-loss rests on a single ordering rule the server applies at every miner-snapshot cadence point (post-recovery, rotation, shutdown): flush the sink before writing the snapshot, so the miner’s snapshot horizon never outruns the sink’s flushed horizon and recovery’s miner-gated replay covers every un-flushed record. Semantics are at-least-once (a pre-crash flush may re-flush on recovery; nothing is lost). The §7 follow-ons (S3 Store selection via RFC 0004; compaction’s publish_cas adoption) and the exactly-once dedup of cross-crash duplicates remain open, outside this RFC’s acceptance.

1. Summary

A buffering RecordSink implementation — the production data write path — accumulates mined MinedRecords per partition and flushes each partition to a Parquet object on the RFC 0013 Store seam. The flush policy is hybrid: a partition flushes when its buffered bytes reach a size target (toward RFC 0005 §3.5’s file-size band) or its oldest buffered record reaches a max age, and every partition force-flushes when the WAL segment rotates (RFC 0008). Total buffered bytes are bounded by a hard ceiling: exceeding it forces an early flush (and, at the hard limit, applies backpressure to ingest). The sink reuses RFC 0008’s batch-window / rotation machinery rather than inventing a parallel cadence. Records reach the sink only after the WAL is durable (CLAUDE.md §3.4), so an un-flushed buffer is always recoverable by WAL replay.

2. Motivation

Why this change now. The first-shipping-milestone thesis is “OTLP in, queryable Parquet out.” The query path reads Parquet that the ingest path must produce — but the ingest path stops at the miner: RecordSink exists with only NoOpRecordSink (drop), InMemoryRecordSink (test), and SharedRecordSink (test) impls. RFC0013.6 (“WAL stays local; only Parquet/manifest reach the store”) cannot be greened because nothing writes data to the store during ingest. Closing this gap completes the ingest half of the thesis.

Why at this layer. The flush policy sits between the miner (RFC 0001, which emits records one at a time and must not own I/O policy) and the Parquet store (RFC 0005, which specifies the file format and row-group sizing but explicitly not when records are flushed to a file — confirmed a genuine gap). It is the natural home for three hazards that no other RFC binds together:

  • CLAUDE.md §4 small-file problem. Flush too eagerly and the store fills with tiny Parquet objects that defeat predicate pushdown and lean entirely on compaction (RFC 0009) to recover. The flush policy is the first line of defence; compaction is the second.
  • CLAUDE.md §3.4 WAL-before-ack durability. The sink buffers acknowledged data in memory. The buffer must never be the durability of record — that is the WAL’s job. A crash mid-buffer must lose nothing acknowledged.
  • CLAUDE.md §3.7 multi-tenancy. Buffers are keyed by PartitionKey, which carries tenant_id; flushing one partition must never touch another tenant’s data.

Why not defer to compaction. Compaction fixes small files after the fact; it does not remove the cost of creating them (every tiny object is a store PUT, a manifest churn, and a footer read until compacted). Right-sizing at write time is cheaper than over-producing and consolidating.

3. Proposed design

3.1 The sink

A ParquetRecordSink implements RecordSink::emit(&mut self, record: MinedRecord). It owns:

  • Per-partition buffers. A map PartitionKey → PartitionBuffer, where a PartitionBuffer accumulates MinedRecords plus a running estimate of its encoded size and the wall-clock time of its oldest record. The PartitionKey (RFC 0005 §3.4) carries tenant_id, so buffers are tenant-scoped by construction (CLAUDE.md §3.7).
  • A handle to the Store (RFC 0013) — the flush target.
  • Flush configuration (RFC 0004): the size target, the max buffer age, and the global buffered-bytes ceiling.

emit derives the record’s PartitionKey, appends it to that partition’s buffer, updates the size estimate, and evaluates the flush triggers (§3.2).

3.2 Flush triggers (the hybrid policy)

A partition flushes when any of:

  1. Size — its buffered (estimated) bytes reach the size target. The target sits inside RFC 0005 §3.5’s 256 MiB–2 GiB file band so a single buffer becomes one right-sized object.
  2. Age — its oldest buffered record’s age reaches max_buffer_age (inclusive: flush when age ≥ the configured max). This bounds the staleness of low-volume tenants/partitions whose size trigger would otherwise never fire.
  3. WAL segment rotation — when the WAL segment seals (RFC 0008’s rotation hook), every partition force-flushes, including sub-threshold low-volume partitions (no size gate). This aligns the published-Parquet horizon with a WAL boundary: once a segment is sealed and its mined records flushed, recovery never needs that segment for data (only the still-open tail’s records are buffered-but-un-flushed), and the acknowledged-but-unpublished data is capped at roughly one segment. The cost — small files from tiny partitions — is deliberately accepted and left to compaction (RFC 0009) to consolidate; the clean recovery invariant is worth more than avoiding a few small objects.

A flush encodes the partition’s buffered records to a Parquet object (encode_records_to_parquet + Store.put, the RFC 0013 buffer-and-put path, UUIDv7-named per RFC 0005 §3.4) and clears the buffer.

3.3 Reusing the WAL machinery (not a parallel cadence)

The age and rotation triggers reuse RFC 0008’s existing batch-window / segment-rotation mechanism rather than standing up a second timer/coordinator. The ingest pipeline already has a rotation hook (RFC 0009 §6.9 wires snapshot writes to it); the sink subscribes to the same hook for trigger 3, and the age sweep piggybacks on the batch-window tick. One cadence, one source of truth for “time has passed / a segment sealed.”

3.4 Memory ceiling and backpressure

The sink tracks total buffered bytes across all partitions against a hard ceiling:

  • Soft pressure (early flush). As the total approaches the ceiling, the sink force-flushes the largest (or oldest) partition(s) ahead of their size trigger, reclaiming memory without blocking ingest.
  • Hard limit (backpressure). If early flush cannot keep the total under the ceiling (e.g. a flush is slow or the store is unavailable), emit blocks until an in-flight flush frees enough memory — so the buffer can never exceed the ceiling (a hard, not soft, bound). Because the OTLP ack already happened (post-WAL), this blocks only mining/flushing throughput, not durability — no acknowledged data is at risk. The client-facing ack is already sent, so the cost under sustained overload is increased WAL→Parquet publish lag (internal backlog), not client-facing ingest latency.

This makes the in-memory buffer a bounded, best-effort accelerator on top of the WAL, never an unbounded liability (cf. §3.2’s hazard list).

3.5 Durability and crash recovery (CLAUDE.md §3.4)

Records reach the sink only after the WAL has durably committed them (ourios-ingester pipeline: append + fsync → ingest gate → miner → sink — the ordering is already in place). Therefore:

  • A crash with a non-empty buffer loses no acknowledged data: every buffered record came from a WAL frame that is on disk. Recovery re-mines the WAL tail (the un-flushed records) and re-buffers them.
  • The flush itself is not crash-durable beyond the store’s own semantics (object PUT atomicity, no fsync) — identical to the RFC 0005 writer / RFC 0013 store contract. The WAL remains the crash-survival horizon.

3.6 What this RFC does not change

  • Not the on-disk Parquet format or partition layout (RFC 0005) — the sink produces ordinary <uuid>.parquet objects.
  • Not the WAL (RFC 0008) — the sink consumes its rotation/tick signals; it does not alter WAL durability or batching.
  • Not the manifest/compaction (RFC 0009) — flushed files are live immediately via the *.parquet glob; compaction consolidates them later as today.
flowchart LR
  OTLP[OTLP batch] --> WAL[WAL append + fsync]
  WAL -->|durable, post-ack| Miner[miner.ingest]
  Miner -->|emit MinedRecord| Sink[ParquetRecordSink]
  Sink -->|append| Buf[(per-partition buffer)]
  Buf -->|size >= target| Flush[encode + Store.put]
  Buf -->|age >= max| Flush
  Rot[WAL segment rotation] -->|force-flush all| Flush
  Ceil[buffered bytes >= ceiling] -->|early flush / backpressure| Flush
  Flush --> Obj[(Parquet object on Store)]

4. Alternatives considered

Pure size+time window (no rotation trigger). Option 1+2 without 3. Gives right-sized files but decouples the published-Parquet horizon from the WAL, so crash-recovery reasoning must independently bound “how far behind can the buffer be.” The rotation trigger is cheap insurance that makes the horizon argument trivial; we keep it.

WAL-segment-rotation only. Flush exactly when a segment seals. Simplest recovery story (Parquet boundary ≡ WAL boundary) and bounded buffering, but file size is hostage to the WAL rotation cadence — tuned for durability latency, not for the CLAUDE.md §4 256 MiB–2 GiB target. Rejected as the sole trigger; kept as the force-flush bound in the hybrid.

Stream per batch (the A1-bench pattern). Open a Writer per partition, append each OTLP batch, close on rotation. Minimal sink logic, but produces many small files between compactions — it leans the entire CLAUDE.md §4 mitigation onto compaction and pays the small-file cost (PUTs, manifest churn, footer reads) in the interim. Rejected for production; it remains the bench’s expedient.

No sink-side ceiling (trust the size+time triggers). Simpler, but a slow store or a burst across many partitions could grow the buffer without bound between triggers. The hard ceiling + backpressure is the difference between a bounded accelerator and an OOM risk; we keep it.

5. Acceptance criteria

Normative scenarios; ids RFC0014.<m> are referenced from the test code (docs/verification.md §2). One scenario per hazard/invariant this RFC touches (CLAUDE.md §4 small-file, §3.4 WAL-durability, §3.7 multi-tenancy).

Scenario RFC0014.1 — Size trigger

  • Given a partition whose buffered bytes are just below the size target
  • When a record is emitted that brings the buffer to or over the target
  • Then emit flushes the partition to exactly one Parquet object sized within the RFC 0005 §3.5 band
  • And the buffer is cleared

Scenario RFC0014.2 — Age trigger

  • Given a low-volume partition below the size target
  • When its oldest record’s age reaches max_buffer_age
  • Then it flushes on the next batch-window tick

Scenario RFC0014.3 — Rotation force-flush (CLAUDE.md §4)

  • Given buffered records across several partitions, including low-volume sub-threshold ones
  • When the WAL segment rotates
  • Then every partition flushes
  • And no buffered record predates the sealed segment

Scenario RFC0014.4 — Bounded memory (CLAUDE.md §4)

  • Given buffered bytes approaching the ceiling
  • When more records arrive
  • Then the sink early-flushes to stay under it
  • And at the hard limit emit blocks until a flush frees memory, so total buffered bytes never exceed the ceiling

Scenario RFC0014.5 — No acknowledged-data loss (CLAUDE.md §3.4)

  • Given a non-empty buffer
  • When the process crashes
  • Then WAL replay re-mines every un-flushed acknowledged record
  • And no acknowledged record is lost

Scenario RFC0014.6 — Tenant isolation (CLAUDE.md §3.7)

  • Given buffered records for tenants X and Y
  • When one partition flushes
  • Then the produced object holds only that partition’s (single tenant’s) rows
  • And no buffer or flush crosses tenants

6. Testing strategy

Mapped to CLAUDE.md §6.2.

  • Unit tests for each flush trigger (RFC0014.1–.3) and the ceiling (RFC0014.4), driving the sink with synthetic MinedRecord streams and a LocalFileSystem-backed Store.
  • Property test (proptest) for RFC0014.5/.6: for any interleaving of emitted records across tenants and any sequence of triggers, every emitted record lands in exactly one flushed object under its own tenant’s partition, and the multiset of flushed rows equals the multiset emitted (modulo the still-buffered tail).
  • Crash-recovery test (RFC0014.5) in ourios-ingester: kill mid-buffer, recover, assert WAL replay reproduces the un-flushed records — extends the existing RFC 0008 crash-recovery harness.
  • The end-to-end “only Parquet/manifest reach the store; WAL stays local” assertion (RFC0013.6) is greened by the follow-on server-wiring work, not this RFC’s acceptance.

7. Open questions

Tuning + implementation detail, decided across red/green:

  • Default values: go-live values set in ourios-server (size target 256 MiB, max_buffer_age 300 s, ceiling 1 GiB, age sweep every 30 s). Promoting them to RFC 0004 config knobs and tuning against representative corpora remains open.
  • Early-flush victim selection at the ceiling: largest partition (flush_largest) — reclaims the most memory per flush. A soft pressure threshold below the hard ceiling was not needed and is not implemented.
  • Integration surface with RFC 0008’s rotation hook and batch-window tick: the pipeline/server drives the sink (it does not subscribe) — flush_all from the rotation hook, flush_aged from a server-owned age sweep — keeping the sink ignorant of the WAL.
  • Size estimation: a cheap running estimate (estimate_bytes over the large variable-length fields), not encoding to measure — bounds memory and roughly right-sizes files without hot-path cost.
  • Follow-on (out of this RFC’s acceptance): the server constructs a local Store today; S3 selection (RFC 0004) is still open. Compaction’s manifest publish adopting Manifest::publish_cas on S3 (RFC0013.3/.4) also remains. RFC0013.6 is already greened (server-wiring landed). The exactly-once dedup of cross-crash at-least-once duplicates is open too.

8. References

  • RFC 0001 — template miner; RecordSink and the emit contract.
  • RFC 0003 — OTLP receiver; WAL-before-ack ordering, response semantics.
  • RFC 0004 — configuration policy; the flush-config knobs.
  • RFC 0005 — Parquet storage; row-group (§3.5) and file-size targets, partition layout (§3.4), the buffer-and-put Writer.
  • RFC 0008 — WAL; batch-window, segment rotation, crash-recovery harness.
  • RFC 0009 — compaction; the small-file second line of defence.
  • RFC 0013 — object-storage Store seam; encode_records_to_parquet, Store.put, Manifest::publish_cas.
  • CLAUDE.md §2 (pillars), §3.4 (WAL-before-ack), §3.7 (multi-tenancy), §4 (small-file problem, hazards).

RFC 0015 — Fuzzing harness


rfc: 0015 title: Fuzzing harness — cargo-fuzz targets & ClusterFuzzLite CI status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-06-19 supersedes: — superseded-by: —

RFC 0015 — Fuzzing harness: cargo-fuzz targets & ClusterFuzzLite CI

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

1. Summary

Add a coverage-guided fuzzing harness: a fuzz/ cargo-fuzz workspace member with libFuzzer targets on the project’s highest-risk surfaces — the template miner and the untrusted-input parsers (OTLP protobuf, OTLP/JSON, WAL frame). The miner target does not merely check for panics: it asserts the §3.3 bit-identical-reconstruction invariant, so the fuzzer actively hunts inputs that round-trip wrong. CI is phased — Phase 1 (this RFC) lands the targets plus a bounded smoke-fuzz job that gates on crashes; Phase 2 (a follow-up) layers ClusterFuzzLite for continuous fuzzing, corpus persistence, and OpenSSF Scorecard detection of the Fuzzing check. The new fuzz/ member is the architectural commitment this RFC exists to authorise (CLAUDE.md §7).

2. Motivation

The template miner is named in CLAUDE.md §2 as “the single biggest engineering risk in the project,” and §3.1 / §3.3 make its merge correctness and bit-identical reconstruction load-bearing invariants. The OTLP decoders (RFC 0003) and the WAL frame reader (RFC 0008) parse adversarial bytes off the wire and off disk — exactly the boundary fuzzing is built for.

proptest already guards these invariants (e.g. crates/ourios-miner/tests/invariants.rs), but property tests explore only the input space a hand-written Strategy describes. Coverage-guided fuzzing instruments the binary and mutates toward unexplored branches, reaching malformed-but-structurally-valid inputs — truncated protobuf, non-UTF-8 bodies, CRC-valid-but-semantically-broken frames — that a generator rarely synthesises. The two techniques are complementary: proptest pins the invariants we can describe; the fuzzer finds the ones we did not think to.

Why now: the ingest and query stack is built and tested behind RFC gates, so the parsing and mining surfaces are stable enough that fuzz findings reflect real bugs rather than churn. Fuzzing was previously parked in the “deferred to the shipping milestone” set alongside Signed-Releases; the maintainer has opted to pull it forward (it finds bugs cheaply, before they calcify into the on-disk contract). Closing Scorecard’s Fuzzing check (currently 0) is a secondary benefit of Phase 2, not the primary driver.

3. Proposed design

3.1 The fuzz/ workspace member

A single new workspace member at the repo root, fuzz/, following the cargo-fuzz convention (cargo fuzz init). It is:

  • not published (publish = false) and carries no library API — it exists only to host fuzz targets;
  • built with nightly Rust. libFuzzer requires sanitizer/-Z support absent from stable. rust-toolchain.toml stays stable (the source of truth for every shipping crate per CLAUDE.md §6.1); the nightly toolchain is requested only by the fuzz CI job and by developers running fuzz locally. This is a contained, documented deviation from the §6.1 stable pin — it never touches the binaries we ship;
  • opts out of the workspace unsafe_code = "deny" lint (root Cargo.toml [workspace.lints.rust]; every shipping crate inherits it via [lints] workspace = true), because the libfuzzer_sys::fuzz_target! macro (the libfuzzer-sys crate) expands to unsafe glue. CLAUDE.md §6.1 permits a per-crate waiver where an RFC justifies one (it cites a possible ourios-parquet zero-copy need as the example; no crate carries such a waiver today — every crate root, ourios-parquet included, is #![deny(unsafe_code)]). This RFC is that justification, scoped to fuzz/ alone — the member simply does not inherit the workspace lint; our harness bodies stay safe.

Adding this member is a CLAUDE.md §7 new-crate decision; this RFC is that decision’s record.

3.2 The targets

Four targets, ranked by risk. Each is a fuzz_target!(|data: &[u8]|) reaching a stable entry point with minimal glue.

TargetEntry pointCrateOracle
miner_roundtripingest (with an observable RecordSink) → drain the MinedRecordtemplates_forreconstruct::render, on a string-body recordourios-minerinvariant: the rendered bytes equal the original string body whether render reports Reconstruction::Faithful (rebuilt) or Reconstruction::RetainedVerbatim (retained) — §3.3
otlp_jsondecode_json(&[u8])ourios-ingesterno panic; Ok/Err both fine
otlp_protobufdecode_protobuf(&[u8])ourios-ingesterno panic; Ok/Err both fine
wal_frameframe::read_frame(&mut Cursor::new(data)) — today pub(crate), exposed to the target via the fuzzing feature (§3.2, §7)ourios-walno panic; malformed input yields a typed FrameError, never UB

miner_roundtrip is the centerpiece. Rather than feed the miner a fixed string, the target uses the arbitrary crate to build an OtlpLogRecord whose body is a String (the Drain template path — the fuzz bytes become the log line; attributes are derived alongside). MinerCluster::ingest returns only a template_id, so the harness follows the miner’s real read-back path (the one crates/ourios-miner/tests/invariants.rs uses): the cluster is built with an observable RecordSink (SharedRecordSink), ingest is called, the emitted MinedRecord is drained from the sink, the leaf’s template tokens are looked up via MinerCluster::templates_for(tenant) matching the record’s (template_id, template_version), and reconstruct::render is called with that record and those tokens. It then asserts the §3.3 contract: the rendered bytes equal the original string body in both outcomes — whether render reports Reconstruction::Faithful (rebuilt from the template) or Reconstruction::RetainedVerbatim (the original body surfaced verbatim, not rebuilt). §3.3 guarantees a string line is either reconstructed exactly or has its original body retained, so either a faithful-rebuild mismatch or a retention failure is a violation — and makes the target panic, which libFuzzer reports as a crash. That turns the fuzzer into a search for reconstruction bugs, not just for unwraps.

The target is deliberately scoped to string bodies: that is the template-mining + line-reconstruction path the §3.3 invariant governs. Structured (kvlist/array) bodies take the §6.1 canonical-encoding path (lossy_flag = false, no template walk), whose round-trip is a distinct property — a candidate for a separate target (§7), not folded into this oracle.

The three parser targets are panic-oracles on untrusted-input boundaries: a decoder must reject garbage with a typed error, never panic, abort, or exhibit UB.

frame::read_frame is currently pub(crate). Rather than widen the WAL public API, expose it to the fuzz target through a #[doc(hidden)] shim (or a fuzzing cargo feature) — resolved in §7.

3.3 Seed corpora

Committed seeds live under fuzz/seeds/<target>/ (a tracked directory, distinct from the gitignored working corpus fuzz/corpus/<target>/). The CI job copies the seeds into the working corpus before each run, so the committed inputs bootstrap coverage without the evolving corpus churning the repo:

  • miner_roundtrip seeds from a few real-shaped log lines;
  • otlp_json seeds from a minimal ExportLogsServiceRequest (an empty {"resourceLogs":[]});
  • otlp_protobuf and wal_frame start from libFuzzer’s generated inputs in Phase 1; binary seeds (valid protobuf encodings / valid frames) can be added later.

Committed seeds are kept minimal (enough to bootstrap coverage); the grown corpus is persisted by ClusterFuzzLite in Phase 2, not committed.

3.4 CI — phased

Phase 1 (this RFC’s green): .github/workflows/fuzz.yml. A bounded smoke-fuzz job on a pinned nightly toolchain, run as a matrix over all four targets — the parser targets are cheap, so there is no reason to gate on the miner alone. Each matrix job runs its target for the budget of the triggering event, e.g.:

# Daily schedule: ~300 s per target. Manual dispatch: ~60 s. --target
# forces the gnu host triple (cargo-fuzz otherwise picks musl, whose
# static libc is incompatible with the ASan sanitizer).
cargo +nightly-2026-06-01 fuzz run <target> --target "$host" -- -max_total_time=300

It runs on a daily schedule and on workflow_dispatch — deliberately not on pull_request: the sanitizer build is too heavy for per-change CI, and continuous per-change fuzzing is Phase 2’s job (ClusterFuzzLite). fuzz run builds before it runs, so a target that stops compiling fails its job; because every target is always in the matrix (fail-fast: false), all four are built and run on every invocation. A crash fails that target’s job and uploads the reproducer as an artifact. Top-level contents: read (the workflow-token least-privilege pattern the other workflows follow).

Phase 2: ClusterFuzzLite. .clusterfuzzlite/ (Dockerfile on the OSS-Fuzz base-builder-rust image + build.sh that cargo fuzz builds the same targets and stages them with their seed corpora) plus cflite_batch.yml (scheduled continuous fuzzing that grows and persists the corpus) and cflite_coverage.yml (weekly corpus line-coverage). Both run on schedule + workflow_dispatch only — no PR-fuzzing workflow, consistent with the per-PR rule above. The corpus persists in the GitHub Actions cache (no external storage backend), resolving the §7 open question. ClusterFuzzLite is what Scorecard’s Fuzzing check detects (via .clusterfuzzlite/Dockerfile; it cannot see a bare cargo-fuzz directory), so Phase 2 is what moves that check 0 → positive (RFC0015.7). The cflite container build is verified by dispatching cflite_batch after merge — it cannot run on the introducing PR with no PR trigger.

3.5 Regression discipline

When the fuzzer finds a crash, the workflow per CLAUDE.md §6.2 is: minimise the reproducer (cargo fuzz tmin), commit it as a permanent seed under the tracked fuzz/seeds/<target>/ (the working fuzz/corpus/ is gitignored, so a reproducer parked there would not persist — §3.3), then fix the bug. The seed stays forever, re-checked on every run — a found bug becomes a standing specification, never silently dropped.

4. Alternatives considered

afl.rs (AFL++) instead of cargo-fuzz/libFuzzer. AFL++ is a capable fuzzer, but cargo-fuzz/libFuzzer is the de-facto Rust default, has the smoothest cargo integration, and is the engine ClusterFuzzLite and OSS-Fuzz drive for Rust. Choosing it keeps Phase 1 and Phase 2 on one engine.

Just extend proptest, no coverage-guided fuzzing. The obvious cheaper move is to widen the existing proptest suites rather than add a fuzz toolchain. We keep and value proptest, but it cannot replace fuzzing here: its inputs come from hand-authored Strategy generators that sample a distribution we describe, with no feedback from the code under test. A coverage-guided fuzzer instruments the binary and mutates toward unexecuted branches, reaching the malformed-but-structurally-valid inputs (truncated protobuf, CRC-valid-but-broken frames, non-UTF-8 body bytes) that a generator only hits by luck. proptest pins the invariants we can describe; the fuzzer finds the ones we did not think to write a strategy for. They are complementary layers, not substitutes — which is also why the miner_roundtrip oracle deliberately reuses the same §3.3 assertion the proptest suite already encodes.

OSS-Fuzz from day one instead of ClusterFuzzLite. OSS-Fuzz is the richer option — Google-hosted compute, long-running campaigns, automatic bug filing — and remains the goal once Ourios ships. But acceptance requires a project to be widely used or critical to the ecosystem, which a pre-release backend is not, and onboarding adds an external dependency and review loop we do not control. ClusterFuzzLite is the same engine (libFuzzer) running in our own CI with our own corpus, available today and detected by Scorecard; it is the pragmatic Phase 2, with OSS-Fuzz held as a post-ship upgrade.

A fuzzing feature inside each crate instead of a separate fuzz/ member. Folding targets into the shipping crates would drag the nightly/sanitizer toolchain and the unsafe macro expansion into code we ship. The cargo-fuzz convention isolates all of that in fuzz/.

Keep fuzzing deferred to the shipping milestone. Rejected by the maintainer: the surfaces are stable now, fuzzing is cheap, and bugs found pre-release never reach the on-disk contract. Deferral only delays the find.

5. Acceptance criteria

Scenario RFC0015.1 — miner round-trip target enforces the §3.3 invariant

  • Given the miner_roundtrip target and a MinerCluster built from MinerConfig::default() with an observable RecordSink attached
  • When the target builds an OtlpLogRecord with a String body from the arbitrary input, ingests it, drains the emitted MinedRecord from the sink, looks up the leaf tokens via templates_for for the record’s (template_id, template_version), and calls render
  • Then the rendered bytes equal the original string body in both outcomes — whether render reports Reconstruction::Faithful (rebuilt from the template) or Reconstruction::RetainedVerbatim (the original body returned verbatim) — since §3.3 guarantees a string line is either reconstructed exactly or has its original body retained
  • And the Reconstruction marker is asserted to be one of those two variants, recording which path produced the bytes
  • And any input whose rendered bytes differ from the original string body makes the target panic (a libFuzzer crash) — a faithful-rebuild mismatch and a retention failure are both §3.3 violations
  • And the assertion references the §3.3 invariant id so the mapping back to CLAUDE.md is greppable

Scenario RFC0015.2 — OTLP/JSON decode never panics

  • Given the otlp_json target
  • When it is run on arbitrary bytes
  • Then decode_json returns Ok(_) or Err(DecodeError)
  • And the target never panics, aborts, or triggers a sanitizer error on any input in a bounded run

Scenario RFC0015.3 — OTLP/protobuf decode never panics

  • Given the otlp_protobuf target
  • When it is run on arbitrary bytes
  • Then decode_protobuf returns Ok(_) or Err(DecodeError)
  • And the target never panics, aborts, or triggers a sanitizer error on any input in a bounded run

Scenario RFC0015.4 — WAL frame decode yields a typed error, never UB

  • Given the wal_frame target wrapping the input in a Cursor
  • When read_frame is run on arbitrary bytes
  • Then it returns Ok((kind, payload)) or a FrameError (bad CRC, length over MAX_FRAME_BYTES, unknown kind, or non-zero pad)
  • And the target never panics or exhibits undefined behaviour, including on truncated headers and length fields that overrun the buffer

Scenario RFC0015.5 — CI smoke-fuzz is bounded and gates on crashes

  • Given .github/workflows/fuzz.yml on the nightly toolchain
  • When a PR touches ourios-miner, ourios-ingester, or ourios-wal, or the daily schedule fires
  • Then each target is built (cargo fuzz build) and run for its configured bounded budget
  • And a crash fails the job and uploads the crashing input as an artifact
  • And the job uses top-level contents: read (least privilege, matching the other workflows)

Scenario RFC0015.6 — a found crash becomes a permanent regression seed

  • Given the fuzzer has found and the team has fixed a crash
  • When the fix lands
  • Then the minimised reproducer is committed under fuzz/corpus/<target>/ (or its regressions/ subdir) and is re-exercised on every subsequent run
  • And the seed is never removed to make a run pass (CLAUDE.md §6.2)

Scenario RFC0015.7 — ClusterFuzzLite is detected by Scorecard (Phase 2)

  • Given the Phase 2 follow-up has landed .clusterfuzzlite/ and the cflite_* workflows
  • When the OpenSSF Scorecard workflow runs
  • Then the Fuzzing check detects ClusterFuzzLite and scores greater than 0
  • Note: this scenario is out of scope for this RFC’s green and gates the Phase 2 PR; it is recorded here so the phasing is explicit.

6. Testing strategy

Per CLAUDE.md §6.2, the fuzz targets are the tests — coverage-guided libFuzzer runs rather than fixed-input unit tests.

  • RFC0015.1 — the miner target’s oracle is the same §3.3 invariant asserted by the existing property tests in crates/ourios-miner/tests/invariants.rs and the round-trip unit tests in crates/ourios-miner/src/reconstruct.rs; the fuzz target reuses that assertion under coverage guidance. Cross-referenced so the two layers stay in sync.
  • RFC0015.2 / .3 / .4 — panic-oracle targets. Verified by a bounded fuzz run (no crash) in CI; cargo fuzz build proves they compile even on runs where they are not executed.
  • RFC0015.5 — the fuzz.yml workflow itself; smoke budgets kept small enough to be non-flaky. The real coverage accrues from the Phase 2 continuous runs, not the per-PR smoke job.
  • RFC0015.6 — exercised the first time a crash is found; the committed reproducer is a standing corpus entry thereafter.

Each scenario id (RFC0015.N) is referenced from the corresponding target source or workflow comment so the spec-to-test mapping is greppable (docs/verification.md §2).

7. Open questions

Maintainer review (2026-06-19) gave direction on the following; recorded here as the planned approach for the implementation PRs (to be confirmed as the RFC advances toward green):

  • Nightly pinpin a dated nightly-YYYY-MM-DD in the fuzz job (not a floating nightly), for reproducibility; Renovate bumps it like the other pinned toolchains.
  • Smoke-fuzz budget~60 s per target on PRs, ~300 s on the daily schedule (see §3.4). Revisit if CI minutes or signal warrant.
  • read_frame exposure → a fuzzing cargo feature on ourios-wal gating the pub export, rather than a #[doc(hidden)] shim — slightly cleaner and reusable for future non-fuzz tests.
  • OtlpLogRecord construction → expect a hand-written Arbitrary impl (or a thin newtype) for the string-body path, rather than relying on derive across the body variants, if a derive proves messy.

Still open, deferred to the implementation PRs:

  • A second miner target driving sequences of records, to fuzz template merge behaviour (§3.1), not just single-line round-trip? Possible Phase 1.5.
  • A structured-body round-trip target exercising the §6.1 canonical encoding (AnyValue ↔ stored bytes determinism), separate from miner_roundtrip’s string-body scope? Possible Phase 1.5.
  • Phase 2 corpus-persistence backendGitHub Actions cache (ephemeral, in-repo, no external storage or secrets), chosen over a storage branch/bucket. Implemented in cflite_batch.yml.
  • unsafe waiver for fuzz/: confirm that having the fuzz member opt out of the workspace unsafe_code = "deny" lint (the first such waiver in the repo) is acceptable, given the fuzz_target! macro requires it.

8. References

RFC 0016 — Query-serving endpoint


rfc: 0016 title: Query-serving endpoint — the HTTP query API over the logs DSL status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-06-19 supersedes: — superseded-by: —

RFC 0016 — Query-serving endpoint: the HTTP query API over the logs DSL

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

Status note. green (2026-06-22; red 2026-06-19). All seven §5 scenarios pass. The querier role is wired into ourios-server as an env-gated HTTP endpoint (POST /v1/query) over the RFC 0007 engine, mirroring the receiver role’s serve/Handle topology: .1.4 (the request/dispatch/error handler driven in-process) landed in #283; .5/.7 (role gating + graceful shutdown + receiver/querier compose) and the §3.6 query metrics (.6) followed. Per the OpenTelemetry usage/state convention, the pruning signal is emitted as raw scanned/pruned row-group counts (ourios.query.row_groups, state = scanned | pruned) plus a ourios.query.duration histogram — the B1 pruned fraction is derived in the backend, not pre-computed. gRPC and authn/z beyond tenant-scoping remain deferred (§7).

1. Summary

Wire the validated query engine (RFC 0007) into ourios-server as a network-reachable querier role: an HTTP endpoint that accepts a logs DSL query (RFC 0002), executes it through Querier::run_query / run_drift, and returns the matching log rows plus pruning statistics as JSON. It mirrors the receiver role’s serve(config) -> Handle topology (RFC 0003) — env-gated, graceful-shutdown, its own listen address. The DSL is the public contract; DataFusion never surfaces (H6). This closes the core product loop — ingest → store → query — and is the keystone the deferred Perses datasource plugin waits on. Returning actual log rows depends on the typed-row payload + read-time render registry delivered by RFC 0017, the engine-layer prerequisite that lands before this transport (§3.1).

2. Motivation

The thesis is proven: B1/B2 (predicate pushdown + the template_id index) pass authoritatively on real corpora (benchmarks.md §9.4), the miner and storage are accepted/green, and the full ingest path is live in the server binary. But the running binary cannot answer a query over the network. ourios-querier (RFC 0007 validated, RFC 0002 DSL green) is a working library that ourios-server does not even depend on; main.rs records the querier role as a “follow-up” and RFC 0007 §8 explicitly defers “the querier role of the server binary (RFC 0003 sibling)” to a sibling RFC. This is that RFC.

Everything Ourios proves is query-side — the value of Parquet pruning and the template index is only realised when an operator can run a query. A backend you can ingest into but cannot query is not a shippable product; packaging it (container image, Helm chart, signed release) should wrap a complete loop, not a write-only collector. The maintainer’s “prove-the-thesis-before-the-DSL-contract” sequencing (the engine drove B1/B2 through a deliberately minimal QueryRequest) is now discharged — the thesis holds, so the DSL can become the real, public query contract.

3. Proposed design

3.1 The pivotal dependency — typed-row payload

Querier::run_query today returns QueryResult { rows: u64, stats: QueryStats }, where rows is a count and stats reports row_groups_{scanned,pruned} + bytes_read. RFC 0007 §4.1 (“Crate shape”) specifies QueryResult as “typed rows + stats”, but the engine currently returns only the count — the typed-row payload is unimplemented (RFC 0007 §8 flags streaming-vs-materialised as the open question). A serving endpoint that returns only counts + pruning stats — never the actual log lines — has little operator value.

This RFC therefore takes returning rendered log rows as a requirement, which makes the typed-row payload a prerequisite. That payload — plus the read-time template registry needed to render each line — is delivered by RFC 0017 (read-time template registry & query-row rendering), landing as the engine-layer slice before this transport. §5 below is written assuming QueryResult.records (RFC 0017) is available.

3.2 Transport — HTTP/JSON, one querier role

A single querier role, mirroring the receiver:

  • ourios_server::querier::serve(QuerierConfig) -> Result<QuerierHandle, String>, returning a handle that exposes the bound address and a shutdown() future, on the same watch::channel(()) graceful-shutdown topology as receiver::serve (RFC 0003).
  • HTTP only for v1 (axum, the receiver’s HTTP stack). gRPC is deferred (§4): operators and the future Perses plugin query over HTTP; the OTLP gRPC path is an ingest concern, not a query one.
  • Env-gated exactly like the receiver: OURIOS_QUERIER_ENABLED (1/true/yes), OURIOS_QUERIER_HTTP_ADDR (default 0.0.0.0:4319), reusing OURIOS_BUCKET_ROOT for the store. Background compaction always runs; the receiver and querier are the env-gated roles, so a binary may run receiver-only, querier-only, or both (with compaction in every case).

3.3 Request

POST /v1/query, body the DSL query. Both DSL front-ends already exist (dsl::parse_statement for the text grammar, parse_structured_statement for the JSON form), so the endpoint accepts either by Content-Type:

  • text/plain → the raw DSL statement, parsed by parse_statement;
  • application/json → either a { "query": "<dsl text>" } wrapper (the text grammar, unwrapped then parse_statement) or RFC 0002’s structured-IR JSON (the top-level IR object, parsed by parse_structured_statement) — these are distinct shapes; the endpoint distinguishes them by whether the body is the {"query": …} wrapper.

The parsed Statement dispatches: Logs(Query)run_query, Drift(DriftQuery)run_drift (RFC 0010). The server supplies now (wall clock) and the configured default time window to the executor, as the DSL compiler expects.

Tenancy. Tenant is required. The querier role takes it from a required X-Ourios-Tenant header (kept out of the query body so the DSL grammar stays tenant-agnostic) and the server rejects a missing/empty header with 400 before invoking the engineQuerier::run_query/ run_drift take a TenantId parameter, so a tenant is always supplied to the engine (the engine’s defined QueryError::TenantRequired variant is thus a guard that the server’s header check makes unreachable in practice). The engine then enforces isolation structurally via the partition-rooted scan (RFC0007.5). Authn/z beyond tenant-scoping is out of scope for v1 (§7).

3.4 Response

200 with application/json: the matching rows (the LogRow shape from RFC 0017) plus the pruning stats (row_groups_scanned, row_groups_pruned, bytes_read) so callers see the pillar-1 win directly. Result-encoding details (a JSON array vs NDJSON streaming for large results, the default limit and its hard cap) are §7 open questions. Drift queries return the RFC 0010 DriftResult shape.

3.5 Error model (H6)

All errors are Ourios-owned; no DataFusion type, SQL string, or plan ever appears in a response. Mapping:

  • DSL parse/validation (DslError, QueryError::InvalidQuery) → 400 with a structured { "error": { "kind": ..., "message": ... } }.
  • Missing/empty X-Ourios-Tenant header → 400, returned by the server’s header check before the engine is invoked (§3.3).
  • Execution failure (QueryError::Storage) → 500, message scrubbed of engine internals (its Display already withholds DataFusion text per RFC0007.3 / H6).

3.6 Observability

The querier role emits metrics through the OTel meter surface (RFC 0001 §6.8 model — per the established “OTel meters, not the Prometheus client” direction): query count, latency histogram, and the pruning ratio (row_groups_pruned / (row_groups_scanned + row_groups_pruned) — the fraction of total row groups skipped, matching QueryStats) so the thesis win is observable in production. New metric/attribute names go through semconv/registry/ + weaver (no hand-written flat names).

4. Alternatives considered

SQL passthrough. Expose DataFusion SQL directly. Rejected by H6 and RFC 0007’s “Not a SQL endpoint” line — leaking the engine’s SQL surface couples the public API to an implementation detail and forfeits the DSL’s template-aware primitives (resolves_to, lossy, drift).

gRPC (instead of / in addition to HTTP) for v1. A query gRPC service is plausible, but adds a second transport and a .proto contract for no v1 consumer — operators use HTTP and the Perses plugin will too. Deferred until a concrete gRPC consumer exists.

Counts-and-stats only for v1 (no row payload). Ship the endpoint over the engine exactly as it is today (return rows: u64 + stats), defer log-line retrieval. Rejected as the primary plan: an endpoint that can’t return logs isn’t a usable query API and wouldn’t justify the packaging work that follows. Recorded because it is the minimal fallback if RFC 0017’s row payload slips.

Serve queries from the receiver process / always-on. Folding the query listener into the receiver role couples ingest and read scaling and removes the querier-only deployment topology. A separate env-gated role (matching CLAUDE.md’s two-role binary) keeps them independent.

Skip the role gate (always serve). Rejected — the binary’s role model (receiver / querier, each env-gated) is established by the receiver; a querier-only or receiver-only deployment is a real operational shape.

5. Acceptance criteria

Scenario RFC0016.1 — querier role serves a DSL query end-to-end

  • Given a populated store and ourios-server started with OURIOS_QUERIER_ENABLED=1 and OURIOS_BUCKET_ROOT set
  • When a client POSTs a logs DSL statement to /v1/query with an X-Ourios-Tenant header
  • Then the server parses it via the RFC 0002 front-end, executes it through Querier::run_query, and returns 200 with the matching rows and the pruning stats (row_groups_scanned, row_groups_pruned, bytes_read)

Scenario RFC0016.2 — tenant scoping is enforced at the API

  • Given two tenants with disjoint data in the store
  • When a query is sent with X-Ourios-Tenant: A
  • Then only tenant A’s rows are ever read or returned, and a request with no tenant header is rejected 400 by the server’s header check, without scanning any data

Scenario RFC0016.3 — a drift query routes to the drift path

  • Given an audit stream with template widening events
  • When a drift from <t1> to <t2> statement is posted
  • Then the endpoint dispatches the Drift arm to run_drift and returns the RFC 0010 DriftResult shape

Scenario RFC0016.4 — malformed DSL is a clean 400, no engine leak

  • Given the querier role running
  • When a syntactically invalid or uncompilable DSL statement is posted
  • Then the response is 400 with an Ourios-owned error body, and no DataFusion type, SQL string, or plan text appears in the response (H6)

Scenario RFC0016.5 — role gating and graceful shutdown

  • Given OURIOS_QUERIER_ENABLED unset
  • When the server starts
  • Then no query listener is bound; and when enabled and then sent SIGINT/SIGTERM, the querier listener drains and the process exits cleanly (mirroring the receiver handle)

Scenario RFC0016.6 — pruning is observable

  • Given a selective query (time window or template_id) over a multi-row-group corpus
  • When it runs through the endpoint
  • Then the response’s row_groups_pruned is non-zero and a query-latency + pruning-ratio metric is emitted via the OTel meter surface

Scenario RFC0016.7 — receiver and querier compose in one binary

  • Given both OURIOS_RECEIVER_ENABLED and OURIOS_QUERIER_ENABLED set, with distinct addresses
  • When the server starts
  • Then both listeners bind and serve, sharing the one OURIOS_BUCKET_ROOT, and shutdown drains both

6. Testing strategy

  • RFC0016.1 / .3 — integration tests in ourios-server (or ourios-ingester-style harness): start the role on :0, POST a DSL statement, assert rows + stats / drift shape. Reuses the querier’s existing fixtures.
  • RFC0016.2 — a two-tenant fixture; assert isolation + the no-header-400 path. Mirrors the engine’s RFC0007.5 partition-prune test at the API layer.
  • RFC0016.4 — table of malformed statements → 400; a grep-style assertion that the response body contains no DataFusion / SQL / LogicalPlan substrings (H6 guard).
  • RFC0016.5 / .7 — process-level tests: env permutations (neither / one / both roles), bind assertions, and a SIGINT-drains-cleanly check (the receiver already has this pattern).
  • RFC0016.6 — assert the pruning stat in the response and the OTel metric emission (testcontainers + the established meter test harness).

Each scenario id is referenced from the corresponding test so the spec-to-test mapping is greppable (docs/verification.md §2).

7. Open questions

  • Typed-row payload sequencing (§3.1) → resolved: the payload + render registry land first as RFC 0017 (engine-layer slice), and this RFC is the thin transport over it.
  • Result encoding for large results — a single JSON array, or NDJSON streaming once row counts are large? And the default limit + its hard cap.
  • Authn/z beyond tenant-scoping — is v1 trusted-network only (tenant header, no auth), or is a token/mTLS story in scope? (Leaning trusted-network for v1; auth as a follow-up RFC.)
  • gRPC query service — revisit when a concrete consumer needs it.
  • Default time window → resolved: a query with no range(...) stage looks back over a server-supplied default window, defaulting to one hour and configurable via OURIOS_QUERIER_DEFAULT_WINDOW_SECS (a non-zero integer of seconds). The server passes it to the compiler as default_window_nanos; it is never unbounded (RFC 0002 §4 P5).
  • Endpoint surface — single POST /v1/query that dispatches Logs/Drift by statement type (proposed), or distinct paths?

8. References

  • RFC 0002 (query DSL — the public query grammar), RFC 0007 (querier engine — §4.1 specifies QueryResult as typed rows + stats, §8 flags the serving role + result materialisation), RFC 0017 (the typed-row payload + read-time render registry this transport returns), RFC 0003 (OTLP receiver — the serve/Handle + env-gating pattern this mirrors), RFC 0010 (drift queries), RFC 0001 §6.8 (OTel metric surface).
  • CLAUDE.md §1 (not a managed service), §3.7 (multi-tenancy on every data path), §6.3 (observability), H6 (query DSL vs DataFusion SQL surface — do not leak engine specifics).
  • docs/roadmap.md §5 (Perses datasource plugin parked behind a stable query API); crates/ourios-querier/src/lib.rs (Querier::run_query, run_drift, QueryResult); crates/ourios-server/src/receiver.rs (serve / ReceiverHandle).

RFC 0017 — Template registry & query rendering


rfc: 0017 title: Read-time template registry & query-row rendering status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-06-20 supersedes: — superseded-by: —

RFC 0017 — Read-time template registry & query-row rendering

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

1. Summary

Make the querier return rendered log lines, not just a count: add records: Vec<LogRow> to QueryResult (keeping the rows count). A LogRow is a faithful OTLP LogRecord — every OTLP field ingest persisted, plus the body (rendered for string bodies, returned as structure for AnyValue bodies). Rendering needs each leaf’s versioned tokens at read time, so this RFC builds a read-time template registry ((template_id, template_version) → tokens) by folding the tenant’s audit stream — and, because a template’s initial creation is unaudited today, amends the audit contract to emit a template_created event on leaf creation. This delivers the typed-row payload RFC 0007 §4.1 specifies but the engine never built, and is the prerequisite for RFC 0016’s endpoint to return actual logs.

This amends RFC 0001: scenario RFC0001.1 (“fresh-leaf creation does not emit an audit event”) is superseded — leaf creation now emits a template_created event (§3.1). It remains a non-merge (merges_total unchanged), so RFC 0001’s merge-counting contract is untouched.

2. Motivation

A query returns QueryResult { rows: u64, stats } today — a count, no rows. RFC 0007 §4.1 specifies QueryResult as “typed rows + stats”, but the engine implemented only the count; the typed-row payload was never built (RFC 0007 §8 left result materialisation open). RFC 0016’s query-serving endpoint is hollow without real rows, and the point of an operator query is to see the logs, which means reconstructing each line from (template_id, template_version, params, separators)template_version selects the correct token set for that leaf over time (§3.5) — per the CLAUDE.md §3.3 bit-identical contract — or returning the retained body for lossy/parse-failure rows.

Reconstruction needs the leaf’s tokens at read time. RFC 0005 §3.7.1 already commits to the audit-stream-derivation model for read-time maps (the alias map is derived this way; the cached artifact is “deferred, not designed away” — the manifest fork #94/#147). So the registry should be derived from the audit stream, consistent with the alias map. The blocker: derivation is only correct if the audit stream records every template version’s tokens. It records widening (new_template) and type-expansion, but not a template’s initial (version 1) creation — so v1 rows have no derivable tokens. Closing that gap (a template_created audit event) makes the registry complete and the rendering correct.

3. Proposed design

3.1 The audit gap → a template_created event

When the miner allocates a new leaf it assigns a template_id / template_version = 1 but emits no audit event; the first event for that leaf is its first widening. So v1 tokens live only in the miner’s in-memory tree, never durably in the audit stream — unrecoverable for a read-time derivation once the originating rows age out.

Add a TemplateChange::Created variant (RFC 0001 §6.4) and a new audit event_kind ordinal 6 — the next free value after the existing 05 (template_widened=0, template_type_expanded=1, template_widening_rejected_degenerate=2, compaction=3, alias_asserted=4, alias_retracted=5 in crates/ourios-core/src/audit.rs) — paired with the event_type string template_created (an append-only addition per RFC 0005 §3.7 — new ordinal, no renumber, so old readers are unaffected and §3.5 migration holds). It reuses the existing audit columns: new_template = the initial tokens, new_version = 1, and old_template/old_version left NULL — the OPTIONAL “not applicable to this event kind” sentinel per RFC 0005 §3.7 (no prior template), not a zero/empty value. The in-memory TemplateChange::Created variant carries only new_template: a leaf is always born at version 1, so rather than carry-and-validate a new_version field the invariant is made unrepresentable (there is no way to construct a creation at another version). The writer supplies the canonical new_version = 1 for the on-disk column (TEMPLATE_INITIAL_VERSION); the reader does not read it back into the variant. The miner emits it at leaf creation, on the same WAL-before-ack path as the existing template events, so by the time a v1 row reaches Parquet its template_created event is durable.

3.2 derive_template_registry — fold the audit stream

A querier function mirroring alias_store::derive_alias_map (crates/ourios-querier/src/alias_store.rs:40): scan the tenant’s audit/tenant_id=… Parquet files, read the template events (template_created, template_widened, template_type_expanded), and fold them — in the pinned deterministic order (timestamp, file path lexicographic, within-file row index) (RFC 0005 §3.7.1) — into

TemplateRegistry = HashMap<(template_id: u64, version: u32), Vec<OwnedToken>>

keyed by (template_id, new_version), value = the new_template tokens parsed by ourios_miner::tree::parse_template from the canonical space-joined lit … <*> encoding (the inverse of tree::format_template, the exact form the miner writes to the audit new_template column — literals verbatim, <*> per wildcard, joined by single spaces). It is derived once per query (like the alias map), only when the query actually returns rows.

3.3 Query-time rendering

Querier::execute (the lib.rs count bottleneck) gains a row-returning path: instead of only the COUNT(*) aggregate, it collects the matching RecordBatches (bounded by the DSL limit), decodes each into the fields reconstruct::render needs, looks up registry[(template_id, template_version)], and renders — honouring the three-zone model (RFC 0001 §6.3 / §6.6):

  • clean (Reconstruction::Faithful) → the line rebuilt from the versioned tokens + params + separators (bit-identical, CLAUDE.md §3.3);
  • lossy / parse-failure (RetainedVerbatim) → the retained body verbatim;
  • structured (body_kind = Structured) → the structured AnyValue, decoded from the body column’s canonical JSON (RFC 0005 §3.3) and returned as structure — not flattened to a byte line, which would discard the map/array shape the OTLP Body is required to preserve (see §3.4).

A row whose (template_id, version) isn’t in the registry (should not happen once §3.1 lands; a corrupt/foreign row) renders RetainedVerbatim from body (or empty) — never a panic, never a wrong line.

3.4 LogRow + QueryResult (B1/B2-compatible)

QueryResult keeps rows: u64 (the count — B1/B2 and existing tests are untouched) and adds records: Vec<LogRow> (the returned rows, ≤ limit). LogRow is Ourios-owned (H6 — no arrow/DataFusion type crosses the boundary). The endpoint (RFC 0016) serialises records.

Priority: OTLP fidelity outranks downstream API stability. Ourios is pre-release and OTLP-native; where faithful OTLP shape requires changing or breaking a public type, that is acceptable — we do not compromise the LogRecord shape to preserve a Rust API. QueryResult is not #[non_exhaustive] today (crates/ourios-querier/src/lib.rs:117 — only QueryError is), so both adding a public field and marking the struct #[non_exhaustive] are one-time Rust semver breaks for downstream struct literals / patterns. Both are accepted — we don’t compromise the shape to preserve the API — and the #[non_exhaustive] mark buys that subsequent field additions (the execution slice will add more) are non-breaking. The change is in any case behaviour-compatible — B1/B2 and existing tests read rows/stats, which are unchanged — so “B1/B2-compatible” is the precise claim, not “non-breaking at the type level”.

OTLP fidelity is a first-class requirement of this RFC, not a v1 best-effort. Ourios is an OTLP-native log backend, so a returned row MUST carry every OTLP LogRecord field that ingest persisted — a read that drops fields the wire carried and the schema stored is a fidelity bug. The storage path (RFC 0005 §3.2 schema; ourios-core record.rs / otlp.rs) already persists the full record, so LogRow mirrors it field-for-field as Ourios-owned typed fields:

  • time_unix_nano (required) and observed_time_unix_nano (optional);
  • severity_number + severity_text;
  • trace context — trace_id (16 B), span_id (8 B), flags;
  • event_name;
  • attributes and resource_attributes, decoded from the stored canonical JSON (RFC 0005 §3.3) into structured key/values — not handed back as an opaque JSON blob;
  • scope_name / scope_version;
  • dropped_attributes_count (carried verbatim, never recomputed);
  • the body (below), with its Reconstruction marker.

Body — the OTLP Body is an AnyValue (string or structured). The storage path already distinguishes the two via the body_kind discriminator (RFC 0005 §3.2) and stores structured bodies as canonical JSON (RFC 0005 §3.3). LogRow models the body as a sum type so invalid states are unrepresentable rather than a flat line + side flags:

#![allow(unused)]
fn main() {
enum LogBody {
    /// body_kind = String — the §3.3 three-zone result.
    Rendered { line: Vec<u8>, reconstruction: Reconstruction },
    /// body_kind = Structured — the AnyValue decoded from canonical JSON,
    /// returned as structure (map/array), never flattened to a line.
    Structured(AnyValue),
}
}

A string body yields Rendered (clean → Faithful; lossy/parse-failure → RetainedVerbatim, §3.3). A structured body (body_kind = Structured) yields Structured, preserving the map/array shape the OTLP spec mandates Body retain — this is the render-contract Faithful case (the canonical JSON in body round-trips, no template walk). Its one edge: a structured row whose body is absent (a corrupt row — there is no structure to return) falls back to Rendered { line: empty, RetainedVerbatim }, never Structured over nothing, matching ourios_miner::reconstruct::render’s BodyKind::Structured → (empty, RetainedVerbatim) arm. So the Reconstruction marker lives on Rendered; a Structured value is faithful by construction.

The three OTLP fields ingest does not persist today — InstrumentationScope.attributes, and the per-resource / per-scope schema_url (dropped at the receiver, RFC 0003 §6.8 / §9) — are consequently not returnable. Closing those is an ingest-side fix (RFC 0003), out of scope here; this RFC’s contract is that LogRow returns everything the schema holds. Flagged in §7 as the residual fidelity gap.

3.5 Version correctness

A row carrying template_version = N renders against the N-version tokens (the event whose new_version = N), not the latest — so a line ingested before a widening reconstructs as it was then. The registry is keyed by (template_id, version) precisely for this.

3.6 Performance

Deriving the registry folds the audit stream per query — O(audit events), the same cost profile as the alias map, acceptable for v1. The materialised cache (the RFC 0005 §3.7.1 / manifest-fork artifact) is the deferred latency/recovery optimisation, not required for correctness. Rendering is bounded to the returned (limit-capped) rows.

4. Alternatives considered

Derive ≥v2, reconstruct v1 from a surviving row. Skip the template_created event; if a v1 token set is missing, recover it from any still-present v1 row’s shape. Rejected — fragile and lossy: once every v1 row of a template is compacted/retention-expired, its tokens are unrecoverable, so a later query over an older file that does reference v1 renders wrong (or can’t render). Auditing creation is the only complete fix.

Cached-map artifact first (the manifest fork #94/#147). Persist the registry as a published per-tenant file. Rejected as the first step: it’s a latency/recovery optimisation over the derivation (RFC 0005 §3.7.1 says exactly this), bigger, and entangled with the deferred atomic-publish manifest decision. Derivation is correct and sufficient once creation is audited; the cache can layer on later without changing the contract.

Store the rendered line in Parquet at ingest. Write the reconstructed line as a column so the querier needn’t render. Rejected — it duplicates the bytes the template/params reduction exists to avoid (pillar #2), and re-introduces the storage cost the design removes.

Push tokens / render client-side. Return (template_id, params, tokens) and let the client reconstruct. Rejected — leaks internal representation through the public surface (H6) and pushes the three-zone reconstruction logic onto every consumer.

Don’t render — structured rows only. Return the columns, no line. Rejected per the maintainer’s decision: a query that can’t show the log line isn’t a usable query API.

5. Acceptance criteria

Scenario RFC0017.1 — initial template creation is audited

  • Given a miner ingesting a line that creates a new leaf
  • When the leaf (and its template_id) is allocated
  • Then a template_created audit event is emitted carrying (template_id, new_version = 1, new_template = the initial tokens) on the WAL-before-ack path
  • And the new event_kind ordinal / event_type string is an append-only addition (no existing ordinal renumbered), per RFC 0005 §3.7

Scenario RFC0017.2 — the registry derives completely from the audit stream

  • Given a tenant audit stream with template_created, template_widened, and template_type_expanded events
  • When derive_template_registry folds it (deterministic (timestamp, path, row) order)
  • Then the registry contains the tokens for every (template_id, version) the stream describes, including version 1, with later versions not clobbering earlier ones

Scenario RFC0017.3 — a clean row renders bit-identically (CLAUDE.md §3.3)

  • Given a stored clean-path row (Faithful-eligible) and the derived registry
  • When the querier renders it via the registry tokens
  • Then the rendered line equals the originally-ingested line byte-for-byte (the CLAUDE.md §3.3 invariant), and the row’s Reconstruction marker is Faithful

Scenario RFC0017.4 — lossy / parse-failure rows return the retained body

  • Given a row flagged lossy or with no template (parse failure), whose body was retained
  • When the querier renders it
  • Then the returned line is the retained body verbatim and the marker is RetainedVerbatim — no template walk, never a wrong reconstruction

Scenario RFC0017.5 — rows render against their own template version

  • Given a template that has widened (versions 1 and 2 both present in the audit stream) and rows at each version
  • When the querier renders a version = 1 row
  • Then it renders against the version-1 tokens, not the widened version-2 tokens

Scenario RFC0017.6 — typed-row payload is returned, B1/B2-compatible

  • Given a query with a limit
  • When it runs
  • Then QueryResult.records holds up to limit LogRows (rendered/structured body + marker + the OTLP fields per §3.4), and QueryResult.rows (the count) and stats are unchanged so B1/B2 and existing tests still pass
  • And QueryResult is marked #[non_exhaustive] (which, with the field addition, is an accepted one-time semver break per §3.4) so that subsequent field additions are non-breaking

Scenario RFC0017.7 — no engine internals leak (H6)

  • Given the public LogRow / QueryResult surface
  • When inspected
  • Then no arrow/DataFusion/SQL type or text appears in it; all fields are Ourios-owned

Scenario RFC0017.8 — every persisted OTLP field round-trips on read

  • Given a stored row whose ingest carried the full OTLP LogRecord field set (timestamps, severity number + text, trace context, scope name/version, attributes, resource attributes, dropped count, event name)
  • When the querier returns it as a LogRow
  • Then each of those fields equals what the schema stored (RFC 0005 §3.2), attributes / resource_attributes are decoded to structured key/values (not an opaque JSON blob), and no stored OTLP field is dropped on the read path

Scenario RFC0017.9 — a structured (AnyValue) body is returned as structure

  • Given a stored row with body_kind = Structured (the OTLP Body was a map/array, canonical JSON in body, RFC 0005 §3.3)
  • When the querier returns it
  • Then the body is LogBody::Structured(AnyValue) preserving the original map/array shape — not flattened into a byte line — and round-trips the ingested AnyValue

6. Testing strategy

  • RFC0017.1 — a miner unit/integration test asserting a template_created event on first leaf allocation (with tokens), plus an audit-schema test that the new event_kind/event_type is appended (existing ordinals unchanged).
  • RFC0017.2 / .5derive_template_registry unit tests over a synthetic audit stream (creation + widening), asserting completeness and per-version keying; deterministic-order test mirroring the alias-map tests.
  • RFC0017.3 — a property test reusing the CLAUDE.md §3.3 invariant: for a corpus of mined rows, registry-rendered line == original (or flagged lossy). Cross-references ourios-miner’s reconstruction property test.
  • RFC0017.4 — fixtures for lossy + parse-failure + structured rows → expected verbatim/canonical body + marker.
  • RFC0017.6 — querier test asserting records length ≤ limit, the rendered content, and that rows/stats are unchanged (a B1/B2-style count assertion still holds).
  • RFC0017.7 — a grep-style guard that the public crate surface has no arrow/datafusion types (mirrors the RFC0007.3 / H6 guard).
  • RFC0017.8 — a querier test that ingests a record populating every OTLP field, stores it, queries it back, and asserts each LogRow field equals the ingested value (a field-completeness assertion over the RFC 0005 §3.2 column set), with attributes / resource_attributes decoded to structured key/values. The assertion enumerates the field set so a newly-added stored column that the read path forgets fails the test.
  • RFC0017.9 — a property/round-trip test: for structured-body inputs (AnyValue maps/arrays), LogRow.body == LogBody::Structured(v) where v equals the ingested AnyValue (decoded canonical JSON), never a flattened line. Cross-references the ourios-core canonical encode/decode property tests.

Each scenario id (RFC0017.N) is referenced from its test so the mapping is greppable (docs/verification.md §2).

7. Open questions

  • Cached-map artifact — when to materialise the registry (the RFC 0005 §3.7.1 / manifest-fork optimisation) vs. always deriving. Deferred; derivation is the v1 contract.
  • Registry memory bound — for tenants with very large template counts, is the per-query in-memory registry acceptable, or does it need a cap / lazy per-(id,version) lookup?
  • template_created payload — does it also carry slot_types (like TypeExpanded), or just tokens? (Leaning tokens-only for v1; slot types are derivable / not needed for render.)
  • Structured-body renderingresolved (§3.3 / §3.4): the OTLP Body is an AnyValue, and the storage path already preserves the structured case (body_kind = Structured, canonical JSON in body, RFC 0005 §3.2/§3.3). LogBody::Structured(AnyValue) returns it as structure; only string bodies walk the template. No flattening.
  • Residual ingest-side fidelity gapLogRow returns every OTLP field the schema stores, but three are dropped at the receiver today and so cannot be returned: InstrumentationScope.attributes, and the per-resource / per-scope schema_url (RFC 0003 §6.8 “out of scope” / §9). For a backend whose thesis is OTLP-native fidelity these are worth closing — but at ingest (an RFC 0003 schema addition + RFC 0005 columns), not in this read-path RFC. Track as an RFC 0003 follow-up; this RFC is faithful to the stored record by construction.
  • Backfill — existing audit streams predate template_created; templates created before this lands won’t have a creation event, so their v1 rows aren’t in the registry and hit the §3.3 not-in-registry fallback. Caveat: that fallback renders RetainedVerbatim from body, but a clean-path body_kind = String row has no body (absent by design, RFC 0005 §3.2) — so the fallback yields an empty line, not the original, unless tokens are recovered. Options: accept empty-line for pre-template_created clean rows (pre-release, leaning this), a one-time audit backfill, or recover v1 tokens from a surviving v1 row’s shape (the §4 “reconstruct v1 from a surviving row” alternative, rejected there as fragile). Pre-release lean: acceptable + documented.

8. References

  • RFC 0001 §6.4 (template audit events), §6.6 (render contract), §6.7 (audit stream); RFC 0005 §3.7 (audit schema; the append-only event-type rule, the canonical token encoding), §3.7.1 (derive-from- audit model; the deferred cached artifact / manifest fork #94/#147); RFC 0007 §4.1 (specifies QueryResult as typed rows + stats — the payload this RFC implements), §8 (result-materialisation open question); RFC 0002 (render stage); RFC 0010 (drift, the other audit-derived query); RFC 0016 (the query-serving endpoint that consumes records).
  • CLAUDE.md §3.1 (audit events on template change), §3.3 (bit-identical reconstruction), §3.5 (schema migration — append-only audit types), hazard H6 (no DataFusion surface leak), §3.7 (multi-tenancy — the registry is per-tenant).
  • crates/ourios-querier/src/alias_store.rs (derive_alias_map, the pattern); ourios_miner::reconstruct::render; crates/ourios-core/src/audit.rs (TemplateChange); ourios_miner::tree::OwnedToken.

RFC 0018 — OTLP log-spec compliance amendments


rfc: 0018 title: OTLP log-spec compliance amendments status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-06-20 supersedes: — superseded-by: —

RFC 0018 — OTLP log-spec compliance amendments

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

1. Summary

Close the OpenTelemetry OTLP log-spec gaps surfaced by the 2026-06-20 compliance audit, as one push. Ourios is an OTLP-native log backend, so spec fidelity outranks downstream API stability — these amendments break and extend public types where the spec requires it. Six fixes spanning three green RFCs (0002, 0003, 0005): (1) persist the dropped InstrumentationScope.attributes and the per-resource / per-scope schema_url (a flat OTLP MUSTAnyValue and the scope tuple must be preserved); (2) map transient ingest failures to retryable gRPC/HTTP codes instead of non-retryable INTERNAL/500 (clients currently drop data they should retry); (3) make event_name a first-class DSL filter; (4) round-trip non-finite doubles in canonical AnyValue JSON; (5) preserve out-of-range SeverityNumber + flag it instead of the current silent clamp-to-0 (the backend is a faithful witness, not a corrector — §3.0); (6) correct the body column documentation. This amends RFC 0002 (DSL), RFC 0003 (receiver), and RFC 0005 (schema).

2. Motivation

A three-area audit (receiver, schema, DSL/querier), graded against the OTLP/OTel spec’s own MUST/SHOULD levels, found that the ingest and query paths under-serve the spec in ways a fidelity-first backend must not. The gaps were verified against the OpenTelemetry knowledge base; one audit claim (that structured AnyValue bodies are “type-erased”) was refuted — the canonical JSON Ourios stores is the OTLP protobuf→JSON mapping and preserves the AnyValue discriminator, so it is not in scope here.

The spec is unambiguous on the load-bearing gap: AnyValues expressing empty/zero/empty-string/empty-array “are considered meaningful and MUST be stored and passed on to processors / exporters” (common/#anyvalue), and the instrumentation scope is the (name, version, schema_url, attributes) tuple (common/instrumentation-scope). Ourios decodes only name/version and discards the rest at the receiver boundary, so those fields never reach Parquet and RFC 0017’s LogRow can never return them. The retry-semantics gap is a quieter data-loss bug: mapping a transient WAL/storage failure to gRPC INTERNAL (which the OTLP retry table marks non-retryable) tells the client to drop the batch (otlp/#failures).

“One compliance push” was the maintainer’s chosen sequencing: clear the whole list before more read-path work, so the query read path — RFC 0016’s serving endpoint returning RFC 0017’s LogRow — lands on a complete, spec-faithful schema.

3. Proposed design

3.0 Governing principle — faithful witness, not corrector

Producing spec-valid telemetry is the upstream’s contract — the SDK, the instrumenting library, and any intermediary collectors/processors (OTel ships the tooling for it: severity parsers, the transform processor). When an upstream emits non-compliant data (e.g. SeverityNumber = 25), it broke the contract. Ourios is the storage/query backend, not the normalizer, so its job is to be a faithful witness:

  1. Preserve what arrived, byte-for-byte, up to the point where a storage invariant physically forbids it.
  2. Surface any spec violation as an observable anomaly (a metric, and where practical a marker on read) — so an operator can find the misbehaving upstream.
  3. Never silently correct (clamping/normalizing destroys the evidence and masks the upstream bug) nor silently reject (dropping punishes the operator for an upstream they may not control, and loses logs).

This is Postel’s law, and it matches what OTLP already asks of receivers elsewhere — tolerate unknown fields, preserve unknown (open-)enum values. The spec gives backends latitude in representation (“Backend and UI may represent…”), never a mandate to correct; “normalized to values described” is a producer-side mapping rule, not a backend one. Normalization on ingest, if ever wanted, is a future opt-in config (gated on a concrete consumer), never the silent default. This principle governs the gaps below — most visibly §3.5.

3.1 Persist InstrumentationScope.attributes + schema_url (RFC 0003 + RFC 0005) — the MUST

The receiver (crates/ourios-ingester/src/receiver/materialize.rs) decodes scope.name and scope.version but drops scope.attributes, ResourceLogs.schema_url, and ScopeLogs.schema_url. Add to the decode and to OtlpLogRecord / MinedRecord (crates/ourios-core/src/otlp.rs, record.rs):

  • scope_attributes — the scope’s KeyValue list, encoded as canonical JSON exactly like attributes / resource_attributes (RFC 0005 §3.3), empty → [];
  • resource_schema_url — the ResourceLogs.schema_url string;
  • scope_schema_url — the ScopeLogs.schema_url string.

Add three OPTIONAL columns to the RFC 0005 §3.2 schema (crates/ourios-parquet/src/lib.rs):

columntyperequirednote
scope_attributesSTRING (canonical JSON)OPTIONALper RFC 0005 §3.3 encoding; [] when empty, NULL only in pre-amendment files
resource_schema_urlSTRINGOPTIONALOTLP ResourceLogs.schema_url
scope_schema_urlSTRINGOPTIONALOTLP ScopeLogs.schema_url

All three are OPTIONAL for the RFC 0005 §3.5 migration rule alone (additive columns; readers MUST tolerate their absence in historical files) — not as a value encoding. The two schema_url columns distinguish present-but-empty from absent: a wire schema_url = "" is stored as "" (a present empty value), and NULL is reserved for the historical “column missing” case; scope_attributes follows the attributes convention ([] when empty, NULL only pre-amendment). scope_attributes rides the §3.3 canonical encoder/decoder unchanged, so it inherits its round-trip property tests. This is the only gap the spec makes a flat MUST; it is also the prerequisite for RFC 0017’s LogRow to carry the complete scope and for RFC 0010 drift to see scope-level schema_url changes.

3.2 Retryable error mapping for transient failures (RFC 0003)

crates/ourios-ingester/src/receiver/grpc.rs maps all non-tenant-resolution failures (including WAL append / fsync failures) to Status::internal, and http.rs maps them to 500. Per the OTLP retry table (otlp/#failures), INTERNAL and 500 are non-retryable — so a client that hits a transient WAL/storage failure drops the batch instead of retrying, violating the spirit of WAL-before-ack durability.

Amend the RFC 0003 error-mapping contract to distinguish transient from permanent:

  • Transient (WAL append I/O failure, post-rotation quiesce, fsync failure, storage unavailable, ingest saturation) → gRPC UNAVAILABLE (optionally RESOURCE_EXHAUSTED with a RetryInfo detail for saturation, per otlp/#otlpgrpc-throttling); HTTP 503 (optionally 429 for saturation) with an optional Retry-After header.
  • Permanent failures stay non-retryable, but are not a single HTTP code: malformed payload and tenant-resolution failure → HTTP 400 (gRPC INVALID_ARGUMENT), while an oversize payload (AppendError::TooLarge, a batch over the 16 MiB WAL frame ceiling) → HTTP 413 (gRPC INVALID_ARGUMENT). An oversize batch is a client sizing error, not a WAL outage: retrying it byte-identical can never succeed, so it MUST stay non-retryable even though it surfaces as a WalAppend error.

The 429/503 throttling surface itself remains a SHOULD and may stay minimal (no rate-limiter yet, RFC 0003 §6.7); the binding change here is that a transient failure MUST NOT be reported with a non-retryable code.

3.3 event_name as a first-class DSL filter (RFC 0002)

event_name is stored (RFC 0005 §3.2) and will be returned by RFC 0017, but the DSL cannot filter on it. Add an EventName variant to the DSL Field enum (crates/ourios-querier/src/dsl/ir.rs), a grammar token event_name, and a compile case projecting to the event_name column — mirroring the existing scope bare field exactly (RFC 0002 §6.1). String operators only (=, contains, …), consistent with other string fields. Also add scope_version as a bare field by the same pattern (currently only scope name is filterable); scope_attributes becomes filterable via the existing scope.<key> attribute-path mechanism once §3.1 stores it.

3.4 Round-trip non-finite doubles in canonical AnyValue JSON (RFC 0005)

The canonical encoder (crates/ourios-core/src/otlp.rs) serialises a non-finite double_value (NaN, ±Infinity) to JSON null, which does not decode back to the original — a lossy round-trip pinned by an existing test. Ourios’s canonical encoding is the OTLP protobuf→JSON mapping (proto3 JSON; the same encoding body/attributes already use, RFC 0005 §3.3), and proto3 JSON represents non-finite floats as the quoted string forms "NaN", "Infinity", "-Infinity". Adopt those string forms (not the bare NaN/Infinity tokens — they are invalid JSON and belong to OTel’s separate lossy non-OTLP-protocol string encoding, not the protobuf-JSON mapping), and replace the “encodes to null” test with a round-trip assertion.

3.5 Preserve out-of-range SeverityNumber, don’t clamp it (RFC 0003)

The receiver already clamps: severity_to_u8 (crates/ourios-ingester/src/receiver/materialize.rs:105) maps any value outside 0..=24 to 0 (UNSPECIFIED). Per §3.0 this is the wrong default — it is a silent correction that both destroys the evidence (an operator can no longer see the upstream emitted a bad value) and inverts meaning: SeverityNumber is monotonic (logs/data-model/#severity-fields), so 25 is “more severe than FATAL4 (24)”, and clamping it to 0 turns the most severe record into the least-informative one. It is also doubly damaging because severity_number is a template-key component ((severity_number, scope_name), crates/ourios-miner/src/cluster.rs:1680): every out-of-range value collapses into the single UNSPECIFIED bucket, co-mingling distinct severities in mining.

Change to preserve verbatim:

  • 0..=24 (defined) and 25..=255 (out of the named ranges but storable and monotone-meaningful) → stored as the wire value;
  • a record with severity_number outside 0..=24 is recorded on the existing ourios.ingest.records counter with the standard error.type attribute set to severity_out_of_range — the OTel “recording errors on metrics” convention (one counter for success + anomaly, reason on a low-cardinality error.type; success records carry no error.type), not a bespoke counter. severity_text is retained, so the violation is observable, not masked;
  • the values a u8 physically cannot hold (negative, > 255) become 0 — here the storage invariant wins (§3.0 point 1’s limit). Because they narrow to 0, they are indistinguishable post-narrowing from a genuine UNSPECIFIED and so are not separately attributed on the counter (an accepted limitation: such values are degenerate corruption, not a meaningful severity); the 25..=255 case — the one an operator actually sees — is fully attributed.

Severity comparisons (RFC 0002, which correctly compares on SeverityNumber) stay monotone and correct: severity >= ERROR still matches a 25. The u8 column is retained: 0..=255 covers the entire defined range with 10× headroom for any conceivable future OTLP expansion, and the only values it cannot represent (negative / > 255) are definitionally garbage with nothing to preserve. Widening the column to i32 for absolute wire-fidelity is a one-line alternative (§7).

3.6 Correct the body column documentation (RFC 0005)

RFC 0005 §3.2 describes the body column as “raw bytes … not text,” but for body_kind = Structured rows it holds UTF-8 canonical JSON (§3.3). Clarify the column note: raw original bytes for retained String rows; UTF-8 canonical-JSON AnyValue for Structured rows; absent on clean String rows. Documentation-only; no schema change.

4. Alternatives considered

Defer everything except the MUST (§3.1). Tempting — §3.1 is the only flat MUST. Rejected per the maintainer’s “one compliance push”: §3.2 is a real data-loss bug and the rest are cheap, so clearing them together avoids a second disruptive amendment to the same files.

Add scope_attributes as typed columns rather than canonical JSON. Rejected — it would diverge from how attributes / resource_attributes are already stored (canonical JSON, RFC 0005 §3.3) for no benefit; the typed-attribute representation is a separate, deferred RFC 0005 question.

Keep INTERNAL and rely on clients retrying anyway. Rejected — the OTLP retry table is normative; compliant clients treat INTERNAL as non-retryable and drop the batch. Relying on non-compliant client behaviour is not fidelity.

Make event_name queryable only via the generic attribute path. Rejected — event_name is a top-level LogRecord field, not an attribute; it deserves a bare field like severity / scope, and forcing attr.event_name would misrepresent the data model.

One amendment RFC per touched RFC (three RFCs). Rejected per the chosen sequencing; a single RFC keeps the cross-cutting fidelity story coherent and the acceptance scenarios in one place. Each touched RFC gets a back-reference.

5. Acceptance criteria

Scenario RFC0018.1 — scope attributes + schema URLs survive ingest→storage

  • Given an OTLP batch whose InstrumentationScope carries attributes, whose ScopeLogs carries a schema_url, and whose ResourceLogs carries a schema_url
  • When the receiver materialises the records and they are written to Parquet
  • Then scope_attributes (canonical JSON), scope_schema_url, and resource_schema_url are persisted with the wire values, and a round-trip read returns them unchanged

Scenario RFC0018.2 — the new columns are OPTIONAL / back-compatible

  • Given a historical Parquet file written before this amendment (no scope_attributes / *_schema_url columns)
  • When the reader opens it
  • Then it reads successfully, the three fields read as absent/NULL, and no error is raised (RFC 0005 §3.5 migration rule)

Scenario RFC0018.3 — transient ingest failure is reported retryable

  • Given a WAL append/fsync failure during an Export call
  • When the receiver responds
  • Then the gRPC status is a retryable code (UNAVAILABLE, or RESOURCE_EXHAUSTED + RetryInfo) and the HTTP status is 503 (or 429) — never INTERNAL / 500
  • And a permanent failure (malformed payload, tenant resolution) still maps to INVALID_ARGUMENT / 400

Scenario RFC0018.4 — event_name is filterable in the DSL

  • Given stored rows with differing event_name values
  • When a DSL query filters on event_name
  • Then the predicate compiles to the event_name column and returns exactly the matching rows, with no DataFusion/SQL surface leaking to the user (H6)

Scenario RFC0018.5 — non-finite doubles round-trip through canonical JSON

  • Given an AnyValue (body or attribute) containing NaN, Infinity, and -Infinity
  • When it is canonical-encoded and decoded
  • Then the decoded value equals the original (no null collapse)

Scenario RFC0018.6 — out-of-range SeverityNumber is preserved, not clamped (§3.0)

  • Given OTLP records with severity_number = 25 and = 200 (out of the named ranges but u8-storable)
  • When the receiver materialises them
  • Then the stored severity_number is 25 / 200 verbatim (never silently clamped to 0), the ourios.ingest.records counter records them with error.type = severity_out_of_range, and a severity >= ERROR query still matches them (monotonicity preserved)
  • And a value a u8 cannot hold (negative, > 255) maps to 0 (the storage invariant, not a correction); narrowed to 0, it is not separately attributed (the §3.5 accepted limitation)

6. Testing strategy

  • RFC0018.1 / .2 — an ingester→parquet integration test asserting the three new fields round-trip (incl. a non-empty scope_attributes decoded to structured kv); a reader test over a fixture file lacking the columns (back-compat). scope_attributes reuses the ourios-core canonical encode/decode property tests.
  • RFC0018.3 — receiver unit tests injecting a transient WAL failure (gRPC → retryable code; HTTP → 503/429) and a permanent failure (INVALID_ARGUMENT / 400), mirroring the existing RFC0003.4 mapping tests.
  • RFC0018.4 — a DSL parse+compile test for event_name filters plus an end-to-end querier test asserting matched rows; the H6 no-leak guard (RFC0007.3 style) extended to the new field.
  • RFC0018.5 — a property test over AnyValue including non-finite doubles, replacing the current “encodes to null” assertion with a round-trip one.
  • RFC0018.6 — a receiver test feeding severity_number 25 and 200 and asserting they are preserved (not clamped), that the ourios.ingest.records counter records them with error.type = severity_out_of_range (in-memory MeterProvider, mirroring the compaction-metric test), and that a severity >= ERROR query still matches them (monotonicity); plus a negative / >255 case asserting 0 (the storage-invariant limit). Replaces the prior clamp-to-0 assertion in severity_to_u8’s tests (a contract change — the old test asserted the behaviour this RFC overturns; CLAUDE.md §6.2).

Each scenario id (RFC0018.N) is referenced from its test so the mapping is greppable (docs/verification.md §2).

7. Open questions

  • Saturation backpressure depth — §3.2 makes transient failures retryable, but a real rate-limiter / queue-depth signal (429 with a computed Retry-After) is still deferred (RFC 0003 §6.7). Land the code mapping now; size the limiter later?
  • scope_attributes as a template-key input?resolved: stay out of the key. The key today is (severity_number, scope_name) (cluster.rs:1680); scope_version is already retained-but-not-keyed, and scope_attributes follow that precedent. The keying principle: the key carries low-cardinality fields that identify the log statement’s semantic class (severity_number, scope_name); higher-cardinality emitter metadata (scope_version, scope_attributes) is retained + queryable (scope.<key>) but not keyed — keying on it would explode template_count (the template-cardinality hazard; CLAUDE.md §3.1 / docs/hazards.md #1) for no fidelity gain (attributes are retained per-row; reconstruction §3.3 never depended on scope). Per-attribute partitioning, if ever needed, is a future opt-in config (gated on a concrete consumer).
  • Pre-amendment backfill — historical files lack the new columns; acceptable as NULL (best-effort) for pre-release, or backfill? (Leaning acceptable, consistent with the effective_time_unix_nano amendment.)
  • SeverityNumber reject vs clampresolved: preserve + flag, neither reject nor clamp (§3.0 / §3.5). The faithful-witness principle settles it: clamping is a silent correction, rejecting is silent data loss; both are the backend overstepping a role that belongs upstream.
  • Severity column u8 vs i32resolved: u8. 0..=255 covers the defined 1..=24 with 10× headroom for any conceivable future OTLP expansion; the only values it cannot hold (negative, > 255) are definitionally garbage with nothing meaningful to preserve, so they take the §3.5 storage-invariant path (0 + anomaly count).
  • Anomaly visibility on read — §3.5 surfaces out-of-range severity via a metric; should the read path (LogRow, RFC 0017) also mark a record as carrying out-of-spec severity, so it’s visible per-record and not only in aggregate? (Leaning a metric for now; per-record marker if operators ask.)

8. References

  • OTLP/OTel spec: logs data model (field set, severity fields), common/#anyvalue (empty/zero MUST be stored), common/instrumentation-scope (the (name,version,schema_url,attributes) tuple), otlp/#json-protobuf-encoding (proto3 JSON mapping — non-finite doubles as "NaN"/"Infinity"/"-Infinity" strings), otlp/#failures (retryable vs non-retryable codes), otlp/#otlpgrpc-throttling.
  • RFCs amended: RFC 0002 (DSL — §6.1 bare fields), RFC 0003 (receiver — §6.1/§6.2 error mapping, §6.6 materialisation, §6.8/§9 the previously-deferred schema_url + scope attributes), RFC 0005 (schema — §3.2 columns, §3.3 canonical encoding, §3.5 migration). Consumed by RFC 0017 (LogRow gains the complete scope) and RFC 0010 (drift over scope schema_url).
  • CLAUDE.md §3.5 (schema migration — additive OPTIONAL columns), §3.7 (multi-tenancy — new columns per-tenant), hazard H6 (no DataFusion leak), §3.3/§3.4 (the durability the retry-mapping fix protects).
  • Code: crates/ourios-ingester/src/receiver/materialize.rs (scope/schema drop), grpc.rs / http.rs (error mapping), crates/ourios-core/src/otlp.rs (canonical encoder; severity decode), crates/ourios-parquet/src/lib.rs (schema), crates/ourios-querier/src/dsl/ir.rs (the Field enum).

RFC 0019 — Storage-backend selection


rfc: 0019 title: Storage-backend selection — wiring the server to choose local vs S3 status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-06-22 supersedes: — superseded-by: —

RFC 0019 — Storage-backend selection: wiring the server to choose local vs S3

1. Summary

ourios-server always constructs Store::local(OURIOS_BUCKET_ROOT) today, even though ourios-parquet already exposes Store::s3(S3Config) (RFC 0013, green). This RFC wires backend selection through the server: an operator picks local or s3 via config, and the chosen Store is threaded into all three roles. To make S3 actually usable, the querier and the compactor — which still address the bucket through raw std::fs — are migrated onto the Store / object_store abstraction the receiver already uses. The write-ahead log stays local always (CLAUDE.md §3.6). This is the follow-on RFC 0014 §7 and RFC 0013 §7 named; it is the prerequisite for an object-storage-native deployment (and the CLAUDE.md §3.6-correct Helm chart).

2. Motivation

CLAUDE.md §3.6 makes object storage the source of truth: “Local disk is cache and WAL. Parquet on S3 is the truth.” RFC 0013 built the storage seam (Store, S3Config, conditional-PUT atomics) and proved it on localstack, but deferred the selection at the server config layer. The consequence today is concrete: a deployment cannot put data on S3, so the first Helm chart had to back the data store with a local ReadWriteOnce volume and a single replica — a stopgap that contradicts CLAUDE.md §3.6 and blocks horizontal querier scaling. Doing selection at this layer, now, unblocks the architecturally-correct shipping shape and exercises the RFC 0013 S3 path end-to-end through the real server.

The work is at this layer (the server + the querier/compactor read paths) because that is the only place the bucket is still addressed as a local path; the receiver write path (RFC 0014) already goes through Store.

3. Proposed design

3.1 Configuration (extends RFC 0004)

A new startup configuration surface — the storage backend and its addressing — is added under RFC 0004’s governance (its validation + secret-hygiene rules). It is not an RFC 0004 tunable in the strict sense: a tunable is global-with-per-tenant-override, whereas backend selection is necessarily process-wide (one store per process). Credentials are not Ourios configuration at all: they are operator secrets resolved by the standard AWS credential chain, or supplied explicitly as S3-named secret keys (OURIOS_S3_ACCESS_KEY_ID / OURIOS_S3_SECRET_ACCESS_KEY / OURIOS_S3_SESSION_TOKEN), distinct from the non-secret addressing keys above and never logged (see §3.4; added by the 2026-06-28 amendment, §9).

Env varBackendMeaningDefault
OURIOS_STORAGE_BACKENDbothlocal or s3local
OURIOS_BUCKET_ROOTlocaldata + audit store root (existing)— (required for local)
OURIOS_S3_BUCKETs3bucket name— (required for s3)
OURIOS_S3_ENDPOINTs3S3-compatible endpoint (MinIO, R2)unset (AWS)
OURIOS_S3_REGIONs3regionunset
OURIOS_S3_PREFIXs3key prefix within the bucketunset (bucket root)
OURIOS_S3_ACCESS_KEY_IDs3static access key (secret, §3.4)unset (→ credential chain)
OURIOS_S3_SECRET_ACCESS_KEYs3static secret key (secret, §3.4)unset (→ credential chain)
OURIOS_S3_SESSION_TOKENs3session token for temporary credentials (secret)unset (valid only with the static key pair)

OURIOS_WAL_ROOT is unchanged and remains a local path under every backend (CLAUDE.md §3.6 — the WAL is never an object-store key). “Local” here means fsync-durable local-filesystem semantics, not ephemeral storage: the WAL is the recovery mechanism (WAL-before-ack, CLAUDE.md §3.4), so the path MUST be backed by storage that survives a process/pod crash — i.e. a persistent volume, never a scratch/emptyDir-style mount. S3 is deliberately not used for the WAL: it offers no atomic append or fsync and would put S3 PUT latency on the ack path, defeating CLAUDE.md §3.4’s batched-fsync latency/durability knob; S3 is the truth for the flushed Parquet, which is all CLAUDE.md §3.6 requires. The WAL’s durability obligation is bounded by the flush horizon (CLAUDE.md §3.6 — local disk need not be durable beyond it). Surviving the loss of the volume itself (node/AZ failure) is a separate, out-of-scope tier — WAL replication / archiving, which CLAUDE.md §3.4 reserves as an addition to the WAL, not a replacement, and which a future RFC may add. The prior art is the PostgreSQL model (CloudNativePG’s Barman Cloud, barman-cloud-wal-archive): a hot fsync’d WAL on a local persistent volume, plus asynchronous archiving of completed segments to object storage for off-node recovery (§8).

3.2 The StoreConfig seam

ourios-server replaces the bucket_root: PathBuf it threads to each role with a resolved, validated descriptor:

#![allow(unused)]
fn main() {
enum StoreConfig {
    Local(PathBuf),   // OURIOS_BUCKET_ROOT
    S3(S3Config),     // OURIOS_S3_* (S3Config is the RFC 0013 type)
}
}

config_from_env parses OURIOS_STORAGE_BACKEND and fails fast on a missing required field (OURIOS_S3_BUCKET when s3; OURIOS_BUCKET_ROOT when local) or an unknown backend. StoreConfig::open() -> Result<Store, …> dispatches to Store::local / Store::s3. The receiver, compactor, and querier each take a StoreConfig (or a constructed Store) instead of a PathBuf.

flowchart LR
  env[OURIOS_STORAGE_BACKEND + addressing] --> cfg{StoreConfig}
  cfg -->|Local| sl[Store::local]
  cfg -->|S3| ss["Store::s3 / AmazonS3Builder::from_env()"]
  sl --> store[(Store)]
  ss --> store
  store --> rcv[receiver write path]
  store --> cmp[compactor sweep]
  store --> qry[querier read path]
  wal[OURIOS_WAL_ROOT] -->|always local| rcv

3.3 Migrating the querier and compactor onto Store

  • Querier. The bulk Parquet scan moves to DataFusion’s native object-store support: register the Store’s object_store on the SessionContext and address tables by object-store URL rather than a local ListingTableUrl path. The audit-stream helpers that read with std::fs (audit_scan, alias_store::derive_alias_map, template_registry::derive_template_registry) move to Store listing + get_blocking. Querier::new takes a Store (or StoreConfig).
  • Compactor. The filesystem walks (tenants, plan_candidates, compact_partition, gc_orphans) move to Store listing + the ourios-parquet Store-based read/write/delete. The manifest swap adopts Manifest::publish_cas (conditional PUT, RFC0013.3/.4) so concurrent or retried sweeps cannot clobber a generation. Compactor::new takes a Store.

Store exposes object/key I/O (get_blocking/put_blocking/…) but not yet a listing method (listing lives on the inner object_store::ObjectStore). This RFC’s implementation adds a thin Store listing wrapper over ObjectStore::list (prefix → keys, bridged off-runtime like the existing blocking helpers) so the querier and compactor never reach past the Store seam; the alternative — calling ObjectStore::list directly via Store::object_store() — is equivalent but leaks the abstraction.

Both migrations preserve the on-disk layout and the partition key scheme (RFC 0005 §3.4) byte-for-byte — only the addressing changes (a local path vs. an object-store key under the prefix), so historical local stores and the existing reader/writer remain valid (RFC 0013 §3.2).

3.4 Credentials and secret hygiene

S3 credentials resolve explicit-over-chain:

  1. Explicit Ourios config. OURIOS_S3_ACCESS_KEY_ID / OURIOS_S3_SECRET_ACCESS_KEY (and optionally OURIOS_S3_SESSION_TOKEN), when set, are read by Ourios and applied to the AmazonS3Builder (with_access_key_id / with_secret_access_key / with_token). These are S3-API names, not AWS-the-cloud names — they authenticate AWS S3 and every S3-compatible store (MinIO, R2, Hetzner, Ceph, …) identically. The static access key and secret are a pair: setting one without the other, or a session token without that pair, fails fast (the error names only the offending key, never a value).
  2. The standard credential chain (fallback). When the explicit keys are all unset, AmazonS3Builder::from_env() resolves the usual way: static AWS_* keys, a shared profile, IRSA, or instance metadata. Retained because AWS IRSA injects its own AWS_ROLE_ARN / AWS_WEB_IDENTITY_TOKEN_FILE (the EKS pod-identity webhook, outside Ourios’s control), for which there is no Ourios-named equivalent.

Secret hygiene. Credential and secret values MUST never appear in logs, error messages, metric attributes, or a Debug rendering. Ourios reads the explicit OURIOS_S3_* credential keys, so it owns their redaction — S3Config’s Debug shows only credential presence, StoreError withholds backend internals, and a missing-required-config error names only the key (OURIOS_S3_BUCKET), never a credential. Non-secret config values (an addressing knob, an interval) MAY be echoed in a resolution error for diagnosability — e.g. the OURIOS_COMPACTION_INTERVAL_SECS parser reporting the offending value — since those carry no secret; the prohibition is specifically on credential/secret material. (Introduced by the 2026-06-28 amendment, §9.)

4. Alternatives considered

  • Overload OURIOS_BUCKET_ROOT with an s3://bucket/prefix URL. One var, no new knobs — but it conflates path, addressing, endpoint, and region into a single string, hides the MinIO/R2 endpoint override, and couples config parsing to object_store’s URL grammar. Rejected for a flat, explicit knob set that RFC 0004 can govern.
  • Only the receiver writes S3; querier/compactor stay local. Incoherent — the data store is a single backend; a querier reading a local path would find nothing the S3 receiver wrote. Rejected.
  • Project S3 as a filesystem (CSI / s3fs mount). Lets the existing std::fs code run unchanged, but defeats the conditional-PUT atomicity RFC 0009/0013 rely on for the manifest swap, and adds an opaque failure surface. Rejected.
  • Defer (keep local-only). Leaves the shipping chart on a single-replica RWO stopgap that contradicts CLAUDE.md §3.6 and blocks querier scaling. Rejected — this RFC is the unblock.

5. Acceptance criteria

Scenario RFC0019.1 — backend selection from config

  • Given OURIOS_STORAGE_BACKEND unset and OURIOS_BUCKET_ROOT set
  • When the server resolves its config
  • Then it selects the local backend from OURIOS_BUCKET_ROOT; and with OURIOS_STORAGE_BACKEND=s3 + OURIOS_S3_BUCKET it selects S3; and s3 without OURIOS_S3_BUCKET, or an unknown backend value, is a clear fail-fast startup error.

Scenario RFC0019.2 — the WAL stays local under every backend (CLAUDE.md §3.6)

  • Given OURIOS_STORAGE_BACKEND=s3
  • When the receiver role runs
  • Then the WAL is written under the local OURIOS_WAL_ROOT and never as an object-store key; the data + audit Parquet go to S3 (extends RFC0013.6).

Scenario RFC0019.3 — end-to-end ingest→query on S3

  • Given the server configured for an S3-compatible backend (localstack)
  • When a batch is ingested and a DSL query runs
  • Then the Parquet lands under the S3 prefix and the query returns the rows with non-zero pruning stats — the same result the local backend produces.

Scenario RFC0019.4 — compaction operates on S3

  • Given several small files for a partition on the S3 backend
  • When a compaction sweep runs
  • Then they are consolidated via Store I/O and the manifest is swapped with a conditional PUT (publish_cas); a losing concurrent sweep does not clobber the winning generation (RFC0013.3/.4).

Scenario RFC0019.5 — tenant isolation on S3 (CLAUDE.md §3.7)

  • Given two tenants’ data on the S3 backend
  • When one tenant queries
  • Then only that tenant’s prefix is read; another tenant’s objects are never returned.

Scenario RFC0019.6 — config is governed by RFC 0004; no secret leakage

  • Given S3 credentials supplied via the AWS chain
  • When the server starts, logs, errors, or exports metrics
  • Then no credential value appears in any log line, error message, or metric attribute; a missing-S3-config error names only the missing key, never a credential (non-secret knobs may be echoed for diagnosability) (CLAUDE.md §6.3, RFC 0004).

Scenario RFC0019.7 — local backend regression

  • Given no OURIOS_STORAGE_BACKEND set and OURIOS_BUCKET_ROOT set (the default local path)
  • When the full existing suite runs
  • Then behaviour is byte-for-byte unchanged from the local path today: receiver, querier, and compactor produce identical results, and every pre-existing local test passes.

Scenario RFC0019.8 — explicit S3 credentials, S3-named and never leaked

  • Given OURIOS_S3_ACCESS_KEY_ID / OURIOS_S3_SECRET_ACCESS_KEY set and no AWS_* static keys in the environment
  • When the server resolves its config and runs an ingest→query against an S3-compatible backend (localstack)
  • Then the explicit keys authenticate the store (the round-trip succeeds), confirming Ourios applies them to the builder; and when the explicit keys are all unset the standard credential chain (AmazonS3Builder::from_env(), including IRSA) is used unchanged; and a partial set (one of the static pair, or a token alone) fails fast naming only the offending key; and no credential value ever appears in a config error, log line, StoreError, Debug output, or metric attribute — extending RFC0019.6’s redaction to the S3 credential keys.

6. Testing strategy

All eight scenarios have passing tests; the RFC is accepted (§9).

  • RFC0019.1 / .6 / .7 — unit tests on build_store_config / build_config (the main.rs pattern), including the missing-key / secret-scrub assertion for hygiene and the local-default regression. They live in crates/ourios-server/src/main.rs (rfc0019_1_* / rfc0019_6_* / rfc0019_7_*) and run in the default cargo test job.
  • RFC0019.2 / .3 / .4 / .5 — server-level testcontainers + localstack integration tests in crates/ourios-server/tests/rfc0019_storage_backend.rs, reusing the rfc0013_object_store.rs harness (Store::s3 against a localstack endpoint) and spawning the ourios-server binary configured for the S3 backend, driven over HTTP. .2 asserts the WAL stays local while the data backend is S3 (the rfc0013_6_wal_stays_local pattern); .3 ingests then queries end to end on S3; .4 runs the background compactor against S3 and asserts the conditional-PUT manifest swap; .5 proves cross-tenant isolation. They are #[ignore]d for the default cargo test run and gated to the CI s3 integration (localstack) job (Docker-API runtime + the AWS_* env), invoked by name via --ignored --exact.
  • RFC0019.7 (regression) — in addition to the unit test above, the existing local receiver/querier/compactor suites run unchanged over the default config path; they are the byte-for-byte regression guard.
  • RFC0019.8 — two halves. The redaction + validation half is unit tests: the rfc0019_6_* no-leak assertion covers the OURIOS_S3_* secret keys, a with_s3_credentials test (main.rs) that the explicit keys land in S3Config (blank reads as unset, local carries none), and ourios-parquet store tests that Store::s3 accepts a full pair, fails fast on a partial set without echoing the value, and that S3Config’s Debug redacts credentials. The authentication half is the localstack rfc0019_8_explicit_s3_credentials_authenticate integration test (the server configured with the S3 credential keys only, AWS_* removed), gated to the s3 integration (localstack) CI job.

7. Open questions

  • Single-writer lease vs. conditional-PUT contention (carried from RFC 0013 §7) — is publish_cas retry sufficient for the compactor under multi-writer races, or is a dedicated lease object warranted? This RFC adopts publish_cas; a lease is a follow-up if contention shows up.
  • Local read cache for hot S3 objects (RFC 0013 §7) — deferred.
  • Migration tool to copy an existing local store to S3 — deferred; new deployments start on the chosen backend.
  • Multipart upload threshold for the 256 MiB–2 GiB RFC 0009 outputs (RFC 0013 §7) — confirm object_store defaults suffice or expose a knob.

8. References

  • RFC 0013 (object-storage backend — Store, S3Config, conditional-PUT; §7 open questions this resolves), RFC 0014 §7 (names this follow-on), RFC 0004 (configuration policy — the tunable/invariant line this extends), RFC 0005 §3.4 (partition layout, preserved), RFC 0009 (compaction — manifest swap), RFC 0007/0016 (the querier read path being migrated).
  • CLAUDE.md §3.6 (object storage is the source of truth; local disk is cache and WAL), §3.7 (multi-tenancy on every data path), §6.3 (observability / self-telemetry — no secret leakage).
  • crates/ourios-parquet/src/store.rs (Store, S3Config, StoreError), crates/ourios-parquet/tests/rfc0013_object_store.rs (the localstack harness), crates/ourios-server/src/main.rs, crates/ourios-server/src/receiver.rs, crates/ourios-server/src/querier.rs, crates/ourios-ingester/src/compactor.rs.
  • Prior art for the deferred WAL-replication/archive tier (§3.1): PostgreSQL WAL archiving (archive_command / archive_library) and CloudNativePG’s Barman Cloud (barman-cloud-wal-archive) — the same layering, a hot fsync’d WAL on a local persistent volume plus asynchronous shipping of completed segments to object storage for off-node recovery / PITR.

9. Amendment history

  • 2026-06-28 — explicit S3-named credentials. §3.4 as originally accepted introduced “no Ourios-specific credential config” and resolved S3 credentials solely through AmazonS3Builder::from_env() (the AWS-SDK-named chain). Ourios is S3-compatible, not AWS-specific (MinIO, Cloudflare R2, Hetzner, Ceph/RADOS, GCS S3-interop, …), so its credential surface should read as S3. This amendment added the S3-named credential keys (OURIOS_S3_ACCESS_KEY_ID / OURIOS_S3_SECRET_ACCESS_KEY / OURIOS_S3_SESSION_TOKEN, §3.1 table + §3.4) layered explicit-over-chain — the chain retained as the fallback AWS IRSA requires — with the partial-set fail-fast and the widened redaction, and added acceptance scenario RFC0019.8 (§5). Specified, then implemented in the same change set; the RFC stays green (all eight §5 criteria pass).
  • 2026-07-10 — accepted (maintainer sign-off). Promoted green → accepted (terminal). All eight §5 criteria have passing tests (green since #301, amended #306/#307): unit coverage of backend selection + credential scrub, and the localstack S3 integration covering WAL-stays-local, the ingest→query round-trip on S3, the compactor’s conditional-PUT manifest swap, and cross-tenant isolation. No validated stage applies — backend selection is server wiring, not a thesis-gate benchmark — so acceptance follows green directly (the RFC 0001 / 0008 precedent).

RFC 0020 — Configuration file


rfc: 0020 title: Server configuration file — YAML with environment-variable substitution status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-06-30 supersedes: — superseded-by: —

RFC 0020 — Server configuration file: YAML with environment-variable substitution

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

1. Summary

ourios-server gains a YAML configuration file, selected with --config <path>, as the primary way to configure a deployment. The file maps onto the same resolved ServerConfig the environment-variable path produces today, and supports the OpenTelemetry Configuration Working Group’s environment-variable substitution model (${env:NAME}, ${NAME}, ${env:NAME:-default}, $$ escape; scalar-only, non-recursive). The file is authoritative: when --config is given, configuration comes from the file, and the environment participates only through ${env:NAME} / ${NAME} references inside it. When --config is absent, the existing pure-OURIOS_*-env path is used unchanged, so this is non-breaking.

2. Motivation

2.1 The env-only surface does not scale to a real deployment

Configuration today is ~15 OURIOS_* environment variables read in config_from_env(). That is fine for a single container but awkward at deployment scale: the Helm chart already wires a dozen env vars across three workloads, there is no single artefact an operator can read, diff, or version to see “how is this cluster configured”, and adding a tunable means threading another env var through every layer. A declarative file is the artefact operators expect.

2.2 Match the ecosystem operators already know

Ourios is an OTLP-native backend; its operators run the OpenTelemetry Collector, which is configured by a YAML file with ${env:…} substitution. Adopting the same file-plus-substitution data model (rather than inventing one) means an operator’s Collector instincts transfer directly, and it keeps Ourios honest about dogfooding OTel conventions. The Configuration WG has specified this substitution grammar precisely, including the security-relevant edge cases (no YAML-structure injection, no recursive expansion), so “mirror the spec” is a concrete, testable target rather than a design space.

2.3 Why at this layer, and why now

This is the server’s startup/config layer only — it changes how a ServerConfig is produced, not what is configurable (that boundary is RFC 0004) nor any data-path behaviour. It is self-contained: it has no dependency on the storage, query, or miner subsystems and can land while larger workstreams (e.g. the DataFusion/Arrow upgrade) are blocked. It also unblocks a cleaner Helm chart (a ConfigMap-mounted file plus a Secret-backed ${env:…} for credentials) — the k8s-idiomatic shape.

3. Proposed design

3.1 Relationship to RFC 0004 and ServerConfig

RFC 0004 fixes what may be configured (the tunables-vs-invariants boundary). This RFC fixes how that configuration is delivered. It adds no new tunables and relaxes no invariant; it introduces a second front-end that produces the same resolved ServerConfig (crates/ourios-server/src/main.rs) the env path produces. There is one config type and one set of validation rules downstream of resolution.

3.2 Selection and precedence

  • A new CLI flag --config <path> names a YAML file.
  • --config present → the file is the sole source of Ourios’s configuration. Environment variables are consulted only where the file references them via ${env:NAME} / ${NAME} substitution (§3.3). A bare OURIOS_* env var does not override a value set in the file. (The standard OTEL_* SDK environment is a separate concern entirely — it configures Ourios’s own telemetry SDK, never the data-plane config; see §3.8.)
  • --config absent → the current config_from_env() path runs unchanged (reads OURIOS_* directly). This preserves today’s behaviour exactly and keeps the change non-breaking.

The two modes are mutually exclusive by construction (the presence of the flag selects the front-end); there is no per-key merge between a file and direct env vars. This mirrors the Collector (the file is the configuration; env is an injection mechanism, not an override layer) and avoids a two-sources-of-truth precedence matrix.

3.3 Environment-variable substitution (mirrors the OTel Config WG)

Substitution follows the OpenTelemetry Configuration WG data model and operates on the parsed YAML scalar values, not the raw text: the file is parsed into a node tree first, then each scalar value has its text substituted. Mapping keys are never candidates, and a substituted value is never re-parsed into YAML structure (a mapping or sequence) — so substitution can neither rewrite keys nor inject structure (rules 4–5 below are properties of this approach, not extra post-checks on a text pass; the scalar’s own type tag is still resolved, per rule 7). The grammar for the subset this RFC supports (optional env: prefix + optional :- default; self-contained, non-normative — the full ABNF is the WG spec):

REF      = "${" [ "env:" ] ENV-NAME [ ":-" DEFAULT ] "}"
ENV-NAME = [A-Za-z_][A-Za-z0-9_]*   ; the environment variable to resolve
DEFAULT  = any characters except "}", possibly empty ; used when ENV-NAME is unset or empty

Rules (each is an acceptance scenario in §5):

  1. ${env:NAME} and the prefix-less ${NAME} are equivalent and both resolve NAME from the process environment.
  2. ${env:NAME:-default} / ${NAME:-default} substitute default when NAME is unset or empty.
  3. An undefined reference with no default resolves to the empty string. What that scalar then is follows rule 7: an unquoted empty scalar is read as YAML null, while a double-quoted one ("${MISSING}") yields an empty string.
  4. Scalar-only: substitution applies to scalar values only. A reference appearing in a mapping key position is left verbatim.
  5. Non-recursive: a substituted value is used as-is and is not re-scanned — it can neither inject YAML structure (newlines/keys) nor trigger a second substitution. This is a security boundary, not a convenience limit.
  6. $$ is an escape for a literal $: $${NAME} yields the literal text ${NAME} with no substitution.
  7. Type after substitution: once a scalar’s text is substituted, its type is resolved — a bare (unquoted) substituted scalar is re-interpreted by YAML’s type rules and then deserialized into the target ServerConfig field, so default_window_secs: ${env:W} with W=3600 yields the integer 3600; a double-quoted scalar is forced to a string. Type interpretation therefore happens on the already-parsed scalar, after its value is substituted — never on a pre-parse text pass.
  8. A ${…} reference that does not conform to REF (e.g. ${1BAD}, ${A$B}), encountered in a scalar value during substitution, is a whole-file parse error — no partial resolution, no silent passthrough. Mapping keys are never substituted (rule 4), so a ${…} in a key position is left verbatim whether or not it would conform.

The WG specification’s worked input→output table (data-model § Environment variable substitution) is adopted verbatim as the conformance vector set (§6).

3.4 File schema

The YAML schema maps onto the resolved ServerConfig. Its top-level grouping (storage / receiver / querier / compaction) deliberately echoes the Helm chart’s values.yaml for familiarity, though field names follow the file’s own snake_case convention rather than the chart’s camelCase:

storage:
  backend: s3                       # local | s3
  s3:
    bucket: ${env:OURIOS_S3_BUCKET}
    endpoint: ${env:OURIOS_S3_ENDPOINT:-}   # empty → AWS regional endpoint
    region: us-east-1
    prefix: ""
    # Credentials are NEVER inline literals — only env references (§3.5).
    access_key_id: ${env:OURIOS_S3_ACCESS_KEY_ID:-}
    secret_access_key: ${env:OURIOS_S3_SECRET_ACCESS_KEY:-}
    session_token: ${env:OURIOS_S3_SESSION_TOKEN:-}
  local:
    bucket_root: /var/lib/ourios/data        # backend: local only

receiver:
  enabled: true
  grpc_addr: 0.0.0.0:4317
  http_addr: 0.0.0.0:4318
  wal_root: /var/lib/ourios/wal              # always local (RFC 0019 §3.1)

querier:
  enabled: true
  http_addr: 0.0.0.0:4319
  default_window_secs: 3600

compaction:
  enabled: true
  interval_secs: 300

Parsing is strict: unknown keys are a startup error (deny unknown fields), matching RFC 0004’s “small, deliberately bounded surface”. The same required/optional rules and value validation that build_store_config / build_*_config enforce today apply unchanged to the file-sourced values — there is exactly one validation path after resolution (§3.1).

3.5 Secrets and hygiene (extends RFC 0019 §3.4)

Object-store credentials MUST NOT appear as inline literals in the file. They are expressed only as env references (${env:OURIOS_S3_SECRET_ACCESS_KEY}), which a deployment injects from a Secret. This is enforced: each credential field (storage.s3.access_key_id / secret_access_key / session_token) must be a single ${env:NAME} / ${NAME} reference spanning the whole value, optionally with an empty default (${env:NAME:-}, meaning “unset → fall back to the AWS credential chain”). A literal, a partial reference (prefix-${env:NAME}), or a non-empty default (${env:NAME:-literal}, which would itself embed a secret) is a startup error naming the offending key, never the value. The check runs on the raw value, before substitution — afterwards a reference is indistinguishable from a literal. An absent or empty field is not a literal and is allowed (it reads as unset).

The existing invariant — resolved credentials are never logged, and a config error names the offending key/path, never a value (RFC 0019 §3.4, RFC0019.6) — extends to the file path: substitution errors, schema errors, and the credential-literal error report the YAML key or env-var name, never the resolved secret text. The credential fields are also redacted in the config’s Debug rendering (mirroring ourios_parquet::S3Config).

3.6 Crate placement

A new config module in ourios-server (no new crate; ServerConfig already lives there). The substitution resolver is a pure text→Result<String, _> submodule (config/env_subst.rs) with no dependence on the schema, so it can be property-tested in isolation against the WG vectors.

3.7 Helm chart follow-on (out of scope here)

Migrating the chart from a dozen env vars to a mounted ConfigMap + --config + Secret-backed ${env:…} is a follow-on tracked separately; this RFC only adds the server capability. The chart change is non-breaking-compatible because the env path remains.

3.8 Out of scope: the OTel SDK environment (OTEL_*)

The Ourios config file governs Ourios’s data-plane tunables only. The configuration of Ourios’s own self-telemetry (its OpenTelemetry SDK — RFC 0001 §6.8) is not modeled here: it is driven by the standard OTEL_* environment variables, which the OTel SDK reads directly from the process environment per the OpenTelemetry Environment Variable Specification. There is no otel: section and no bespoke telemetry knob — re-modeling those would duplicate (and drift from) a stable, language- agnostic spec the SDK already implements. The relevant variables are, at least:

  • General: OTEL_SDK_DISABLED, OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES, OTEL_LOG_LEVEL, OTEL_PROPAGATORS.
  • Exporter selection: OTEL_LOGS_EXPORTER / OTEL_METRICS_EXPORTER / OTEL_TRACES_EXPORTER.
  • OTLP exporter: OTEL_EXPORTER_OTLP_ENDPOINT (and per-signal variants), …_PROTOCOL, …_HEADERS, …_TIMEOUT, …_COMPRESSION, …_CERTIFICATE / …_CLIENT_KEY.

So OTEL_* is the one environment namespace that is deliberately not absorbed into the file — it sits beside the file, consumed by the SDK. (Consequence: the chart’s current otel.exporterEndpoint value should become a plain OTEL_EXPORTER_OTLP_ENDPOINT env passthrough — folded into the §3.7 chart follow-on, not this RFC.)

4. Alternatives considered

4.1 Layered: env overrides file

A file as the base with direct OURIOS_* env vars overriding per key (12-factor). Rejected: two ways to set every value and a precedence matrix operators must keep in their heads; diverges from the Collector, which our operators already know. The file-authoritative model with ${env:…} injection covers the same use cases (inject per-environment values, keep secrets out of the file) without the ambiguity.

4.2 A bespoke substitution syntax (or none)

Inventing our own {{VAR}} templating, or only supporting whole-value $VAR. Rejected: the OTel Config WG already specified this grammar including the security edge cases (no structure injection, no recursion); reusing it is less code, less surprise, and directly testable against a published vector table. A bespoke syntax would re-litigate solved problems and surprise Collector users.

4.3 TOML / JSON instead of YAML

Rejected: the Collector, the Helm values.yaml, and Kubernetes manifests are all YAML; an operator configuring Ourios is already in YAML. JSON has no comments; TOML is a third syntax in the stack.

4.4 A full Collector-style provider/URI scheme (--config file:…|env:…|yaml:…, multi-config merge)

The Collector accepts multiple --config URIs across providers and merges them. Rejected as over-scoped for a single binary with a small bounded surface: one --config <path> covers the need. The provider/merge model can be revisited if a real multi-source requirement appears.

5. Acceptance criteria

Scenario ids RFC0020.<m>, referenced from the test code.

Scenario RFC0020.1 — a complete file resolves to the expected ServerConfig Given a YAML file setting storage.backend: s3 with a bucket, an enabled receiver with a wal_root, an enabled querier, and a compaction interval, When the server resolves configuration with --config <that file>, Then the resulting ServerConfig equals the one the equivalent OURIOS_* environment would produce, field for field.

Scenario RFC0020.2 — environment substitution follows the OTel Config WG model Given a file whose scalar values use ${env:NAME}, ${NAME}, ${env:NAME:-default}, a $$-escaped $, and a reference in a mapping key position, When the file is resolved with a known environment, Then ${env:NAME}/${NAME} are replaced by the variable’s value; the default is used when the variable is unset or empty; an undefined reference with no default becomes empty; $$ yields a literal $; the key-position reference is left verbatim; and a substituted value is not re-scanned (no recursive expansion, no injected YAML structure). And the WG specification’s published input→output vectors all hold.

Scenario RFC0020.3 — file is authoritative; bare env does not override Given a file that sets querier.default_window_secs: 1800, When the server is started with --config <that file> and an environment that also sets OURIOS_QUERIER_DEFAULT_WINDOW_SECS=3600, Then the resolved value is 1800 (the file), and the bare env var has no effect.

Scenario RFC0020.4 — no --config preserves the env-only path Given no --config flag, When the server resolves configuration from OURIOS_* variables, Then the resolved ServerConfig is identical to today’s behaviour (the existing config_from_env scenarios continue to pass unchanged).

Scenario RFC0020.5 — invalid configuration fails fast Given a file containing any of: a malformed substitution reference (${1BAD}), an unknown top-level key, or a value the existing validation rejects (e.g. storage.backend: s3 with no bucket), When the server resolves it, Then startup fails with an error identifying the offending key or reference, and no partially-applied configuration is used.

Scenario RFC0020.6 — secret hygiene across the file path Given a file referencing secret_access_key: ${env:OURIOS_S3_SECRET_ACCESS_KEY} with that variable set, When the configuration resolves and when a deliberately invalid sibling value triggers a config error, Then the resolved secret is never emitted to logs, and the error text names the YAML key / env-var name only — never the secret value (extends RFC 0019 §3.4 / RFC0019.6).

6. Testing strategy

Per CLAUDE.md §6.2.

  • Property tests (proptest) for the substitution resolver (config/env_subst.rs, RFC0020.2): generate scalar text with arbitrary interleavings of literals, ${…} refs, defaults, and $$ escapes; assert the invariants (escape round-trips, non-recursion, scalar-only, undefined→empty). The OTel WG worked-example table is encoded as a fixed table test alongside the generators (the normative conformance vectors).
  • Unit tests for schema mapping and validation (RFC0020.1/.3/.5): table of YAML inputs → expected ServerConfig or expected error; the file path and the env path are asserted to converge (RFC0020.1) and to diverge only as specified (RFC0020.3). Reuse the existing build_store_config / build_*_config validation tests as the shared oracle.
  • Regression (RFC0020.4): the existing config_from_env unit tests run unchanged under “no --config”.
  • Secret-hygiene test (RFC0020.6): extends the RFC0019.6 redaction test to the file front-end (assert no secret substring in error/log output; the error names the key).
  • No criterion benchmark — config resolution is a one-shot startup cost, not a hot path.

7. Open questions

  • --config vs OURIOS_CONFIG: also accept an env var naming the config path (convenient for the chart), or flag-only? (Leaning flag-only to keep one selection mechanism; the chart passes the flag.)
  • Empty-vs-unset default semantics: the WG model treats unset and empty identically for :-default. Confirm that matches our “trim, empty → unset” normalisation already used for OURIOS_* (it appears to; verify against build_store_config).
  • Strict unknown-key errors vs warn: this RFC specifies error (deny unknown). Confirm no forward-compat need for tolerated-unknown keys (none expected pre-1.0).
  • Per-tenant overrides (RFC 0004 §3.4): out of scope here; the file configures the server globally. Note for a future RFC whether per-tenant tunables ever want a file representation.

8. References

RFC 0021 — DataFusion / Arrow upgrade


rfc: 0021 title: Coordinated DataFusion / Arrow upgrade — phased behind upstream status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-03 supersedes: — superseded-by: —

RFC 0021 — Coordinated DataFusion / Arrow upgrade, phased behind upstream

Status: accepted (2026-08-25, maintainer sign-off). Covers the shipped phase 1 scope (RFC0021.1–.6, the ladder this RFC ran). Not unconditionally terminal: §5’s own lifecycle reopens the RFC — phase 2 lands .7.9 as red stubs and moves the status back to red — when a DataFusion release accepts object_store 0.14 / parquet 59. That reopening trigger is upstream-gated future work, not an unresolved pre-accepted gate.

1. Summary

Upgrade the storage/query dependency stack (epic #314) in two phases, each following what upstream has actually shipped:

  • Phase 1 (now): DataFusion 53.1 → 54.0, one arrow. DataFusion 54 pins arrow/parquet ^58.3 — the same arrow the querier already pulls — so ourios-parquet moves from arrow/parquet 55.2 to 58.3 and the whole workspace unifies on a single arrow. That removes the RFC 0017 row-path workaround (#276): the dual decoder and the schema_force_view_types = false override exist only because two arrow major versions coexist. MSRV moves 1.85 → 1.88 (DataFusion 54’s floor).
  • Phase 2 (when upstream ships it; expected around DataFusion 55): object_store ≥ 0.14 and parquet 59. parquet 59 drops the thrift dependency entirely (clears the GHSA-2f9f-gq7v-9h6m advisory, #295); a DataFusion release that carries object_store 0.14 unblocks #310 and lifts the renovate hold (#313). The quick-xml deny.toml ignores (RUSTSEC-2026-0194/0195) are removed in this phase iff the object_store release pins quick-xml ≥ 0.41 — that may trail phase 2.

The phase boundary is not a preference; it is where upstream currently is: no released DataFusion accepts object_store 0.14 or parquet 59 (DataFusion 54.0.0 pins object_store ^0.13.2, parquet ^58.3.0).

2. Motivation

2.1 The security cluster

thrift 0.17 (GHSA-2f9f-gq7v-9h6m, DoS) enters via parquet 55.2 and is still pinned by parquet 58.3; it is gone only in parquet 59. The advisory is GHSA-only today, so cargo deny does not fail yet — but it will the day RustSec mints an id. The quick-xml DoS advisories (RUSTSEC-2026-0194/0195) are accepted in deny.toml with an explicit removal condition that also sits behind this upgrade chain. Waiting for a single coordinated bump keeps both windows open longer than needed; phase 1 shrinks the eventual security-driven change to a small step.

2.2 The dual-decoder debt (#276)

The RFC 0017 row read path decodes DataFusion’s arrow-58 RecordBatches separately from ourios-parquet’s arrow-55 reader, and pins schema_force_view_types = false to keep the two schemas compatible. Every read-path feature pays this tax twice. Unifying on one arrow removes the second decoder and restores the upstream view-types default.

2.3 Pillar drift

Parquet-on-disk and DataFusion-as-engine are §2 pillars; the longer the stack sits behind upstream, the larger (and riskier) the eventual jump on exactly the code we can least afford to destabilise. Phasing keeps each jump small.

2.4 Why the phases follow upstream

The version locks, as of this writing:

LockFact
DataFusion 54.0.0 → arrow/parquetpins ^58.3.0
DataFusion 54.0.0 → object_storepins ^0.13.2
parquet 58.3 → thriftpins ^0.17 (vulnerable); dropped in parquet 59
object_store 0.14.0 → quick-xmlpins ^0.40.1 (< patched 0.41)
ourios-querier ↔ ourios-parquetmust share one object_store (the querier registers Store::object_store() with DataFusion’s SessionContext, RFC 0013 §2.2)
DataFusion 54.0.0 → rustcMSRV 1.88.0 (workspace documented 1.85 pre-phase-1; now 1.88)

A “single coordinated bump” resolving the whole epic is therefore not constructible from released crates today. What is constructible now — arrow unification — happens to be the riskiest part (it touches the on-disk format pillar), and doing it in isolation means the property/corpus/reconstruction suites validate exactly one change.

3. Proposed design

3.1 Phase 1 — DataFusion 54, arrow 58 everywhere, MSRV 1.88

One coordinated workspace bump: datafusion = 54, and ourios-parquet moves arrow/parquet 55.2 → 58.3 so the lockfile carries a single arrow major. Expected churn:

  • ourios-parquet: writer, reader, schema declaration, encode_records_to_parquet — arrow 55 → 58 API changes. This is the load-bearing on-disk format (§2 pillar #1); the §3.3 and §3.5 invariants below bound the change.
  • ourios-querier: DataFusion 53 → 54 API changes (logical plans, ListingTable / SessionContext, pruning statistics); removal of the RFC 0017 dual decoder and the schema_force_view_types = false override (#276).
  • ourios-bench: compile-level churn only.
  • rust-toolchain.toml note + workspace rust-version 1.85 → 1.88 (documented per CLAUDE.md §6.1; CI already runs a newer stable).

What must not change (the §3 invariants this RFC touches):

  • On-disk bytes are the contract (§3.5). The upgrade introduces no schema change: field names, types, repetition, and the RFC 0005 §3.2 column set stay identical. Files written before the upgrade MUST read identically after it (RFC0021.2). Any arrow-58 behaviour change that would alter written bytes (encodings, statistics defaults) must be pinned back to the current behaviour or explicitly RFC’d as a §3.5 schema migration — not absorbed silently.
  • Bit-identical reconstruction (§3.3). The reconstruction property and corpus tests run unchanged and must stay green.
  • The compactor’s conditional-PUT CAS (RFC 0013 §3.3/§3.4) is untouched — object_store does not move in this phase.

3.2 Phase 2 — object_store ≥ 0.14 + parquet 59 (upstream-gated)

Opens when a released DataFusion carries them (watch DataFusion 55). Scope, known today:

  • object_store 0.13 → 0.14+ API churn concentrated in crates/ourios-parquet/src/store.rs (AmazonS3Builder, PutMode/PutOptions, S3ConditionalPut, list/ list_with_delimiter, UpdateVersion). The compactor’s publish-CAS (RFC0013.3/.4) must be preserved and is re-proven by the existing localstack suite.
  • parquet 59: thrift leaves the lockfile → close #295.
  • Supply chain: object_store 0.14 pulls new transitives (aws-lc-rs / aws-lc-sys family among them) — deny.toml licenses/advisories re-cleared, osv-scanner.toml updated if needed.
  • Renovate: lift the <0.14.0 hold (#313); close #310.
  • quick-xml: drop the RUSTSEC-2026-0194/0195 ignores iff the object_store release pins quick-xml ≥ 0.41; otherwise the ignores stay with their documented removal condition.

3.3 Non-goals

No Parquet schema change, no logs-DSL surface change, no Store trait change, no query-semantics change. This RFC is a dependency migration with pinned invariants, not a feature vehicle.

4. Alternatives considered

4.1 Wait for DataFusion 55 and do one coordinated bump

Rejected: it couples the riskiest migration (arrow 55 → 58 on the on-disk pillar) with the object_store API churn in the same change, leaves #276’s dual decoder and the security windows open for longer, and gambles on DataFusion 55’s actual contents. Phase 1 is exactly the de-risking slice: upstream’s own arrow unification with nothing else moving.

4.2 Fork/patch object_store 0.13 onto quick-xml 0.41

Rejected: a patched fork of a supply-chain-sensitive crate trades a documented, low-exposure DoS ignore for permanent maintenance burden and a worse provenance story.

4.3 Bump only ourios-parquet to parquet 59 (thrift fix first)

Rejected: parquet 59 means arrow 59 in ourios-parquet while DataFusion 54 carries arrow 58 — reintroducing the dual-arrow split (#276) one version higher, on the read and write path this time.

5. Acceptance criteria

Scenario ids RFC0021.<m>. Phase 1 = .1.6; phase 2 = .7.9 (upstream-gated: their stubs land red only when phase 2 opens).

Status note (2026-07-03): phase 1 is complete — .1.6 are discharged (#339 dependency bump, #340 decoder unification; indicative B1/B2 ci-runner run on #340). green here covers phase 1; opening phase 2 lands .7.9 as red stubs and moves the status back to red until they pass.

Scenario RFC0021.1 — one arrow. Given the phase-1 bump, When the workspace lockfile is inspected, Then exactly one arrow major (58.x) and datafusion 54.x are present, and the workspace builds with MSRV 1.88.

Scenario RFC0021.2 — old files read identically (§3.5). Given Parquet files written by the pre-upgrade writer (committed fixture + freshly generated), When the post-upgrade reader reads them, Then every row and column decodes identically to the pre-upgrade reader’s output, with no schema-mismatch errors.

Scenario RFC0021.3 — reconstruction stays bit-identical (§3.3). Given the existing reconstruction property and corpus suites, When they run on the upgraded stack, Then they pass unchanged (no test weakened or deleted).

Scenario RFC0021.4 — the dual decoder is gone (#276). Given the RFC 0017 row read path, When a query renders rows end-to-end, Then decoding goes through the single unified arrow path, schema_force_view_types is no longer overridden, and the RFC 0017 suites pass.

Scenario RFC0021.5 — the pruning thesis holds (B1/B2). Given the benchmarks.md B1/B2 gates, When the query benchmarks run on the upgraded stack (indicative ci-runner pass; authoritative baseline rerun on maintainer opt-in), Then selective-query row-group pruning shows no regression beyond run-to-run noise.

Scenario RFC0021.6 — the full gate is green. Given the phase-1 change, When CI runs, Then the complete suite passes — including s3 integration (localstack) (the CAS paths, untouched) and live-check (weaver).

Scenario RFC0021.7 (phase 2) — CAS survives object_store 0.14. Given the object_store bump, When the RFC0013.3/.4 conditional-PUT localstack suites run, Then concurrent-sweep publish semantics are preserved.

Scenario RFC0021.8 (phase 2) — thrift is gone. Given the parquet 59 bump, When the lockfile is inspected, Then no thrift crate is present (#295 closed).

Scenario RFC0021.9 (phase 2) — supply chain re-cleared. Given the new transitive set, When cargo deny check runs, Then it passes with the renovate hold lifted (#313) and the quick-xml ignores removed iff object_store pins quick-xml ≥ 0.41.

6. Testing strategy

Per CLAUDE.md §6.2. The existing suites are the oracle — this RFC adds one artefact and changes no test semantics:

  • Fixture for RFC0021.2: before the bump, a small Parquet file (representative rows: structured + templated bodies, attributes, non-finite doubles) is generated by the current writer and committed under testdata/; a new test reads it and asserts decoded equality against its committed expected rows. This makes “old files still read” a permanent regression test, not a one-off migration check.
  • Property/corpus/reconstruction suites (§3.3), the RFC 0005 §3.9 absent-column tests, and the RFC 0017 suites run unchanged.
  • RFC0013.3/.4 localstack CAS tests re-prove the store seam (phase 1: unchanged; phase 2: the actual subject).
  • Benchmarks: B1/B2 indicative on ci-runner first, per the standing bench policy; the paid baseline rerun only on explicit opt-in.

7. Open questions

  • DataFusion 55 contents and timing — does it pick up object_store 0.14 and parquet 59 together? (Determines whether phase 2 is one step or two.)
  • aws-lc-rs / aws-lc-sys license family under deny.toml’s allow-list when object_store 0.14 lands (ISC + Apache-2.0 variants; needs review, possibly an exceptions entry).
  • Is an authoritative baseline B1/B2 rerun wanted after phase 1, or indicative-only until phase 2 completes the epic?
  • MSRV cadence: 1.85 → 1.88 is forced here; do we want a documented policy (e.g. “MSRV may follow DataFusion’s floor”) instead of per-RFC decisions?

8. References

  • Epic #314 (this RFC), #310 (object_store 0.14, blocked), #295 (thrift GHSA-2f9f-gq7v-9h6m), #276 (RFC 0017 dual decoder), #313 (renovate hold).
  • RFC 0011 (A1 demotion — context for the bench gates), RFC 0013 (Store / object_store seam, CAS), RFC 0017 (row read path).
  • CLAUDE.md §2 (pillars #1, #3), §3.3 (bit-identical reconstruction), §3.5 (schema migration), §6.1 (MSRV), §6.2 (tests are specifications).
  • deny.toml RUSTSEC-2026-0194/0195 ignore block (removal condition).

RFC 0022 — Queryable attribute columns


rfc: 0022 title: Queryable attribute columns (RFC 0005 amendment) status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-03 supersedes: — superseded-by: —

RFC 0022 — Queryable attribute columns (RFC 0005 amendment)

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip; thesis-gates passed at validated.

Status note. validated (2026-07-18) — the RFC0022.5 no-regression contract holds on both rungs of the bench ladder: benchmarks.md §9.9 (indicative ci-runner, git 6e3301b — the green merge itself: B1 34.8× vs the ≥ 10× bar, B2 flat few-ms windowed latencies, pruning counters pinned structurally in crates/ourios-querier/tests/it/rfc0022_attr_columns.rs), and benchmarks.md §9.11 (authoritative baseline-8vcpu-32gib, git 19e0886, a descendant of 6e3301b — the first ≥ 10 GiB B1/B2 readings, both PASS (authoritative) with the promoted-column write path and two-arm predicate compile in the measured tree). §9.9 named the authoritative rerun as the outstanding green → validated step; §9.11 completed it the next day — this note records the flip the evidence already supported. The next rung — validated → accepted — is the maintainer’s, per the docs/rfcs/README.md lifecycle ladder.

1. Summary

Discharge the typed-attribute amendment that RFC 0005 §3.3 reserved (“the gate is a concrete consumer”) — the consumer exists: the RFC 0002 DSL exposes service, resource.<key>, and attr.<key> as first-class fields, and today they compile to a substring LIKE over the canonical-JSON attribute columns (#146, tracked by #147). That stopgap is correct for string equality and nothing else: no row-group pruning, and ordering / regex comparisons are rejected.

This RFC adds a promoted attribute column set to the RFC 0005 data schema:

  • Each promoted key is projected at write time into its own OPTIONAL Utf8 column (dictionary + page index + bloom filter), named literally after the DSL path (resource.service.name, attr.http.request.method).
  • The promoted set always contains the resource key service.name (the Required, Stable identity attribute of the OTel service resource entity per the semconv registry, the DSL’s bare service field); operators extend it via a new storage.promoted_attributes key that this RFC adds to the RFC 0020 config schema (§3.2).
  • DSL predicates on promoted keys compile to the typed column with the full cmp_op set (ordering + regex) and row-group pruning; a hybrid fallback arm keeps results correct on pre-amendment files and on non-string values. Non-promoted keys keep the RFC 0002 LIKE behaviour unchanged.
  • The JSON columns (attributes, resource_attributes) remain the source of truth. Promoted columns are query-only projections: the read path (RFC 0017 LogRow) never consumes them, so OTLP fidelity and reconstruction are untouched.

Sibling (out of scope here): RFC 0007 §8’s reserved param-predicate pushdown amendment.

2. Motivation

service == "api" and severity >= error | count by template_id is the canonical Ourios query shape (RFC 0002 §1). Today the service half is a LIKE '%{"key":"service.name",…}%' scan over resource_attributes in every row group the other predicates fail to prune. Three concrete costs:

  1. No pruning. The JSON columns carry no useful min/max statistics and no bloom filters (RFC 0005 §3.6 table); an attribute-only query reads the full corpus. That is exactly the failure mode the pillar-2 thesis exists to avoid (CLAUDE.md §2, benchmark gates B1/B2).
  2. Operator gap. Substring-on-JSON can only do exact key+string-value matching, so the DSL rejects ordering and regex on attributes (InvalidQuery) — a visible feature cliff between severity_text =~ "..." and attr.http.route =~ "...".
  3. The reservation is due. RFC 0005 §3.3 deliberately deferred the typed-attribute column set until “we have a concrete consumer”; the DSL surface and #146’s stopgap are that consumer.

3. Proposed design

3.1 Promoted columns in the data schema

For each promoted key, the writer appends one column to the RFC 0005 §3.2 data schema:

PropertyValue
NameThe DSL path, literally: resource.<key> or attr.<key> (so resource.service.name, attr.http.request.method)
Arrow / Parquet typeArrow Utf8 / Parquet STRING logical type over BYTE_ARRAY (matching the RFC 0005 §3.2 string columns), OPTIONAL
ValueThe attribute’s string value, exactly as stored in the JSON column — no truncation, no normalisation
NULL whenThe key is absent on the record, or its value is not a string AnyValue
EncodingsDictionary yes, page index yes, bloom filter yes (extends the §3.6 table)

Rules:

  • service.name is always promoted. The effective promoted set is {resource: ["service.name"] ∪ configured, log: configured}.
  • String values only. A promoted key whose value is an int, bool, double, bytes, array, or kvlist projects NULL. ==/!= predicates on such cells fall through to the JSON arm (§3.3) — precisely the stopgap’s semantics for values it cannot match; ordering/regex predicates are typed-arm-only (§3.3) and never match them. Typed numeric promotion is a future extension (§7).
  • No truncation. Truncating a projected value would make == silently miss; the projection is byte-faithful or NULL. The cardinality/size exposure this creates is handled by telemetry, not by lying (§3.5).
  • Projection, not truth. attributes / resource_attributes keep their RFC 0005 §3.3 contract unchanged. The RFC 0017 read path (LogRow, rendering, OTLP fidelity) continues to decode from the JSON columns only — a divergence between a promoted cell and the JSON is impossible to observe through the read path, and the §5 round-trip suites stay the oracle for fidelity.

Column-name note: literal dots in column names are valid in Parquet and Arrow. On the DataFusion side the compiler must not reference these columns through datafusion::prelude::col("…") — that parses its argument, so col("resource.service.name") reads as a qualified reference (relation resource, column …). The required mechanism is explicit unqualified construction: Expr::Column(Column::new_unqualified("resource.service.name")) (datafusion::common::Column), which treats the whole string as the literal column name. The querier never routes these names through the SQL identifier parser, so no mangling scheme (and therefore no collision handling) is needed. The resource. / attr. prefixes are reserved column-name namespaces in the data schema from this RFC on.

3.2 Configuration (an RFC 0020 schema extension)

storage.promoted_attributes does not exist in RFC 0020’s schema today — this RFC adds it (RFC 0020’s own evolution path for new knobs):

storage:
  promoted_attributes:
    resource: [k8s.namespace.name]   # service.name is implicit, always on
    log: [http.request.method, http.route]  # string-valued keys
  • Keys are plain attribute-key strings, taken literally (no globbing).
  • The set applies to new files at write time; it is not retroactive. Files written under different sets coexist (§3.4).
  • Defaults: empty beyond the implicit service.name — promotion beyond that is an explicit operator decision because each promoted key costs file bytes on every row (§3.5). The key itself is optional: configs that omit it are unchanged.
  • Rollout ordering: RFC 0020 parses strictly — an unknown key is a startup error — so a config carrying storage.promoted_attributes requires a binary at or above this RFC’s green. Upgrade first, extend the config second; rolling back the binary requires removing the key. (Data written meanwhile stays readable either way — §3.4’s unknown-column rule covers files a rolled-back binary encounters.)
  • Per-tenant sets are deferred (§7); the knob is global, consistent with every other RFC 0020 setting.

3.3 Predicate compilation

Promoted-key predicates compile by operator class (P = promoted column, J = the JSON arm — the existing #146 LIKE fragment machinery, which expresses exactly == and, with its presence guard, != on a key + string value, and nothing else):

== / != — the two-arm form:

match_expr(==, v) :=
      (P = v)                          -- typed arm: prunable
   OR (P IS NULL AND J(==, v))         -- fallback arm: pre-amendment
                                       --   files, non-string values

match_expr(!=, v) :=
      (P IS NOT NULL AND P != v)       -- presence check explicit: don't
                                       --   lean on 3-valued logic
   OR (P IS NULL AND J(!=, v))
Ordering (< <= > >=) and regex (=~ / !~) — the typed arm only. J cannot express these (that inexpressibility is why the stopgap rejects them), so there is no fallback arm: `match_expr(op, v)
= (P op v). The explicit consequence: on **pre-amendment files** the column reads as all-NULL (§3.9) and **rows in those files never match an ordering/regex predicate** — a silent non-match, consistent with the DSL's missing-field rule (NULLnever matches), not an error. Operators querying history older than their promotion cutover with these operators get promoted-era data only; the stopgap's answer to the same query was a hardInvalidQuery`, so no existing query degrades.

Both operator classes keep the missing-field semantics the DSL uses everywhere: NULL never matches, and != requires the key present with a different value (P IS NOT NULL AND P != v, mirrored in the JSON arm’s presence guard).

Why the fallback arm is cheap where it matters:

  • Post-amendment files, key present on every row: the row group’s P null-count is 0, so P IS NULL prunes the entire fallback arm and the typed arm’s dictionary/bloom/min-max stats do the work — this is the steady-state fast path.
  • Pre-amendment files: P is absent; the RFC 0005 §3.9 missing-column carve-out reads it as all-NULL, the typed arm matches nothing, and the JSON arm reproduces today’s exact behaviour. No historical file is rewritten and no query returns different rows than the stopgap (only ordering/regex, which the stopgap rejected outright, are newly answerable — and they are answerable only on promoted keys).
  • Mixed row groups (some rows lack the key / hold non-string values): the fallback arm scans that row group’s JSON — correctness costs a scan exactly where a scan is the only correct answer.

Operator set on promoted keys: the full RFC 0002 cmp_op== != < <= > >= (lexicographic, as for every other string field) plus =~ / !~, with the per-class compile above. The RFC 0002 rejection text moves from “attributes don’t support this” to “non-promoted attributes don’t support this”.

Non-promoted keys: compile exactly as today (#146). No behaviour change.

flowchart LR
    Q["attr.k op v"] --> C{k promoted?}
    C -- no --> J["JSON LIKE arm only<br/>(== / != , unpruned)"]
    C -- yes --> T["typed column arm<br/>full cmp_op, bloom + stats prune"]
    T --> O["OR"]
    J2["== / != only:<br/>P IS NULL AND JSON arm<br/>(old files, non-string values)"] --> O
    C -- yes --> J2

3.4 Schema evolution and migration plan (CLAUDE.md §3.5)

  • All promoted columns are OPTIONAL and additive — the §3.9 missing-column carve-out covers every pre-amendment file, and the unknown-column rule covers post-amendment files read by older binaries. This is the same evolution class as RFC 0018’s columns.
  • No rewrite of historical data. The §3.3 fallback arm is the migration plan: old files answer correctly (identically to today) without touching them. Compaction (RFC 0009) naturally re-projects rows it rewrites using the current promoted set, so history converges toward pruneability as a side effect, but nothing depends on that.
  • Changing the configured promoted set between deploys is safe by the same rules (the implicit service.name cannot be removed — §3.1): a key removed from the set stops being projected in new files (old files keep the column; the compiler still emits the two-arm expression whenever the scanned union schema carries the column), a key added starts NULL-backed in history. Scan-time schema union across files with different promoted sets is the ordinary §3.9 case.

3.5 Hazards (CLAUDE.md §4)

  • Cardinality / file bloat (hazard #2). A promoted key with unbounded values (request IDs, URLs with query strings) bloats the dictionary and the bloom filter of its column. Mitigations: the set is opt-in per key (the failure is contained to an explicit operator decision), and the writer emits per-promoted-column byte telemetry (via the weaver registry, §3.6) so the tradeoff is observable. No truncation (§3.1) and no automatic demotion — predictability over cleverness; revisit if telemetry shows real-world foot-guns.
  • Small-file / wide-schema pressure (hazard #4). Each promoted key adds one column chunk per row group. The config default (empty beyond service.name) keeps the floor where RFC 0005 left it.
  • Query DSL leakage (hazard #6). The DSL surface is unchanged — the same field paths gain operators and speed; nothing about column names or promotion leaks into query syntax.

3.6 Telemetry

Per the weaver-registry discipline: an ourios.storage.parquet.promoted.size instrument (attribute: promoted column name) recording per-flush projected bytes, mirroring ourios.storage.parquet.file.size (histogram, UCUM unit By) in both namespace and shape. The unit lives in instrument metadata, not the name, per the OTel metric semantic conventions (“metrics that have their units included in OpenTelemetry metadata SHOULD NOT include the units in the metric name”). Exact instrument fields are settled in the semconv registry entry at implementation time, as always. Query-side pruning is already observable through the RFC 0016 scanned/pruned row-group counters — RFC0022.5 uses them as its oracle.

4. Alternatives considered

  • MAP<STRING,STRING> column (the sketch in RFC 0005 §3.3). One column regardless of set size, but Parquet statistics and bloom filters on a map’s value leaf are not key-scoped, and DataFusion has no map-key predicate pushdown — it prunes nothing. Pruning is the entire point (#147’s ❌ list); rejected.
  • Full flattening (a column per key ever seen). Schema explosion under attribute-key churn, unbounded wide-schema pressure, and every file carries every key’s column chunk. The explicit promoted set is the deliberate, operator-owned subset of this.
  • Name mangling (attr__http_method). Avoids dots in column names but needs an escaping scheme plus collision handling (http.method vs http_method), and the mangled names leak into every diagnostic. Literal names cost only unqualified-column construction on the DataFusion side; chosen.
  • JSON path expressions at query time (DataFusion UDF over the JSON column). Fixes the operator gap but not pruning; strictly worse than the two-arm compile for the same implementation weight.
  • Rewrite history at cutover. A compaction-style backfill would make pruning retroactive but couples the amendment to a corpus-wide rewrite (cost, object-store churn, §3.6 truth-of-storage risk during the swap). The fallback arm delivers correctness without it; convergence via ordinary compaction is free.

5. Acceptance criteria

Scenario ids RFC0022.<m>.

Scenario RFC0022.1 — service.name is always projected. Given records whose resource attributes carry service.name as a string (plus records where it is absent or non-string), When the writer flushes them, Then the file carries an OPTIONAL Utf8 resource.service.name column whose cells equal the JSON values byte-for-byte where the value is a string and are NULL otherwise, and the resource_attributes JSON column is byte-identical to a pre-amendment writer’s output.

Scenario RFC0022.2 — configured keys project the same way. Given storage.promoted_attributes naming a resource key and a log key, When records carrying those keys (string and non-string) are flushed, Then resource.<key> / attr.<key> columns exist with the §3.1 projection semantics, and a key not in the set produces no column.

Scenario RFC0022.3 — old files answer identically (§3.9 / §3.4). Given a scan spanning a pre-amendment file (no promoted columns) and a post-amendment file, When service == X / attr.<k> == X / != queries run, Then the result set equals the pure-LIKE compile’s result set on the same data, row for row.

Scenario RFC0022.4 — full operator set on promoted keys only. Given a promoted key and a non-promoted key, over a scan spanning a pre-amendment file and a post-amendment file, When ordering (<, >=, …) and regex (=~, !~) predicates are issued against each, Then the promoted key answers them from the typed arm only — rows in the pre-amendment file never match (§3.3’s documented silent non-match) — and the non-promoted key still rejects them with InvalidQuery, ==/!= continuing to work as today.

Scenario RFC0022.5 — promoted predicates prune (pillar 2). Given a multi-row-group corpus where a promoted key’s value is concentrated in a minority of row groups, When a selective equality query on that key runs, Then the RFC 0016 scanned/pruned counters show pruned > 0 (scanned < total), and B1/B2 gates are unchanged (indicative ci-runner; authoritative on maintainer opt-in, per the standing bench policy).

Scenario RFC0022.6 — the read path is projection-blind (§3.1). Given files with promoted columns, including a hand-forged file where a promoted cell disagrees with the JSON, When rows are returned through the RFC 0017 read path, Then every OTLP field round-trips from the JSON columns exactly as before — the forged promoted cell is invisible — and the existing full-fidelity suites pass unchanged.

Scenario RFC0022.7 — promoted-set drift across deploys (§3.4). Given three files written under configured promoted sets {}, {a}, {a,b} for keys a,b (each on top of the implicit, non-removable service.name), When one scan spans all three and predicates on a and b run, Then the scan unions schemas without error and each predicate returns the correct rows from every file (typed arm where the column exists and is non-NULL, JSON arm otherwise).

6. Testing strategy

Per CLAUDE.md §6.2. RFC0022.1/.2 are writer unit + footer-inspection tests in ourios-parquet (encodings asserted from the Parquet metadata, as the RFC 0005 §3.6 suites do). RFC0022.3/.4/.7 are querier acceptance tests over generated old/new file mixes — .3 reuses the pre-amendment fixture discipline RFC 0021 §6 established (a committed file written before the schema change). RFC0022.6 extends the RFC 0017 fidelity suites with a forged-divergence file. RFC0022.5 is a deterministic pruning test in the shape of rfc0007_1_* (counters, not wall-clock), plus the indicative bench dispatch. Property tests: the projection function (AnyValue → cell) round-trips against the canonical-JSON encoder for arbitrary string values (proptest, shared generators with the RFC 0001 §6.1 codec suite).

7. Open questions

  1. Typed numeric promotion. Numeric attributes split into two cases today. A string-encoded number (“500” as a string AnyValue) projects and compares lexicographically — >= "500" works within one magnitude, cross-magnitude comparisons don’t. A true numeric AnyValue (http.status_code as an int, the common OTLP emission) projects NULL (§3.1), so ordering/regex predicates on it silently never match and even == only answers through the JSON arm. A future Int64-typed promotion class (per-key type declaration in config) is what makes numeric attributes first-class; deferred until a consumer demands it.
  2. Per-tenant promoted sets. Global-only in this RFC. Multi-tenant operators with divergent schemas may want scoping; the column mechanism doesn’t change, only config addressing.
  3. Automatic demotion / cardinality guards. Telemetry-first (§3.5); revisit if promoted-column bloat shows up in practice.
  4. Bloom filter sizing. Writer defaults initially; per-key tuning is config surface we can add without schema impact.

8. References

  • #147 (this amendment’s tracking issue), #146 (the LIKE stopgap PR), RFC 0002 (#143 epic) — the DSL field surface.
  • RFC 0005 §3.2 (data schema), §3.3 (AnyValue encoding rule + the reserved amendment this RFC discharges), §3.6 (encodings table this RFC extends), §3.9 (evolution rules the migration plan leans on).
  • RFC 0016 (scanned/pruned counters — the RFC0022.5 oracle).
  • RFC 0017 (the projection-blind read path in RFC0022.6).
  • RFC 0020 (the config schema this RFC extends with storage.promoted_attributes; strict parsing → §3.2 rollout ordering).
  • RFC 0007 §8 (the sibling param-pushdown reservation, untouched).
  • CLAUDE.md §2 pillar 2, §3.5 schema-migration invariant, §4 hazards #2/#4/#6.

RFC 0023 — Bounded template memory


rfc: 0023 title: Bounded template memory (RFC 0001 amendment) status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-04 supersedes: — superseded-by: —

RFC 0023 — Bounded template memory (RFC 0001 amendment)

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

1. Summary

The miner’s per-tenant template store grows without bound. The first 10–100 GiB scale run (2026-07-04, LogHub HDFS_v2 — 16 GiB / 71 M lines of Hadoop daemon logs, on baseline-8vcpu-32gib) was OOM-killed at 31.5 GiB RSS during the B2 store build: the miner had minted ≥ 56,000 templates by the 1.8 GiB mark (the busiest covering only 0.67 % of rows), with memory growing roughly linearly at ~2× corpus bytes. The bench-side suspects were eliminated first — the corpus loader streams (#350) and the harness’s quadratic snapshot capture was fixed (#351) — leaving the miner’s tree itself as the proven cause.

This is not bench-only: the production ingester runs the same MinerCluster, so a single tenant shipping shape-diverse logs (stack traces, multi-format daemon output) can OOM an ingester pod. That is a direct hit on hazards #1 and #2. Upstream Drain3 carries max_children and a cluster cap for exactly this input class; MinerConfig today has neither.

This RFC adds three configurable bounds — a per-node fan-out cap, a per-tenant template ceiling, and a per-line token cap — with one overflow rule everywhere: fail honestly (parse-failure path, body retained, counted and observable), never force-merge. The §3.1 no-silent-merge invariant is load-bearing throughout.

2. Motivation

  • Hazard #2 (cardinality blowup), tree edition. RFC 0001 §6 bounds parameter bytes (param_byte_limit) but nothing bounds the number of leaves or the token width of a stored template. A corpus whose lines are structurally diverse (HDFS_v2’s node logs interleave block events, GC lines, and multi-hundred-token stack traces) mints a new leaf every few lines forever.
  • Measured, not hypothetical. The scale-run evidence chain: streaming loader held 1.3 GiB flat for hours (loader exonerated); gdb stack samples during the slow phase landed in bench-harness snapshot capture (CPU pathology fixed in #351, miner CPU exonerated); the rerun then OOM-killed at 31.5 GiB anon RSS (dmesg), while 1.1 GiB and 1.8 GiB subsets completed — with the 1.8 GiB subset showing template ids ≥ 56,199. Linear growth at ~2× corpus bytes extrapolates exactly to the observed kill.
  • The fragmentation itself is a correctness smell. 56 k templates with the busiest at 0.67 % of rows means pillar #2’s logical reduction (50–200×) has failed on this corpus shape: pruning value collapses along with memory. A bounded miner turns that failure mode from “process dies” into “observable degradation with bodies retained”.

3. Design

3.1 Three bounds, one overflow rule

All three are MinerConfig fields, enforced per tenant (the tree is per-tenant, CLAUDE.md §3.7). Overflow never attaches a line to a template it did not match (§3.1 no-silent-merge); it takes the RFC 0001 §6.3 parse-failure path: template_id = NO_TEMPLATE, body retained verbatim, counted.

  1. max_node_children (default 100, Drain3’s default) — cap on an internal prefix node’s distinct-token children. When a node is full, unseen tokens route through a <*> wildcard child (minted on first overflow) instead of a new branch. This bounds tree width. Routing is not merging: leaf attach below the wildcard child stays simSeq-gated exactly as everywhere else — a line that matches no leaf at or above the floor still mints its own leaf (subject to bound 2) or fails parse.
  2. max_templates (default 20,000) — per-tenant ceiling on Drain-tree leaves. At the ceiling, both minting paths (the §6.3 lossy-zone new leaf and the no-candidate new leaf) divert to parse-failure. The first ceiling hit per tenant logs a structured warning; every diverted line increments the parse-failure counter with a reason attribute (§3.4). Existing leaves keep widening normally — the ceiling stops growth, not matching.
  3. max_line_tokens (default 512) — lines that tokenize past the cap go straight to parse-failure with the body retained. This bounds stored-template token width (a 900-token stack-trace line today mints a 900-token template) and, with bound 2, makes worst- case tree memory a computable product instead of an open-ended sum.
flowchart LR
    L[line] --> T{"tokens ≤ max_line_tokens?"}
    T -- no --> PF["parse-failure:<br/>body retained, counted"]
    T -- yes --> D["descend tree<br/>(full node → wildcard child)"]
    D --> M{"simSeq vs leaves"}
    M -- "≥ threshold" --> A[clean attach / widen]
    M -- "lossy zone /<br/>no candidate" --> C{"leaves < max_templates?"}
    C -- yes --> N[mint new leaf]
    C -- no --> PF
    M -- "< floor" --> PF

3.2 Why fail-honest instead of Drain3’s alternatives

Drain3 under pressure either force-merges into the nearest cluster or LRU-evicts old clusters (max_clusters). Both are wrong here:

  • Force-merge is precisely the §3.1 corruption the project treats as its worst failure: a search for one event returning another’s rows. Rejected outright.
  • LRU eviction invalidates template_ids already written into Parquet: the read-time registry (RFC 0017) renders rows from the audit-derived template history, and eviction either breaks those renders or demands tombstone machinery in the audit stream. That cost isn’t justified before a real tenant needs template churn (as opposed to a cap); deferred to §7.

Parse-failure with body retention is already a first-class, bit-faithful path (RFC 0001 §6.3, C1 excludes it by construction and the body column preserves the line exactly), so overflow degrades to “unmined but fully stored and searchable” — the honest floor.

3.3 What does not change

  • No schema change. Parquet layout, template_id semantics, and every existing file are untouched (CLAUDE.md §3.5 satisfied trivially).
  • Healthy corpora are unaffected. HDFS_v1, the OTel-Demo captures, and the seed corpus mine to well under 5 % of the default ceiling with fan-out far below 100; defaults must be invisible there (RFC0023.5 pins byte-identical template sets).
  • Existing knobs keep their meaning. similarity_threshold, similarity_floor, param_byte_limit, prefix_depth are untouched; the new bounds compose with them.

3.4 Telemetry (weaver registry, per the standing discipline)

  • ourios.miner.parse_failures (existing counter) gains a ourios.miner.parse_failure.reason attribute — values below_floor | line_too_long | template_ceiling — following the OTel “error.type on an existing instrument” convention rather than minting per-cause counters.
  • ourios.miner.template.count (existing gauge) is the ceiling’s observable: count == max_templates plus a non-zero template_ceiling failure rate is the operator’s saturation signal.
  • Exact registry entries are settled in semconv/registry/ at implementation time via weaver registry generate, as always.

3.5 Configuration surface

The bounds land as programmatic MinerConfig fields with the defaults above. Exposure in the RFC 0020 config file (a miner.* section) is a small follow-up schema extension in the RFC 0020 evolution style — the same pattern storage.promoted_attributes used (RFC 0022 §3.2) — and is not required for this RFC to go green: defaults protect every deployment immediately.

4. Alternatives considered

  • Byte-budget accounting (cap tree bytes, not counts). More direct, but the trigger becomes opaque (“why did mining stop at 17:42?”) and the accounting itself is invasive. Count × width caps give the same asymptotic bound with explainable, testable knobs.
  • Force-merge under pressure (Drain3 default-ish). Violates §3.1; rejected — see §3.2.
  • LRU eviction (max_clusters). Breaks written-data guarantees; deferred — see §3.2 / §7.
  • Do nothing, document the limit. Leaves the ingester OOM-able by a single tenant’s log shape — an operational DoS vector (hazard #2) — and leaves the 10–100 GiB thesis gates unmeasurable.

5. Acceptance criteria

Scenario ids RFC0023.<m>.

Scenario RFC0023.1 — the ceiling holds and never merges. Given a MinerConfig with a small max_templates and a corpus that would mint more, When the corpus is ingested, Then the tenant’s template count plateaus at the ceiling, every would-mint line takes the parse-failure path with its body retained, and no overflow line is attached to any existing template (no silent merge: template row sets are identical to an uncapped run truncated at the ceiling).

Scenario RFC0023.2 — overflow lines stay stored and searchable. Given ceiling-overflow lines from RFC0023.1 written to Parquet, When the bodies are read back, Then each round-trips bit-identically through the body column.

Scenario RFC0023.3 — node fan-out caps via wildcard routing. Given a corpus whose lines present more than max_node_children distinct tokens at one prefix level, When ingested, Then the node’s child count never exceeds the cap, later tokens route through the wildcard child, and attach under that child remains threshold-gated (a below-floor line still fails parse rather than merging).

Scenario RFC0023.4 — the long-line guard. Given a line tokenizing past max_line_tokens, When ingested, Then it takes the parse-failure path, its body round-trips bit-identically, and no template of that width exists in the tree.

Scenario RFC0023.5 — defaults are invisible on healthy corpora. Given the default bounds, When the corpus suites (HDFS_v1, seed, OTel-Demo captures) run, Then the mined template sets are identical to an unbounded run (C1/C2 and the reconstruction property suites pass unchanged).

Scenario RFC0023.6 — saturation is observable. Given a ceiling-saturated tenant, When telemetry is scraped, Then ourios.miner.parse_failures carries reason = template_ceiling increments and ourios.miner.template.count reads the ceiling value.

Scenario RFC0023.7 — the scale run completes (the falsifier). Given LogHub HDFS_v2 (16 GiB) on baseline-8vcpu-32gib under default bounds, When the B1/B2 store builds run, Then mining completes with peak RSS under 8 GiB and the query benches produce results (indicative ci-runner first, authoritative on maintainer opt-in, per the standing bench policy). If bounded mining still cannot complete this corpus, the design is wrong — reopen.

6. Testing strategy

Per CLAUDE.md §6.2. RFC0023.1/.3/.4 are miner unit + property tests (proptest over adversarial token streams for the no-silent-merge half: an overflow line’s row must never carry another template’s id). RFC0023.2 rides the existing writer round-trip suites. RFC0023.5 is the existing corpus gate rerun under defaults — the “tests are specifications” tripwire for this whole RFC: no existing suite may be weakened to make the bounds fit. RFC0023.6 uses the in-memory meter harness the other miner-metric tests use. RFC0023.7 reuses the scale runner (scratch/baseline/) with its peak-RSS sampler.

7. Open questions

  1. Eviction / template aging. Long-lived tenants with genuine template churn (deploys renaming log sites) will eventually fill any ceiling with dead templates. An aging mechanism needs audit tombstones + registry support; deferred until a consumer exists.
  2. Per-tenant overrides. Global defaults now, consistent with every RFC 0020 knob; revisit with multi-tenant operations.
  3. Ceiling-hit audit event. A system-scoped audit event (the RFC 0008 §9 deferred family) would give drift queries visibility into when saturation began; metrics-only for now.
  4. miner.* config-file section — §3.5 follow-up.

8. References

  • Scale-run evidence (2026-07-04): attempt logs + subset probes retained by the maintainer; summarized in docs/benchmarks.md §9.10 and §1 above. Bench-side fixes: #350 (streaming corpus loads), #351 (snapshot-capture skip).
  • Drain3 (logpai/Drain3): max_children, max_clusters — the upstream mechanisms this RFC adapts (adopting the first, rejecting the second’s eviction semantics for §3.2’s reasons).
  • RFC 0001 §6.2 (tree walk), §6.3 (three-zone model + parse-failure path this RFC reuses as its overflow floor).
  • CLAUDE.md §2 pillar 2, §3.1 (no silent merges), §3.7 (per-tenant scoping), §4 hazards #1/#2.

RFC 0024 — OTLP-envelope property testing


rfc: 0024 title: OTLP-envelope property testing and corpus-calibrated generation (RFC 0006 amendment) status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-05 supersedes: — superseded-by: —

RFC 0024 — OTLP-envelope property testing and corpus-calibrated generation (RFC 0006 amendment)

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

1. Summary

The verification surface has two layers today, and a gap between them. Frozen corpora (RFC 0006: the seed corpus, the OTel-Demo captures, the LogHub stress family) exercise reality — but only the exact records that happened to be captured. Property suites (RFC 0003’s wire-decode equivalence over the proto value space, the miner’s hazard and RFC 0023 fanout properties) exercise arbitrary inputs — but each at one unit’s boundary, never through the full ingest → store → query pipeline.

The gap: nothing generates realistic-but-arbitrary OTLP and asserts end-to-end invariants over it — and the strongest such invariant, query-result correctness against an independent oracle, does not exist anywhere in the suite today.

This RFC amends RFC 0006 with:

  1. Calibration manifests — small, committed distribution summaries extracted from a real capture (attribute-count and body-length histograms, severity mix, AnyValue shape frequencies), so generators are shaped by measured reality rather than guesses.
  2. OTLP-envelope generatorsproptest strategies over OtlpLogRecord, with a calibrated mode (the realistic centre) and an adversarial mode (the envelope’s legal extremes).
  3. Four end-to-end properties — bit-faithful round-trip, no silent merge, RFC 0023 bounds, and the query oracle: for generated data and generated predicates, the querier’s answer must equal an independent linear-scan evaluator’s.

Scope is OTLP only, per the standing product decision: legacy log formats are Collector concerns; generators target the OTLP envelope space exclusively.

2. Motivation

  • There is no at-scale OTLP corpus to test against. OTLP logging is the least-adopted OTel signal; public corpora at the §8 sizes are legacy text. The demo captures are real OTLP but friendly — a dozen well-behaved services will never emit deeply nested AnyValue bodies, thousand-entry attribute maps, zero timestamps, or adversarial attribute keys. Production feeds are the only truly representative corpus and arrive only after deployment. Generation is the pre-production instrument that covers the space around the captures.
  • The §9.11 lesson generalises. The 16 GiB run surfaced an input-shape-driven failure (unbounded template minting) that no existing corpus had triggered. RFC 0023 bounded it; this RFC makes “an input shape we didn’t anticipate” a generated, repeatable test class instead of a paid-infrastructure discovery.
  • Query correctness has no oracle. C1 pins reconstruction; RFC0022.3 pins old-file parity against the prior compile; but no test asserts that a DSL query returns the right rows against an independent evaluator over data the test didn’t hand-shape. For a query backend, that is the invariant users actually rely on.

3. Design

3.1 Calibration manifests

A calibration.json per corpus release (committed under testdata/calibration/<corpus-tag>.json, single-digit KiB), extracted by a new ourios-bench --calibrate <corpus-dir> pass:

  • attribute-count histogram (per-record resource + log attributes),
  • body length histogram and body_kind mix,
  • severity number/text distribution,
  • AnyValue shape frequencies (string / int / double / bool / bytes / array / kvlist, and nesting depth),
  • distinct-key counts for attribute keys (cardinality signal).

The manifest is a measurement, versioned with the corpus it summarises; regenerating it is deterministic for a given corpus.

3.2 Generators

proptest strategies over [OtlpLogRecord] (the RFC 0003 §6.6 in-memory shape — generation happens past wire decode, which the RFC 0003 equivalence suites already cover):

  • Calibrated mode — field distributions weighted by a calibration manifest. Statistical, not exact: the §5 sanity criterion checks gross moments, not equality.
  • Adversarial mode — uniform-ish over the envelope’s legal extremes, bounded only by documented product limits: AnyValue nesting to the canonical-JSON depth bound, attribute maps to a few thousand entries, empty/absent everything, zero and u64::MAX timestamps, non-ASCII and confusable keys, text-heavy bodies past max_line_tokens (the Collector-fronted-legacy shape).

Both modes will live in crates/ourios-testgen, a dev-only crate the calibration green slice introduces (no production crate grows a proptest dependency; ourios-bench cannot host them because it already depends on ourios-querier, and the querier’s P4 suite consuming generators from it would create a dev-dependency cycle). The crate is test infrastructure; naming it in this RFC satisfies CLAUDE.md §7, which treats any new crate as an architectural commitment requiring an RFC. It will never be published, and nothing in the workspace’s production graph will depend on it.

3.3 The four properties

Over generated batches (both modes), through the real pipeline (MinerCluster → RFC 0005 writer → reader / querier):

  • P1 — round-trip fidelity. Every generated record’s stored form round-trips per the RFC 0017/0018 fidelity contract; string bodies bit-identical, structured bodies canonical-JSON equal.
  • P2 — no silent merge. A generated record’s row carries either a template its line actually attached to under §6.3’s zones, or NO_TEMPLATE with the body retained — never another line’s template. (The §3.1 invariant, now under arbitrary input.)
  • P3 — bounds hold. RFC 0023’s three bounds are never exceeded mid-stream: template count ≤ ceiling, node fan-out ≤ cap, over-long lines always divert. (Generalises the tree-level fanout property to the full pipeline.)
  • P4 — the query oracle. For a generated batch written to a store and a generated predicate from the supported DSL surface (severity / time-window / template-id / promoted- and non-promoted-attribute equality), the querier’s row count equals an independent in-memory evaluator’s over the same MinedRecords. The reference evaluator is deliberately naive (linear scan, no DataFusion) — its correctness must be reviewable by eye.

Case counts: CI runs proptest defaults (fast, deterministic regressions via committed failure persistence); a scheduled deep run may crank PROPTEST_CASES (§7).

3.4 What this does not change

No production code paths, no schema, no telemetry. This is test infrastructure; RFC 0006’s corpus methodology and every recorded §9 number are untouched. The LogHub family keeps its role as the Collector-output stress corpus.

4. Alternatives considered

  • More frozen corpora only. Necessary (the v7 capture is happening) but not sufficient: a corpus can only contain what its emitters emitted; §9.11-class findings live in the combinations.
  • Fuzzing the full pipeline (cargo-fuzz). The existing fuzz targets cover wire decode, where coverage-guided byte mutation shines. Pipeline invariants need structured inputs and cross-checked outputs — property testing’s home ground.
  • Differential testing against another backend (e.g. DuckDB over the same Parquet). Powerful but heavyweight; P4’s naive evaluator buys most of the assurance at a fraction of the machinery, and the Parquet files remain externally checkable by hand when wanted.

5. Acceptance criteria

Scenario ids RFC0024.<m>.

Scenario RFC0024.1 — calibration extraction. Given a corpus release, When --calibrate runs, Then a deterministic manifest is produced (byte-identical on rerun) and committed alongside the corpus tag it summarises.

Scenario RFC0024.2 — calibrated generators are shaped by the manifest. Given a calibration manifest, When N records are generated, Then gross distribution moments (mean attribute count, body-length quartiles, severity mix) fall within a documented tolerance of the manifest’s.

Scenario RFC0024.3 — P1 holds. Round-trip fidelity over generated batches, both modes.

Scenario RFC0024.4 — P2 holds. No silent merge over generated batches, both modes.

Scenario RFC0024.5 — P3 holds. RFC 0023 bounds over generated streams with deliberately tiny configured bounds.

Scenario RFC0024.6 — P4 holds. Query-oracle equality over generated batches and generated predicates, covering every operator class the DSL supports on each field kind — including at least one promoted-attribute predicate (RFC 0022’s two-arm compile) and one non-promoted one.

Scenario RFC0024.7 — adversarial mode finds nothing today. The full property set passes at an elevated case count on the adversarial generators. (This scenario is the regression tripwire: any future failure here is a minimal reproducer by construction.)

6. Testing strategy

The RFC is testing strategy; the §5 scenarios are the suites themselves. Failure persistence files are committed so any generated counterexample becomes a permanent regression case. The properties run in the crates that own the invariant (miner: P2/P3; parquet: P1; querier: P4) so a failure lands at the responsible boundary.

7. Open questions

  1. Deep-run cadence. A scheduled high-case-count run (nightly? weekly?) vs CI-only defaults — decide once the suite’s wall-clock is known.
  2. Trace/metric envelopes. Out of scope (logs backend), noted only because the demo capture contains correlated trace ids that generators should populate realistically.

8. References

  • RFC 0006 (bench corpus methodology — amended), RFC 0003 §6.6 (the generated shape) and its wire-decode property suites, RFC 0017/0018 (fidelity contracts P1 pins), RFC 0022 §3.3 (the two-arm compile P4 must cover), RFC 0023 (the bounds P3 pins; §9.11 for why generated shapes matter).
  • Standing scope decision (2026-07-05): OTLP only; legacy formats are Collector concerns.

RFC 0025 — Absent-body representation


rfc: 0025 title: Absent-body representation and permanent-encode-error quarantine (RFC 0005 amendment) status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-05 supersedes: — superseded-by: —

RFC 0025 — Absent-body representation and permanent-encode-error quarantine (RFC 0005 amendment)

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

1. Summary

A legal OTLP log record with an absent body (LogRecord.body unset) is currently a poison pill: the receiver materializes it faithfully (body: None), the miner emits it faithfully (BodyKind::Absent, RFC 0001 §6.1), and the Parquet encode rejects it permanently (BatchError::UnsupportedAbsentBody — RFC 0005 §3.2’s body_kind column pins ordinals 0 = String, 1 = Structured). The ingest sink retains its buffer on flush error, so one absent-body record halts Parquet persistence for its (tenant, hour) partition forever and pins buffer memory (#362, found by the RFC 0024 adversarial suite on its first run).

This RFC amends RFC 0005 with:

  1. A third body_kind ordinal2 = Absent — with a NULL body cell, making the wire-legal state representable on disk.
  2. A read-path contract — absent-body rows render with no body (the RFC 0017 LogRow carries none), never as an empty string.
  3. A sink quarantine rule — a permanent encode error must never wedge a partition: the sink separates the rejected record(s) from the buffer, persists the rest, and surfaces the rejection through the existing flush-error counter with an error.type attribute. Defense in depth: with (1) in place, UnsupportedAbsentBody disappears, but the wedge mechanism would fire identically for any future permanent BatchError (timestamp overflow is one that exists today).

2. Motivation

  • Absent bodies are spec-legal and real. OTLP permits records with no body — event-shaped records carrying only event_name + attributes are the canonical case. A backend that wedges on them fails the RFC 0003 fidelity posture from the wire side.
  • The failure mode is silent and unbounded. The WAL holds the acknowledged data (§3.4 holds), but the ingest→Parquet path stalls for the partition; buffers grow to the memory ceiling; nothing reaches object storage. Operators see a flush-error counter tick and stalled data — the worst diagnosis surface.
  • Timestamp overflow shares the mechanism. A record whose observed_time_unix_nano exceeds i64::MAX is also a permanent encode rejection today; quarantine fixes both.

3. Design

3.1 Schema (RFC 0005 §3.2 amendment)

body_kind gains ordinal 2 = Absent. For such rows the body column is NULL, params and separators are empty, and lossy_flag = true is retired for this case: absence is not loss — the row reconstructs to “no body” exactly. The miner’s emission changes from lossy_flag = true to false for BodyKind::Absent rows (RFC 0001 §6.1 note: reconstruction is defined and total — it renders nothing).

Migration (§3.5 compliance): additive only. Old files never contain ordinal 2 and remain fully readable. Old readers (any pre-amendment binary) encountering a future file with ordinal 2 must error per the §3.2 shape-validation contract — this is the standard forward-compatibility posture already pinned by RFC0005.14 (unknown-ordinal rejection), and operators upgrade readers before writers as with every schema-affecting release. No historical rewrite.

3.2 Read path (RFC 0017 amendment)

  • Reader accepts ordinal 2 and materializes body_kind = Absent, body = None.
  • Query rendering (LogRow): the body field is absent (None / omitted in JSON), not "" — an empty string body is a different legal record.
  • The RFC 0002 DSL: absent-body rows match non-body predicates normally; body-text predicates never match them.

3.3 Sink quarantine (ourios-ingester)

On flush, when the encode fails with a permanent BatchError (the existing is_transient split already classifies this):

  1. Bisect the buffer to the offending record(s) (binary search on singleton encodes — O(k·log n) for k poison records, and k is almost always 1).
  2. Emit the poisoned record(s) to the audit stream (event kind: record_quarantined, carrying the tenant, the partition key, and the error text; the WAL retains the record itself) and drop them from the buffer.
  3. Flush the remainder normally.
  4. Count via the existing flush-error counter with error.type = the BatchError variant name (per the OTel recording-errors convention — no new metric).

The WAL retains the record (durability unchanged); the quarantine audit event is the operator’s pointer for manual recovery or replay after a fix. The cadence-drain publish path applies the same rule — both routes to the encoder quarantine rather than requeue. No new config: quarantine is not optional behavior — the alternative is the wedge.

4. Alternatives considered

  • Map absent to Body::String("") at the receiver. Destroys fidelity (RFC 0017/0018): empty-string and absent are distinct wire states, and the read path already distinguishes them.
  • Drop absent-body records at the receiver. Data loss for spec-legal input; violates the acknowledged-data contract.
  • Retry-forever with alerting (status quo + alarm). Leaves the partition wedged and the memory pinned; alerting on an unbounded failure is not a fix.
  • Quarantine to a side file instead of the audit stream. A new on-disk artifact class (lifecycle, retention, discovery) for a rare event the audit stream already models.

5. Acceptance criteria

Scenario ids RFC0025.<m>.

Scenario RFC0025.1 — absent bodies round-trip. Given a mined BodyKind::Absent record, When it is written and read back, Then every RFC 0005 §3.2 column round-trips, body is NULL, and the RFC 0024 P1 suite’s pinned-rejection arm for absent bodies is replaced by round-trip assertion.

Scenario RFC0025.2 — old files unaffected. Given a pre-amendment file, When read by the amended reader, Then results are identical to the prior reader (committed-fixture parity, the RFC 0021 §6 discipline).

Scenario RFC0025.3 — rendering distinguishes absent from empty. Given one row with body = "" and one with body_kind = Absent, When both are rendered through the query path, Then the empty-string row carries "" and the absent row carries no body field.

Scenario RFC0025.4 — the sink no longer wedges. Given a buffer containing an absent-body record (pre-amendment encoder simulated) or a timestamp-overflow record, When flush runs, Then the healthy records persist, the poisoned record is quarantined to the audit stream with a record_quarantined event, and subsequent flushes of the partition succeed.

Scenario RFC0025.5 — quarantine telemetry. Given a quarantine, Then the existing flush-error counter increments with error.type set to the BatchError variant, and no new metric name is introduced.

6. Testing strategy

RFC0025.1/.3 as integration tests in ourios-parquet / ourios-querier; RFC0025.2 via the committed pre-amendment fixture; RFC0025.4/.5 in ourios-ingester (the quarantine path is deterministic — no property machinery needed, though the RFC 0024 adversarial umbrella inherits coverage automatically once the P1 arm flips).

7. Open questions

  1. Miner sentinel for absent bodies. Absent rows currently take the NO_TEMPLATE id with lossy_flag = true; with §3.1 they keep NO_TEMPLATE but drop the lossy flag. Should they instead share the structured-sentinel mechanism (per (severity, scope))? Deferred — NO_TEMPLATE is adequate and queryable.
  2. Quarantine replay tooling. The audit event carries the WAL position; an operator replay-quarantined subcommand is deferred until demand exists.

8. References

  • #362 (the finding), RFC 0024 §2 (the suite that found it), RFC 0005 §3.2 (body_kind ordinals), RFC 0001 §6.1 (BodyKind::Absent emission), RFC 0017 (read-path fidelity), RFC 0008 (WAL durability the quarantine leans on), RFC 0015 §9 of RFC 0008 (audit-event precedent for system-scoped events).

RFC 0026 — Authentication & tenant binding


rfc: 0026 title: Authentication and tenant binding (ingest + query) status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-05 supersedes: — superseded-by: —

RFC 0026 — Authentication and tenant binding (ingest + query)

1. Summary

Ourios’s multi-tenancy is structural but unauthenticated. The ingest side derives tenant_id from resource attributes the sender controls (RFC 0003 §6.3), and the query side takes the x-ourios-tenant header on faith (RFC 0016 shipped deliberately as “trusted-network for v1; authn as a follow-up RFC”). Any client that can reach either listener can write into and read from any tenant. RFC 0003 §9 has carried the open question — “does the authenticated identity feed into the tenant_id derivation?” — since the receiver landed. This RFC is that follow-up:

  1. Ingest authn — static bearer tokens on the OTLP listeners (gRPC metadata / HTTP Authorization), configured through the RFC 0020 config file with ${env:VAR} substitution so secrets never live in the file.
  2. Query authn — the same token mechanism on the RFC 0016 HTTP API (and thereby everything layered on it, e.g. RFC 0027).
  3. Tenant binding (authz) — each token carries an allowed tenant set. Ingest: the RFC 0003 §6.3 attribute-derived tenant must fall inside the token’s set, else the batch is rejected before the WAL ack. Query: the x-ourios-tenant header must fall inside the token’s set. This closes RFC 0003 §9: identity constrains derivation rather than replacing it.

Transport encryption stays delegated (TLS termination at the operator’s proxy, per the existing posture); this RFC is identity and scoping, not channels.

2. Motivation

  • The gap is now user-facing. The tester-recruitment push invites people to run Ourios beyond localhost; the first shared deployment turns “structural tenancy” into “no tenancy” — sender-controlled attributes choose the tenant, so isolation is cooperative, not enforced. §3.7 (“multi-tenancy is not bolted on”) demands the enforcement half before exposure, not after.
  • Two RFCs already point here. RFC 0003 §9 (identity → tenant derivation) and RFC 0016 §1 (authn follow-up) both deferred to an authentication RFC. Leaving the question open now blocks RFC 0027 (an MCP surface productizes remote query access) and the Helm chart’s security story (workstream C).
  • Ack semantics make ingest authz special. Rejection must happen before the WAL ack (§3.4): once acknowledged, data is durable — an unauthorized batch must never reach that point.

3. Design

3.1 Token store (RFC 0020 config amendment)

A new top-level auth section:

auth:
  tokens:
    - name: edge-collector          # audit/metric label, not secret
      token: ${env:OURIOS_TOKEN_EDGE}
      tenants: ["acme", "globex"]   # explicit allow-list
    - name: admin-cli
      token: ${env:OURIOS_TOKEN_ADMIN}
      tenants: ["*"]                # wildcard: all tenants
  • Tokens are opaque strings, compared in constant time; the config holds them only via ${env:...} indirection (the RFC 0020 substitution engine), so files stay committable.
  • tenants is an exact-string allow-list or the single wildcard "*". No patterns — pattern semantics on a security boundary invite grief; revisit only with demand (§7).
  • No auth section ⇒ open mode, preserving today’s behavior for local/dev, with a structured startup warning naming the exposure. An empty auth.tokens list is a startup configuration error (locked-out server is never the intent).

3.2 Ingest enforcement (RFC 0003 amendment)

  • Both OTLP listeners (gRPC + HTTP) require Authorization: Bearer <token> when auth is enabled. Missing or unknown token ⇒ gRPC UNAUTHENTICATED / HTTP 401 before any decode work.
  • Per-batch authz: every ResourceLogs group’s derived tenant (RFC 0003 §6.3, unchanged) is checked against the token’s set. Any out-of-set tenant rejects the whole batch with PERMISSION_DENIED / 403 before the WAL append — partial-batch acceptance would make the OTLP partial-success surface a tenancy oracle, and §3.4 forbids acking anything not durably accepted.
  • The RFC 0003 §9 question resolves as: derivation stays attribute-based (the sender’s resource attributes remain the source of truth for which tenant), and identity bounds the set of tenants a sender may speak for. A token pinned to one tenant is the single-tenant-sender case; no attribute rewriting.

3.3 Query enforcement (RFC 0016 amendment)

  • The HTTP query API requires the same bearer scheme. Status contract: missing/unknown bearer ⇒ 401; missing or empty x-ourios-tenant ⇒ 400 (today’s contract, unchanged — the header stays the tenant selector); a well-formed tenant outside the token’s set ⇒ 403.
  • Enforcement composes with — never replaces — the structural scoping: the querier still roots every scan under the tenant’s partition directory (RFC0007.5). The failure bound is worth stating precisely: a fail-open authz bug would re-open the pre-RFC exposure (any tenant selectable by header for an authenticated caller) — a real regression — but the structural scoping still confines each request to the single tenant it names; no bug in this layer yields cross-tenant reads within one query or unscoped scans.

3.4 Telemetry and audit

  • Rejections count on the existing request counters with error.type (unauthenticated | permission_denied) — no new metric names (OTel recording-errors convention; new attributes go through the weaver registry).
  • Ingest authz rejections additionally emit an audit event carrying the token name (never the token) and the offending tenant — cross-tenant write attempts are exactly what an operator audits.

4. Alternatives considered

  • mTLS as the identity mechanism. Delegating TLS to a fronting proxy is the project’s posture; client-cert identity does not survive typical proxy hops without header-forwarding conventions that are themselves a trust decision. Bearer tokens work through every OTLP exporter and HTTP client today. mTLS remains available at the proxy layer, orthogonal to this RFC.
  • JWT / OIDC. Brings expiry, issuers, key rotation, clock dependence, and a validation dependency tree — for a system whose senders are collectors with static config. Static tokens match the OTel Collector ecosystem’s operational reality (headers: on the OTLP exporter). An IdP integration can layer on later (§7) without changing the tenant-binding model.
  • Identity-derived tenancy (token ⇒ tenant, ignore attributes). Breaks the multi-tenant-collector case (one edge collector forwarding many teams’ telemetry) and silently discards the RFC 0003 §6.3 contract. Constraining beats replacing.
  • Per-tenant listeners / network policy as authz. Pushes tenancy into deployment topology; contradicts the single-binary shape and makes the Helm chart combinatorial.

5. Acceptance criteria

Scenario ids RFC0026.<m>.

Scenario RFC0026.1 — token store configuration. Given a config with an auth.tokens list using ${env:VAR} values, When the server starts, Then tokens resolve through the RFC 0020 substitution engine; an empty auth.tokens list is a startup configuration error; a missing auth section starts in open mode and emits a structured startup warning naming the exposure.

Scenario RFC0026.2 — ingest authentication. Given auth enabled, When an OTLP export arrives with a missing or unknown bearer token (gRPC metadata and HTTP Authorization, both listeners), Then it is rejected (UNAUTHENTICATED / 401) before wire decode, nothing reaches the WAL, and no ack is returned.

Scenario RFC0026.3 — ingest tenant binding. Given a token bound to tenants {a, b}, When a batch whose derived tenants are all within {a, b} arrives, Then it is accepted and acked normally; When a batch containing any ResourceLogs group deriving to a tenant outside the set arrives, Then the whole batch is rejected (PERMISSION_DENIED / 403) with no WAL append and no partial success — nothing of the batch becomes durable.

Scenario RFC0026.4 — query enforcement and status contract. Given auth enabled, Then the query API returns 401 for a missing/unknown bearer, 400 for a missing or empty x-ourios-tenant (today’s contract, unchanged), 403 for a well-formed tenant outside the token’s set, and correct results for an in-set tenant — with the drift endpoint under the same gate.

Scenario RFC0026.5 — wildcard binding. Given a token with tenants: ["*"], When it ingests to and queries arbitrary tenants, Then both paths behave as if every tenant were listed.

Scenario RFC0026.6 — open-mode parity. Given no auth section, When the full existing ingest + query acceptance suites run, Then behavior is byte-for-byte today’s (the amendment is invisible until configured), warning aside.

Scenario RFC0026.7 — rejection telemetry and audit. Given authn/authz rejections on either path, Then the existing request counters increment with error.type (unauthenticated / permission_denied) and an ingest authz rejection emits an audit event carrying the token name and the offending tenant — and never any token value, on any surface (metrics, audit, logs, errors).

5.1 Discharge record (green, 2026-07-06)

  • RFC0026.1 — #390 (token store: config schema, ${env:…}-only secrets, startup error/warning arms) + #395 (store moved to ourios_core::auth for the ingest enforcement point).
  • RFC0026.2/.3 — #398: bearer authn before wire decode on both listeners (gRPC interceptor / HTTP handler), whole-batch tenant binding before the WAL append, served-stack gRPC arm; WAL emptiness asserted on the journal.
  • RFC0026.4/.5/.6 — #408: the 401→400→403 gate order pinned with exact bodies, wildcard binding on both halves, open-mode parity (exactly-once warning + a live listener connection).
  • RFC0026.7 — #409: rejections on the existing counters via error.type (unauthenticated | permission_denied; the query histogram under the new rejected kind member), and the ingest_denied audit event (kind 8, denied_token_name column — §3.7 additive-OPTIONAL, schema pin updated) with a no-token-value sweep across every surface.

5.2 Validation record (validated, 2026-07-07)

Run: scratch/validation/rfc0026-0027-validate.sh — the release binary served over real sockets with a file config whose tokens resolve through ${env:…} (RFC 0020), two tokens (tenant-bound + wildcard), both roles + MCP enabled. 16/16 checks pass:

  • Ingest matrix (OTLP/HTTP): no bearer 401, unknown bearer 401, out-of-set tenant 403, in-set 200, wildcard-to-arbitrary-tenant 200.
  • Query matrix: no bearer 401, missing tenant 400 (valid bearer), out-of-set 403, in-set 200.
  • Denial audit: the ingest_denied event is durable in the store’s audit Parquet (event-type string present in the flushed files) after the cadence/shutdown flush.
  • End-to-end data flow: rows ingested under a valid token survive a graceful restart (the RFC 0014 drain) and serve on both query surfaces.

6. Testing strategy

RFC0026.1 in ourios-server config tests (the RFC 0020 suite’s home); .2/.3 as receiver integration tests against both listeners (the RFC 0003 suite pattern), asserting WAL emptiness on rejection; .4/.5 in the querier-role HTTP tests (RFC 0016 suite pattern); .6 runs the existing suites under a no-auth config — parity is the assertion; .7 through the in-memory OTel reader (the established telemetry-test pattern) plus the audit-sink test fixtures. Token comparison is constant-time by construction (a dedicated comparison helper with a unit test on the API shape, not a timing measurement — timing assertions in CI are noise).

7. Open questions

  1. Token rotation ergonomics. Config reload vs restart; whether two tokens per name (old + new) is worth first-class support.
  2. IdP / OIDC layering. If demanded, a validator that maps a verified claim to the same (name, tenants) shape — the binding model is designed to be the stable layer.
  3. Tenant patterns. Prefix grants (team-*) if explicit lists prove operationally painful; needs careful semantics before any wildcard beyond "*".
  4. Rate limiting per token. Adjacent concern; deliberately out of scope here.

8. References

  • RFC 0003 §6.3 (tenant derivation) and §9 (the open question this closes), RFC 0016 §1/§3 (the query API and its authn deferral), RFC 0020 (config file + ${env} substitution the token store rides), CLAUDE.md §3.4 (ack-before-WAL interplay) and §3.7 (multi-tenancy invariant), RFC 0027 (the MCP surface gated on this RFC).

RFC 0027 — MCP query surface


rfc: 0027 title: MCP query surface (agent-facing read tools over the querier) status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-05 supersedes: — superseded-by: —

RFC 0027 — MCP query surface (agent-facing read tools over the querier)

1. Summary

Expose the querier’s read surface as a Model Context Protocol (MCP) server, so LLM agents can query logs, inspect templates, and run drift analysis as typed, discoverable tools instead of hand-rolled HTTP calls. The surface is a thin adapter over what already exists — the RFC 0002 DSL through the RFC 0016 endpoint machinery, the RFC 0017 template registry, the RFC 0010 drift query — hosted on the querier role’s existing HTTP listener (streamable HTTP transport) at /mcp. Read-only by design: no ingest, no administration, no state mutation reachable through it.

Implementation is gated on RFC 0026: an MCP endpoint is exactly the thing agents reach from laptops and CI over shared networks, and it must not ship ahead of query-side authentication.

2. Motivation

  • The product story. Ourios is an OTLP-native backend built in the open with agents; “point your agent at your logs” is the natural demo and the sharpest differentiator available to a pre-1.0 backend courting testers. Template mining is unusually agent-friendly: list_templates gives an agent the shape of a corpus in a few hundred rows — something raw-log backends cannot offer without a scan.
  • Tool typing beats API docs. Agents can already hit the RFC 0016 JSON API, but every consumer must be taught the DSL, the tenant header, and the response shape by prompt. MCP moves that contract into the protocol: schemas are discovered, the DSL grammar ships as a resource, and errors are structured.
  • Cheap by construction. The querier already owns the DSL parse → compile → run path and its HTTP hosting; the adapter adds tool plumbing, not query machinery. Hazard §4.6 (don’t leak DataFusion through user surfaces) is inherited, already-solved behavior, not new work.

3. Design

3.1 Placement and transport

  • A module in ourios-server’s querier role — no new crate (§7 layout untouched; the adapter is small and shares the querier’s types). The MCP SDK dependency (rmcp, the official Rust SDK) lives in ourios-server only.
  • Transport: streamable HTTP on the existing querier listener at /mcp, enabled by a querier.mcp.enabled config flag (RFC 0020 section; default off). No stdio transport in v1 — the querier is a deployed server, not a spawned subprocess; a local stdio bridge can be a later convenience (§7).
  • Authentication: the RFC 0026 bearer scheme, identically to the JSON API. The token’s tenant set bounds every tool call; the tenant is an explicit tool argument validated against that set.

3.2 Tool set (v1)

ToolBacks ontoNotes
query_logsRFC 0002 DSL via the RFC 0016 pathargs: tenant, query (DSL string), optional limit; returns count + up to limit rendered rows + pruning stats
list_templatesRFC 0017 registryargs: tenant; returns (template_id, rendered_template, version) rows — the corpus’s shape at a glance
template_driftRFC 0010 drift surfaceargs: tenant, from, to; the audit-stream drift analysis over the half-open window [from, to) (RFC0010.2’s boundary rule, inherited verbatim)

Plus one resource: the DSL grammar/reference doc, served verbatim so agents learn the query language from the protocol rather than from prompt engineering.

Deliberately absent: any write, any admin (compaction, snapshots), any raw-SQL escape hatch (hazard §4.6), and any cross-tenant enumeration — there is no list_tenants tool; a token knows its tenants out of band.

3.3 Output discipline

  • Tool results are the RFC 0016 JSON shapes re-encoded as MCP content — one serialization boundary, no new response schema to drift.
  • query_logs defaults to a conservative limit (rows are LLM context, not a data export); the full count always accompanies the rows so agents know what they’re not seeing.
  • Returned log bodies are untrusted text. A log line is attacker-influenceable input that will be placed into an LLM context; the server cannot sanitize meaning away, but the tool descriptions MUST carry the standard treat-as-data warning so well-behaved clients render results as content, not instructions. (This is a consumer-side hazard the RFC documents rather than solves; see §7.)

4. Alternatives considered

  • No MCP; agents use the JSON API directly. Works today, loses discovery, typing, and the grammar-as-resource; every integration re-teaches the DSL by prompt. The adapter is small enough that “just use HTTP” saves little.
  • A separate ourios-mcp sidecar binary/crate. Another artifact to version, deploy, and secure, wrapping an API that lives one process away. A module behind a config flag delivers the same surface with none of the operational spread. Revisit only if the MCP dependency tree bloats the server build measurably.
  • stdio-first transport. Natural for laptop-local tools, wrong for a deployed backend: it would couple agent hosts to process lifecycle on the server host. Streamable HTTP is MCP’s remote story and matches the existing listener.
  • Exposing SQL instead of the DSL. Directly violates hazard §4.6 (DataFusion specifics leaking through a user surface) and widens the authz analysis from three tools to a query planner.

5. Acceptance criteria

Scenario ids RFC0027.<m>. Maintainer sign-off: 2026-07-05 (“go on 0027 and 0019”). This RFC treats serving /mcp as gated on RFC 0026 (§1 — a remote query surface must never precede authn); that gate is satisfied as of RFC 0026’s green, 2026-07-06.

Scenario RFC0027.1 — gating and placement. Given querier.mcp.enabled unset or false, When the querier role serves, Then /mcp returns 404 and the existing JSON API endpoints are behaviorally unchanged (same routes, status contracts, and response schemas — the RFC 0016 and RFC 0026 §5 suites still pass verbatim); Given the flag true, Then /mcp speaks MCP streamable HTTP on the same listener, And no new crate exists (the adapter is an ourios-server module).

Scenario RFC0027.2 — the RFC 0026 gate applies verbatim. Given auth enabled, When an MCP request arrives with a missing/unknown bearer, Then it is rejected as unauthenticated before any tool dispatch; When a tool call names a tenant outside the token’s set, Then it fails with the tenant-denied error and touches no data; And open mode (no auth section) serves MCP exactly as it serves the JSON API.

Scenario RFC0027.3 — query_logs. Given a seeded tenant, When query_logs runs a DSL statement, Then the result carries the total count, at most limit rendered rows (the conservative default when unset), and the scanned/pruned stats, matching the JSON API’s answer for the same statement; And a malformed statement returns the DSL error as a tool error, never a transport failure.

Scenario RFC0027.4 — list_templates. Given a tenant with mined templates, When list_templates runs, Then every row is (template_id, rendered_template, version) and matches the RFC 0017 registry surface for that tenant.

Scenario RFC0027.5 — template_drift. Given audit history, When template_drift runs over [from, to), Then the analysis equals the RFC 0010 drift surface’s for the same half-open window (RFC0010.2’s boundary rule inherited verbatim).

Scenario RFC0027.6 — the grammar resource. Given the server is enabled, When the client lists/reads resources, Then the DSL grammar/reference doc is served from the canonical source, docs/rfcs/0002-query-dsl.md, embedded at compile time (include_str!) and trimmed to its §7 grammar section at startup — the served text is byte-identical to that section, so the resource cannot drift from the documentation.

Scenario RFC0027.7 — output discipline. Given any tool result, Then it is the RFC 0016 JSON shape re-encoded as MCP content (one serialization boundary), And every tool description carries the treat-log-bodies-as-data warning, And no tool or resource enumerates tenants or accepts SQL.

5.1 Discharge record (green, 2026-07-07)

  • RFC0027.1 — #413 (transport): rmcp server-side at /mcp behind querier.mcp.enabled (file + env paths), the RFC 0026 bearer layer answering before any MCP dispatch, the loopback Host guard kept in open mode, the body cap on the nested router.
  • RFC0027.2/.3/.4/.5/.7 — #414 (tools): the §3.2 three over the querier engine with per-call tenant binding off the request’s own Authorization (sessions outlive requests); .3/.5 are payload-equality proofs against the JSON API over the same seeded store; the limit argument is a hard cap; all tools record on the shared ourios.query.duration histogram (logs/drift/the new templates kind member).
  • RFC0027.6 — #415 (resource): ourios://dsl-grammar serves the RFC 0002 §7 section byte-identically (include_str!, extracted once at role startup with a loud panic on shape drift), text/markdown, asserted by an independent extraction in the test.

5.2 Validation record (validated, 2026-07-07)

Run: scratch/validation/rfc0026-0027-validate.sh — the release binary with querier.mcp.enabled + RFC 0026 auth, driven by the official MCP inspector CLI (@modelcontextprotocol/inspector, the TypeScript SDK — an independent client implementation, not this repo’s test client). 16/16 checks pass; the RFC 0027 arms:

  • tools/list advertises exactly the §3.2 three.
  • resources/read ourios://dsl-grammar serves the §7 section (heading-checked; byte-identity is the §5.1 CI test’s oracle).
  • query_logs over really-ingested rows returns a payload equal to /v1/query’s for the same statement and tenant.
  • template_drift answers over the audit stream.
  • An unknown bearer is rejected before any MCP dispatch.

6. Testing strategy

.1/.2 at the served-querier level (the RFC 0016 §5 pattern: spawn or in-process router, flag off/on, the RFC 0026 status matrix over /mcp). .3.5 as equivalence tests: drive the tool through an MCP client against a seeded store and assert equality with the corresponding JSON-API/engine answer — the adapter must add nothing but the protocol. .6/.7 by inspection of the served resource/descriptors against the RFC 0002 §7 grammar source named in .6 and a deny-list assertion on the advertised tool/resource set.

7. Open questions

  1. Result pagination. Whether query_logs grows a cursor for result sets past the row limit, or agents are expected to refine predicates instead (the DSL makes refinement cheap).
  2. stdio bridge. A ourios mcp-stdio --endpoint <url> local proxy for clients that only speak stdio — convenience, not architecture; demand-driven.
  3. Prompt-injection posture. Whether to offer an opt-in result-wrapping mode (e.g. explicit content fencing) beyond the tool-description warning, once client conventions settle.
  4. Aggregation tools. count by template style pre-shaped tools vs teaching agents the DSL’s pipe stages; start with the grammar resource and observe.

8. References

  • RFC 0026 (authentication — the implementation gate), RFC 0002 (the DSL surface exposed), RFC 0016 (the querier HTTP role this co-hosts on, incl. the x-ourios-tenant scoping), RFC 0017 (template registry behind list_templates), RFC 0010 (drift), CLAUDE.md §4.6 (DSL vs engine leakage hazard), §3.7 (tenancy), §1 (scope — this stays a query surface, not a new product line); Model Context Protocol spec (streamable HTTP transport).

RFC 0028 — Build-feedback program


rfc: 0028 title: Build-feedback program — test-harness consolidation and workspace decomposition status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-06 supersedes: — superseded-by: —

RFC 0028 — Build-feedback program: test-harness consolidation and workspace decomposition

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

1. Summary

Developer feedback latency is a first-order engineering constraint (“slow feedback is a development and velocity killer” — maintainer, 2026-07-06, with explicit precedence over feature work). This RFC turns the measured build-cost profile (epic #382) into a program:

  1. Test-harness consolidation — collapse the workspace’s 104 integration-test binaries (ingester 31, querier 19, parquet 17, wal 11, server 9, miner 7, …) into ~1–3 harnesses per crate. Every binary links its crate’s full dependency stack (DataFusion, tonic); link count dominates cargo test wall time, measured at 57 s for touch core → querier test binaries before a single test runs. No new crates; test names and assertions are preserved exactly — files move under a harness root, nothing is weakened (CLAUDE.md §6.2).
  2. ourios-core decomposition — split the fat hub along its fault line: pure data types (tenant, records, OTLP, audit, alias, confidence) stay in ourios-core; MinerConfig and its validation move to a new ourios-config crate (name bikesheddable). A core edit currently rechecks 9 crates (38 s); config churn — a frequent edit class — stops invalidating type-only consumers.
  3. Deferred-with-tripwire: ourios-parquet split (reader/writer/compaction/store). Re-measure after (1); a parquet edit’s 27 s / 5-crate fan-out may be acceptable once the link storm is gone. Splitting prematurely costs API churn across the RFC 0005 surface for unproven gain.
  4. cargo-nextest for test execution (local + CI): per-test parallelism over the consolidated binaries, faster reruns, crisper failure output. Additive; cargo test keeps working.

Measured honestly: incremental check feedback is already fine (17–38 s). The program targets the three verified sinks — link count, branch-churn invalidation (worktrees are the practice; documented in CONTRIBUTING), and hub fan-out — in that order.

2. Motivation

  • The numbers (epic #382, 2026-07-06): 9 m 46 s warm-up after branch churn; ~10 min full-workspace cargo test; 57 s to relink querier tests after a core touch; target/debug hit 314 GiB before the #373 debuginfo trim. A single session repeatedly tripped 10-minute task budgets on rebuilds.
  • Every test file is a linker invocation. The RFC-ladder discipline creates one integration-test file per scenario group — correct for clarity, quadratic-feeling for links. 31 binaries in ourios-ingester each link the tonic/tokio receiver stack.
  • sccache does not save the local loop (measured: 37/199 hits, all C/C++ build scripts) — cargo’s incremental dev builds bypass it by design. Its value is CI; local latency must come from structure.
  • The hub tax compounds. Every future crate consuming core types inherits the config-churn invalidation unless the split happens while the workspace is still 11 crates.

3. Design

3.1 Test-harness consolidation (slices 1–2)

Per crate: a single tests/it/main.rs harness (Cargo’s one-binary idiom) with mod declarations per current file — tests/it/rfc0003_1_wal_before_ack.rs etc. keep their content and test names verbatim. Shared fixtures (tests/common, tests/ingest_support) become harness modules, ending the compile-per-binary duplication of helpers.

  • Worst crate first (ourios-ingester, 31 → 2: one general harness plus keeping any test that requires process isolation — e.g. SIGKILL crash-recovery — as its own binary, explicitly annotated).
  • Scenario-name greppability is preserved: cargo test rfc0003_1 still works; CI invocations by --test <name> are updated in the same slice (the rfc0024 deep-run workflow names four).

3.2 ourios-core split (slice 3)

New crate ourios-config holding MinerConfig, MinerConfigError, bound constants and builders. ourios-core keeps pure data types and the canonical codec. Consumers move one use path; no behavior change. The §7 layout table gains one row — this RFC is the architectural commitment §7 requires.

Explicitly out: splitting audit/alias/otlp out of core — no measurement implicates them, and every split multiplies version lockstep costs.

3.3 Parquet split (slice 4, decision gate)

Re-run the #382 probe set after slices 1–2. Proceed with a reader/writer split only if a parquet edit still costs > 30 s of check fan-out or shows up in the top of cargo build --timings critical path; otherwise record the decision and close.

3.4 nextest (slice 5)

cargo nextest run locally and in CI’s test job; cargo test remains supported (property suites’ proptest integration is runner-agnostic). CI keeps the exact same suite inventory.

4. Alternatives considered

  • Only crate splits (the original instinct). The data says the link storm, not check fan-out, is the dominant cost; splits alone would leave 104 binaries linking.
  • One mega test binary per workspace. Cross-crate harnesses can’t exist (integration tests are per-crate), and a single binary per crate that force-includes isolation-sensitive tests (crash recovery) would serialize or destabilize them.
  • CARGO_INCREMENTAL=0 + sccache locally. Trades away incremental compilation (the thing that makes 17–38 s checks possible) to feed sccache; strictly worse for the edit loop.
  • Shared monolithic tests/common crate. A dev-only fixtures crate would rebuild on every core change and re-couple the crates the split decouples; harness-local modules suffice.

5. Acceptance criteria

Scenario ids RFC0028.<m>. Maintainer sign-off: 2026-07-06 (the proposed scenarios accompanied the drafting PR, #383).

Scenario RFC0028.1 — consolidation preserves the test inventory. Given the pre-consolidation cargo test -p <crate> -- --list inventory, When the crate’s harness consolidation lands, Then the post-consolidation inventory lists the same set of tests, differing only by the harness’s module-path prefix (--list prints test names as module paths; a file moving under tests/it/ gains its module segment), And no test body changed in the move.

Scenario RFC0028.2 — isolation-sensitive tests stay isolated. Given the slice-1 inventory of tests requiring process isolation (process-global installers, env-mutating, hardware-gated), Then those tests are not merged into a shared harness — they stay in dedicated integration-test binaries (grouped where they can safely share one), each annotated with the reason it cannot join the harness.

Scenario RFC0028.3 — the probe set improves. Given the epic #382 probe set re-run as the slices land — the edit-loop probe after slices 1–2, the runner-dependent full-suite gate after the runner slice it names — on the same machine and under the same conditions the baseline was captured (warm workspace, same toolchain; environment recorded next to the numbers in the epic) — Then the incremental-edit probe — touch crates/ourios-core/src/lib.rs (an mtime-only update, exactly as the epic’s baseline measured it) followed by cargo test -p ourios-querier --no-run — drops below 30 s, And full-workspace suite wall time — under the test runner CI adopts (plain cargo test, or cargo nextest run once slice 5 lands; clarified at the green flip so the criterion matches the slice-5 design) — drops by at least 30% against the epic’s baseline.

Scenario RFC0028.4 — the core split is behavior-free. Given the ourios-config extraction, When the full workspace suite runs, Then results are identical pre/post split, And a MinerConfig edit no longer rechecks type-only core consumers.

Scenario RFC0028.5 — CI parity. Given the consolidated harnesses (and nextest, if slice 5 adopts it), Then CI runs the identical suite inventory and stays green.

5.1 Discharge record (green, 2026-07-06)

  • RFC0028.1 — per-PR inventory proofs: #399 (ingester, 129/129), #400 (querier, 162/162), #401 (parquet, 162/162), #402 (wal, 64/64), #403 (server, 90/90), #404 (miner, 206/206); every diff a pure module-path-prefix rename.
  • RFC0028.2 — committed exemption lists: crates/ourios-ingester/tests/README.md and crates/ourios-miner/tests/README.md (+ the server harness header); all exemptions are process-global OTel meter-provider installers.
  • RFC0028.3 — both gates pass (measurement tables on epic #382, 2026-07-06): touch core → querier --no-run 57 s → 28.6 s (< 30 s); warm workspace suite ~10 min → 48.2 s under nextest (≥ 30% gate). Steady-state protocol notes (macOS first-exec assessment) recorded with the numbers.
  • RFC0028.4 — proven in #405: a MinerConfig whitespace edit leaves ourios-core and ourios-parquet Fresh; the rebuild set is the semantic one (config → miner → querier).
  • RFC0028.5 — #406: CI runs cargo nextest run --workspace --all-features + cargo test --doc, preserving the exact suite inventory; the workflow invocations that named old binaries were retargeted in the same PRs that moved them (#401, #403, #404).
  • Slice 4 (parquet split) — closed not-triggered per the §3.3 tripwire: RFC0028.3’s < 30 s edit-loop probe passed without it.
  • red note — this RFC’s scenarios are review/measurement mechanisms (§6), not stub-able tests; there was no red rung, as recorded in the slice-1 PR.

6. Testing strategy

Inventory diffs are the mechanism for RFC0028.1/RFC0028.5’s name half, and the PR diff is the mechanism for its no-body-change half: a consolidation PR is restricted to file moves plus the mechanical harness scaffolding (tests/it/main.rs mod lines, import-path adjustments); the reviewer rejects any hunk inside a test function body. For RFC0028.1’s inventory, a cargo test -p <crate> -- --list snapshot (scoped to the crate being consolidated, matching RFC0028.1) is captured in each consolidation PR’s description and diffed against the post-move run — the reviewer checks the diff is a pure path-prefix rename. RFC0028.2 is a committed list (the harness-exempt binaries and their reasons, in the consolidating crate’s tests/ README or module docs). RFC0028.3’s probe numbers are recorded in epic #382 alongside the baseline so the before/after is one table. RFC0028.4 is the full suite run plus a recheck-set spot check: a whitespace-only edit inside the MinerConfig definition (crates/ourios-core/src/config.rs today; its new home after the split), then cargo build -vv on a type-only core consumer, asserting the build reports the consumer Fresh (no Compiling/Dirty line for it).

7. Open questions

  1. Crash-recovery isolation inventory. Which tests genuinely need their own process/binary (SIGKILL, env-mutating, #[ignore]d hardware gates)? Slice 1 produces the list.
  2. Per-branch target dirs. Worktrees already give this implicitly; whether to document CARGO_TARGET_DIR conventions for branch-heavy local work, or leave it to worktree practice.
  3. ourios-config naming and scope — config only, or does the RFC 0020 file-config layer’s schema (currently in ourios-server) eventually belong beside it?

8. References

  • Epic #382 (measurements, 2026-07-06), maintainer precedence instruction (same date), #373 (debuginfo trim), CLAUDE.md §6.2 (tests are specifications — consolidation moves, never weakens), §7 (new crates are RFC-level), §8.2 (worktrees for parallel work), cargo book (integration-test harness layout), cargo-nextest.

RFC 0029 — OIDC bearer layer


rfc: 0029 title: OIDC bearer layer (issuer-agnostic, Dex-validated) status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-07 supersedes: — superseded-by: —

RFC 0029 — OIDC bearer layer (issuer-agnostic, Dex-validated)

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

1. Summary

RFC 0026 (accepted) authenticated both data-plane surfaces with static bearer tokens bound to tenant sets, and deliberately deferred identity-provider integration (§7.2) — designing the (name, tenants) binding as “the stable layer” a verified-claim validator could later map onto. This RFC is that layer:

  1. OIDC JWT verification as a second credential kind on every RFC 0026 gate (OTLP ingest, the query API, the RFC 0027 MCP surface): standard iss/aud/exp/signature validation against the issuer’s published JWKS, with a configured claim → tenant mapping that resolves each verified token to exactly the (name, tenants) shape the existing enforcement consumes. No enforcement point changes; only the resolution in front of it grows a branch.
  2. Issuer-agnostic by construction, Dex-blessed by test. Ourios implements the OIDC standard, not a vendor SDK; any conforming issuer works. Dex (the CNCF identity broker) is the recommended lightweight deployment and the implementation the acceptance suite runs against (a real Dex container via testcontainers — the LocalStack pattern from RFC 0019).
  3. Additive, never replacing. Static tokens (RFC 0026 §3.1) remain fully supported and can coexist with OIDC in one config — static for dev/single-box, OIDC for fleets. Open mode is untouched.

Touches invariant §3.7 (multi-tenancy — the binding derivation gains a second source) and rides the RFC 0026 audit/telemetry surfaces unchanged. Resolves RFC 0026 §7.1 (token rotation) as a side effect: JWTs expire and renew; no long-lived shared secret crosses the wire.

2. Motivation

  • Fleets outgrow static tokens. A handful of collectors with ${env} tokens is fine; dozens of teams rotating shared secrets through config management is the operational failure mode OIDC exists to remove. Expiry, rotation, and revocation become the issuer’s job — solved once, not per backend.
  • The ecosystem path already exists. The OTel Collector’s oauth2client extension performs the client-credentials flow against any OAuth2 token endpoint and attaches the bearer to exporters — collectors can authenticate to Ourios through an IdP today, with zero collector-side custom code. Dex supports the grant (opt-in: DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT) and token exchange as the documented machine-to-machine paths.
  • MCP’s authorization model is OAuth 2.1. RFC 0027 shipped the agent surface under the static-bearer gate; the MCP specification’s own auth story is OAuth. An OIDC layer is the prerequisite for spec-compliant agent authentication rather than a parallel invention.
  • RFC 0026 planned for this. §4 rejected JWT/OIDC as the baseline (“expiry, issuers, key rotation, clock dependence, and a validation dependency tree — for senders that are collectors with static config”) and §7.2 named the layering as the follow-up. The baseline argument stands; this RFC adds the layer without disturbing it.

3. Design

3.1 Configuration (RFC 0020 amendment)

A sibling to auth.tokens:

auth:
  tokens:                          # RFC 0026, unchanged; optional
    - name: dev-cli
      token: ${env:OURIOS_TOKEN_DEV}
      tenants: ["dev"]
  oidc:                            # this RFC; optional
    issuer: https://dex.internal.example
    audience: ourios
    tenant_claim: ourios_tenants   # claim carrying the tenant list
    name_claim: sub                # audit/metric label (default sub)
  • issuer is the OIDC discovery root: Ourios fetches /.well-known/openid-configuration once at startup and the JWKS it names, then re-fetches keys on rotation (cache with the standard kid-miss refresh; a bounded grace covers issuer blips — §7).
  • audience is required — an Ourios deployment must never accept tokens minted for another service.
  • tenant_claim names a claim whose value is a list of tenant ids (or the wildcard "*"), mapped verbatim onto RFC 0026’s TenantSet; name_claim (default sub) feeds the audit/metric label. The mapping is deliberately dumb — group-to-tenant indirection lives in the issuer (Dex connectors already map upstream groups into claims), not in Ourios.
  • At least one of tokens / oidc must be configured in an auth section; both together are valid. The RFC 0026 empty-list rule is unchanged and unconditional: an explicit tokens: [] always fails startup — to run OIDC-only, omit tokens entirely. No auth section remains open mode with the RFC 0026 startup warning.

3.2 Verification and resolution

  • One resolution path in front of the existing gates: a presented bearer is first matched against the static store (constant-time, RFC 0026 §6); an unmatched credential that parses as a JWT is verified OIDC-side — signature against the cached JWKS (asymmetric algorithms only: RS256/ES256 family; alg: none and HMAC are rejected outright), iss equality, aud containment, exp/nbf with a small configured clock skew. A verified token resolves to the RFC 0026 (name, tenants) binding — the values of the configured name_claim / tenant_claim keys — and flows into the unchanged RFC 0026 enforcement: whole-batch tenant binding before the WAL ack, the query/MCP 403 contract, the same rejection telemetry (error.type values unchanged) and ingest_denied audit event carrying the name label.
  • Verification is local (a signature check against cached keys) — no per-request issuer round-trip, so the §3.4-adjacent ingest hot path gains arithmetic, not network. The issuer is contacted only at startup, on JWKS rotation, and on kid misses.
  • Failure stays one undifferentiated 401 on the wire (RFC 0026’s no-oracle rule); the telemetry may distinguish unauthenticated reasons only at the existing low-cardinality error.type level.

3.3 Dex as the blessed deployment

  • Docs and the acceptance suite treat Dex as the reference issuer: single Go binary, CNCF, federates upstream identity (LDAP, GitHub, SAML, OIDC) through connectors, and issues the JWTs Ourios verifies. Machine senders use the client-credentials grant (Collector oauth2client → Dex token endpoint) or token exchange; humans/agents use the standard flows Dex provides.
  • The §5 suite runs against a real Dex container (testcontainers, CI-gated like the LocalStack S3 jobs): mint real tokens, verify against Dex’s real JWKS, exercise expiry and rotation. Nothing in ourios-server links Dex-specific code — conformance is to the OIDC standard.

3.4 What deliberately does not change

  • Static tokens, open mode, the enforcement points, the audit schema, the metric names, and the (name, tenants) model are all untouched. This RFC is a second resolver, not a second model.
  • Transport encryption remains the fronting-proxy posture (RFC 0026 §1); bearer-over-plaintext caveats apply identically to JWTs.

4. Alternatives considered

  • Keycloak (or a cloud IdP) as the blessed issuer. Heavier to run than Dex and no more standard; since Ourios implements the protocol, they all work anyway — the blessing is about docs and CI weight, and Dex’s single-binary, connector-broker shape matches this project’s deployment story. CNCF alignment is a tiebreaker, not the argument.
  • Vendor-SDK integration (issuer-specific). Couples the backend to one IdP’s release train and dependency tree for zero standard coverage gain. Rejected.
  • OpenFGA (Zanzibar-style ReBAC) for the authorization half. Answers a different question — what may this identity touch — and answers it with a separate stateful service plus a check-API round-trip on the pre-ack ingest path, where today’s model is one in-memory set-membership test over a flat tenant list. Adopt-if: tenancy grows hierarchy (orgs → teams), per-stream ACLs, or delegation. The seam is already clean — RFC 0026’s binding check is a single tenants().allows(...) call an FGA-backed resolver could slot behind without reshaping the model. Until that requirement exists, an external authz service is operational surface without a question to answer.
  • mTLS client identity. Re-rejected on RFC 0026 §4’s grounds: it does not survive the fronting-proxy posture without header-forwarding trust decisions.
  • Opaque tokens + issuer introspection (RFC 7662). Puts the issuer on the request path (introspection call per token) — the availability coupling §3.2 exists to avoid. JWTs verify locally.

5. Acceptance criteria

Scenario ids RFC0029.<m>. Scenario .1 is pure config resolution (no issuer); .2–.6 run against a fixture issuer (a local keypair serving discovery + JWKS over a loopback listener — fast, deterministic, no container); .7 is the real-Dex acceptance arm.

Scenario RFC0029.1 — config resolution. Given a config with an auth.oidc section whose values use ${env:VAR}, When the server starts, Then they resolve through the RFC 0020 substitution engine; a missing audience is a startup configuration error; an auth section with neither tokens nor oidc is a startup configuration error; an explicit tokens: [] is a startup configuration error regardless of whether oidc is present; an oidc-only section starts and serves; a missing auth section starts in open mode with the RFC 0026 warning, unchanged.

Scenario RFC0029.2 — verification matrix. Given OIDC configured against the fixture issuer, When a request presents a bearer that is (a) a valid in-audience token, Then it is accepted; and when it presents (b) an expired token, (c) a token before its nbf beyond the configured skew, (d) a wrong-aud token, (e) a wrong-iss token, (f) a token with a corrupted signature, (g) an alg: none token, (h) an HMAC-signed token whose key is the public JWKS material (downgrade), or (i) a non-JWT unknown bearer, Then every one of (b)–(i) is rejected as the same undifferentiated 401 (identical status and body — no oracle), before wire decode on ingest, and nothing reaches the WAL.

Scenario RFC0029.3 — claim binding drives unchanged enforcement. Given a verified token whose tenant_claim value is ["a", "b"], Then the RFC 0026 §5.3/§5.4 contracts hold verbatim with the OIDC-resolved binding substituted for the static one: in-set ingest batches ack; any batch touching a tenant outside {a, b} is whole-batch 403 with no WAL append; the query API and the MCP surface enforce the same 401→400→403 order; and the name_claim value appears as the name label where the token name appears today.

Scenario RFC0029.4 — wildcard claim. Given a verified token whose tenant_claim value is ["*"], Then ingest and query to arbitrary tenants behave as if every tenant were listed (RFC 0026 §5.5 parity).

Scenario RFC0029.5 — coexistence and resolution order. Given one config with both tokens and oidc, Then a static token authenticates via the constant-time store, a JWT from the issuer authenticates via OIDC, each carrying its own tenant binding side by side; a static-only config and an oidc-only config each serve; and with no auth section the full RFC 0026 §5.6 open-mode parity arm passes unchanged.

Scenario RFC0029.6 — JWKS rotation. Given a served instance verifying against the fixture issuer, When the issuer rotates its signing key mid-run, Then a token signed by the new key (unseen kid) triggers a JWKS re-fetch and verifies without restart, and a token signed by the withdrawn key is rejected once the refreshed key set no longer contains it.

Scenario RFC0029.7 — Dex end-to-end with telemetry parity. Given a real Dex container (testcontainers, CI-gated like RFC 0019’s s3 integration (localstack) job) with the client-credentials grant enabled and a static client whose claims carry the tenant list, When a token minted from Dex’s token endpoint drives ingest, query, and MCP against a served instance verifying Dex’s real JWKS, Then all three succeed; a short-TTL token is rejected with the undifferentiated 401 after expiry; rejections increment the existing counters with the unchanged error.type values and an ingest authz denial emits the ingest_denied audit event carrying the name_claim value — and no JWT material (token, header, claims payload) appears on any surface (metrics, audit, logs, error bodies).

6. Testing strategy

Unit level: .1 is pure config resolution (no issuer at all); the §5 fixture issuer (local keypair) covers .2–.6 — fast, deterministic, no container. Acceptance level: the real-Dex testcontainers job (.7), CI-gated alongside RFC 0019’s s3 integration (localstack) job.

Image note (2026-07-07, .7 green slice): the client-credentials grant and staticClients[].clientCredentialsClaims (the static client’s tenant-list claims this scenario relies on) are merged upstream (dexidp/dex#4691) but not yet in a Dex release — v2.45.1 predates both. The CI job therefore runs Dex master pinned by image digest (reproducible; recorded in ci.yml and the test). Bump to the release tag when Dex v2.46 ships.

The RFC 0026 §5 suite re-runs unchanged with an OIDC-resolved binding substituted for the static one — the enforcement-invariance proof behind .3–.5.

7. Open questions

  1. JWKS outage grace. How long verified-key caches may serve after the issuer becomes unreachable (bounded staleness vs. fail-closed on rotation-with-outage).
  2. Human/agent flows for the query and MCP surfaces. Device flow via Dex for CLI/agent login, and whether /mcp should advertise OAuth metadata per the MCP authorization spec once this layer exists.
  3. Claim schema convention. Whether ourios_tenants becomes a documented convention Dex configs ship, or stays fully deployment-chosen.
  4. Revocation latency. Short TTLs are the plan; whether any deployment class needs sub-TTL revocation (and thus introspection after all) is demand-driven.

8. References

  • RFC 0026 (the binding model, §4’s JWT-baseline rejection, §7.1–.2 the rotation/IdP follow-ups this RFC discharges), RFC 0027 (the MCP surface; MCP’s OAuth 2.1 authorization model), RFC 0020 (config schema + ${env}), RFC 0019 §6 (the testcontainers CI-gating pattern), CLAUDE.md §3.7.
  • Dex: https://dexidp.io (CNCF; client-credentials grant opt-in via DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT, token exchange per its machine-auth guide). OTel Collector oauth2client extension (the collector-side client-credentials flow). OpenFGA: https://openfga.dev (the adopt-if ReBAC engine, §4).

RFC 0030 — TLS/mTLS on the listeners


rfc: 0030 title: TLS/mTLS on the data-plane listeners status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-08 supersedes: — superseded-by: —

RFC 0030 — TLS/mTLS on the data-plane listeners

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

1. Summary

Ourios supports authentication on every data-plane surface (RFC 0026 static bearers, RFC 0029 OIDC JWTs; open mode remains for perimeter-trust deployments) but serves all of them over plaintext TCP. Bearer credentials over plaintext are not auth — any on-path observer can replay them. This RFC closes the gap identified as “gates everything below” in the #331 epic:

  1. Server TLS on all three listeners — OTLP gRPC (:4317), OTLP HTTP (:4318), and the querier HTTP surface (:4319, including the RFC 0027 /mcp route) — via rustls (already the workspace TLS stack; no OpenSSL linkage is required — the only openssl-named crate in the tree stays openssl-probe, rustls-native-certs’s pure-Rust trust-store path prober).
  2. Optional mTLS per listener: a configured client CA turns on require-and-verify client-certificate authentication, as transport hardening. Identity stays with the RFC 0026/0029 bearer layer — a client cert proves network admission, not tenant binding (deferred; §7.1).
  3. Certificate reload without restart: cert/key pairs are re-read on a configurable interval so cert-manager-style rotation works with no dropped listener.
  4. Config mirrors the OTel Collector’s configtls server model (cert_file, key_file, client_ca_file, min_version, reload_interval_secs) so operators configure Ourios like the Collector in front of it (names adapted to RFC 0020’s flat *_secs conventions; semantics identical).

TLS remains opt-in per listener: an unconfigured listener serves plaintext, preserving the documented perimeter-trust deployment mode (gateway/mesh terminates TLS) and every existing config. Enabling auth on a plaintext listener logs a prominent startup warning (§3.4).

Touches hazard §4.6 adjacent surfaces (the listener layer in front of the DSL) and the §3.7 tenancy perimeter indirectly (credential confidentiality); no storage or query semantics change.

2. Motivation

  • Bearer tokens require confidentiality. The OTel Collector’s bearertokenauth extension “explicitly requires TLS” for exactly this reason; RFC 0026 §7 acknowledged the same and deferred the transport question to this RFC. Until it lands, the honest guidance for production is “put a TLS-terminating proxy in front” — workable but easy to skip silently.
  • The ecosystem default is native TLS. Every Collector receiver takes a tls: block; operators pointing a Collector exporter at Ourios today must set insecure: true, which reads (correctly) as a warning sign.
  • mTLS is the fleet norm for collector→backend links. Where an IdP is overkill (edge collectors with provisioned certs), a client CA is the established alternative; the Collector’s server side supports client_ca_file for the same reason.
  • Rotation is not optional. Kubernetes cert-manager renews certificates on a cadence; a listener that requires a restart to pick up a renewed cert turns rotation into an outage generator.

3. Design

3.1 Configuration (RFC 0020 amendment)

The amendment is purely additive to RFC 0020’s existing flat listener keys (receiver.grpc_addr, receiver.http_addr, querier.http_addr are untouched): each listener gains an optional sibling *_tls block.

receiver:
  grpc_addr: 0.0.0.0:4317
  grpc_tls:
    cert_file: /etc/ourios/tls/server.crt     # required to enable TLS
    key_file: /etc/ourios/tls/server.key      # required alongside cert_file
    client_ca_file: /etc/ourios/tls/ca.crt    # optional: enables mTLS
    min_version: "1.2"                        # default; "1.3" allowed
    reload_interval_secs: 300                 # optional: never if unset
  http_addr: 0.0.0.0:4318
  http_tls: { ... }                           # same shape
querier:
  http_addr: 0.0.0.0:4319
  http_tls: { ... }                           # same shape, covers /mcp

The field names and semantics inside the block are the Collector’s configtls server settings; the two adaptations to RFC 0020’s house conventions are the flat <listener>_tls placement (no nested listener objects exist to hang a tls: key off) and the duration spelling (below).

Rules:

  • cert_file and key_file come as a pair; one without the other is a config error at startup (named field in the message).
  • client_ca_file without cert_file/key_file is a config error — mTLS presupposes server TLS.
  • min_version accepts "1.2" (default) and "1.3" only. TLS 1.0 and 1.1 are not implemented (rustls does not ship them; the Collector deprecates them).
  • reload_interval_secs is a positive integer number of seconds — RFC 0020’s existing duration convention (default_window_secs, interval_secs), not the Collector’s Go-style duration string. Zero or negative is a config error; unset means never reload.
  • Paths may use ${env:VAR} (RFC 0020 §3.5) like any other config value; the file contents are read at startup and on reload, never embedded in config.
  • Unknown fields under a *_tls block are rejected (RFC 0020 strict-mode parsing, unchanged).

3.2 Implementation shape

One shared ourios-ingester-side (receiver) and ourios-server-side (querier wiring) seam:

  • A TlsSettings -> rustls::ServerConfig builder in one place: certificate chain + key from the configured files, client CA into a RootCertStore + WebPkiClientVerifier when present, ALPN h2/http/1.1 as appropriate per listener (gRPC requires h2).
  • Both HTTP-family listeners (OTLP HTTP, querier axum router) accept through tokio-rustls’s TlsAcceptor in front of the existing hyper serve loop; the gRPC listener uses the same acceptor in front of tonic’s Server::serve_with_incoming (tonic’s own tls feature is not enabled — one rustls wiring for all three listeners instead of two).
  • Reload (reload_interval_secs): the acceptor holds the active Arc<rustls::ServerConfig> behind a read-mostly std::sync::RwLock (each handshake clones the Arc out); a task re-reads the files on the interval and swaps on content change. In-flight connections keep their session; new handshakes see the new material. A reload failure (unreadable/invalid files) logs an error and keeps the last good config — it never takes the listener down.
  • New dependencies: tokio-rustls only (already in the transitive tree; declared directly at the seam’s home), plus rcgen as a dev-dependency to mint test CAs and leaf/client certs. The reload swap uses std::sync::RwLock — no new runtime crate.

3.3 mTLS semantics

client_ca_file set ⇒ RequireAndVerifyClientCert (the Collector’s documented behavior for the same field): a handshake without a valid client cert chain to that CA fails — the request never reaches the auth layer. mTLS composes with, and does not replace, bearer auth: the RFC 0026/0029 resolver still runs on every request that survives the handshake. Client-cert identity extraction (SAN → tenant binding) is deliberately out of scope (§7.1).

3.4 Plaintext + auth = warning

When any credential source (auth.tokens / auth.oidc) is enabled and a listener has no *_tls block, startup logs one prominent warning naming the listener (“bearer credentials over plaintext”). It is not a hard error: TLS may legitimately terminate at a fronting proxy/mesh. Whether a future major flips this to opt-out strictness is an open question (§7.2).

3.5 What deliberately does not change

  • The auth layer (RFC 0026/0029): resolvers, bindings, audit events, telemetry — untouched. TLS sits strictly below it.
  • Open mode: a listener with neither a *_tls block nor credentials behaves exactly as today.
  • Outbound TLS (object storage): already rustls via object_store; not this RFC.
  • The Helm chart gains value plumbing (secret-mounted certs → the grpc_tls/http_tls blocks) in a follow-up chart release; the chart is not part of the acceptance gate here.

4. Alternatives considered

  • tonic’s built-in tls feature for gRPC + separate axum-side wiring. Two TLS stacks to configure and keep consistent; tonic’s feature also pins its own rustls wiring. One TlsAcceptor in front of all three serve loops is smaller and uniform.
  • Terminate TLS only at the gateway, document, and skip native support. The Loki model. Rejected: it leaves bearer tokens plaintext on every non-mesh deployment, contradicts the Collector norm our operators expect, and #331 explicitly scopes native TLS as the base everything else builds on.
  • SIGHUP-triggered reload instead of an interval. Signals are awkward in containers (PID 1 handling) and unavailable on some targets; the Collector’s reload_interval is the established shape. Interval it is.
  • Hard-fail auth-over-plaintext (§3.4 as an error). Would break every current mesh-terminated deployment on upgrade; a warning preserves them while making the risk visible.

5. Acceptance criteria

Each criterion is a Given/When/Then that lands as a red test first (the Red gate, docs/verification.md). Test CAs/certs are minted at test-time with rcgen — no committed key material (house rule since the RFC 0029 fixture-key incident).

Scenario RFC0030.1 — gRPC ingest over TLS. Given a receiver grpc listener with cert_file/key_file from a test CA, When an OTLP gRPC client connects over TLS trusting that CA and exports a batch, Then the export succeeds and the batch is ingested; And When a plaintext gRPC client dials the same port, Then the connection fails at the transport layer and nothing reaches the auth layer or the WAL.

Scenario RFC0030.2 — HTTP ingest over TLS. Given a receiver http listener with cert_file/key_file from a test CA, When an OTLP/HTTP client posts a batch to https://…:4318 trusting that CA, Then the export succeeds and the batch is ingested; And When a plaintext http:// request hits the same port, Then it fails at the transport layer and nothing reaches the auth layer or the WAL.

Scenario RFC0030.3 — querier + MCP over TLS. Given a querier listener with TLS enabled and a static bearer configured, When a query request (valid bearer + X-Ourios-Tenant for a tenant the token binds) and an MCP initialize (valid bearer) arrive over TLS, Then both succeed — transport is the only variable under test; And a plaintext request to the same port fails at the transport layer.

Scenario RFC0030.4 — mTLS require-and-verify. Given a listener with client_ca_file set and a static bearer configured, and a valid bearer presented in every case below (only the client cert varies), When the client presents a cert signed by that CA, Then the request proceeds through bearer auth (RFC 0026) and is ingested; When the client presents no cert, Then the handshake fails; When the client presents a cert from a different CA, Then the handshake fails. In the two failure cases nothing reaches the request handler or the auth layer.

Scenario RFC0030.5 — config validation. Given cert_file without key_file, or client_ca_file without a server pair, or min_version: "1.1", When the server starts, Then startup fails with an error naming the exact offending field; Given an unreadable or non-PEM cert_file, Then startup fails naming the path.

Scenario RFC0030.6 — certificate reload. Given a TLS listener with reload_interval_secs set and an established baseline connection, When the cert/key files are replaced with a new pair (same CA) on disk and the interval elapses, Then new handshakes serve the new certificate (observed via the peer certificate’s serial) without a process restart; And When the files are replaced with garbage, Then new handshakes keep serving the last good certificate and an error is logged.

Scenario RFC0030.7 — plaintext-auth warning. Given auth.tokens configured and a listener without a *_tls block, When the server starts, Then exactly one warning naming that listener is emitted; Given the same listener with its *_tls block configured, Then no such warning.

Scenario RFC0030.8 — served end-to-end (Collector-shaped client). Given the served ourios-server binary running both roles in one process (the deployment-level end-to-end; the test lives in ourios-server, §6), with TLS on both receiver listeners, mTLS on gRPC, and TLS on the querier, When an OTLP exporter that is configured the Collector way (tls.ca_file plus a client cert pair) exports over gRPC, and a second exporter posts the same way over HTTPS, Then both batches land and are queryable over the TLS querier — the full stack, with no plaintext hop.

Scope (clarified 2026-07-10, maintainer-approved). RFC0030.8 asserts transport end-to-end only: every hop — gRPC ingest, HTTP ingest, and query — is TLS, gRPC is mutually authenticated at the transport layer, and no plaintext hop exists in the served stack. It deliberately does not assert any client-cert-identity → tenant binding; that is open question §7.1, deferred. Application-layer authentication is verified by RFC 0026 / 0029; .8 is the transport composition of those layers, not a re-test of them. (“Queryable over the TLS querier” is met by the query surface serving over TLS; the served sink flushes only on graceful drain, so landing is read back from the store after shutdown — the batches are durable and the read transport is exercised.)

Scenario RFC0030.9 — min_version enforcement. Given min_version: "1.3", When a client attempts a TLS 1.2-only handshake, Then the handshake is refused; a TLS 1.3 handshake succeeds.

6. Testing strategy

  • §5 arms live as integration tests in the owning crates (ourios-ingester for the .1/.2/.4/.5/.6/.9 receiver + seam arms, ourios-server for .3/.7/.8 — .7 observes the spawned binary’s startup warning, which only the server crate can do), joining the consolidated harnesses (RFC 0028) — no new test binaries.
  • rcgen mints a CA + server/client leaves per test; nothing key-shaped is committed (RFC 0029 precedent).
  • Reload (.6) drives a temp-dir cert swap and polls handshakes with a short interval — bounded, no wall-clock sleeps beyond the interval.
  • TLS handshake overhead on the ingest hot path is measured indicatively on ci-runner (house bench rule) and recorded on the epic; it is a diagnostic, not a gate — TLS cost is a known, accepted tax.

7. Open questions

  1. Client-cert identity → tenant binding. mTLS here is transport only. Mapping a client-cert SAN to an RFC 0026 (name, tenants) binding (the Envoy-style pattern) would make certs a third credential kind. Deferred until a deployment actually asks for it.
  2. Auth-over-plaintext as a hard error. §3.4 warns. A future major could flip the default (opt-out via an explicit allow_plaintext_credentials: true), matching the Collector’s bearertokenauth stance. Maintainer call, post-1.0 discussion.
  3. cipher_suites / curve_preferences exposure. The Collector exposes both; rustls’s defaults are deliberately safe and narrow. Left out until someone presents a compliance requirement.
  4. HTTP→HTTPS redirect / dual-listen. Some operators expect the plaintext port to keep answering with a redirect during migration. Out of scope; a listener is either TLS or plaintext.

8. References

  • #331 — the authn/transport epic this RFC advances (“TLS/mTLS on both listeners first (everything else depends on it)”).
  • RFC 0026 — authentication + tenant binding (accepted); RFC 0029 — OIDC bearer layer (green). The layers this RFC carries.
  • OTel Collector configtls server settings — the config model mirrored here (cert_file/key_file/client_ca_file/ min_version; the Collector’s reload_interval is spelled reload_interval_secs in RFC 0020 terms; client_ca_file ⇒ RequireAndVerifyClientCert).
  • rustls / tokio-rustls — the TLS stack (already the workspace’s via reqwest/object_store; no OpenSSL).

RFC 0031 — Comparative evaluation vs Loki


rfc: 0031 title: Comparative evaluation against Grafana Loki status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-11 supersedes: — superseded-by: —

RFC 0031 — Comparative evaluation against Grafana Loki

Status note. accepted (2026-07-22, maintainer sign-off — the terminal state). Reached validated 2026-07-21: the §7 gate set is frozen and CI-enforcing (green via the asserting ci-runner series, §9.13–§9.18), and the first fully authoritative comparative run on baseline-8vcpu-32gib (§9.24, PR #583) passed all 11 frozen gate decisions: L1 97.82× / L3 22.52× (storage-primary), L2 38.37× / L4 85.14× (processed-primary) with storage floors 1.239× / 3.60×, and both L6 latency floors (0.370 / 4.341). Needle-query latency ratios compress on dedicated hardware (90.44× / 77.99× vs the indicative 308–323×) — the §9.13 caveat, now measured. Losses are published per §5: L6 storage is a recorded diagnostic, not a win. The program ledger closed in #498; the only deferred M_L4 item is F_L7 (#548). accepted is a maintainer flip.

1. Summary

Pins the methodology for the one measurement the project has never made: Ourios against the incumbent it defines itself against. CLAUDE.md §1 states the existence test — “Not a Loki/Mimir/ ClickHouse clone. If the answer is ‘just use $X,’ we should not be building this” — and to date every thesis-gate in docs/benchmarks.md is self-referential (Ourios versus its own full scan, versus zstdcat | grep). This RFC adds Grafana Loki as a second reference system and fixes the comparative methodology: the same OTLP stream ingested into both, the same logical queries run against both on the same hardware, and a fixed set of comparative gates (the L-gates) written into docs/benchmarks.md. The headline corpus is a real OpenTelemetry-Demo OTLP capture — Ourios is an OTLP-native backend, so the honest test is real OTLP logs, the workload we claim to do best, not a favourable plain-text corpus. The query taxonomy is anchored to OpenTelemetry’s own stated log correlation/analysis model (§2.3): the four must-win classes exercise the four ways Ourios turns OTLP structure into pruning — template id, resource/attribute columns, high-cardinality trace context, and typed template parameters for frequency aggregation. The load-bearing metric is bytes read from object storage per query — the implementation- independent expression of the pruning thesis — with wall-clock latency reported as practical corroboration. Result-set equivalence (multiset-exact), a committed and competent (non-strawman) Loki configuration, and mandatory publication of losses are acceptance criteria, not afterthoughts. This RFC amends docs/benchmarks.md §1 (reference systems) and §7 (thesis-gate escalation); it does not touch any CLAUDE.md §3 invariant or the Parquet schema.

2. Motivation

2.1 The thesis has only been tested against a strawman

docs/benchmarks.md §1 names exactly one reference system: zstdcat <file.zst> | grep <pattern>. The B1 gate is “≥ 10× faster than zstdcat | grep”; B2 is “scales with result size, not corpus size.” Both are real and both pass (§9.4, §9.8) — but both measure the mechanism, not the choice. Parquet footer statistics do prune row groups; the template count does converge. What no number in the repository shows is that this beats the system a prospective user would otherwise reach for. Loki also beats zstdcat | grep. The question CLAUDE.md §1 raises — is there a reason to run Ourios instead of Loki — is the project’s existential question, and it is unmeasured.

2.2 Why Loki, specifically

Of the three systems CLAUDE.md §1 names, Loki is the sharpest comparison because it shares the premise and differs in the mechanism. Both Ourios and Loki reject the full inverted index of Elasticsearch/Quickwit; both store compressed log blocks on object storage and lean on cheap storage plus selective reads. Where they diverge is exactly the Ourios thesis:

  • Loki indexes a small set of operator-chosen labels and, within the matching label streams, brute-force scans (greps) compressed chunks.
  • Ourios mines a template id per line at ingest and leans on Parquet’s per-row-group min/max statistics, bloom filters, and page indexes to skip chunks the query cannot match — automatically, without the operator choosing labels, and at a granularity finer than a label stream.

The comparison therefore tests the precise claim in CLAUDE.md §2 pillar #1–#2: that automatic template mining + Parquet pruning skips more data than label-index + chunk scan on the selective queries that dominate real log investigation. ClickHouse (general-purpose columnar) and Quickwit (full-text index) are different enough in philosophy that comparing to them answers a different question; they are noted in §4 and deferred.

2.3 OTLP is where the gap is widest, and OTel names the axes

The comparison runs on real OTLP logs (§3.3) because that is the workload Ourios exists for, and because OTLP structure is exactly where the two mechanisms diverge hardest. Critically, the query taxonomy is not invented here: the OpenTelemetry Logs specification’s Log Correlation section names the dimensions along which logs are navigated, filtered, queried and analysed — “these correlations can be the foundation of powerful navigational, filtering, querying and analytical capabilities” — and they are precisely the axes Ourios prunes on:

  • Time of execution — every query is time-bounded; Parquet row-group time statistics prune it.
  • Execution (trace) contexttrace_id / span_id on the LogRecord. The spec calls this out as what “would make logs significantly more valuable in distributed systems”: it directly correlates logs with traces and correlates logs across the components that served one request.
  • Resource contextservice.name, k8s.*, and other resource attributes identifying the telemetry’s origin.

An OTLP log record arrives with severity_number, these resource attributes, log attributes, and the trace context. Ourios promotes this structure into queryable, statistics-bearing columns automatically (template id at ingest per RFC 0001; severity, service, and configured attributes as Parquet columns per RFC 0022), so per-row-group min/max and bloom filters prune on OTLP fields without the operator declaring anything. Loki’s model is the inverse: an operator must hand-pick a small set of low-cardinality labels, and everything else is brute-force chunk scan. Four consequences follow, and they are the four must-win query classes (§3.4):

  • Where template mining fires, Ourios prunes on template id (L1).
  • Where it does not (the OTel-Demo capture is heavily NO_TEMPLATE on some services — RFC 0023), Ourios still prunes on the promoted resource/attribute columns (severity, service.name — L2). The pruning thesis on native OTLP is therefore template mining + attribute promotion, a stronger and more honest framing than a synthetic well-templated corpus would show.
  • trace_id is high-cardinality by construction, so it cannot be a Loki label without exploding Loki’s index. Loki must brute-force scan to answer “show me every log line for this trace”; Ourios promotes trace_id to a bloom-filtered column and prunes to the handful of row groups that contain it (L3).
  • Template mining yields typed parameters, so “how often does template X fire over time, grouped by extracted field Y” is a columnar GROUP BY for Ourios. This is a first-class OTLP operator workflow — the canonical OTLP-log query set opens with a severity-count time series, and an OTel-native vendor doing Drain-style mining demonstrates “a log-frequency alert filtering on the pattern and grouping by the product-id field … without any metrics, without an extra metric, without a regular expression” (OTel Night, Berlin 2025). Loki, holding unstructured chunks and no typed params, must scan and regex-and-count. This is the workload the template + params pillar exists to serve (L4).

2.4 Why measurement now

The engineering is substantially complete (RFCs 0001–0030 green or beyond; both data paths shipped and gated). The marginal green RFC no longer changes whether the project should exist; the comparative number does. docs/benchmarks.md §7 already states the discipline: “The worst failure mode for a greenfield project is shipping something whose central claim quietly fails on real data and then papering over it with more implementation.” An unmeasured existential comparison is that failure mode latent. This RFC converts it into a gate — one that can be lost, and whose loss is a pillar-level signal, not a tuning knob.

2.5 Why bytes-read is the primary metric, not latency

Ourios is a young implementation on top of DataFusion; Loki is a mature engine with years of query-path optimisation. A naive latency-only comparison confounds two very different claims — “Parquet pruning reads less data” (an architectural claim, the thesis) and “our query engine is faster today” (an implementation- maturity claim, not the thesis). We isolate the thesis by making the primary gate bytes read from object storage per query: the direct, engine-independent measure of how much data each architecture must touch to answer a query. Pruning is, definitionally, reading less. Wall-clock latency (p50/p99) is reported alongside as the number an operator actually feels, but a latency loss paired with a decisive bytes-read win is interpreted as “sound architecture, young implementation” — a roadmap signal — whereas a bytes-read loss on a selective query is a thesis failure. This asymmetry is the honesty core of the RFC and is fixed in §5, not left to interpretation after the fact.

3. Proposed design

3.1 Shape

A new comparative bench workstream, layered on the RFC 0006 harness and the RFC 0007 querier, that:

  1. Ingests one fixed corpus into both systems over their native OTLP path (§3.3).
  2. Runs a fixed query taxonomy (§3.4) against both, expressed once in the Ourios DSL and once in LogQL, asserting result-set equivalence (§3.5) before any timing is trusted.
  3. Records the L-gate metrics (§3.6) into docs/benchmarks.md §9 in the same diff-reviewable shape RFC 0006 established.
  4. Ships the exact Loki configuration and orchestration in-repo so the comparison is third-party-reproducible (§3.7).

The Ourios-side numbers come from the existing querier and its OTel query metrics (RFC 0016: scanned / pruned row-group counts, which this RFC extends to bytes — §3.6). The Loki-side numbers come from Loki’s own query-statistics API (Summary.totalBytesProcessed, execTime), which Loki returns per query — no instrumentation of Loki’s internals is required or permitted (that would be a fairness hazard).

3.2 Infrastructure

Both systems run as containers under GitHub Actions (containerd; no local Docker dependency), against the same object-store backend — a single MinIO/localstack S3 endpoint — so the storage substrate is byte-for-byte identical and cannot bias the bytes-read metric. Loki runs in single-binary mode with the tsdb index and S3 chunk storage; Ourios runs its normal ingester + querier against the same bucket. Per the established norm (benchmarks.md §1, and the project’s bench-on-ci-runner-first discipline), the first comparative run is indicative on ci-runner; the authoritative run is on the baseline-8vcpu-32gib tag and is gated on maintainer opt-in. Neither system is co-scheduled with the other during a timed query (they share a bucket, not a CPU): ingest both, then quiesce, then query each in isolation with the other stopped, to remove noisy-neighbour effects from the latency numbers.

3.3 Corpus and ingest parity

The headline corpus is the OTel-Demo v8 capture — the canonical real-OTel corpus per the project’s corpus policy, native OTLP with the full attribute/trace_id structure §2.3 turns on. This is the number the project stands behind. It is worth stating plainly that the OTel-Demo logs are comparatively well-structured (shipped over OTLP as JSON with rich attributes); real-world Kubernetes logs are typically messier raw-string bodies with only basic attributes (OTel Night 2025, ibid.). So OTel-Demo is the honest OTLP headline but not a worst case — the harder real case (mostly NO_TEMPLATE, sparse attributes) is exactly where attribute promotion and the L4 aggregation carry the thesis, and §7 keeps “a messier captured corpus” as a follow-up. LogHub HDFS_v1 (already wired, bench-time-fetched, ~1.47 GiB) is retained as a secondary, well-templated sanity floor — it reproduces the best case for template mining and anchors against the Drain-paper corpora — but it is explicitly not the headline, and it is non-native (plain text replayed as text-body OTLP), so it exercises template pruning without the OTLP-attribute story.

Both corpora are fed to both systems as the same OTLP log stream: a single replay driver emits OTLP/gRPC to Ourios’s receiver and, in parallel, to Loki over OTLP (native OTLP endpoint preferred, or an OTel Collector loki exporter — §7), so neither system gets a preprocessing advantage and both derive their structure from the identical OTLP records. Label selection for Loki is part of the committed config (§3.7) and must be a competent operator’s choice (service.name, severity, a small set of low-cardinality resource attributes), not a single catch-all label that would force a full scan (an unfair strawman in Ourios’s favour), nor a high-cardinality label (trace_id, or one label per template) that smuggles Ourios’s promoted columns into Loki’s index and would blow it up in a real deployment (an unfair strawman in Loki’s favour, and not how anyone operates Loki). The label set is frozen in the config and machine-checked as the §5 RFC0031.10 gate.

3.4 Query taxonomy

Seven query classes, each with a must-win / acknowledged-loss / floor / parity disposition fixed up front so the result cannot be reframed after it is known. The four must-win classes map one-to-one onto OTel’s log analysis axes (§2.3) and the four ways Ourios turns OTLP structure into pruning:

ClassQueryOTel axis / Ourios pruning mechanismDisposition
L1Template-exact lookup: all lines of one rare template over the full corpusbody pattern → template id (RFC 0001)must-win (thesis)
L2Attribute predicate: severity ≥ ERROR AND service.name = X over a bounded windowresource context → promoted columns + Parquet stats (RFC 0022)must-win (thesis)
L3Trace correlation: every log line for one trace_idexecution context → high-cardinality bloom columnmust-win (thesis, OTLP-native)
L4Frequency aggregation: count of a template over time, grouped by an extracted paramtyped template params → columnar GROUP BYmust-win (thesis, OTLP-native)
L5Substring needle: an arbitrary literal not captured by a template or a promoted column (embedded in a param)none (brute scan for both)acknowledged — loss permitted, published
L6Broad scan: all lines in a wide time range, low predicate selectivitylittle prunesfloor — bounded, not must-win
L7Ingest throughput: sustained OTLP lines/s to steady stateparity — within a stated factor

L1–L4 are where the pruning thesis lives and must win on bytes-read (§3.6). L3 and L4 are the two Loki structurally cannot serve efficiently: L3 because trace_id cannot be a label, L4 because Loki holds no typed params and must scan-then-regex-and-count where Ourios does a columnar aggregation. L5 is the honest inclusion: neither template mining nor attribute promotion helps a substring the miner folded into a parameter, and Loki’s brute-force chunk grep may match or beat Ourios there — we publish it. L6 tests the floor (when little can be pruned, Ourios must not be catastrophically worse — bounded, not required to win). L7 checks that thesis-side query wins are not bought with an unacceptable ingest regression.

3.5 Result-set equivalence (the integrity gate)

A latency or bytes comparison between two queries that return different answers is meaningless. For every query in the taxonomy, the harness compares the two systems’ answers exactly before any metric for that query is recorded:

  • For the line-returning classes (L1–L3, L5, L6) it extracts each system’s matching lines keyed by (timestamp_unix_nanos, body_bytes) and compares as a multiset — the count of each key must match, not merely the set, so a system returning three identical duplicate lines where the other returns two is a mismatch, not a silent pass.
  • For the aggregation class (L4) the grouped result itself is the answer: the (bucket, group_key) → count map must be identical between systems.

A mismatch fails the run (non-zero exit, no metric written for that class) — it means the two queries are not asking the same question and the comparison is invalid. This is RFC0031.1 and it gates every other L-scenario.

3.6 Metrics and the bytes-read extension

Per query, per system, the harness records:

  • bytes_read — bytes fetched from object storage to answer the query. Ourios: extended from the RFC 0016 scanned/pruned row-group counts to the bytes of the row groups actually read (footer + read row-group byte length), emitted on the existing OTel query-metrics path. Loki: recorded on two channels (definitions in the 2026-07-13 amendment below): storage-side (compressedBytes + headChunkBytes) and processed (totalBytesProcessed); each frozen gate cites one (§7). Primary gate metric — with the rationale applying to Ourios’s figure and Loki’s storage-side channel; the processed channel measures decompressed engine work, not fetched bytes. Because the storage-side comparison counts bytes fetched from the shared object store, it is by construction insensitive to CPU speed and engine maturity; to keep it insensitive to local page cache as well, each measured query runs against a freshly started server with OS page cache dropped, so a warm local cache cannot mask an architecture that would fetch more from storage.
  • latency_p50 / latency_p99 — wall-clock over N repetitions, reported for both a cold reading (fresh process, dropped cache — the same state the bytes-read gate is measured in) and a warm reading (repeated in-process), stated separately. Corroborating, not sole-gating (§2.5).
  • storage_footprint — total bytes each system persists for the corpus on the shared bucket. Recorded diagnostic (like A1, per RFC 0011 — a byte codec captures redundancy the thesis does not claim to beat); not gating.
  • ingest_throughput — steady-state OTLP lines/s (L7 only).
  • peak_rss — high-water memory of each system’s query path, diagnostic.

Measurement-fidelity amendment (2026-07-12, RFC in red). The Ourios-side bytes_read figure is the total bytes fetched from object storage to answer the query: the count/pruning scan plus the row-materialization scan that fetches the ≤ limit returned records plus the template-registry derivation (the RFC 0017 §3.2 audit-stream read that reconstructs string bodies). The channel previously reported the count scan alone, silently excluding two real IO components and biasing the ratio in Ourios’s favour; Loki’s counterpart figure includes delivering results, so the §3.7 anti-strawman discipline requires ours to as well. The querier’s QueryStats::bytes_read keeps its count-scan-only meaning (the B1/B2 gates and the RFC 0016 metrics depend on it); the two new components are additive QueryResult fields the harness sums.

Template-map acquisition (amendment, 2026-07-13, RFC 0033). Since RFC 0033’s cached template-map artifact, the third component is the template-map acquisition bytes: the total bytes fetched to obtain body-rendering capability, whatever the source — the audit-stream fold on a cache miss (byte-for-byte the registry derivation described above) or the template_map.json artifact GET on a cache hit. QueryResult::registry_bytes_read keeps its name and its additive place in the three-component sum; only the source of the bytes changes. One field, one honest meaning — the harness needs no code change.

Channel definitions (amendment, 2026-07-13). The Loki comparator is recorded on two channels, and each frozen gate names which it uses (§7): the storage-side channel (compressedBytes + headChunkBytes from the query-stats tree — compressed chunk bytes fetched from storage plus memory-served head-chunk bytes, the latter counted so data not yet flushed is not free; the conservative apples-to-apples counterpart of Ourios’s fetched-compressed total) and the processed channel (totalBytesProcessed — decompressed engine work, the measure of the scanning the §1 thesis eliminates). Both are always recorded; gates cite one. Where a §5 scenario’s shorthand reads loki.bytes_read (or names Summary.totalBytesProcessed directly — legacy wording, kept for scenario stability, not a redefinition of that key), interpret it as the channel the frozen gate cites in §7: storage-side for RFC0031.2/.4, processed for RFC0031.3 under the interim rule.

3.7 Reproducibility and anti-strawman commitment

The entire comparison — Loki config (index, chunk, retention, S3, label selection), the OTLP-into-Loki config (native endpoint or an OTel Collector loki exporter — §7), the query pairs (DSL ↔ LogQL), and the orchestration — is committed under bench/comparative/ and runnable by a third party with one command. The Loki configuration must be a good-faith competent deployment: tuned chunk target size, appropriate index period, the label set from §3.3. The config carries a header comment inviting challenge, and the L-gate results in benchmarks.md §9 link the exact config commit. Crucially the label set is machine-checked, not merely eyeballed (RFC0031.10): a test asserts the committed labels are drawn from a declared low-cardinality allowlist and that the disallowed keys (trace_id, span_id, and any per-template id) are absent, so a strawman config cannot slip in unnoticed. A benchmark whose loser’s configuration cannot be inspected and re-run is not evidence; this section is what makes the number defensible rather than a claim.

3.8 benchmarks.md amendments

  • §1 gains Loki as a second reference system, described as above.
  • §7 gains the L-gate escalation: an L1, L2, L3, or L4 bytes-read loss on the headline OTel-Demo corpus is a pillar-level finding (revisit CLAUDE.md §2 before further implementation), exactly as two failing thesis-gates are today. A must-win latency loss with a bytes-read win is a roadmap item, not an escalation. L5 (substring) loss is expected and never escalates. L6 beyond its floor, or an L7 regression past its factor, is a tuning RFC.

4. Alternatives considered

Compare against ClickHouse instead of Loki. ClickHouse is the closest system architecturally (columnar, statistics-based skipping), so a ClickHouse comparison would test “did we build a worse ClickHouse” rather than “should you use Ourios over the log-native incumbent.” It is the more flattering comparison to defer and the more dangerous one to skip; it belongs in a follow-up RFC once the Loki number exists, because losing to ClickHouse-on-logs is a distinct and also-existential finding. Deferred, not dismissed.

Compare against Quickwit / Elasticsearch. These carry a full-text inverted index — the exact structure CLAUDE.md §2 claims to collapse. They will win outright on arbitrary substring search (L5-like queries) and pay for it in storage and ingest. That trade is already understood and is not the question Ourios’s thesis stakes itself on; benchmarking it measures a different product. Out of scope (benchmarks.md §8 already excludes SIEM-style full-text latency).

Keep zstdcat | grep as the only reference. This is the status quo and it is insufficient for the reason in §2.1: it validates the mechanism, not the choice. Retained as a floor, not removed.

Latency as the primary gate. Rejected in §2.5: it confounds the architectural thesis with implementation maturity and would let a young-engine latency loss read as a thesis failure (or, worse, tempt us to chase engine micro-optimisation to rescue a number that the architecture already wins on bytes). Bytes-read is the honest primary.

No result-set equivalence check — just run “the same query” in each DSL. Rejected: LogQL and the Ourios DSL have different matching semantics (label streams vs template ids vs substrings), and “looks equivalent” is exactly how comparative benchmarks lie. §3.5 makes multiset-exact equivalence a hard precondition.

Make it an RFC 0006 amendment rather than a new RFC. RFC 0006 pins the self-referential thesis-gate methodology; this introduces a second system, an equivalence harness, and a fairness contract — enough new surface, and enough new failure modes, to warrant its own decision record. It references RFC 0006’s harness rather than editing it.

5. Acceptance criteria

Scenario RFC0031.1 — Result-set equivalence gates every comparison

  • Given a query from the §3.4 taxonomy expressed as an Ourios-DSL / LogQL pair, and the fixed corpus ingested into both systems
  • When the harness executes both queries
  • Then for a line-returning class it extracts each system’s matching lines keyed by (timestamp_unix_nanos, body_bytes) and asserts the two multisets are identical (per-key counts equal, so duplicates are not silently collapsed); for the L4 aggregation class it asserts the (bucket, group_key) → count maps are identical within the L4_COMPLETENESS_MARGIN documented in §7 (2026-07-17), checked per group_key — a phantom cell (one Loki reports that Ourios’s own answer doesn’t contain at all) or any group_key whose total across all its buckets exceeds Ourios’s is never tolerated regardless of margin; only Loki under-counting a group_key’s own total, up to the margin, is tolerated
  • And if the answers differ beyond what’s tolerated, the harness records no L-metric for that class, writes the symmetric-difference (or count-delta) summary and up to N example keys to stderr, and exits non-zero
  • And no benchmarks.md §9 row is written for a class whose equivalence check did not pass

Scenario RFC0031.2 — L1 selective template lookup wins on bytes read

  • Given the headline OTel-Demo corpus ingested into both systems and a template that matches < 0.1% of corpus lines
  • When the harness runs the L1 query against each and reads bytes_read (Ourios: row-group bytes actually read per the RFC 0016 metric extension; Loki: Summary.totalBytesProcessed)
  • Then ourios.bytes_read / loki.bytes_read ≤ 1 / M_L1 where M_L1 is the committed must-win margin (§7)
  • And the class disposition in the results is must-win, so a result above the ratio flips l1.pass = false and is surfaced as a pillar-level finding per benchmarks.md §7 (amended)
  • And latency_p50, latency_p99 (cold and warm) are recorded for both systems as corroborating, non-gating numbers

Scenario RFC0031.3 — L2 attribute predicate wins on bytes read

  • Given the headline corpus ingested into both systems and the L2 predicate (severity ≥ ERROR AND service.name = X over a bounded window) expressed equivalently in both DSLs, equivalence per RFC0031.1 holding
  • When the harness runs L2 against each
  • Then ourios.bytes_read / loki.bytes_read ≤ 1 / M_L2
  • And the same pillar-level escalation as RFC0031.2 applies on failure

Scenario RFC0031.4 — L3 trace correlation wins on bytes read (OTLP-native)

  • Given the headline corpus ingested into both systems and a trace_id present in it, with trace_id not a Loki label (per the §3.3 frozen set — high-cardinality and un-labelable), equivalence per RFC0031.1 holding
  • When the harness runs “every log line for this trace_id” against each (Ourios: bloom-filtered promoted column; Loki: label-stream scan)
  • Then ourios.bytes_read / loki.bytes_read ≤ 1 / M_L3
  • And the class disposition is must-win with the same pillar-level escalation as RFC0031.2 on failure — this is a query Loki’s model cannot answer without a full scan (§2.3), so a loss here is among the strongest possible signals against the thesis

Scenario RFC0031.5 — L4 frequency aggregation wins on bytes read (OTLP-native)

  • Given the headline corpus ingested into both systems and a frequency-aggregation query — count of one template over time, grouped by an extracted param (Ourios: columnar GROUP BY on template_id + a typed param column; Loki: count_over_time with a LogQL pattern/label_format extraction over scanned chunks) — equivalence per RFC0031.1 (the grouped-count maps) holding
  • When the harness runs L4 against each
  • Then ourios.bytes_read / loki.bytes_read ≤ 1 / M_L4
  • And the class disposition is must-win with the same pillar-level escalation as RFC0031.2 on failure — this is the query the template + typed-params pillar exists to serve (§2.3)

Asserted since the 2026-07-18 M_L4 freeze (below, §7): the dispatch run gates the L4 pair on the processed channel at M_L4 = 10 (primary) plus the 1.1× storage-side floor (m_l4_storage_floor_tenths = 11) via l4_gate_failures, and scenario RFC0031.5 pins the frozen values and boundary math (rfc0031_5_l4_frozen_gates). The storage-side must-win at the full margin stated above remains aspirational (measured 3.69–3.73× vs the 10× this Then-clause names) — the freeze records the honest split-channel claim, the same shape as L2’s.

Scenario RFC0031.6 — L5 substring needle is measured and published, loss permitted

  • Given an L5 query for a literal not captured by a template or a promoted column (embedded in a param, so nothing prunes it), equivalence per RFC0031.1 holding
  • When the harness runs L5 against each
  • Then both systems’ bytes_read and latency are recorded with class disposition acknowledged
  • And the run passes irrespective of which system wins — an Ourios loss here does not fail the run and does not escalate, but it must appear in the published benchmarks.md §9 table (a suppressed L5 loss is a process violation)

Scenario RFC0031.7 — L6 broad scan stays within the floor

  • Given an L6 low-selectivity wide-time-range query, equivalence holding
  • When the harness runs L6 against each
  • Then ourios.latency_p50 ≤ F_L6 × loki.latency_p50 where F_L6 is the committed floor factor (§7)
  • And exceeding the floor is a tuning-RFC signal, not a pillar-level escalation

Scenario RFC0031.8 — L7 ingest throughput parity within a stated factor

  • Given the OTLP replay driver feeding both systems to steady state on the same hardware
  • When the harness measures sustained lines/s for each
  • Then ourios.ingest_throughput ≥ loki.ingest_throughput / F_L7 where F_L7 is the committed parity factor (§7)
  • And the WAL-before-ack invariant (CLAUDE.md §3.4) is not relaxed to obtain the number — Ourios’s throughput is measured with durable acks, and the config proving it is recorded

Scenario RFC0031.9 — Storage footprint is recorded as a diagnostic, not a gate

  • Given both systems having ingested the full corpus into the shared bucket
  • When the harness sums each system’s persisted bytes
  • Then both storage_footprint values and their ratio are written to benchmarks.md §9 as a diagnostic row
  • And no pass/fail is derived from it (parity with A1’s RFC 0011 demotion — a byte codec captures redundancy the thesis does not claim on disk)

Scenario RFC0031.10 — The Loki configuration is committed, competent, and machine-checked

  • Given the comparative workstream under bench/comparative/
  • When a third party checks out the repo
  • Then the exact Loki config (index, chunk target size, S3 backend, retention, and the frozen label set), the OTLP-into-Loki config, and the DSL↔LogQL query pairs are present and the whole comparison runs with a single documented command
  • And a test asserts the label set is drawn from a declared low-cardinality allowlist and that trace_id, span_id, and any per-template id are absent — so neither a single catch-all label (forcing Loki into a full scan) nor a high-cardinality label (smuggling Ourios’s promoted columns into Loki’s index) can slip in; the config header states this and invites challenge
  • And each L-gate row in benchmarks.md §9 links the config commit used to produce it

Scenario RFC0031.11 — Losses are published and escalation follows benchmarks.md §7

  • Given a completed comparative run
  • When results are written to benchmarks.md §9
  • Then every class in the taxonomy appears — wins and losses — with its disposition, both systems’ numbers, the corpus, and the hardware tag
  • And an L1, L2, L3, or L4 bytes-read loss on the headline OTel-Demo corpus is recorded as a pillar-level finding that pauses further implementation pending a CLAUDE.md §2 revisit (the §7 amendment), whereas a must-win latency-only loss with a bytes-read win is recorded as a roadmap item

6. Testing strategy

Per CLAUDE.md §6.2, mapped to the §5 scenario ids:

  • Equivalence harness (RFC0031.1) — an integration test over a small committed fixture corpus (not the full OTel-Demo/HDFS fetch) that runs a DSL↔LogQL pair against a containerised Loki and the in-process querier and asserts multiset-equality of the keyed line sets (and grouped-count maps for L4); a deliberately mismatched pair, and a duplicate-count mismatch, both assert the non-zero-exit / no-write path.
  • L-gate computation (RFC0031.2–RFC0031.9) — unit tests over recorded/synthetic per-query metric inputs assert the ratio math, the pass/fail dispositions, and the diagnostic-vs-gating distinction (mirroring RFC 0006’s a1/c2 gate-math unit tests). The margins M_L1, M_L2, M_L3, M_L4, F_L6, F_L7 are configuration, so a calibration test pins their wiring, not their values.
  • Bytes-read metric extension (RFC0031.2–.5) — a querier test asserts the new bytes-read figure equals the summed byte length of the row groups the RFC 0016 path reports as scanned (and excludes pruned), so the primary gate metric is verified against the existing pruning counters rather than trusted.
  • Config machine-check (RFC0031.10) — a test parses the committed Loki + OTLP-path configs, asserts the label allowlist / disallowlist property, and asserts the documented one-command entry point exists and references them.
  • Full comparative run (RFC0031.11) — a workflow_dispatch job (indicative on ci-runner first, authoritative on baseline-8vcpu-32gib on opt-in) ingests the OTel-Demo capture (the headline) and HDFS_v1 (the secondary floor), runs the taxonomy end to end, and appends the §9 table. Not a per-PR gate (it fetches large corpora and runs two systems); it is the RFC-validated step, consistent with benchmarks.md’s authoritative-run cadence.

Validation (benchmarks.md §7): RFC 0031 reaches validated when the authoritative comparative run has been recorded in §9 with L1, L2, L3, and L4 passing on the headline OTel-Demo corpus. A must-win failure does not block validated in the “we didn’t finish” sense — it is a result, and per §5 RFC0031.11 a pillar-level one.

7. Open questions

  • Must-win margins — PARTIALLY FROZEN (2026-07-13, informed by the benchmarks.md §9.13 calibration record — whose channel choice was still open at its writing; this amendment resolves it. Maintainer delegated). M_L1 = 10 and M_L3 = 10 are frozen on the storage-side channel (the conservative one, §3.6 channel definitions): both classes clear it with headroom (L1 77.2–77.7×, L3 21.2–21.9×) across 3–4 consecutive equivalence-verified runs, and both wins are structural rather than tuned. M_L2 is deferred with a named condition: the measured storage-side band is 1.05–1.31× — an honest parity, not a 10× claim — and two named levers (the RFC 0033 cached template map, constant 513,862 bytes per query, and write-side sizing) are expected to move it; freeze after RFC 0033 lands. Until then L2 gates on the processed channel at M = 10 (measured 32.5–39.3×), with the storage-side figure recorded as informational. M_L4 was deferred until L4 was first measured (query shape below) and frozen 2026-07-18 once it was — see the freeze record at the end of the L4 entry below. Rationale for the split channels is the benchmarks.md §9.13 assessment: the storage channel is the conservative claim where we can make it, and the processed channel measures the work the §1 thesis eliminates.

  • M_L2 — UNFROZEN AND FROZEN (2026-07-14, per the named condition above; maintainer delegated as with the 2026-07-13 freeze). The condition is met: RFC 0033’s v2 compressed template map merged (#522) and comparative run #21 measured every pair warm at 187,904 B template-map acquisition against the 513,862 B audit fold it replaces (benchmarks.md §9.15) — a ~326 KB cut off every body-rendering query’s honest total. Frozen values, per channel:

    • Processed channel (primary): M_L2 = 10. Measured 32.5–39.3× across the counted §9.13 runs (#10–#17) on the pre-artifact total, and 37.3–45.1× recomputed on the post-artifact total; like F_L6, the freeze sits well below the weakest measurement. This is the channel the §9.13 assessment named as measuring the work the §1 thesis eliminates — Loki decompresses the corpus slice to answer the predicate; Ourios never fetches those bytes.
    • Storage-side channel: a floor of 1.1× (integer-exact as ourios × 11 ≤ loki_storage × 10; carried as m_l2_storage_floor_tenths = 11). Derived from the record, not remeasured: applying §9.15’s warm acquisition to the §9.13 run-record components gives the post-artifact honest total 2,223,171 B (count 0 + materialize 2,035,267 + registry 187,904), and the §9.13 reproduction rows’ Loki storage band (2,673,545–3,349,897 B) then computes to 1.20–1.51×. The 1.1 floor sits below the weakest computed point with margin for Loki’s documented chunk-boundary wobble, and the pre-artifact total (2,549,129 B, 1.05× on the weakest row) correctly fails it — the floor is not vacuous. This is deliberately a parity-plus floor, not a 10× claim: the deferral’s second named lever (write-side sizing) remains the only route past the 2.04 MB/row page-granularity residual, and the honest storage-side story stays published per RFC0031.11.

    With this freeze, scenario RFC0031.3 asserts (the stub’s #[ignore] is lifted) and the dispatch run gates the L2 family pair on both channels. The run #21 acquisition measurement also turns RFC 0033’s amended §5.6 corpus gate (warm ≤ fold/2, dated 2026-07-14 in that RFC) into a dispatch-run assertion whenever a warm pair exists. M_L4 and F_L7 deferrals are untouched.

  • Floor / parity factors — F_L6 FROZEN, F_L7 DEFERRED (2026-07-13). F_L6 = 3 is frozen on the latency channel, as RFC0031.7 is written: run #18 measured all three window pairs inside the floor (ratios 0.34 / 3.43 / 1.32, oriented loki_p50 / ourios_p50 so > 1 means Ourios is faster; the floor passes at ≥ 1/3 — Ourios outright faster on two of three). Harness alignment (asserting the frozen gates instead of reporting them) lands in the companion slice immediately after this amendment. The window pairs’ bytes figures are reclassified from a gated floor to a published diagnostic (informational bar, benchmarks.md taxonomy): the storage-channel loss (0.003–0.018 across the record; 0.007–0.018 on current code, post-#486) is real, structural to time-partitioned chunks vs columnar layout, small in absolute terms (≤ 4.5 MB), and its only lever is the write-side layout fork — publishing it honestly is the commitment; gating on it would gate on a number we do not intend to chase. F_L7 = 2 stays deferred until L7 (ingest parity) is first measured.

  • L4 aggregation query shape — DECIDED, and RFC0031.1 equivalence amended with a documented completeness margin (2026-07-17; maintainer delegated). The picker chooses (template_id, param, bucket_width) dynamically per corpus (pick_frequency_pair): the first candidate, in ascending (template_id, param) order, that clears every shape floor including L4_MIN_AVG_INTERVAL_SECONDS (below) — first-fit, not an exhaustive search for the single lowest-frequency candidate in the whole corpus (a real ranking pass over every template would cost a ourios_aggregate_answer query per candidate against a corpus with tens of thousands of templates; deliberately not built given first-fit has now found a working, validated candidate three real dispatches running). On the frozen otel-demo-v8 corpus this lands on template_id=60 (“Periodic task <type> generated”), param(0), bucket(12h). The LogQL equivalent is sum by (value) (count_over_time({service_name=~".+"} |= "<needle>" | regexp `<pattern>` [<width>])), with start/end epoch-aligned to bucket_width — Loki’s query_range step-grid evaluates the range vector at each instant t in [start, end), covering (t - width, t]; the instant at exactly start therefore decodes to an empty phantom bucket (nothing in the corpus precedes start by construction) that gets silently dropped, while every real bucket is still fully covered by the remaining evaluated instants — Ourios’s own bucket(width) semantics are epoch-aligned (floor(ts/width)*width) and line up with the same grid. Snapping the query window to a bucket boundary (run #11) closed part of the gap this entry’s margin covers the rest of.

    The harder half of the question — “how is equivalence achievable” — turned out to have no exact answer. Sixteen real dispatches (RFC 0031 L4 workstream, runs #1-#16) fixed three genuine harness bugs early (LogQL escaping, a control-flow ordering bug, a missing row ceiling), then hit a persistent, structural shortfall no further harness fix closed: even after every checkable mechanism narrowed it (an unhelpful results-cache guess, -validation.max-entries-limit, epoch-aligned query windows, a lower-frequency candidate — cutting the loss from ~17.5% to ~4%), Loki never once returned 100% of a real candidate’s expected rows. Runs #13-#16 exhausted every mechanism checkable from the harness’s side, each a hard negative:

    • A plain unaggregated line-filter count came back exactly as short as the count_over_time aggregation path (rules out anything specific to the metric-query shape).
    • A corpus-side check found zero exact (timestamp, body) collisions among a candidate’s matching records (rules out Loki’s documented same-key ingester dedup — the leading theory until disproven directly).
    • The corpus’s one genuine mid-capture event (a kafka container restart, visible as two service.instance.id values under the same container.id) is cleanly sequential with no interleaving.
    • push_corpus_to_loki/push_otlp were read end to end; no drop path exists, and partial_success.rejected_log_records is asserted clean on every push in every run.
    • Loki’s own container stderr carries zero level=warn/level=error lines (bar one harmless startup "empty ring" transient) — nothing Loki considers log-worthy.
    • Loki’s own loki_discarded_samples_total/loki_discarded_bytes_total Prometheus counters — its dedicated accounting for silent/expected discards, incremented even when nothing is logged — never appear in /metrics at all: zero discards, of any kind, for any reason.

    This matches an open, unresolved upstream Loki issue (grafana/loki#10658 and related): wide-time-range queries silently missing a small, consistent percentage of lines, with no error, no discard signal, and no maintainer-identified root cause as of this writing. It is a documented, external, currently-unfixable characteristic of the comparison partner — not a defect in Ourios, this harness’s query construction, or the corpus.

    Decision: L4_COMPLETENESS_MARGIN = 0.90 (harness constant, crates/ourios-bench/tests/rfc0031_comparative.rs) — Loki must capture at least 90% of a candidate’s expected rows, per group_key, for the dispatch to accept the answer. This is real headroom (~2.3×) over the observed 3.9-4.4% loss band (runs #13/#14/#16: 95.6%/95.8%/96.1% complete), not tuned to the exact number. The margin is narrowly scoped, not a general weakening of RFC0031.1 (“equivalence is never optional”): compare_aggregations_within_margin still hard-fails, at any margin, on a phantom cell (a (bucket, group_key) Loki reports that Ourios’s own answer doesn’t contain at all) or any group_key whose total across all its buckets exceeds Ourios’s — the signals that would actually indicate a query-construction or Ourios-side bug. Only aggregate under-counting, up to the margin, is tolerated, and the check is per-key rather than a single grand total specifically so a Loki over-count on one key can’t silently compensate for an under-count on another and still read as “complete.”

    The design went through two real rounds of hardening after this decision was first written:

    • Run #17 (the first real dispatch under the margin) validated it and immediately found a gap: the poll-completion check passed cleanly (1153/1197, 96.3%), but the equivalence check then hard-failed on a single cell landing 1 row over Ourios’s count for that cell (114 vs 113) while the aggregate total stayed a solid under-count — consistent with the same step-grid boundary imprecision already characterized above, not fabrication. The original design checked “Loki > Ourios” on every individual (bucket, group_key) cell, too strict for that kind of noise.
    • PR #536 code review (same day) caught that the run #17 fix — checking only the grand total — opened a different gap: Ourios {A: 100, B: 100} vs Loki {A: 190, B: 10} sums to a “complete” 200/200 while silently hiding A being fabricated to compensate for B being nearly lost. Refined to aggregate ourios/loki by group_key first (summing each key across every bucket it appears in), then apply the phantom/overcount/margin checks per-key: this still tolerates run #17’s exact shape (one bucket’s +1 doesn’t change electPreferred’s own total across its buckets) while rejecting the cross-key redistribution a pure grand-total check missed. Both shapes are now regression tests (margin_comparison_tolerates_inter_bucket_jitter_within_the_same_key, margin_comparison_rejects_cross_key_redistribution_even_at_100_percent_total).
    • Run #18, with the per-key design, is L4’s first fully clean measurement: template_id=60 (“Periodic task”), param(0), bucket(12h), 1197 rows, equivalence held. Storage-side loki/ourios = 3.73×, processed-channel 87.1× — both reported only, per the deferral below.
    • PR #536 code review, round 2 (same day, after run #18): a per-group_key PERCENTAGE margin breaks down at low cardinality. Run #19 (dispatched to confirm the round-1 review fixes) found it directly: a real group_key with exactly 1 total Ourios row, where Loki captured 0 — 0%, below any normal margin, but for n = 1 there is no percentage between 0% and 100% a margin could land on; losing one isolated occurrence is exactly the kind of event the already-characterized ~4-8% aggregate loss rate predicts. Converted the per-key check from a pure ratio to an absolute row tolerance, floored at 1: ceil(ourios_key_total * (1 - margin)) .max(1). This tolerates a cardinality-1 key losing its only row while still catching a real shortfall on a large key (100 rows, tolerance 10, losing 20 still rejects) — regression tests for both ends (margin_comparison_tolerates_losing_a_cardinality_one_keys_only_row, margin_comparison_still_rejects_a_large_key_losing_more_than_its_tolerance).
    • PR #536 code review, round 3 (same day, after run #19): the ceil(...).max(1) formula itself was miscalibrated — Copilot found (independently, twice) that it loosens the margin for every small-but-not-1 total, not just n = 1 (o = 2 at a 90% margin: ceil(2 * 0.1) = 1 tolerance permits losing 1 of 2, i.e. 50% completeness, nowhere near 90%). Switched the non-n=1 case to floor, which cannot round up past what the margin allows — but local verification (cargo test -p ourios-bench --lib) immediately caught a different, self-introduced bug in that fix: floor(40.0 * (1.0 - 0.9)) truncates to 3, not the exact 4, because 1.0 - 0.9 is not exactly representable in f64 (0.09999999999999998) — silently tightening the tolerance at any boundary case where completeness lands exactly on the margin (margin_comparison_tolerates_undercount_within_margin’s svcB case, 36/40 = exactly 90%, started failing). Replaced the subtract-then-round tolerance entirely with a direct, epsilon-guarded comparison — loki_key_total >= ourios_key_total * margin — which avoids that class of rounding error by construction (multiplication accumulates far less relative error here than subtracting two close floats and rounding the remainder). All prior regression tests plus this boundary case now pass together.

    M_L4 FROZEN (2026-07-18, maintainer decision). With the measurement stable — four consecutive equivalence-verified runs (benchmarks.md §9.17) in a 3.69–3.73× storage / 86.5–87.1× processed band on the frozen corpus — the margin freezes on the same split-channel shape as M_L2, from the same kind of evidence (a strong processed win with storage nearer parity):

    • M_L4 = 10 on the processed channel as primary (measured 86.5–87.1× — ~8.7× headroom), and
    • a 1.1× storage-side floor (m_l4_storage_floor_tenths = 11, decided as ourios × 11 ≤ loki × 10; measured 3.69–3.73× — ~3.4× headroom),

    both asserted by the dispatch run (l4_gate_failures, alongside the other frozen §7 gates after every report has printed) and pinned by scenario RFC0031.5 (rfc0031_5_l4_frozen_gates: frozen values, boundary math, the §9.17-recorded measurements clearing both channels). The completeness margin above is unchanged — it conditions what counts as an equivalent answer; this freezes what the bytes must then show. F_L7 remains the only deferred §7 value (until L7 is first measured).

  • Headline corpus — DECIDED: OTel-Demo. Ourios is an OTLP-native backend, so the honest headline is real OTLP logs — the workload the project claims to do best — not the favourable well-templated HDFS_v1. HDFS_v1 is retained only as a secondary well-templated sanity floor (§3.3). A messier real-world captured corpus (sparse-attribute k8s text) is a worthwhile follow-up but not required for the first result. (Maintainer decision, 2026-07-11.)

  • Loki index backend. tsdb (current Loki default) vs boltdb-shipper. Pick the one a competent 2026 operator would deploy; likely tsdb. Confirm against Loki’s current guidance at implementation time.

  • OTLP → Loki path. Loki’s native OTLP endpoint vs an OTel Collector with the loki exporter. Native OTLP is the fairer apples-to-apples (both consume OTLP directly); confirm label derivation is equivalent to the frozen set either way.

  • New crate vs ourios-bench extension. Does the comparative driver + equivalence harness live in ourios-bench or a new bench/comparative/ (non-crate) harness plus a small querier-side metric addition? A new crate is a CLAUDE.md §7 commitment; a harness under bench/ is not. Leaning bench/ + a querier metric extension. Maintainer call.

  • Does this touch docs/hazards.md? The comparison itself adds no runtime hazard, but the bytes-read metric extension touches the RFC 0016 query-metrics path; confirm no regression to those counters.

8. References

  • CLAUDE.md §1 (the existence test — “just use $X”), §2 (pillars #1 Parquet pruning, #2 template mining), §3.4 (WAL-before-ack, held in L7), §7 (new-crate commitment, open question).
  • docs/benchmarks.md §1 (reference systems — amended), §7 (thesis-gate escalation — amended), §8 (out-of-scope: full-text latency), §9 (results shape).
  • RFC 0006 — bench harness (the self-referential thesis-gate methodology this extends; A1/C1/C2 gate-math test pattern reused).
  • RFC 0007 — querier (provides the query path measured here).
  • RFC 0010 — audit-stream / drift queries (template-frequency aggregation precedent the L4 gate builds on).
  • RFC 0011 — A1 demotion to diagnostic (precedent for the storage-footprint diagnostic disposition, RFC0031.9).
  • RFC 0016 — query-serving endpoint and OTel query metrics (scanned/pruned counts, extended here to bytes-read).
  • RFC 0022 — promoted attribute columns (the resource-context pruning L2 exercises).
  • RFC 0023 — bounded template memory (the NO_TEMPLATE fraction on heterogeneous corpora that makes the OTel-Demo corpus the honest hard case).
  • OpenTelemetry Logs specification, Log Correlation (time / execution-context / resource-context correlation — the axes the §3.4 must-win taxonomy is anchored to).
  • Canonical OTLP-log query patterns: clickhouseexporter (severity-count time series, service/attribute filters, substring, trace-id skip index) — the query classes a real OTLP log backend serves.
  • OTel Night Berlin 2025, Leveraging AI for OpenTelemetry data (an OTel-native vendor doing Drain-style template mining on OTLP logs; the template-frequency-alert workload the L4 gate models; the “real k8s logs are messier than OTel-Demo” caveat).
  • Grafana Loki — architecture (label index + chunk store) and the query Summary statistics (totalBytesProcessed, execTime) used for the Loki-side numbers.
  • Jieming Zhu et al., Loghub: A Large Collection of System Log Datasets for AI-driven Log Analytics, ISSRE 2023 (HDFS_v1 corpus; license notice in benchmarks.md §1).

RFC 0032 — Query-schema and cost-model resource


rfc: 0032 title: Query-schema and cost-model resource for the MCP surface (RFC 0027 amendment) status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-13 supersedes: — superseded-by: —

RFC 0032 — Query-schema and cost-model resource for the MCP surface

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

1. Summary

Add a second MCP resourceourios://query-schema — beside the RFC 0027 grammar resource, carrying the stored-log field vocabulary and a query-class cost model: the fixed OTLP log columns (including the OTel 1–24 severity scale the DSL’s severity names compile onto), the promoted attribute columns of this deployment (the running PromotedAttributes set, RFC 0022), and a structural classification of predicate kinds into cost tiers (index-backed / pruned / scan). RFC 0027 is accepted (terminal), so this lands as a new RFC amending it, à la RFC 0022/0023/0024: read-only, additive, no new tool — the existing list_resources/read_resource hook gains one resource. The grammar resource already teaches an agent how to write a query; list_templates gives the body vocabulary; this completes the set with the field vocabulary and with which query shapes the backend answers cheaply.

2. Motivation

  • Agents guess field names; the deployment knows them. An agent can learn the DSL from ourios://dsl-grammar, but the grammar’s resource.<key> / attr.<key> productions are open-ended — which keys exist as typed, prunable columns is per-deployment configuration (storage.promoted_attributes) that no client can guess. OTel field practice supports the claim: an OTel-native vendor doing Drain-style mining reports that handing an agent semantic-convention + resource-attribute context makes it “way better at writing correct queries on the first try” (OTel Night Berlin 2025, sig-end-user transcript). Issue #465 is the scoping record.
  • The severity scale is a first-try failure mode. severity >= error only works if the client knows severity is the numeric OTel SeverityNumber and the names are four-wide bands (error → 17..=20). That mapping is Ourios’s documented choice (RFC 0002 §6.1, SeverityName::floor/ceil); it belongs in the protocol, not in each consumer’s prompt.
  • The cost model is structural, and agents can exploit it. The RFC 0031 comparative program (§9.13, epic #498) demonstrated the durable shape of Ourios query cost: exact-id lookups and promoted equality prune to a handful of row groups, time windows prune by statistics, body-substring browses scan. That tiering is true by construction — it follows from which columns the writer bloom-filters and page-indexes — so it can be published as structure without ever publishing benchmark numbers (which rot). A consumer that knows trace_id == … is cheap and contains(body, …) is a scan writes better queries at zero per-query cost to the backend, and is steered onto the backend’s structural strengths (pillar #1/#2, CLAUDE.md §2).
  • Cheap by construction. The list_resources/read_resource hook, the config plumbing (storage.promoted_attributes is already resolved into a PromotedAttributes at startup, ourios-server/src/main.rs), and the tier facts (which columns are bloomed, ourios-parquet/src/writer.rs) all exist. The new work is one JSON document and threading the promoted set into the querier role’s MCP handler.

3. Proposed design

3.1 Placement

  • A second Resource in the RFC 0027 module (crates/ourios-server/src/mcp.rs), URI ourios://query-schema, MIME application/json. list_resources returns both resources; read_resource dispatches on URI.
  • The querier role today does not receive the resolved PromotedAttributes (only the receiver’s write path does). The server threads config.promoted into mcp_routerOuriosMcp, and the resource document is built once at role startup from that set — configuration is startup-static (RFC 0020), so the document is immutable for the process lifetime, like the grammar section.
  • Read-only contract untouched: no new tool, no write, no tenant-scoped data. The document derives exclusively from static configuration and compiled-in schema facts — never from ingested telemetry, so it is the one MCP payload that carries no untrusted-content caveat.

3.2 The document

The resource body is one versioned JSON object (format_version evolution hook per the RFC 0033 precedent: consumers treat an unknown version as “fetch nothing, fall back to the grammar + docs”):

{
  "format_version": 1,
  "fields": [
    { "name": "ts",          "type": "timestamp" },
    { "name": "observed_ts", "type": "timestamp" },
    { "name": "severity",    "type": "integer" },
    { "name": "body",        "type": "string" },
    { "name": "trace_id",    "type": "hex_string" },
    { "name": "span_id",     "type": "hex_string" },
    { "name": "scope",       "type": "string" },
    { "name": "flags",       "type": "integer" },
    { "name": "service",     "type": "string" },
    { "name": "template_id", "type": "integer" },
    { "name": "confidence",  "type": "float" },
    { "name": "lossy",       "type": "boolean" }
  ],
  "severity": {
    "comparison": "numeric, OTel SeverityNumber 1-24",
    "names": [
      { "name": "trace", "floor": 1,  "ceil": 4 },
      { "name": "debug", "floor": 5,  "ceil": 8 },
      { "name": "info",  "floor": 9,  "ceil": 12 },
      { "name": "warn",  "floor": 13, "ceil": 16 },
      { "name": "error", "floor": 17, "ceil": 20 },
      { "name": "fatal", "floor": 21, "ceil": 24 }
    ]
  },
  "promoted_attributes": {
    "resource": ["service.name", "k8s.namespace.name"],
    "log": ["http.route"]
  },
  "cost_model": {
    "tiers": ["index_backed", "pruned", "scan"],
    "classification": [
      { "kind": "exact_equality", "fields": ["trace_id", "span_id", "template_id"],
        "tier": "index_backed", "mechanism": "bloom" },
      { "kind": "ordering_or_equality", "fields": ["severity"],
        "tier": "index_backed", "mechanism": "statistics" },
      { "kind": "promoted_attribute_equality",
        "fields": ["service", "resource.<promoted key>", "attr.<promoted key>"],
        "tier": "index_backed", "mechanism": "bloom" },
      { "kind": "time_window", "fields": ["ts", "observed_ts"],
        "tier": "pruned", "mechanism": "statistics" },
      { "kind": "non_promoted_attribute_predicate",
        "fields": ["resource.<other key>", "attr.<other key>"],
        "tier": "scan" },
      { "kind": "body_substring_or_regex", "fields": ["body"],
        "tier": "scan" },
      { "kind": "unscoped_browse", "fields": [],
        "tier": "scan" }
    ]
  }
}

Normative content rules:

  • fields — exactly the RFC 0002 §7 field production (the DSL surface, not the raw Parquet schema; hazard §4.6 — the resource must not leak storage columns the DSL does not expose). Each entry MAY carry a short description string; the shape above is the minimum.
  • severity — the six names with their floor/ceil bands MUST equal the DSL’s SeverityName::floor/ceil mapping (crates/ourios-querier/src/dsl/ir.rs): ordering comparisons use the floor, equality tests the band. This is the resource’s answer to “how do I write severity >= ERROR”.
  • promoted_attributes — the effective running set from the threaded PromotedAttributes (resource_keys() / log_keys()): service.name always present and first, configured keys after, in the deduplicated config order. This is the per-deployment half an agent cannot guess, and it is what makes the cost_model deployment-specific: promoted_attribute_equality is index-backed for exactly these keys, in this instance; the same predicate on any other key is non_promoted_attribute_predicate (the RFC 0022 §3.3 JSON-LIKE fallback — correct, unpruned).
  • cost_model — structure only, never numbers: no latencies, no byte counts, no ratios. The tier facts are true by construction of the writer:
    • bloom mechanism entries correspond one-to-one to the columns writer.rs actually bloom-filters today: template_id (RFC 0005 §3.6), trace_id/span_id (the RFC 0031 L3 fix), and every promoted attribute column (RFC 0022 §3.1).
    • severity carries no bloom filter — its predicates prune through min/max page statistics (ordinal data, where statistics are the right index); the resource says statistics, not bloom, because claiming index-backing that the writer does not provide is exactly the drift RFC0032.4 gates against.
    • time_window is the range(t1, t2) stage pruning on the time columns’ statistics; unscoped_browse (no range stage beyond the default look-back) and body substring/regex predicates are scans — expensive, still correct.

3.3 Tool-description placement rule

Each of the three RFC 0027 tool descriptions gains one advisory sentence pointing at the resource, e.g. for query_logs: “Read the ourios://query-schema resource first for the queryable fields, the severity scale, and which predicates are index-backed.” The full tiering lives only in the machine-readable resource — tool descriptions are prompt real estate in every client context, and the tiers would otherwise be paraphrased into prose that drifts. One pointer, one source of truth.

3.4 What this RFC does not change

No Parquet schema change, no DSL change, no new tool, no new crate, no change to any RFC 0027 tool’s arguments or output. The RFC 0027 §5 suite must pass after this lands, with only the §3.1 two-resource amendment applied (RFC0032.6 pins the exact contract).

4. Alternatives considered

  • Static-only resource (fixed columns + severity scale, no config plumbing — issue #465’s first fork). Trivial to ship, but it omits exactly the half an agent cannot guess: which resource.<key>/attr.<key> predicates are typed, prunable columns here. Without the promoted set the cost model cannot be stated honestly either (promoted equality and non-promoted fallback land in different tiers). Rejected; the plumbing is one threaded value.
  • Put the schema in the tool descriptions. Descriptions ship into every client’s context on tools/list; a schema + cost table there is paid on every session and invites clients to treat prose as data. A resource is fetched on demand and machine-readable. Rejected — this RFC pins the one-advisory-sentence rule instead (§3.3).
  • Extend the grammar resource instead of adding a second one. The grammar resource’s contract is byte-identity with RFC 0002 §7 (RFC0027.6) — appending deployment-specific JSON would break that invariant and mix a static doc with dynamic config. Rejected.
  • A describe_schema tool. Tools imply arguments and per-call work; this content is constant per process and tenant-independent. MCP resources exist precisely for this. Rejected (also keeps the RFC 0027 deny-list — “exactly the §3.2 three tools” — intact).
  • Serve ourios-semconv names. Wrong vocabulary: that crate holds Ourios’s own emitted-telemetry names (how the backend describes itself), not the stored-log query surface (issue #465 notes this explicitly). Rejected.
  • Include benchmark-derived cost numbers. The RFC 0031 numbers are corpus- and channel-dependent and rot with every writer change; the tier structure is what is durable. Rejected — the cost model is structural by rule (§3.2).

5. Acceptance criteria

Scenario ids RFC0032.<m>, referenced from test code.

Scenario RFC0032.1 — listed and readable. Given querier.mcp.enabled, When a client lists resources, Then exactly two resources are advertised — the RFC 0027 grammar resource and ourios://query-schema (application/json); When the client reads ourios://query-schema, Then the body parses as JSON with format_version: 1 and carries the §3.2 top-level keys (fields, severity, promoted_attributes, cost_model); And tools/list still advertises exactly the RFC 0027 §3.2 three — no new tool.

Scenario RFC0032.2 — content matches the running config. Given storage.promoted_attributes configured with resource and log keys, When the resource is read, Then promoted_attributes equals the effective PromotedAttributes set — service.name first, configured keys deduplicated in order; And with the section omitted, promoted_attributes.resource is ["service.name"] and .log is empty; And two servers with different promoted sets serve different resource bodies (the per-deployment property).

Scenario RFC0032.3 — severity scale correctness. Given the resource body, Then the severity.names entries equal the DSL’s SeverityName mapping — for each of the six names, floor equals SeverityName::floor and ceil equals SeverityName::ceil — the test asserts against the ourios-querier functions, not repeated literals, so the resource cannot drift from the compiler.

Scenario RFC0032.4 — cost-tier classification stability. Given the resource body, Then every cost_model.classification entry with mechanism: "bloom" names only columns the writer actually bloom-filters — the test derives the expected set from the writer’s properties for the configured PromotedAttributes (template_id, trace_id, span_id, and every PromotedAttributes::column_names column) and asserts the resource’s index-backed equality kinds cover exactly the DSL fields backed by that set; And severity’s entry carries mechanism: "statistics", never "bloom"; And no classification entry carries a numeric cost value (structure, never numbers).

Scenario RFC0032.5 — tool-description placement. Given tools/list, Then each of query_logs, list_templates, and template_drift carries exactly one advisory sentence naming ourios://query-schema, And no tool description enumerates tiers, severity bands, or promoted keys (the full tiering lives only in the resource).

Scenario RFC0032.6 — read-only contract preserved. Given the amendment applied, Then the RFC 0027 §5 suite passes with the §3.1 two-resource amendment applied (same tools, same outputs, grammar byte-identity and mime assertions intact — the one relocated assertion is rfc0027_6_grammar_resource’s exactly-one-resource count, which moved to RFC0032.1’s exactly-two; the grammar test now locates the grammar among the advertised resources), And reading ourios://query-schema performs no query, touches no tenant data, and its body contains no ingested-telemetry-derived content; And an unknown resource URI still returns the resource-not-found error.

6. Testing strategy

Mapped to CLAUDE.md §6.2:

  • RFC0032.1/.2/.5/.6 — integration tests in crates/ourios-server/tests/it/rfc0027_mcp.rs’s harness shape (in-process router, MCP JSON-RPC over /mcp): resources/list, resources/read, tools/list against servers built with distinct storage.promoted_attributes configs; .6 additionally re-runs the existing RFC 0027 suite untouched (tests are specifications — none may be weakened).
  • RFC0032.3/.4 — unit tests beside the resource builder in mcp.rs, asserting against SeverityName::floor/ceil and against the writer-properties bloom set derived from the same PromotedAttributes value, so both halves of the document are pinned to the code they describe rather than to literals.
  • At validation, the RFC 0027 §5.2 precedent applies: the official MCP inspector CLI (an independent client) lists and reads the resource against the served release binary, extending scratch/validation/rfc0026-0027-validate.sh.

7. Open questions

  • Template-vocabulary hints. Should the resource carry a pointer at (or a sample of) the template vocabulary, or does list_templates already cover the body-shape half cleanly? Current position: the resource stays tenant-independent and static per process; templates are per-tenant, queryable data and belong to the tool. Confirm before green.
  • Config reload. Configuration is startup-static today, so the document is built once. If a future RFC makes storage.promoted_attributes reloadable, the resource must follow and MCP listChanged/subscription semantics become relevant — out of scope here, but the once-at-startup build is the assumption to revisit.
  • severity_text exposure. The stored schema carries severity_text, but the DSL deliberately compares on the numeric scale (RFC 0002 §6.1). If the DSL ever exposes it, the resource’s fields follows the grammar automatically — noting so the two don’t drift silently.
  • Tier vocabulary stability. index_backed/pruned/scan are this RFC’s names; if RFC 0031’s docs settle on different public terminology for the query classes, align before green (renames after clients consume the resource cost a format_version bump).

8. References

  • Issue #465 — the scoping record, including the 2026-07-13 maintainer comment adding the query-class cost model and the placement rule.
  • RFC 0027 — the MCP query surface this RFC amends (accepted, terminal); §3.2 resource precedent, §5.2 inspector-validation precedent.
  • RFC 0022 — promoted attribute columns: PromotedAttributes, storage.promoted_attributes, the promoted-vs-fallback compile split the cost model encodes (crates/ourios-parquet/src/promoted.rs).
  • RFC 0002 §6.1/§7 — the DSL field surface and the severity name→number choice (crates/ourios-querier/src/dsl/ir.rs, SeverityName).
  • RFC 0005 §3.6 / RFC 0031 (L3, trace-context blooms) — the bloom set the tiers rest on (crates/ourios-parquet/src/writer.rs).
  • RFC 0033 §3.2 — the format_version evolution-hook precedent for small versioned JSON artifacts.
  • OTel Logs Data Model — SeverityNumber 1–24 and the compare-on-number mandate.
  • OTel sig-end-user, OTel Night Berlin 2025 transcript — the schema-context-for-agents motivation.
  • CLAUDE.md §2 (pillars #1/#2 — the pruning structure being published), §4.6 (DSL vs engine leakage — the resource describes the DSL surface only), §3.7 (tenancy — the resource is tenant-independent by design).

RFC 0033 — Cached template-map artifact


rfc: 0033 title: Cached template-map artifact status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-12 supersedes: — superseded-by: —

RFC 0033 — Cached template-map artifact

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

1. Summary

Discharges the RFC 0005 §3.7.1 deferral: a per-tenant cached fold of the audit stream — one artifact carrying both the RFC 0017 §3.2 template registry and the RFC 0005 §3.7.1 alias map — published to object storage next to the audit files it is derived from. The audit stream remains the source of truth; the artifact is a derived acceleration, valid only when the exact audit-file set it folded equals the tenant’s live audit-file listing, discardable at any time, and bypassed (fresh fold, exactly today’s behaviour) whenever absent, stale, torn, or unreadable. Publication follows the RFC 0009 §3.4 write-tmp-then-atomic-swap precedent; the writer is the querier itself, write-through after a cache-miss derivation. This replaces a per-query read of the tenant’s entire audit history — measured at a constant 513,862 bytes per body-rendering query in RFC 0031 comparative run #8 — with one small object GET.

2. Motivation

2.1 The deferral condition has been met — and measured

RFC 0005 §3.7.1 pinned v1 as “no persisted per-tenant artifact: the audit stream is the alias store”, and deferred the cached map with an explicit escape hatch — “a pure recovery/latency cache over this derivation”, to be designed when it measurably matters. RFC 0017 §3.6 took the same stance for the template registry: “O(audit events), the same cost profile as the alias map, acceptable for v1.”

It now measurably matters. RFC 0031’s honest total-bytes accounting (§3.6, measurement-fidelity amendment 2026-07-12) counts the registry derivation into every comparative query’s bytes_read, and comparative run #8 (2026-07-12, otel-demo-v8 corpus, 4.9 M records) measured it at a constant 513,862 bytes per query: every query that renders bodies calls template_registry::derive_template_registry_measured, which walks audit_scan::read_all_events over the tenant’s entire audit subtree — every audit Parquet file, full-object GETs on the S3 backend — before answering. The alias map (alias_store::derive_alias_map) folds the same full stream the same way at query-compile time whenever the DSL uses resolves_to.

Three properties make this a tax worth an RFC rather than a shrug:

  1. It is per-query. The fold is derived once per executed query (RFC 0017 §3.2), so the 514 KB is paid on every body-rendering query, not amortised.
  2. It grows with tenant age, not query selectivity. The audit stream is append-only; every widening, type expansion, creation, and alias assertion in the tenant’s history adds to it. A selective query over a day of data pays for the tenant’s entire template history. This is the inverse of the pruning thesis (CLAUDE.md §2 pillar #2): the data scan shrinks with selectivity while the registry scan only ever grows.
  3. It is now inside the headline metric. RFC 0031’s L-gates gate on total bytes read from object storage. A constant ~514 KB floor under every Ourios query is a direct, growing drag on the project’s existential comparison.

2.2 Why this layer

The fix belongs at the derivation seam, not in the fold semantics: derive_template_registry / derive_alias_map already sit behind narrow functions whose contract is “the fold of the tenant’s audit history in the §3.7.1 total order”. Caching the result of that contract keyed on the exact inputs changes no query-visible semantics — the same “v1 full-replay now, accelerate later, no format change” shape RFC 0001 §6.9 pinned for the miner snapshot and RFC 0005 §3.7.1 explicitly promised for this artifact.

3. Proposed design

3.1 One artifact, not two

The registry and the alias map are folds of the same append-only audit stream, resolved through the same audit_scan::audit_files walk, with the same total fold order and the same validity domain (the exact set of audit files folded). They ship as one artifact:

  • One freshness check (one LIST comparison) instead of two.
  • One atomic publish, so the two folds can never disagree about which frontier they reflect — a split artifact could serve a registry at frontier F1 and an alias map at F2 in the same query.
  • The dominant cost being replaced is the shared read_all_events byte scan, not the per-fold CPU; splitting buys nothing there.

The alternative (two artifacts, independently refreshed) is recorded in §4.

3.2 The artifact — format and location

A single JSON object per tenant, named template_map.json (legacy v1 encoding — key and transport encoding superseded by the 2026-07-13 amendment at the end of this section; the JSON structure below is unchanged, but v2 carries format_version: 2 and ships zstd-compressed at the v2 key), living at the root of the tenant’s audit subtree:

audit/tenant_id=<percent-encoded>/template_map.json
  • Object storage per CLAUDE.md §3.6 — the artifact lives on the same store as the audit files it folds; local disk holds no copy the store does not.
  • Tenant-scoped path per CLAUDE.md §3.7 — under the same tenant_id= partition key the audit walk is already scoped to.
  • Invisible to every existing reader by construction: the local audit walk collects only *.parquet entries and the S3 listing filters ends_with(".parquet") (audit_scan.rs), so a JSON object in the subtree contributes nothing to any deployed binary’s scan. This is the additive-artifact property §3.6 relies on.

JSON follows the manifest.json precedent (RFC 0009 §3.4): small, human-inspectable, serde-round-tripped, no Parquet machinery for a kilobyte-scale object. Content:

{
  "format_version": 1,
  "tenant_id": "acme",
  "folded_files": ["year=2026/month=07/day=11/….parquet", "…"],
  "registry": [
    { "template_id": 7, "version": 1, "template": "user <*> logged in" },
    { "template_id": 7, "version": 2, "template": "user <*> logged <*>" }
  ],
  "alias_map": [
    { "representative": 3, "members": [3, 9, 12] }
  ]
}
  • folded_files — the frontier: the exact audit *.parquet file set the folds consumed, as store-relative keys under the tenant’s audit root, sorted lexicographically. Audit files are immutable once committed (uncommitted writers use *.parquet.tmp, which the walk already ignores), so set equality is a complete validity condition — no per-file ETags needed.
  • registry — the RFC 0017 §3.2 (template_id, version) → tokens map, template encoded in the canonical space-joined format_template form the audit stream itself stores (the exact input parse_template already consumes), so the artifact adds no second token encoding to the system.
  • alias_map — the folded RFC 0001 §6.7 equivalence classes, one entry per class, representative = min(members) as §6.7 defines. Storing the folded classes rather than the event log keeps the reader a deserialization, not a re-fold.
  • tenant_id — the row-vs-path discipline (RFC 0005 §3.9, CLAUDE.md §3.7) applied to the artifact: a reader MUST verify it matches the tenant whose path it was fetched from and fail loudly on mismatch, exactly as read_all_events does for audit rows.
  • format_version — evolution hook: a reader encountering an unknown version treats the artifact as absent (fresh fold, then republish at its own version), mirroring the RFC 0005 §3.9 unknown-column tolerance. No migration of old artifacts is ever required because the artifact is derived and discardable.

Size bound. The registry is bounded by RFC 0023’s bounded template memory (per-tenant template count is capped) times the per-template version count; templates are token strings, not log data. The alias map is rare operator actions. The artifact is therefore expected to be kilobytes-to-low-megabytes, and on any tenant with meaningful history substantially smaller than the audit stream it folds (which carries the same templates plus envelopes, samples, hashes, and full history) — but this is a measured expectation, not a guarantee: on a very small tenant the frontier list and JSON envelope can exceed the audit bytes. The publisher therefore abstains when the serialized artifact is not smaller than the audit bytes just folded (nothing to win) or exceeds a configured size ceiling; the exact ceiling is an open question (§7). Abstention costs nothing — the no-artifact path is today’s behaviour.

Amendment (2026-07-13, run #20 / §9.14): compressed artifact encoding — format_version 2. Comparative run #20 (docs/benchmarks.md §9.14) showed the v1 encoding losing to its own guard on the headline corpus: on otel-demo-v8 the uncompressed JSON of every (template_id, version) canonical template meets or exceeds the 513,862 B zstd-compressed Parquet fold it must undercut, so the size abstention correctly refused every publish and the corpus never ran warm (RFC0033.6’s corpus arm undischarged, status green → red). The defect is transport encoding, not structure; this amendment changes only how the bytes ship.

  • Encoding. The artifact body is the §3.2 JSON object, zstd-compressed as a single frame. The JSON structure, field meanings, canonical sort orders, and validation rules above are unchanged — only the bytes on the wire are. The compression level is the crate default (3), an implementation constant, not configuration: the object is kilobyte-scale and written once per miss, the template-string JSON is highly redundant (the same strings zstd-compress into the 513,862 B audit Parquet with their full event history alongside), so the needed order-of-magnitude win does not hinge on the level; raise the constant in code if run #21 measures the ratio marginal.
  • Key: template_map.v2.json.zst, same tenant audit root (audit/tenant_id=<enc>/template_map.v2.json.zst) — the version moves into the key. A pre-amendment reader GETs only template_map.json, so for it the v2 artifact is literal absence — genuine fresh fold, correct by §3.3’s design, with honest telemetry. A post-amendment reader GETs only the v2 key. Both keys stay invisible to every audit walk by construction (local walk keeps only extension == "parquet" entries, S3 listing filters ends_with(".parquet")audit_scan.rs; the filter ignores any number of non-Parquet keys, so a second one changes nothing). Future encoding-affecting bumps repeat the pattern: new key, new in-body version, best-effort delete of the predecessor (§3.4 amendment).
  • Rejected: same key, magic-frame sniff. The alternative keeps template_map.json and has readers sniff the zstd frame magic (0xFD2FB528) before JSON parse. It is correct — an old reader GETs the compressed body, fails JSON parse, classifies Torn, folds fresh, self-heals — but it lies twice: §3.7 pins torn as the RFC 0008-style corruption signal, so a mixed-version fleet would page on healthy state for as long as one old binary keeps querying; and §3.3’s UnknownVersion disposition — designed for exactly this evolution — is unreachable there, because the parse fails before the version probe runs. A .json key carrying zstd bytes also misnames the object. The one cost of the new-key route — a bounded second key during the mixed-version window, deleted best-effort — is cheaper than a permanently lying corruption signal.
  • format_version: 2, in the decompressed body. The version names the whole artifact contract including the transport encoding, not just the JSON shape: TEMPLATE_MAP_FORMAT_VERSION becomes 2, and the probe runs on the decompressed bytes as defense-in-depth — a v1 body planted at the v2 key (or a decompressed v2 body at the v1 key) classifies UnknownVersion → treated as absent, harmless, per §3.3’s rule.
  • Dispositions at the v2 key (§3.3 table unchanged in spirit): not a zstd frame, failed decompression, or post-decompression parse/validation failure → Torn; decompressed format_version ≠ 2 → UnknownVersion; tenant_id mismatch → loud failure. Everything else as tabulated.
  • Abstention, restated (unchanged in spirit). Publish iff the compressed artifact byte size is smaller than the audit bytes just folded: the comparison is between the bytes a warm GET would pay and the bytes the fold just paid — the v1 rule applied to the bytes actually shipped.
  • Telemetry (§3.7). The artifact-size histogram records the compressed (published-object) bytes — the GET cost, which is what the instrument always measured (it records the published bytes, and those are now compressed). Lookup and publish outcome values are unchanged.
  • Reading rule. References to template_map.json elsewhere in this RFC (§3.3–§3.5, §5, the §3.4 diagram) read as the versioned key post-amendment; the local tmp is template_map.v2.json.zst.tmp (extension tmp, ignored by the walk as before).
  • Dependency. Zero new dependencies: zstd 0.13 is already compiled into every querier build (parquet 58’s zstd feature via ourios-parquet, and arrow-ipc), and ourios-bench already binds the crate directly as the A1 reference codec (RFC 0006 §3.4.1). Adding zstd = "0.13" to ourios-querier introduces no new transitive crate and passes the existing cargo-deny license gate unchanged.
  • Validation: comparative run #21, before merge. The amendment is validated by a comparative dispatch from the implementation branch before it merges (the measure-before-merge workflow), and the harness MUST print each pair’s publish outcome explicitly — published (with the compressed size), abstained (with the would-be size vs. the folded audit bytes), lost_race, or error — so run #20’s ambiguity (abstention and publish IO failure both leaving the same “no artifact” label) cannot recur.

3.3 Freshness — the frontier check

The audit stream is append-only, so cache validity is exactly:

the artifact’s folded_files set equals the tenant’s live audit-file listing at read time.

The read path becomes:

  1. List the tenant’s audit *.parquet set (the existing audit_files walk / prefix LIST — no GETs).
  2. GET template_map.json. If absent, torn (JSON parse failure), unknown format_version, or tenant_id-mismatched — see dispositions below.
  3. If folded_files == the live set (set equality; both sides are sorted-unique already): cache hit — deserialize, use.
  4. Otherwise (new files appended, or files removed by a future retention/GC): stale — fall back to the fresh fold over the live set, exactly today’s read_all_events path, then write-through (§3.5).

Dispositions, pinned:

ConditionDisposition
Artifact absentFresh fold (today’s behaviour), write-through
Frontier ≠ live setFresh fold, write-through at the new frontier
Torn / unparseable JSONTreat as absent; fresh fold, write-through overwrites; emit telemetry (§3.7)
Unknown format_versionTreat as absent (forward compat)
tenant_id mismatchFail the query loudly — corrupt or foreign object under the tenant’s root, same stance as the audit row-vs-path backstop

The first four never produce a wrong answer — every non-hit path is the v1 fold. The stale-cache fallback is re-derive, never serve stale: a hit reflects exactly the events a fresh fold at the same listing would, so the RFC 0005 §3.7.1 consistency bound (audit-flush visibility) is unchanged by this RFC. The only ordering requirement is LIST-before-GET-is-compared: the frontier comparison uses one listing, taken once, for both the validity check and the fallback fold, so a file appearing mid-query affects a cached and an uncached query identically.

Amendment (2026-07-13, run #20 / §9.14). At the v2 key (§3.2 amendment), “torn / unparseable JSON” includes a missing zstd frame or a failed decompression, and the unknown-format_version probe runs on the decompressed bytes. The table’s dispositions and the LIST-before-GET rule are otherwise unchanged. Note the version-in-key choice means a pre-amendment reader never fetches a v2 artifact at all: for old binaries the encoding bump manifests as literal absence — the cleanest possible realization of the unknown-version-is-absent rule this table was designed around.

3.4 Atomic publish — the RFC 0009 §3.4 precedent

The artifact is published the way the compaction manifest is committed:

  • Local backend: write template_map.json.tmp, rename into place (Manifest::write_atomic shape). A crash mid-write leaves the prior artifact (or its absence) authoritative and a harmless .tmp for the GC sweep; the rename is the only visibility point.
  • S3 backend: single-object conditional put (Manifest::publish_cas shape). Object stores make the whole PUT visible atomically; the conditional (create / ETag-match) precondition prevents interleaved writers from tearing each other.

Unlike the manifest, a lost race is harmless here: every writer publishes a correct fold of some frontier, the reader verifies the frontier independently at every read (§3.3), and a superseded artifact is simply detected stale on the next query and rewritten. So on CAS conflict the loser discards its write and moves on — no retry loop, no error. The manifest needed CAS to prevent lost updates of authoritative state; the cache needs only atomicity of the object itself, and gets CAS cheaply because the primitive already exists.

Amendment (2026-07-13, run #20 / §9.14). The publish targets the v2 key (template_map.v2.json.zst; local tmp template_map.v2.json.zst.tmp, same rename; same CAS ladder on S3, the expectation being the v2 key’s observed ETag or create-if-absent), and on a successful publish best-effort deletes the stale v1 template_map.json key. The delete is unconditional (no CAS needed — any v1 artifact is derived and discardable by definition) and never a query failure; a crash or failure between publish and delete leaves both keys, which is harmless: each reader population GETs only its own key and verifies the frontier at every read, and the next successful v2 publish retries the delete implicitly. During a mixed-version window an old binary’s write-through may republish the v1 key (on tenants where the uncompressed artifact still beats the fold); correctness is unaffected — each version population maintains its own cache — and hygiene converges once old binaries retire.

sequenceDiagram
    participant Q as Querier
    participant FS as Object store (tenant audit subtree)
    Q->>FS: LIST audit/tenant_id=t/*.parquet → live set S
    Q->>FS: GET template_map.v2.json.zst
    alt hit — folded_files == S
        Q->>Q: deserialize registry + alias map (no audit GETs)
    else miss / stale / torn / unknown version
        Q->>FS: GET every audit file in S (today's fold)
        Q->>Q: fold registry + alias map (§3.7.1 order)
        Q-->>FS: publish template_map.v2.json.zst @ frontier S (tmp+rename / CAS, best-effort)
    end
    Q->>Q: answer the query (identical either way)

3.5 Who writes it — querier write-through

Position: the querier publishes, write-through, after every cache-miss derivation. After a fresh fold (miss or stale), the querier serializes the fold it already holds plus the frontier it already listed, and publishes best-effort — a publish failure is telemetry, never a query failure.

Both folds, one scan — never a partial artifact. The two derivation call sites are asymmetric (body rendering derives only the registry; only resolves_to queries derive the alias map), so a naive write-through after a registry-only miss would publish an artifact with an empty alias fold that a later alias query would trust. The miss path therefore folds both maps from the single read_all_events capture it already paid for — the marginal cost is CPU over in-memory events, zero extra IO — and publication of a partially populated artifact is forbidden by construction. RFC0033.1’s property test covers both folds, and RFC0033.6’s integration arm includes a body-rendering query followed by a resolves_to query against the artifact the first one published.

Rationale:

  • Zero extra derivation work. The fold and the frontier are in hand at exactly the moment of publish; no component re-derives anything to warm the cache.
  • No ingest-path coupling. The WAL-before-ack hot path (CLAUDE.md §3.4) gains no IO, no new failure mode, and no knowledge of reader-side fold semantics.
  • Warms exactly where it pays. Tenants that query get a warm cache after the first miss; tenants that never query never pay a publish.
  • Self-healing. Any wrong, torn, or ancient artifact costs one fresh fold and is overwritten on the same query.

The consequence to own honestly: on an actively-mutating tenant (templates still being widened), every mutation staleness-misses the next query, which pays one fresh fold plus one publish. That is today’s cost plus a small PUT — never worse than v1 by more than the publish — and template mutation decays as a tenant’s template set converges (the miner’s convergence thesis, RFC 0001). The ingester-side write-through at mutation time is the recorded alternative (§4, §7).

3.6 Back-compat — additive and advisory (CLAUDE.md §3.5)

This RFC changes no Parquet schema: no columns added, removed, renamed, or retyped. The artifact is a new single JSON object whose name no existing code path matches (§3.2). Concretely:

  • Old binaries, new stores: deployed readers filter *.parquet; they never see the artifact and behave byte-for-byte as today.
  • New binaries, old stores: artifact absent → fresh fold, today’s behaviour, then write-through.
  • Deletion at any time: an operator (or a GC policy) may delete template_map.json unconditionally; the sole cost is one re-derivation. Nothing durable depends on it.
  • The audit stream remains the single source of truth for template and alias history — this RFC makes that normative for the cache: no code path may treat the artifact as authoritative over the stream, and any doubt (parse failure, unknown version, frontier mismatch) resolves by folding the stream.

3.7 Observability (CLAUDE.md §6.3)

Via OTel meters on the existing querier metrics path, names to be minted through the semconv registry (weaver):

  • cache lookups, keyed by outcome (hit / miss / stale / torn / unknown_version) — the torn/unknown outcomes are the RFC 0008-style corruption signal for a derived artifact;
  • publishes, keyed by outcome (published / lost_race / error);
  • the artifact byte size at publish (the number RFC0033.6 gates on).

QueryResult::registry_bytes_read is today documented as bytes fetched from the tenant’s audit stream; a cache hit fetches the artifact instead, and the artifact carries the alias fold and frontier alongside the registry. At green this RFC therefore amends the field’s contract (and RFC 0031 §3.6’s wording) to template-map acquisition bytes: the total bytes fetched to obtain body-rendering capability, whatever the source — the audit-stream fold on a miss, the artifact GET on a hit. One field, one honest meaning, no separate channel; the comparative harness needs no code change, and the alternative (a separate artifact-bytes field with the old field pinned to audit-stream-only) is recorded in §4.

4. Alternatives considered

Two artifacts (registry and alias map separately). Independent refresh would let an alias assertion invalidate only the alias artifact. Rejected: both folds share one byte-dominant input scan and one validity domain; splitting doubles the freshness checks and publish points, and admits frontier divergence between the two folds inside a single query (§3.1). Alias events are also so rare that independent refresh buys nothing measurable.

Ingester write-through at template-mutation time. The component that emits the audit event updates the artifact in the same breath, so queries never miss. Rejected for v1: it puts derived- artifact IO and reader-side fold semantics on the ingest path (against §3.4’s discipline of keeping the hot path minimal), it publishes on every widening for tenants nobody queries, and under ingester/querier role separation the ingester would need the querier’s fold code. Recorded as the natural v2 if miss-rate telemetry (§3.7) shows mutation-driven staleness dominating. §7.

A background refresher (compactor-style loop). A periodic task re-folds and republishes per tenant. Rejected: it adds a scheduling component and a staleness window policy for something the write-through gets for free at the moment of demand, with the freshness check making the window irrelevant to correctness anyway.

Parquet instead of JSON for the artifact. Consistency with the data plane and columnar compression. Rejected: the object is kilobyte-scale, read whole or not at all, never predicate-pushed; manifest.json set the precedent that flat derived metadata is JSON. Parquet here is machinery without a query.

Incremental fold on staleness (fold only the new files onto the cached state). Attractive — staleness usually means a few appended files. Rejected as the pinned behaviour because it is not generally equivalent to a fresh fold: the §3.7.1 total order sorts by event timestamp across files, and an appended file may carry an event timestamped before already-folded events (clock skew, late flush), which an append-only incremental fold would order incorrectly. A guarded fast path (apply only when the new files’ minimum timestamp ≥ the folded maximum, recorded in the artifact) stays open in §7; the unconditional fallback is the fresh fold.

Do nothing (keep the v1 fold). The RFC 0005 §3.7.1 deferral was explicitly conditioned on measurement; run #8 produced the number (§2.1). A constant per-query floor that grows with tenant age and sits inside the RFC 0031 headline metric fails the condition.

5. Acceptance criteria

Scenario RFC0033.1 — Cached fold ≡ fresh fold (property)

  • Given any generated per-tenant audit-event history (template creations/widenings/type-expansions/rejections and alias assertions/retractions, arbitrary timestamps including same-nanosecond ties), flushed to one or more audit Parquet files
  • When the fold is derived fresh and published, and a second read resolves it through the artifact (frontier equal, cache hit)
  • Then the cache-hit registry equals the fresh derive_template_registry result and the cache-hit alias map equals the fresh derive_alias_map result, for every key
  • And the query answer produced through either path is identical.

Scenario RFC0033.2 — Staleness is detected and never served

  • Given a published artifact at frontier S
  • When new audit events are flushed (one or more new audit files appear) and a query runs
  • Then the frontier check fails, the artifact is bypassed, and the answer equals the no-cache fold over the live set — including events in the new files
  • And the querier republishes at the new frontier, and a subsequent unchanged-store query is a cache hit
  • And the same holds when files disappear from the live set (frontier is set equality, not subset).

Scenario RFC0033.3 — Crash/tear safety around the publish

  • Given a publish interrupted mid-write (simulated: a stray template_map.json.tmp, a truncated/corrupt template_map.json, or an S3 CAS loss to a concurrent writer)
  • When the next query runs
  • Then a stray .tmp is ignored, a torn artifact is treated as absent (fresh fold — the query succeeds with the correct answer, no error surfaced), a CAS loss discards the losing write without failing its query
  • And the torn-artifact case emits the §3.7 torn outcome
  • And the fresh fold’s write-through overwrites the torn artifact, so the store self-heals.

Scenario RFC0033.4 — Additive and advisory (back-compat)

  • Given a store with no artifact (old data) and a binary with cache support
  • When a body-rendering query runs
  • Then the result is identical to the pre-RFC binary’s result and the fold reads the audit stream exactly as today
  • And deleting the artifact between two queries changes neither query’s answer
  • And the artifact’s presence changes nothing a *.parquet scan sees: the audit file walk/listing over a store carrying the artifact returns the same file set as without it.

Scenario RFC0033.5 — Tenant isolation

  • Given two tenants with distinct template/alias histories and published artifacts
  • When each tenant queries
  • Then each cache hit serves only that tenant’s registry and alias map (paths tenant-scoped under tenant_id=<enc>)
  • And an artifact whose body tenant_id differs from the tenant of the path it was fetched from fails the query loudly (the row-vs-path stance), never silently serving or ignoring foreign data.

Scenario RFC0033.6 — The measured tax collapses (RFC 0031 channel)

  • Given the RFC 0031 headline-corpus shape (otel-demo-v8, 4.9 M records — run #8 baseline: registry_bytes_read = 513,862 B constant per query) ingested, and a warm published artifact
  • When a body-rendering query runs cache-warm
  • Then QueryResult::registry_bytes_read equals the artifact object’s byte size exactly (the only registry-path GET is the artifact)
  • And the ratio warm.registry_bytes_read / cold.registry_bytes_read ≤ 1/2 on that corpus — the gate is the ratio, not an absolute byte count, so it holds as the corpus and baseline evolve
  • And both numbers are recorded in docs/benchmarks.md alongside the run #8 baseline.

Amendment (2026-07-14, run #21 / §9.15): the corpus ratio gate is ≤ 1/2, superseding the original ≤ 1/10. The 1/10 was calibrated when the artifact was assumed kilobyte-scale; run #21 measured the v2 (zstd) artifact at 187,904 B against the 513,862 B fold — warm/cold ≈ 1/2.73 — and revealed the actual structure: the artifact is O(live template state) while the fold is O(audit history). The fold grows append-only forever (every widening event carries both old and new template text); the artifact is bounded by the tenant’s template cardinality. On a young corpus like otel-demo-v8 history has not yet outgrown state, so a fixed 1/10 measures the corpus’s age, not the design. The ≤ 1/2 floor asserts a real margin (comfortably past the §3.2 abstention bound of < 1), and the ratio only improves as a tenant ages. The uncredited wins stay recorded alongside: one GET replaces the whole audit-tree walk (request count, latency), and the honest per-query total drops by the fold-minus-artifact delta. zstd-level tuning beyond the default is a §7 open question, not a requirement.

Run #20 note (2026-07-13, §9.14): undischarged on the corpus. The dispatch shows every pair cold with no artifact published — consistent with §3.2’s size abstention: the artifact is uncompressed JSON of every (template_id, version) canonical template, while the 513,862 B it must beat is the zstd-compressed Parquet of the same strings, so on otel-demo-v8 the guard correctly refuses a publish that would make warm acquisition cost more bytes than the fold. The local-shape arm (rfc0033_6_measured_tax_collapses, 55.8×) stands; this corpus arm needs a compressed artifact encoding (format_version 2 — cheap by §3.3’s own rule: unknown versions are treated as absent, no migration) before the gate can be measured. The scenario stays as written; the RFC status returns to red until it passes.

Amendment pointer (2026-07-13): the compressed encoding this note calls for is specified in the §3.2 amendment. The scenario text above needs no change: the “artifact object’s byte size” a warm GET pays is the compressed object’s size, and the ratio gate is encoding-agnostic. Validation is comparative run #21, dispatched from the implementation branch before merge (measure-before-merge), with the harness printing each pair’s publish outcome — published (compressed size) / abstained (would-be size vs. folded audit bytes) / lost_race / error — so run #20’s abstention-vs-failure ambiguity cannot recur.

Scenario RFC0033.7 — Observable outcomes

  • Given a served querier with the OTel metrics pipeline (RFC 0016) active
  • When queries drive a miss, a hit, a staleness, and a torn artifact
  • Then the §3.7 lookup-outcome and publish-outcome instruments record each with the correct outcome attribute, and the publish-size instrument records the artifact size
  • And the instrument names exist in the semconv registry (weaver-generated constants, no hand-written flat names).

6. Testing strategy

Mapped to CLAUDE.md §6.2:

  • RFC0033.1proptest: generated event histories (the RFC 0024 generator discipline), round-tripped through real audit Parquet files on the local backend; equivalence asserted per key. This is the invariant test — the cache must be a pure memoization.
  • RFC0033.2, RFC0033.3, RFC0033.4, RFC0033.5 — integration tests in crates/ourios-querier/tests/ against both backends (local root; S3 via the existing localstack harness, RFC 0019), scenario ids referenced from the test code.
  • RFC0033.6 — the RFC 0031 comparative harness (ourios-bench), cold-vs-warm on the headline corpus; the ratio is the gate, the absolute numbers are recorded.
  • RFC0033.7 — the RFC 0016 metrics-pipeline test shape (in-memory exporter), plus the semconv no-diff CI gate.

Amendment (2026-07-13). §6 is structurally unchanged by the compressed encoding: the same tests exercise the v2 artifact (round-trip and torn-classification now run through the compressed body; RFC0033.6’s local arm asserts warm acquisition bytes equal the artifact’s byte size exactly, which holds unchanged because the GET is the compressed object). The one addition is run #21’s harness printing publish outcomes (§3.2 amendment / the Scenario RFC0033.6 run-#20 note’s pointer).

7. Open questions

  • zstd level beyond the default (2026-07-14 amendment): run #21’s 187,904 B artifact used level 3; a higher level shrinks the warm GET further at once-per-miss CPU cost. Measure only if the §9.15 numbers stop satisfying — the ratio floor passes without it.
  • Guarded incremental fold on staleness: apply new files on top of the cached fold only when their minimum event timestamp ≥ the artifact’s recorded maximum folded timestamp (else fresh fold). Worth it, or is the fresh fold on miss cheap enough forever?
  • Ingester write-through at template-mutation time (the §4 alternative): adopt if §3.7 miss-rate telemetry shows mutation-driven staleness dominating on live tenants? Requires an ingester/querier code-sharing decision.
  • Hard size guard: should a publish above a byte threshold be skipped (cache abstention) rather than published, and what is the threshold? RFC 0023 bounds the registry, but versions accumulate per template over tenant lifetime.
  • Frontier growth: folded_files lists every audit file; a very old tenant’s frontier list could itself grow large. Fold the frontier to a digest (sorted-keys hash) once measured to matter?
  • Retention/GC of audit files: no audit retention exists today; when it lands, deleting folded files shrinks the live set and correctly staleness-misses (RFC0033.2), but the fresh re-fold loses history — that is a property of audit retention itself, to be pinned by the retention RFC, not by this cache.
  • Should the freshness LIST count into RFC 0031 bytes_read? Today neither backend counts listing overhead; the comparative-fairness call belongs to RFC 0031’s harness.
  • Drift queries (RFC 0010) intentionally do not use the artifact (they need raw events, not the fold) — confirm no future consumer is tempted to.
  • Legacy v1 key hygiene (raised by the 2026-07-13 amendment): the v2 publish best-effort-deletes template_map.json; once no pre-amendment binaries remain, keep the delete as permanent hygiene (one cheap idempotent DELETE) or drop it?

8. References

  • RFC 0005 §3.7.1 — the deferral this RFC discharges; §3.7 audit schema; §3.9 row-vs-path backstop.
  • RFC 0017 §3.2 (registry fold), §3.5 (version keying), §3.6 (the performance stance being revised).
  • RFC 0009 §3.4 — the per-partition manifest: the atomic-publish precedent (write_atomic / publish_cas, crates/ourios-parquet/src/manifest.rs) this artifact follows.
  • RFC 0031 §3.6 — the honest total-bytes channel and registry_bytes_read; comparative run #8 (2026-07-12, otel-demo-v8, 4.9 M records): 513,862 B constant per query.
  • RFC 0001 §6.7 (alias semantics), §6.9 (the “full-replay now, accelerate later” precedent).
  • RFC 0019 §3.3 — the hybrid local/S3 audit scan the freshness check reuses.
  • RFC 0023 — bounded template memory (the artifact’s size-bound argument).
  • RFC 0006 §3.4.1 / crates/ourios-bench — the workspace’s existing zstd (0.13) binding, the A1 reference codec; the 2026-07-13 compressed-encoding amendment reuses the same crate, zero new dependencies.
  • docs/benchmarks.md §9.14 — comparative run #20 (2026-07-13): the abstention finding the compressed-encoding amendment answers.
  • CLAUDE.md §3.5 (schema/migration — satisfied additively), §3.6 (object storage is the source of truth), §3.7 (per-tenant scoping), §6.3 (observability).
  • Code: crates/ourios-querier/src/audit_scan.rs, crates/ourios-querier/src/template_registry.rs, crates/ourios-querier/src/alias_store.rs.

RFC 0034 — D1 re-scope


rfc: 0034 title: D1 re-scope — ingest throughput is a per-node capacity on baseline hardware, not a per-core rate status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-20 supersedes: — superseded-by: —

RFC 0034 — D1 re-scope

Status note. accepted (2026-07-22, maintainer sign-off — the terminal state for this re-scope RFC, which has no thesis-gate of its own to validate). All three §5 criteria were in force from 2026-07-21. RFC0034.1 is enacted by this change: the docs/benchmarks.md § D1 block is recast per-node (≥ 100 000 lines/s on baseline-8vcpu-32gib, multi-tenant, shared commit stream, p99 ack ≤ 200 ms at the sustained rate) with the old per-core target and the per-tenant single-stream ceiling retained as recorded diagnostics, the RFC 0011 A1 pattern; the §7 gate table lists only the five [THESIS] goals, so the annotation lives at the § D1 block. RFC0034.2 is satisfied by §9.23 — RFC 0035 reached green and the one-hour --tenants 8 asserting soak achieved 99.92% of the offered 100k lines/s (0 failed batches, p99 ack 153.63 ms, D2 PASS). RFC0034.3 was already satisfied by the soak harness’s report format (aggregate + per-tenant + per-core fields, #567/#570). The §7 one-run-two-records question resolved as one run, one record (§9.23), cited by both this RFC and RFC 0035.

How to read this document. A tuning RFC in the RFC 0011 mold: the D1 gate, as written, measures a dimension the architecture deliberately does not scale on, so the gate is reconciled with its own stated intent and with measurement — after the measurement was made honest. The sequencing matters and is part of the record: an earlier draft of this RFC (never PR’d) proposed recasting D1 as per-node multi-tenant capacity on the premise that node throughput scales with tenant parallelism; the in-process measurement (benchmarks.md §9.21) refuted that premise (the ceiling was flat across tenant counts — a global serialization), which spawned RFC 0035 (ingest concurrency) first. This RFC now recalibrates D1 against RFC 0035 Design A’s measured capacity (§9.22). It amends only the benchmarks.md § D1 block and §7 gate table; no code, no on-disk byte. Per docs/rfcs/README.md, specified means the §5 acceptance criteria are written and review has confirmed them testable in principle — the #575 review round did exactly that (tightening RFC0034.2 to an observable below-saturation condition and a deterministic N); RFC0034.2’s assertion additionally waits on RFC 0035 reaching green.

1. Summary

D1 — “OTLP → WAL throughput ≥ 100 000 lines/s /core, p99 ingest-ack ≤ 200 ms” — has a metric (lines/second/core) that contradicts its own falsifier (“a meaningful share of production traffic per node). Measurement showed per-core is the wrong axis twice over: single-tenant ingest is bounded by the per-tenant sequential miner (CLAUDE.md §3.7’s deliberate isolation), and node ingest as a whole was bounded by a global commit-gate serialization — a software artifact, since removed by RFC 0035 Design A (132,289 lines/s measured on the baseline class, §9.22, vs ~86k before). D1 is recast: the must-win becomes per-node sustained throughput ≥ 100 000 lines/s on baseline-8vcpu-32gib (multi-tenant load, one shared WAL/commit stream — Design A clears it with ~32% margin), the per-core and per-tenant rates become recorded diagnostics, and the p99 ≤ 200 ms ack bar is unchanged, clarified to apply at the sustained rate (below saturation). D2 is untouched — it passed at every measured load.

2. Motivation

2.1 The metric contradicts the falsifier

D1’s target is per-core; its falsifier is per-node. Per-core reads as a portability normalization bolted onto a node-level operational claim. The tension stayed latent until D1 was first measured (§9.19 — unrun before 2026-07-19).

2.2 Per-core is the wrong axis, for two measured reasons

(a) Single-tenant load cannot scale on cores by design. The per-tenant template miner is sequential — CLAUDE.md §3.7’s per-tenant trees, the least-common-mechanism isolation choice. §9.20’s capacity ladder found a flat single-tenant service-rate ceiling regardless of offered rate; more cores cannot help one tenant’s in-order stream.

(b) The node-level ceiling was a software artifact, now removed. §9.21: node capacity was flat (~86k lines/s) across 1/8/16 tenants — a global commit-gate + miner-lock serialization with the machine ~85% idle (1.2 of 8 cores). RFC 0035 (specified) took the order-insensitive Parquet emit off the gate; its Design A prototype measured 132,289 lines/s on the baseline class (§9.22, D2 PASS, ack latencies down ~40% at saturation). The residual serial fraction (~0.62 — ordered mining + WAL group commit) is what a per-core bar would demand scale that the WAL’s single durable append stream (§3.4) intentionally does not offer.

2.3 What the falsifier actually asks

“A meaningful share of production traffic per node.” At 100k lines/s a node ingests ≈ 8.6 B lines/day; horizontal scaling multiplies nodes (§9.20’s independent-lane result showed near-linear multi-node extrapolation). The per-node bar measures the operational claim directly, on the axis the architecture scales on after RFC 0035.

3. Proposed design

  1. D1’s must-win: sustained ≥ 100 000 lines/s per node on baseline-8vcpu-32gib, multi-tenant load (soak --tenants N with N = cores, i.e. 8 on the baseline class) through one shared WAL/commit stream, WAL fsync batched at 100 ms, with p99 ingest-ack ≤ 200 ms at that sustained rate. “Below saturation” is observable, not asserted: the run offers exactly the bar rate (100 000 lines/s) and must achieve ≥ 99% of offered — a saturated pipeline cannot keep pace with the paced load, so achieved ≈ offered is the below-saturation proof, and the p99 is measured over that same run (queue-bound latencies at over-offered load are a different regime, per §9.20’s reading, and do not count). Judged on the §9 series; first evidence: §9.22’s 132,289 lines/s (RFC 0035 Design A prototype). The bar asserts only once RFC 0035’s production implementation is green — the prototype number is the calibration input, not the verdict.
  2. Diagnostics (informational, still recorded): per-core rate (the old bar’s axis) and the per-tenant single-stream ceiling (the most one service can push into one tenant — a real operational number that guards the mining path against regression). Neither gates any RFC’s validated (the RFC 0011 A1 pattern).
  3. D2 unchanged — already node-level, passed at every measured load including saturation.
  4. Falsifier retained verbatim — it was right all along; the metric moves to match it.

4. Alternatives considered

  • Keep the per-core bar. Rejected: it demands scaling on an axis the architecture deliberately serializes twice (sequential per-tenant mining — §3.7-scoped trees with first-seen ids that must match WAL-order replay, RFC 0001 §3.5.3; single durable WAL stream, §3.4). A permanently-red gate whose redness is unrelated to quality trains readers to ignore gates.
  • Recast as per-node multi-tenant scaling (the refuted first draft). Rejected by measurement: §9.21 showed node capacity flat across tenant counts pre-RFC-0035. Tenancy is not the scaling axis in-process; concurrency within the node (RFC 0035) is.
  • Set the bar at the measured 132k. Rejected: gates are floors the architecture clears with margin (the RFC 0031 must-win convention), not peaks that fail on noise. 100k keeps ~32% margin, matches the falsifier’s round operational claim, and gives the old per-core number a per-node home.
  • Wait for RFC 0035 green before writing this RFC. Rejected: the recalibration design is measurement-independent (the 132k only sets the margin); reviewing it in parallel shortens the path, and §3.1 explicitly defers assertion until RFC 0035 is green.

5. Acceptance criteria

Scenario RFC0034.1 — the gate table is recast.

  • Given the benchmarks.md § D1 block and §7 gate table
  • When this RFC is enacted
  • Then D1’s must-win reads per-node ≥ 100 000 lines/s on baseline-8vcpu-32gib (multi-tenant, shared commit stream), p99 ack ≤ 200 ms at the sustained rate, with a pointer to this RFC
  • And per-core and per-tenant rates are labelled diagnostic (informational) and appear in no validated blocking set.

Scenario RFC0034.2 — the bar asserts on baseline hardware once RFC 0035 is green.

  • Given RFC 0035’s production implementation at green and a soak --tenants 8 run on baseline-8vcpu-32gib (N = cores, deterministic)
  • When the one-hour soak offers exactly 100 000 lines/s
  • Then achieved ≥ 99% of offered (the observable below-saturation condition) with 0 failed batches, p99 ack ≤ 200 ms over that run, D2 PASS — recorded in the §9 series as the asserting run.

Scenario RFC0034.3 — the diagnostics stay visible.

  • Given any soak run
  • When the harness finalises
  • Then per-core and per-tenant rates are computed and recorded, flagged diagnostic.

6. Testing strategy

Mapped to CLAUDE.md §6.2. This RFC changes documentation and gate semantics, not code, so its tests are the measurement harness’s: RFC0034.1 is enacted by editing benchmarks.md (reviewed, greppable pointer to this RFC); RFC0034.2 rides the existing soak --tenants N harness (#567) on the baseline class — the same instrument §9.21/§9.22 used — recorded by hand in §9 per the series discipline; RFC0034.3 is already satisfied by the soak report format (aggregate + per-tenant + per-core fields, #567/#570) and pinned by its existing report tests.

7. Open questions

  • Whether the asserting run (RFC0034.2) doubles as RFC 0035’s RFC0035.4 measurement (same instrument, same hardware) — resolved as one run, one record (benchmarks.md §9.23), cited by both.
  • N for the asserting run: settled at N = cores (8 on the baseline class, the §9.22 shape) for determinism; a tenant-scaling curve remains a worthwhile diagnostic exploration but never moves the gate’s N.
  • benchmarks.md § D1’s prose retains the old per-core target as the diagnostic’s reference line (RFC 0011 did this for A1) — confirmed at enactment: the § D1 diagnostics bullet keeps the ≥ 100 000 lines/s/core line, labelled informational.

8. References

  • docs/benchmarks.md § D1/§ D2 (amended), §7 (scope/bar vocabulary), §9.19 (first D1 run — paced, latency bar pass), §9.20 (single-tenant ceiling ladder + tenant-parallel approximation), §9.21 (in-process flat ceiling + serialization profile), §9.22 (RFC 0035 Design A A/B: 82.1k → 132.3k, the calibration input), §1 (baseline-8vcpu-32gib).
  • RFC 0035 (specified) — ingest concurrency; this RFC’s must-win asserts only at its green.
  • RFC 0011 (accepted) — the must-win/diagnostic recalibration precedent.
  • Issue #571 — the serialization profile.
  • CLAUDE.md §3.4 (WAL-before-ack — why the commit stream is one), §3.7 (per-tenant trees — why single-tenant load doesn’t scale on cores).

RFC 0035 — Ingest concurrency


rfc: 0035 title: Ingest concurrency — take the Parquet encode off the global commit gate status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-20 supersedes: — superseded-by: —

RFC 0035 — Ingest concurrency

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

Status note. green (2026-07-21). Design A is implemented and its §5 criteria hold: RFC0035.1 (multi-tenant determinism differential + proptest), RFC0035.2 (the encode-drain-and-flush barrier, fault-injected), RFC0035.3 (WAL-before-ack suites unchanged) and RFC0035.5 (decoded-row + query differential, no on-disk change) are green in-repo — the #577 implementation suite plus the #579 review-fix round (capture-slot unwind safety, honest barrier docs, each accepted fix carrying its test). RFC0035.4 is satisfied by docs/benchmarks.md §9.23, the RFC0034.2/RFC0035.4 asserting soak: 99,921 lines/s sustained over one hour at the RFC 0034 asserting shape (--tenants 8, offered 100k, p99 ack 153.63 ms, D2 PASS) versus the pre-RFC ~82k saturation baseline (§9.22) — the serialization is relaxed in production, not just in the prototype. The sweep-window hazard #578 (a rotation could stamp wal_high_water while the age sweep’s off-lock write_ordered was still in flight — records out of the buffers, durable nowhere but the WAL) is fixed: the coordinator’s drains hold an in-flight publish guard on the shared record sink, and every stamping path quiesces those publishes in flush_then_snapshot before flushing + stamping — the publish half of the §3.1 barrier, pinned by a mutation-checked stamp-waits race arm and a SIGKILL mid-window replay arm alongside the RFC0035.2 suites.

How to read this document. A profile (docs/benchmarks.md §9.20/§9.21; issue #571) showed the ingest hot path saturates ≈ 86k lines/s while using only ~1.2 of 8 cores — an ~85%-idle machine held back by a global serialization, not by compute. This RFC’s recommended form (Design A) relaxes that serialization by moving the order-insensitive Parquet encode off the global commit gate, while keeping the order-sensitive template-id assignment globally ordered — so it makes no on-disk schema, format, or migration change (row order within a partition may differ, but schema, template_id values, and query results do not — §5 RFC0035.5). (The fully-per-tenant alternative, Design B, would relax mining itself but requires an on-disk migration; it is considered and deferred, §4.) It touches the project’s highest-risk invariants — WAL durability (CLAUDE.md §3.4), miner determinism (RFC 0001 §3.5.3), tenancy (CLAUDE.md §3.7) — so it is design-first and changes nothing until redgreen. Per docs/rfcs/README.md, §§1–4 are the design contract, §5 the acceptance criteria and §6 the testing strategy — all written; the implementation landed (#577, review fixes #579) and all five §5 criteria pass — including RFC0035.4 on the §9.23 asserting soak — which places this RFC at green.

1. Summary

Every ingested batch, for all tenants, is serialized through one global WAL-sequence gate (CommitCoordinator::await_ingest_turn) and one global miner mutex (Mutex<MinerCluster>), and the expensive per-record work — Drain match, template-id assignment, and Parquet encoding — runs inside that single-file section (pipeline.rs:314–354). Because the correctness constraint the gate exists for is only per-tenant WAL-order (each tenant owns its Drain tree and template-id slice, §3.7), the gate over-serializes: it makes tenant B wait behind tenant A’s encode.

This RFC keeps the WAL append + fsync globally ordered (durability is non-negotiable — §3.4) and relaxes only the miner hand-off. It recommends Design A: keep template-id assignment globally ordered and cheap under the gate, but move the expensive, order-insensitive Parquet encoding off the critical section onto a concurrent pool — capturing the bulk of the idle headroom with no on-disk format change and no change to how template-ids are assigned. It documents, and defers, Design B (a genuinely per-tenant template-id space), which reaches the full per-core ceiling but requires an on-disk template_id migration (§3.5) and a query-surface change.

2. Motivation

2.1 The measurement

At the 86k ceiling the machine is ~85% idle (1.2 / 8 cores; §9.21 pidstat). Of the CPU that runs, ~52% is MinerCluster::ingest and ~16% is Parquet encoding. Throughput is flat across 1 / 8 / 16 tenants and across offered rate — the signature of one serialized lane. The bound is the serial fraction, not total work: freeing the order-insensitive majority of per-batch work onto idle cores is what unlocks throughput (Amdahl — the achievable multiple is 1 / serial_fraction, measured in §6, not assumed here).

2.2 The two orderings, only one of which must stay global

  • (a) WAL append order — global, MUST stay. The WAL is a single writer (Wal::append(&mut self)) behind the coordinator’s journal mutex; seq assignment is atomic with the append (commit.rs:190–204). WAL-before-ack (§3.4) and recovery’s strict-order replay (recovery.rs:259–282, ids re-derived in WAL order) depend on this. Untouched. Group commit still folds all tenants into one fsync.
  • (b) Miner hand-off order — currently global, relaxable to per-tenant. The per-tenant Drain tree and structured-template map only require their own tenant’s records in WAL-order. The global ingest_gate (commit.rs:239–256) and single Mutex<MinerCluster> make it global — the over-serialization this RFC targets.

2.3 The constraint that shapes the design

MinerCluster::next_template_id is a single cluster-wide counter (cluster.rs:116–126): a template’s concrete id depends on the global interleaving of first-sightings across tenants. RFC 0001 §6.1 + §3.7.2 reconcile this as “the id space is cluster-wide, each tenant’s slice monotonic in that tenant’s allocation order,” with the hard rule no template-id shared across tenants (pinned by invariant_3_7_2_same_template_two_tenants_distinct_template_ids). This is the pivot: relaxing hand-off order to per-tenant would let records reach that shared counter in a different interleaving than global WAL-order replay, changing assigned id values → RFC 0001 §3.5.3 snapshot-restore divergence. So per-tenant mining is unsafe as long as the id-space is a shared counter touched in-line. The two designs differ precisely in how they resolve this.

3. Proposed design

Split MinerCluster::ingest into an ordered, cheap phase and a concurrent, expensive phase:

  1. Ordered phase (under the existing global gate, per batch): for each record, Drain-match and assign/look-up the template-id — exactly today’s ordering, so the cluster-wide counter is still advanced in strict WAL-append order and every id keeps its current value. Produce a MinedRecord carrying the assigned template_id, template version, and slot values. This phase does no Parquet work. It stays under the ingest_gate + a (now much shorter) critical section.
  2. Concurrent phase (off the gate): hand each MinedRecord to a bounded worker pool that performs the Parquet encoding (RecordSink::emit / encode_records_to_parquet_with_promoted). Encoding is a pure function of the already-assigned id and the record’s values — order-insensitive — so it parallelises across cores and tenants freely.

Why determinism is preserved (unchanged, not re-argued): template-id assignment — the only order-sensitive step — stays globally ordered under the gate. Ids keep their exact values; the live tree still equals a WAL-order replay; the snapshot captures the trees, updated in the ordered phase, so template state is coherent at any point. No template_id representation changes → no Parquet schema change, no §3.5 migration, no query-surface change.

The rotation / snapshot encode barrier (a required addition, not free). Today the global gate makes “all frames ≤ mark are fully processed” true for free at a rotation (pipeline.rs:343–354): nothing above the mark has run, and everything at or below it has finished — including its Parquet emit, because emit runs inside the gated section. Design A breaks that second half: after the ordered phase releases the gate, a record’s encode may still be in flight in the concurrent pool when the WAL rotates. The snapshot’s global wal_high_water (recovery.rs:199–205) asserts frames ≤ mark are durably captured, so advancing it while an encode ≤ mark is unfinished would let a crash lose a record the mark claims is safe. Design A therefore adds an explicit encode-drain-and-flush barrier: the rotation hook (and shutdown snapshot, and any wal_high_water advance) must quiesce the encode pool up to the rotation offset and durably flush the sink’s buffered partitions for those records — every MinedRecord with seq ≤ mark has both completed its RecordSink emit and been flushed to durable object storage — before the high-water is stamped. The flush half is load-bearing: RecordSink::emit leaves records in an in-memory partition buffer (today written by rotation-time flush_all), and recovery replays the WAL only above the high-water — so a record below the mark whose Parquet was buffered-but-not-flushed would be lost on a crash. The barrier thus preserves the existing “flush covers everything the high-water claims durable” contract rather than weakening it. Because the barrier is keyed to WAL rotation (WAL segments default to 128 MiB — RFC 0008) and shutdown, not to every batch, its amortised cost is negligible while the between-rotation steady state runs fully concurrent. The barrier’s mechanism (a per-seq completion watch the drain awaits, or per-partition encode-completion offsets the writer folds into the high-water) is an §7 open question; that it MUST exist is not. This is the one place Design A’s “no free lunch” shows, and RFC0035.2 tests it directly.

What must be verified (Design A’s real risks, §7):

  • Parquet row-order independence. Concurrent encode may buffer a tenant/partition’s rows in a different order. Queries filter by predicate, not position, and C1 reconstruction is per-record, so this should be semantically inert — but the RFC must confirm no test or invariant depends on intra-file row order, and that per-partition RecordSink buffering is concurrency-safe (today it is written under the single miner lock).
  • Audit-sink ordering. The shared audit_sink (cluster.rs:127–135) has an RFC 0001 §6.4 “ordering-plus-durability-barrier” contract. Template-created/widened audit events are produced in the ordered phase (they are id-assignment events), so they stay ordered; the RFC must confirm no audit emission moves into the concurrent phase.
  • Backpressure. The concurrent pool’s queue must bound memory and apply backpressure to the gate so an encode-bound burst can’t grow an unbounded in-flight backlog (ties into D2 / hazard #4).

3.2 What stays exactly as-is

The WAL, the coordinator’s global seq + group-commit fsync, WAL-before-ack ack timing, the cluster-wide template-id space and its on-disk representation, the query DSL, and the snapshot/restore format. Design A is an internal re-partitioning of MinerCluster::ingest into ordered-vs-concurrent phases behind the same public contract.

4. Alternatives considered

  • Design B — genuinely per-tenant template-id space (full scaling, deferred). Make each tenant’s mining fully independent: per-tenant ordering gate, per-tenant miner lock, and a per-tenant id-space that still satisfies §3.7.2 cross-tenant uniqueness (a compound/namespaced id, e.g. (tenant_ordinal, per_tenant_seq), not a naive per-tenant u64 — which the code explicitly warns collides, cluster.rs:120–122). This reaches the full per-core ceiling (the ~341k independent-lane approximation, §9.20). Two shapes exist, with very different costs: a compound id ((tenant, seq) as separate fields) is an on-disk Parquet schema change (§3.5 migration plan required — historical files, reader forward-compat) rippling into the query surface (template_id == N), the audit stream, and the snapshot format. The cheaper named variant is bit-partitioning the existing u64 (tenant_ordinal << K | per_tenant_seq): still a u64 — no Parquet schema change, dictionary/bloom behaviour unaffected (they exploit repetition, not density), the DSL untouched — applied forward-only (old files keep old ids, defined as ever by the audit stream). Its own unexamined edges are real, though: the tenant-ordinal map is new global state needing a durable, replay-stable recovery story; the K-bit split imposes tenant/template cardinality caps that must be justified; and snapshots/high-water marks become per-tenant either way. Deferred because even the cheap shape is an id-allocation redesign on the silent-corruption-risk path, not settled enough to implement without its own RFC round — a separate commitment. Design A ships and is measured first; B is revisited only if A’s measured ceiling (§6) leaves the D1 must-win unmet. Per-tenant snapshot high-water marks (or a rotation drain-barrier) would also be required (recovery.rs:199–203 stamps one global mark on every tenant today).
  • Per-tenant miner lock only, keep the global gate. Insufficient — the global ingest_gate still serialises; and unsafe — per-tenant mining into the shared counter reorders id assignment (§2.3).
  • Do nothing; recast D1 down to ~86k (the do-nothing arm of the forthcoming D1 re-scope, RFC 0034 — held pending this RFC’s measurement, not yet in-tree). Rejected as the primary path (this RFC exists because ~86k is a software artifact, not a ceiling — recasting would enshrine it, the §6.2 “don’t weaken the spec to match the code” trap). RFC 0034 remains, sequenced after this RFC: recalibrate D1 against Design A’s measured number.

5. Acceptance criteria

Scenario RFC0035.1 — determinism is preserved (the load-bearing guard).

  • Given Design A implemented and a multi-tenant workload
  • When N tenants ingest concurrently and the live miner state is compared to a control that replayed the same WAL frames in strict global order
  • Then every tenant’s snapshot_state (leaves, template ids, versions) is equal to the control — i.e. concurrent encode does not perturb any assigned id or tree.
  • And the existing rfc0008_8_concurrent_ingest_preserves_wal_order_at_the_miner passes unchanged, extended by a new multi-tenant variant (the current one is single-tenant and would not catch cross-tenant reordering).

Scenario RFC0035.2 — snapshot/restore + rotation stay coherent under in-flight encodes (the encode-drain barrier, §3.1).

  • Given Design A and a WAL rotation that fires while encodes for seq ≤ mark are still in flight in the concurrent pool
  • When the rotation (or shutdown) snapshot stamps wal_high_water, the process is then killed, and it restores from the snapshot plus tail replay
  • Then the high-water was stamped only after the encode pool quiesced to the mark and the sink durably flushed those partitions (no record ≤ mark unencoded or buffered-but-unflushed), so restore
    • tail == full rebuild per tenant with no record loss at the mark, and the rotation hook still observes the pre-rotation high-water with no batch above the mark applied — the full rfc0001_3_5_* and rfc0008_10_* suites pass unchanged, extended by a barrier test that fails if the high-water can outrun an unfinished encode or an unflushed buffer.

Scenario RFC0035.3 — WAL-before-ack and durability are untouched.

  • Given Design A
  • When N concurrent exports are acked
  • Then each is durable before its ack, exactly N frames land, and group commit still folds them into shared fsyncs — rfc0003_15_* and rfc0008_8_batched_fsync pass unchanged.

Scenario RFC0035.4 — the serialization is actually relaxed (the point).

  • Given Design A on baseline-8vcpu-32gib, soak --tenants 8
  • When the saturating soak runs
  • Then node throughput exceeds the pre-RFC ~86k by the §6 measured multiple with core utilisation materially above 1.2/8, p99 ack ≤ 200 ms at the sustained rate, and D2 still PASS — recorded in the §9 series and feeding RFC 0034’s D1 recalibration.

Scenario RFC0035.5 — no on-disk or query change (Design A scope guard).

  • Given Design A
  • When files written before and after the change are read, and template_id == N queries run
  • Then the Parquet schema, template_id values, and query results are identical — Design A introduces no migration.
  • And byte-for-byte file identity is explicitly not claimed: concurrent encode may reorder rows within a partition, so the guarantee is schema + semantic + query-result stability (what “no migration” requires), not identical bytes. Any test must assert set/multiset equality of decoded rows, never file-hash equality.

6. Testing strategy

Mapped to CLAUDE.md §6.2. The load-bearing property is equivalence to the WAL-order serial baseline, so most scenarios are differential tests against a serial control, plus the existing determinism/durability suites kept green.

  • RFC0035.1 (determinism)differential + property (proptest). A new multi-tenant concurrent-ingest test extends rfc0008_8_concurrent_ingest_preserves_wal_order_at_the_miner (today single-tenant): drive N tenants’ records concurrently, assert every tenant’s snapshot_state equals a serial control that replayed the same WAL frames in strict global order. A proptest generator over interleavings + template mixes makes the “any interleaving ⇒ same ids” claim adversarial, not example-based.
  • RFC0035.2 (rotation/snapshot barrier)fault-injection + the existing recovery suites. A barrier test injects an in-flight encode for seq ≤ mark at rotation, then asserts wal_high_water is not stamped until the pool quiesces to the mark (the test fails if the high-water can outrun an unfinished encode — mutation-checked by reverting the barrier). The full rfc0001_3_5_* (snapshot-restore) and rfc0008_10_* (rotation cadence) suites run unchanged, plus the crash-recovery test (SIGKILL mid-batch) from CLAUDE.md §6.2.
  • RFC0035.3 (WAL-before-ack)existing suites unchanged. rfc0003_15_concurrent_exports_are_each_durable and rfc0008_8_batched_fsync gate that durability and group-commit timing are untouched; they must pass without edit (a change here is a contract change, not a refactor — CLAUDE.md §6.2).
  • RFC0035.4 (throughput)criterion + the soak --tenants N harness on baseline-8vcpu-32gib. The ingest write-path bench and a saturating multi-tenant soak measure the achieved multiple and core utilisation; recorded in the docs/benchmarks.md §9 series. This is the number that fills the pre-implementation measurement below and sets RFC 0034’s recalibrated bar.
  • RFC0035.5 (no on-disk/query change)decoded-row + query differential. Read Parquet written before and after the change and assert schema + template_id values identical and decoded rows equal as a multiset (never a file hash — row order may differ); run template_id == N queries and assert identical result sets. Design A must be a semantic no-op on disk.

Pre-implementation measurement (fills RFC0035.4’s target). Design A’s achievable multiple is 1 / serial_fraction after moving encode off the gate. The serial fraction is measured by prototyping the ordered/concurrent split and re-running the §9.21 profile (per-thread CPU + throughput) on baseline-8vcpu-32gib; the pre-RFC baseline is ~86k at 1.2/8 cores. This measurement is the red-stage gate: if the prototype’s serial fraction shows Design A cannot clear the D1 must-win, Design B (§4) is escalated before implementation proceeds.

7. Open questions

  • Prototype the split and measure the serial fraction (the §6 measurement) at red — done, benchmarks.md §9.22: 82.1k → 132.3k lines/s on the baseline class (1.61×, residual serial fraction ≈ 0.62) — the throughput/capacity gate (a saturating run; its queue-bound latencies say nothing about the p99 bar). The full D1 must-win incl. p99 at the sustained rate is §9.23’s asserting run; Design B stays deferred.
  • If Design B is ever escalated (§4): settle bit-partitioned u64 vs compound id first — the former may avoid the §3.5 migration entirely, but needs a durable tenant-ordinal map design and K-bit cardinality-cap justification. Its own RFC; recorded here so the cheaper shape isn’t forgotten.
  • The encode-drain-and-flush barrier mechanism (§3.1) — a per-seq completion watch the rotation/shutdown drain awaits, or per-partition encode-completion offsets the writer folds into the high-water, plus the durable-flush step that must cover every partition holding a record ≤ mark. That the barrier (drain and flush) must exist is settled (RFC0035.2); which mechanism, and its cost at rotation, is open.
  • Confirm no test/invariant depends on intra-file Parquet row order, and make per-partition RecordSink buffering concurrency-safe (or shard it per tenant/partition).
  • Confirm all audit emissions stay in the ordered phase (RFC 0001 §6.4 barrier); if any are in the record-emit path, keep them ordered.
  • Backpressure design for the concurrent encode pool (bound memory; propagate to the gate; interaction with D2 backlog / hazard #4).
  • Worker-pool sizing and whether encode runs on the tokio blocking pool or a dedicated rayon-style pool (CPU-bound work off the async runtime).
  • CLAUDE.md §3.4 / §3.7 and RFC 0001 §3.5.3 are preserved by Design A, so no meta: RFC — confirm at sign-off. (Design B would touch CLAUDE.md §3.5 — a separate RFC if pursued.)

8. References

  • Issue #571 — the profile finding this RFC resolves.
  • docs/benchmarks.md §9.20 / §9.21 — the ~86k ceiling, 85%-idle profile, and the ~341k independent-lane approximation (Design B’s ceiling); §1 baseline-8vcpu-32gib.
  • RFC 0034 — D1 re-scope, sequenced after this RFC (recalibrate the D1 bar against Design A’s measured number). Forthcoming: held as a local draft pending this RFC’s measurement, not yet in-tree — a forward-reference by number, not a document to read yet.
  • RFC 0001 (accepted) — the template miner: §6.1 template-id semantics, §3.7.2 cross-tenant uniqueness, §3.5.3 snapshot-restore, §6.4 audit-sink barrier — the invariants this RFC must preserve.
  • RFC 0008 (accepted) — WAL: single-writer append order, group commit, WAL-before-ack (§3.4), rotation cadence.
  • Code: pipeline.rs:314–354 (the serialized region), commit.rs:239–256 (ingest_gate), cluster.rs:116–126 (the shared next_template_id), recovery.rs:199–205 / 259–282 (global high-water; strict-order replay).
  • CLAUDE.md §3.4 (WAL-before-ack), §3.5 (schema-change migration — why Design B is deferred), §3.7 (per-tenant trees — why the constraint is per-tenant, not global). Snapshot determinism is RFC 0001 §3.5.3 (above), not a CLAUDE.md section.

RFC 0036 — Write-side layout


rfc: 0036 title: Write-side layout — compacted-partition clustering and row-group sizing status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-21 supersedes: — superseded-by: —

RFC 0036 — Write-side layout

Status note. accepted (2026-07-22, maintainer sign-off — the terminal state), amended 2026-07-22 with the §3.3 adaptive compacted-threshold (target-K): the fixed 32 MiB threshold was proven inert on real v8 (§9.28/§9.29 — a real hour compacts to one group and prunes nothing) yet fragments large hours, so it is replaced by clamp(input_total / 8, 1 MiB, 32 MiB) (accepted RFCs may be amended — docs/rfcs/README.md; maintainer-approved 2026-07-22). §9.30 measures the win at the adaptive default (7.61× real-corpus materialization-bytes win, 20 of 21 groups pruned, where the fixed 32 MiB default had skipped).

Reached validated on all five §5 scenarios green plus the comparative evidence below. RFC0036.1 (footer inspection — compacted threshold, sorting_columns, per-group service min/max — plus the §6 merge property), RFC0036.3 (D3 file-band + forced-spill memory bound), RFC0036.4 (shuffled-listing byte-identity rebuild), and RFC0036.5 (pre-/post-RFC read-path parity, no schema migration) pass in crates/ourios-parquet/tests/it/rfc0036_write_side_layout.rs (and compaction.rs’s forced-spill unit test); RFC0036.2 (window-query scanned-row-group bound) passes in crates/ourios-querier/tests/it/rfc0036_window_materialization.rs. The external merge sort (run formation → capped-fan-in k-way merge), the §3.3 adaptive threshold (adaptive_flush_bytes), and the §3.4 sorting_columns declaration are in compaction.rs/writer.rs. Design review is done — maintainer go, 2026-07-21. The §7 decisions needed to ship this slice are recorded below (the threshold question is now resolved by the §3.3 adaptive model; the authoritative ceiling/target-K sweep against the L1/L3 curve stays deferred to the ourios-bench harness); the remaining §7 questions stay open as noted there.

validated evidence (2026-07-22). The deferred comparative arm resolved in two parts, both recorded in docs/benchmarks.md:

  • RFC0036.5 no-regression (§9.26, authoritative baseline-8vcpu-32gib, HEAD 5e5aa66). The frozen RFC 0031 dispatch reran on post-RFC-0036 main: every measured frozen gate passes in §9.24’s band (L1 ~99×, L2 ~43× + floor 1.40, L4 ~85× + floor 3.62, L6 k=2000 latency 4.0). Finding: the comparative harness writes one ingest file per partition, so compact_partition no-ops and RFC 0036’s sort never runs there — every ourios_bytes_read is byte-identical to §9.24. So the baseline run proves no regression but structurally cannot show RFC 0036’s win; a multi-file-per-partition harness that re-bases the frozen-gate store is future work, not a validated blocker (RFC0036.2 bytes is a §2.2 diagnostic, not a gate).
  • RFC0036.2 materialization before/after (§9.27, in-repo, deterministic). Measured directly on a genuinely-compacted store: the sort takes a one-service window from materialising the whole file (unsorted: 1/1 groups, 100.5 MB — no row group prunes) to a contiguous minority (sorted: 2/6 groups, 70.0 MB) for the identical answer — a 1.43× materialization-bytes win, modest and honest (the compacted file is ~2× larger on disk; §2.2’s registry floor is why the gate is the scanned-row-group bound, which rfc0036_2_window_materialization_bound enforces).

The scanned-row-group gate was always the load-bearing acceptance criterion (RFC0036.2 §5, green in-repo); the v8-corpus comparative materialization number remains desirable future work behind the harness change above.

How to read this document. This is the write-side layout lever that docs/benchmarks.md §9.13 named and §9.24 left “parked on its own line” — hazard #4’s layout fork, carried from the #498 scoreboard. The comparative program measured why time-window browses lose the storage-bytes channel to Loki (§9.13 runs #8–#17): a compacted hour is one file whose row groups rotate only at 128 MiB uncompressed and declare no sort, so a one-service window query materializes essentially the whole hour. This RFC clusters rows at compaction time by (promoted service.name, time_unix_nano), rotates compacted row groups at a smaller threshold, and declares Parquet sorting_columns — ingest is untouched, no Parquet schema change, store-build determinism preserved. The framing is deliberately honest: the L6 storage-bytes channel stays a published diagnostic (RFC 0031 §5 declined to gate it, and the ~188 KB layout-independent template-registry acquisition is its floor — §2.2). The goal here is to collapse the materialization term — a window query over one service in one hour should fetch a few small row groups, not the whole hour — not to beat a floor that layout cannot touch.

1. Summary

Post-compaction, a (tenant, hour) partition is one file (compaction.rs outputs a single consolidated file per partition) whose row groups rotate only when the writer’s uncompressed in-progress buffer crosses 128 MiB (writer.rs:60), whose rows sit in ingest/append order, and which declares no sorting_columns (none exist anywhere in the codebase). On the v8 comparative corpus that yields roughly one row group per hour holding all services (§9.13 run #17), so neither the time min/max statistics nor the RFC 0022 promoted service.name bloom can skip anything — the measured mechanism of the L6 window-browse loss. This RFC changes the compacted layout only: (1) compaction sorts the partition’s rows by (promoted service.name, time_unix_nano) via a bounded-memory sort-run merge that preserves compaction’s existing one-input-file peak-memory property; (2) compacted row groups rotate at a smaller, adaptive threshold that scales with partition size (§3.3 — clamp(input_total / 8, 1 MiB, 32 MiB), amended 2026-07-22), giving time/service pruning its granularity back on hours of any size; (3) the writer declares Parquet sorting_columns so order-aware readers can exploit the layout. No schema change, no migration, no ingest-path change, and byte-identical store rebuilds are preserved. The explicit non-goal: winning the L6 storage-bytes channel, whose floor is the layout-independent registry acquisition (§2.2).

2. Motivation

2.1 The measured mechanism

docs/benchmarks.md §9.13 published the L6 window-browse loss honestly and diagnosed it precisely. On a browse-k-rows query Loki reads only the tiny chunk slice its label stream + time index point at (16,250 B storage-side at k=100), while Ourios pays fixed per-query costs that dwarf a k-row answer: 1,931,911 B at k=100 on the authoritative run (§9.24). Run #17’s diagnostic sharpened the why: scoping the same window to the lowest-volume service (“ad”) improved Ourios only ~22% — no bloom collapse — because v8’s hour partitions each hold roughly one row group containing all services, so the promoted service.name bloom has nothing to skip and the time min/max spans the whole hour. §9.13’s closing assessment named the fix: “the tier-changing lever is write-side layout (service clustering / row-group sizing — hazard #4 territory, an RFC-level change), not query-side tuning.” §9.24 repeated it: the write-side lever “stays parked on its own line.” This RFC is that line.

The layout facts, verified in code:

  • Ingest seals a partition buffer at a 256 MiB estimate target, a 1 GiB total ceiling, or 300 s of age (ourios-server/src/receiver.rs:55–57; these are RFC 0014 §3 defaults, not yet RFC 0004 knobs — RFC 0014 §7).
  • The writer flushes a row group when ArrowWriter::in_progress_size — an uncompressed estimate — crosses 128 MiB (writer.rs:60, checked at writer.rs:627 and writer.rs:644). There is no max_row_group_size property and no sorting_columns declaration anywhere; rows are in append order.
  • PartitionKey is (tenant, year, month, day, hour) only (partition.rs:29–43) — no service dimension in the path.
  • Compaction (compaction.rs:184–308) streams inputs one file at a time (peak decoded memory = one input file — a deliberate, commented property, compaction.rs:228–234), appends them in input order into one output file per partition, and rotates row groups at the same 128 MiB threshold. §9.7’s band-scale D3 output was a single 456.7 MiB file.

2.2 What layout can fix, and the floor it cannot

Two distinct terms make up an Ourios window query’s bytes:

  1. The registry acquisition. Every body-rendering query pays the RFC 0033 template-map acquisition — the warm v2 artifact is 187,904 B (RFC 0033 §9; 187,906 B on the §9.24 authoritative run). This term is layout-independent: it is paid before any row group is touched, and for k=100 it alone exceeds Loki’s entire 16,250 B read by ~11×. No write-side layout changes it.
  2. The materialization term. Everything else — the column chunks of the row groups that survive pruning. At k=100 on §9.24 this is roughly 1,931,911 − 187,906 ≈ 1.74 MB: the whole hour, because one all-services row group survives pruning by construction.

This RFC targets term 2 only, and says so plainly: layout cannot beat the ~188 KB registry floor and does not try. Accordingly the L6 storage-bytes channel stays exactly what RFC 0031 made it — a published diagnostic, not a gate (RFC 0031 §5 declined to gate it; the §7 frozen L6 gate is the latency floor, which already passes: 0.370 / 4.341 on §9.24). Illustrative arithmetic, not a promise: with the hour clustered by (service, time) and rotated at the §3.3 adaptive threshold, a one-service k=100 window’s answer lives in one or two small row groups; the materialization term drops from ~1.74 MB toward the size of those chunks’ matched columns, leaving total bytes dominated by the registry constant. The §5 criteria gate the mechanism (row groups fetched), and the before/after bytes are measured and published in the §9 series as the diagnostic they are.

2.3 Why compaction is the layer

The ingest path just got its concurrency model rebuilt (RFC 0035: streamed append, order-insensitive Parquet encode on a bounded pool, an encode-drain-and-flush barrier keyed to WAL rotation). Sorting at ingest would sit directly on that fresh machinery and on the ack path (§4). Compaction, by contrast, already rewrites every row of the partition — it is the RFC 0022 §3.4 re-projection point, where history converges toward the current promoted attribute set — so clustering rides a pass that exists, off the hot path, with its cost bounded by the compaction cadence. Hazard #4’s mitigation already owns this layer (“background compaction job per tenant; cadence is a tunable”); this RFC extends what that job does, not where work happens.

3. Proposed design

3.1 The clustering key

Compacted rows are ordered by, in precedence:

  1. Promoted service.name value, lexicographic byte order of the UTF-8 string, absent/null first. Lexicographic, not first-seen or dictionary-ordinal order: first-seen order depends on ingest interleaving and would break rebuild determinism (§3.5). Tenants whose promoted set does not include service.name fall back to key 2 alone — a time-only sort still buys row-group time-pruning granularity (§7).
  2. time_unix_nano, ascending.
  3. Deterministic tie-break — (input-file ordinal in sorted-basename order, row ordinal within that input). Never exposed as a declared sort; it exists so equal-key rows have one canonical order and the output is byte-identical across runs and listing orders (§3.5). Compaction already sorts input basenames for its audit event (compaction.rs:267–268); the tie-break reuses that order.

3.2 Bounded-memory sort: run formation + k-way merge

A plain k-way merge of the input files cannot produce this order: the key leads with service.name, which append order does not cluster at all, and RFC 0035 explicitly disclaims intra-partition row order on ingest-side files (RFC0035.5 — concurrent encode may reorder rows; “near-time-ordered” is an observation, not an invariant). The design is therefore a textbook external merge sort whose initial runs are the input files themselves:

  1. Run formation. For each input, one at a time: decode all rows (exactly today’s per-input read_all — peak decoded memory = one input file, the existing bound), sort them by the §3.1 key (a stable sort; the tie-break’s row ordinal is the pre-sort position), and spill a sorted run to local scratch (local disk is cache, not truth — CLAUDE.md §3.6 clean; the run format, Arrow IPC vs Parquet, is §7).
  2. Merge. Stream a k-way merge over the sorted runs, holding one decoded batch per run (the Reader already wraps the streaming ParquetRecordBatchReader; a batched-read entry point alongside read_all is the only reader addition). Emit into the existing Writer, rotating row groups at the §3.3 threshold.

Why the one-input-file memory property is preserved (the load-bearing claim). Phase 1’s peak is one fully-decoded input — identical to today’s bound (compaction.rs:228–234), since it processes inputs strictly one at a time. Phase 2’s peak is N × one-batch, where N is the input count — bounded in practice by the ingest seal policy (a partition accrues files at the 256 MiB target / 300 s age cadence; §9.7’s band-scale case was 32) and bounded unconditionally by a fan-in cap F: if N > F, merge hierarchically (F runs → one intermediate run, repeat), so phase-2 memory never exceeds F × batch_bytes regardless of backlog. With batch sizes in the low-thousands of rows, F × batch is far below one decoded input file. The writer’s in-memory output accumulation (ArrowWriter<Vec<u8>>, writer.rs:105) is unchanged in both phases. Everything around the sort — manifest bootstrap, CAS commit, GC, the RFC0009.5 per-row partition validation at input open — is untouched.

The one exception is the §7 skip-spill optimisation for small partitions: while the encoded input total stays within in_memory_max_bytes (one ingest seal target, default 256 MiB), all inputs are held decoded at once and sorted in place rather than spilled one at a time. That bound is one seal-target’s worth of input — no larger than decoding a single worst-case input file — so the load-bearing claim holds at the bound, but the strict “one file at a time” residency is the spill path’s, not the in-memory path’s. RFC0036.3’s memory test asserts the accurate bound for each path.

3.3 Compacted row-group threshold — adaptive (target-K)

Amended 2026-07-22. This section originally shipped a fixed 32 MiB compacted threshold. §9.28/§9.29 proved that value is inert on the real v8 corpus: a real per-hour partition is only a few MiB compressed — far below 32 MiB — so it compacts to a single row group and prunes nothing (§9.29 skipped at the 32 MiB default), while the same fixed value fragments large hours. The threshold is therefore replaced by an adaptive one that scales with partition size, so a partition of any size lands on roughly a target group count. The fixed value is retained as the ceiling. Maintainer-approved design decision, 2026-07-22.

Compacted output rotates row groups at an adaptive, per-partition threshold (ingest-side files keep the fixed 128 MiB ROW_GROUP_FLUSH_BYTES):

adaptive_flush_bytes = clamp(
    estimated_output_bytes / TARGET_COMPACTED_ROW_GROUPS,
    MIN_COMPACTED_RG_BYTES,
    MAX_COMPACTED_RG_BYTES,
)
  • TARGET_COMPACTED_ROW_GROUPS = 8 — the group-count target: enough to cluster services and give pruning granularity, not so many the per-group footer/page-index overhead dominates.
  • MIN_COMPACTED_RG_BYTES = 1 MiB — the floor. It clamps the computed rotation threshold (estimate / K) up to at least 1 MiB, so compaction never rotates pathologically often on a small partition. It bounds the threshold, not the resulting group size: a partition that never crosses the threshold is a single row group, the final remainder group can itself be < 1 MiB, and whether the partition rotates at all depends on the writer’s in_progress_size crossing the threshold — not a fixed size rule. Its role is that a small-but-not-tiny hour — a few MiB compressed — rotates into several groups instead of one, the lever that makes small real-v8 hours prunable (§9.29/§9.30).
  • MAX_COMPACTED_RG_BYTES = 32 MiB — the ceiling (the old fixed COMPACTED_ROW_GROUP_FLUSH_BYTES value). A huge partition gets more than K groups, each capped at 32 MiB, so a compacted row group never grows back toward the 128 MiB ingest band.
  • estimated_output_bytes is the sum of the partition’s live input file sizes, read from the store during compaction (the inputs are already listed in compact_sorted). The sorted output is the same rows re-compressed — ≈ or a little smaller — so the input total is a safe upper estimate. The function is deterministic in its input, so the same partition compacts to byte-identical output (§3.5 / RFC0036.4).

Worked examples: a 14 MB hour → 14/8 = 1.75 MB flush → ~8 groups (pruning works); a 400 MB hour → 400/8 = 50 MB, capped to 32 MB → ~13 groups, each ≤ 32 MB; a 4 MB hour → 4/8 = 0.5 MB, floored to 1 MiB → ~4 groups (instead of the single group the fixed 32 MiB gave).

Precedence. An explicit compact_partition_with_flush_threshold argument (the §7 sweep seam / tests) wins over the OURIOS_COMPACTED_RG_BYTES env override (the operator escape hatch), which wins over the adaptive value. Production sets neither and gets the adaptive threshold.

Rotation fires on ArrowWriter::in_progress_size, the writer’s estimate of the buffered row-group bytes (dominated by already-encoded page data, not raw uncompressed input), so on-disk row-group size tracks the threshold closely. Combined with the §3.1 sort, each row group’s service.name min/max spans one service — or a boundary pair, or (for a service smaller than one row group, wedged between two others) that service plus its two neighbours — while its time min/max stays tight within a service. A query for any single service scans only the group(s) whose min/max contains it and prunes the rest on plain footer statistics, without even needing the bloom.

This amends hazard H4’s row-group band. docs/hazards.md H4 targets “row-group size 128 MB – 1 GB”; this RFC deliberately drops compacted row groups below that band. The band’s purpose is file economics — LIST calls, footer reads, cold-cache hits — and those are governed by the file band (256 MiB – 2 GiB), which is untouched: compaction still emits one file per partition, D3 still measures files. Within one file, a smaller row group costs a few more footer metadata entries (~14 vs ~4 at D3 scale) and buys pruning granularity — the trade this RFC exists to make. On acceptance, H4’s mitigation bullet is reworded to scope the 128 MB – 1 GB row-group target to ingest-side files and state the compacted threshold as the pruning-granularity knob (a one-line docs/hazards.md edit shipped with the implementation; H4.4’s detection signals are file-based and unaffected).

3.4 Declared sorting_columns

The compaction writer declares Parquet sorting_columns — the §3.1 keys 1 and 2 (or key 2 alone for time-only tenants) — via WriterProperties. Two honest clarifications: (a) the pruning win of §3.3 comes from the physical clustering making per-row-group statistics tight, not from this metadata — statistics prune whether or not a sort is declared; (b) the declaration is what lets order-aware execution (DataFusion sort-elision, merge scans, future DSL ORDER BY/limit pushdown) trust the layout without a defensive re-sort. It is pure footer metadata: no Parquet schema change, old files without it read exactly as before, readers that ignore it are unaffected — CLAUDE.md §3.5 is satisfied with no migration (RFC0036.5 pins this). Ingest-side files declare nothing in this RFC (their rows are genuinely unsorted post-RFC 0035; declaring a sort they don’t have would be a lie — a seal-time sort that would make a time-only declaration true is §7).

3.5 Determinism

The comparative harness depends on byte-identical store rebuilds (§9.13’s determinism note: “for repeated measurements of the same build and configuration, Ourios’s bytes are byte-identical” — that property is what lets the run series read as an optimisation ledger). The §3.1 key is a total order over the partition’s rows: lexicographic service value + timestamp + the (sorted-basename input ordinal, row ordinal) tie-break leave no two rows unordered, so the merged row sequence is a pure function of the input files’ contents and names. Row order alone does not imply byte identity, though — page and row-group boundaries, dictionary state, and footer contents must also be deterministic. They are, by the same writer-level invariants today’s §9.13 property already rests on: fixed sub-batching (SUB_BATCH_ROWS), a fixed row-group threshold evaluated on the same deterministic in_progress_size accounting, fixed writer properties (codec level, dictionary/statistics/bloom settings), and no time-or-randomness-dependent metadata. Deterministic rows fed through a deterministic serializer yield deterministic bytes — the identical argument that makes today’s unsorted builds byte-identical, with the sort adding only a deterministic permutation and (for spilled runs) deterministic run boundaries from fixed spill thresholds. RFC0036.4 pins the end-to-end claim with a byte-identity rebuild test, which subsumes the row-order property.

3.6 What stays exactly as-is

The ingest path in full — streamed append, the RFC 0035 encode pool and its drain-and-flush barrier, WAL-before-ack, the flush policy constants. The Parquet schema and every column’s encoding. The partition path scheme (no service dimension — §4 rejects it). The compaction manifest protocol (bootstrap, CAS, GC, orphan sweep) and the RFC 0022 re-projection semantics — clustering rides the same rewrite pass and re-projects under the same current promoted set. The query DSL and querier surface. The L6 gate disposition: the latency floor stays the frozen gate, the storage channel stays a published diagnostic.

4. Alternatives considered

  • Service sub-partitioning (tenant × time × service paths). Adding service.name to PartitionKey would make pruning trivial — and reintroduce exactly the hazard this RFC lives under: per-service files multiply file counts by service cardinality, and low-volume services (v8’s “ad” at ~34 s of activity per window) produce precisely the small-file/LIST blowup H4 exists to prevent. It is also a physical path layout change — every existing partition would need rewriting or dual-path read logic, a migration burden §3.5 reserves for schema-level necessity. Row-group-level clustering buys most of the pruning at zero path/migration cost. Rejected.
  • Ingest-time sorting. Sort rows before the ingest-side writer instead of at compaction. This breaks the streamed-append model: a sort needs the partition’s rows resident and re-orderable, so buffers pin their contents until seal, inflating residency against the 1 GiB SINK_CEILING_BYTES and adding latency-shaped work to the path RFC 0035 just relieved — and it tangles directly with the fresh encode pool, whose correctness argument (order-insensitive emit) was accepted weeks ago. Ingest-side files are short-lived (compaction consumes them); sorting them buys granularity only until the compactor runs. Rejected in favour of sorting where rows are already rewritten.
  • Gating the L6 storage channel. Set a storage-bytes floor and drive layout work against it. RFC 0031 §5 already considered and declined this — and §2.2 shows why it is unwinnable as a gate: the registry acquisition alone exceeds Loki’s entire k=100 read, independent of layout. Gating it would either force dishonest accounting (exclude the registry) or freeze a guaranteed FAIL. The channel stays a published diagnostic; the mechanism gets the gate (RFC0036.2). Rejected.
  • Do nothing. The frozen gates all pass (§9.24) — no gate forces this work. But §9.13 and §9.24 both name write-side layout as the remaining storage-side lever, window materialization is the honest weak spot the comparative program documented, and hazard #4 explicitly escalates layout tuning to an RFC. Leaving the named lever unpulled leaves every window query paying whole-hour materialization for a k-row answer. Rejected.

5. Acceptance criteria

Scenario RFC0036.1 — compacted layout (clustering + sizing + declaration).

  • Given a partition holding ≥ 2 input files whose rows span multiple promoted service.name values and interleaved times
  • When the partition is compacted
  • Then footer inspection of the consolidated file shows: row groups rotated at the configured compacted threshold (each uncompressed size ≤ threshold + one sub-batch’s bounded overshoot), sorting_columns declared as §3.1 keys 1–2 on every row group, and per-row-group service.name min/max spanning at most a boundary pair of services
  • And decoding the file yields rows in §3.1 key order, with the row multiset equal to the inputs’ union.

Scenario RFC0036.2 — window-query materialization (the point).

  • Given a compacted store built from a §9-style corpus (the v8 shape: one hour, many services, promoted service.name)
  • When the L6-shape query (one service, k-row time window) runs
  • Then the row groups scanned (the RFC 0016 scanned/pruned counts) are ≤ ceil(B_sw / T) + 2, where B_sw is the queried service’s bytes within the window (measurable from the compacted file’s footer: the sorted layout places one service’s window in contiguous row groups) and T is the configured row-group threshold — i.e. the groups that hold the answer plus at most two boundary groups, not the whole hour
  • And the before/after materialization bytes (total minus the registry acquisition) are measured on the same corpus and published in the §9 series as the storage-channel diagnostic — expected to fall by roughly the row-group-count ratio; the gate here is the scanned-row-group bound, not a bytes ratio (§2.2 — the registry floor makes bytes a diagnostic).

Scenario RFC0036.3 — compaction properties preserved (D2 / D3 / memory).

  • Given the §9.7-scale compaction workload (band-scale partition, tens of input files)
  • When the sorted compaction runs
  • Then D3 holds unchanged (one output file per partition, inside the 256 MiB – 2 GiB band, < 5% of live files below 128 MiB) and D2 compaction throughput stays within an agreed band of the §9.7 measure (sorting is not free; the band is set at red from a first measurement, and “keeps up” — throughput ≫ per-partition seal rate — must still hold)
  • And a memory-bound test shows peak decoded-row residency of the order of one input file (phase 1) and F × batch (phase 2) — compacting an N-file partition must not regress to whole-partition residency.

Scenario RFC0036.4 — determinism (the harness’s contract).

  • Given the same set of input files (same bytes, same names)
  • When the partition is compacted twice — including with the store returning listings in different orders
  • Then the two consolidated outputs are byte-identical, preserving the §9.13 determinism property the comparative ledger depends on.

Scenario RFC0036.5 — no read-path or schema regression.

  • Given stores built before and after this change, and old (pre-RFC) compacted files read by the new reader
  • When B1/B2 and the frozen RFC 0031 comparative gates run against the post-change store
  • Then every frozen gate still passes with the L1/L3/L4 pairs not degraded beyond the documented Loki-wobble band (sorted, smaller row groups should help or be neutral — measured, not assumed), query results are identical row-sets, and old files (no sorting_columns, 128 MiB row groups) read without error or special-casing — no migration exists because none is needed (CLAUDE.md §3.5).

6. Testing strategy

Mapped to CLAUDE.md §6.2; techniques per §5 scenario id:

  • RFC0036.1 — footer-inspection unit tests in ourios-parquet: compact a synthetic multi-service partition, then assert via ParquetMetaData the row-group sizes, sorting_columns, and per-group service.name/time statistics; decode and assert §3.1 order. Plus a property test (proptest) for the merge itself: arbitrary input files with arbitrary service/time/duplicate-key mixes ⇒ output multiset equals input union, output is §3.1-sorted, and equal-key rows land in tie-break order (the stability clause RFC0036.4 leans on).
  • RFC0036.2 — the comparative dispatch + querier counters. The L6-shape pair on the v8 corpus through the RFC 0031 harness; assert the scanned/pruned row-group counts (RFC 0016 emits them raw) against the ceil-bound; record before/after bytes in the §9 series. A smaller in-repo integration test pins the scanned-count bound on a synthetic hour so CI catches granularity regressions without the full harness.
  • RFC0036.3 — criterion compaction bench + a memory test. The existing compaction bench group re-run with sorting to set and then hold the D2 band; D3 assertions unchanged (rfc0009_1_*-style structural tests extended). Memory: compact an N-file partition under an allocation-tracking harness (or peak-RSS measurement in the bench) and assert the phase-1/phase-2 bounds — the test fails if the merge ever holds the whole partition decoded.
  • RFC0036.4 — a rebuild differential. Compact the same inputs twice — second run with a shuffled listing order (store fake) — and assert byte equality of the outputs (a file hash is correct here, unlike RFC0035.5’s decoded-row equality: byte identity is exactly the property claimed).
  • RFC0036.5 — existing suites + the comparative gates. B1/B2 and the frozen-gate dispatch on a post-change store; the RFC 0005 reader forward/backward tests extended with a pre-RFC-0036 fixture file (no sorting_columns) to pin no-migration reads.

7. Open questions

  • The compacted row-group threshold — RESOLVED 2026-07-22 by the §3.3 adaptive amendment (target-K). Originally settled at a fixed 32 MiB; the amendment replaces it with adaptive_flush_bytes = clamp(estimated_output_bytes / 8, 1 MiB, 32 MiB), keeping the 32 MiB value as the ceiling. Why the fixed value had to go: §9.28/§9.29 proved it inert on the real v8 corpus — a real per-hour partition is a few MiB compressed, so at 32 MiB it compacts to one row group and prunes nothing (§9.29 skipped at the 32 MiB default), while the same fixed value fragments large hours. The adaptive floor (1 MiB) is what rotates a small real hour into several service-clustered groups; the ceiling (32 MiB) caps huge hours. §9.30 measures the win at the adaptive default (no longer inert). The scanned-count gate (RFC0036.2) does not hard-code a threshold: it recomputes T = adaptive_flush_bytes(input_total) — exactly what the writer derived — and the bound ceil(B_sw / T) + 2 from it, so the gate moves with whatever the layout produces. It is not auto-satisfied — the gate still fails if the target service stops clustering contiguously and extra groups are scanned. The indicative in-repo sweep is done (§9.28, local M-series, synthetic-compressible, service-clustered corpus — the shape §9.27’s random payload was not); the authoritative 16/32/64 MiB sweep against the L6-shape scanned-bytes curve and L1/L3 neutrality (more row groups = more footer entries and per-group index overhead) stays a paid baseline-8vcpu-32gib measurement deferred to the baseline harness and not a green blocker. §9.28 findings (2,160,000-row six-service hour, 7.20× compression): (1) the compacted file does not grow as the threshold shrinks — 32 and 64 MiB give a byte-identical file, 16 MiB is larger by only +0.80% — so §9.27’s “compacted file ~2× larger” was a random-bytes artifact, not a real-log property; (2) a fixed one-service window materialises half the bytes at 16 MiB (14.66 MiB) vs 32/64 MiB (28.94 MiB), same answer, for that +0.80% disk cost — the pruning-granularity trade is good on compressible data and improves as T shrinks; (3) 32 and 64 MiB are near-identical because arrow’s default 1,048,576-row group cap (no max_row_group_size is set) fills a group at ~30 MiB on this ~30 B/row corpus and so trips before either byte threshold — a granularity floor finer than 32/64 MiB. It is not a bug to “fix” by raising the cap: doing so would let 32/64 MiB coarsen (~2 groups), the wrong way for pruning. The pruning lever is a smaller byte threshold (16 MiB, byte-governed → finer groups), the §7 sweep question — not the row cap. Disposition (superseded 2026-07-22): these indicative numbers showed the trade leans toward smaller thresholds and, together with §9.29’s finding that a fixed 32 MiB is inert on real per-hour v8 volume, motivated the §3.3 adaptive amendment: the threshold now scales as input_total / 8, floored at 1 MiB and capped at 32 MiB, so small hours get the finer groups §9.28 favoured (down to the 1 MiB floor) and large hours stay capped. §9.30 confirms the win at the adaptive default. The authoritative full-v8 sweep of the ceiling and target-K against L1/L3 neutrality stays deferred to baseline-8vcpu-32gib. Still tunable via the OURIOS_COMPACTED_RG_BYTES env knob (overrides the adaptive value) and the explicit compact_partition_with_flush_threshold seam (the sweep’s test arm; setting a process env var is unsound under cargo test’s parallelism).
  • Sort-key stability definition — settled as lexicographic. The §3.1 key is lexicographic service.name, not first-seen/dictionary ordinal (first-seen is interleaving-dependent and would break RFC0036.4). Confirmed: dictionary encoding of the compacted service.name column is unaffected by row order (it keys on the set of distinct values, not their arrival order), and RFC 0022 bloom sizing is per-value, not order-dependent — nothing downstream prefers first-seen order.
  • Unpromoted-attribute tenants. No promoted service.name ⇒ time-only sort (§3.1). Still wins time-pruning granularity; confirm the sorting_columns declaration degrades to the single time key cleanly and RFC0036.1’s assertions have a time-only variant.
  • Ingest-side time-only sorting_columns. Ingest files are near-time-ordered but not sorted (RFC0035.5), so declaring order today would be false. A seal-time sort of the in-memory buffer by time_unix_nano is cheap (the buffer is already resident, ≤ the 256 MiB target) and would make a time-only declaration true — a separate small win for queries that hit not-yet-compacted files. Possibly in-scope at red if it falls out of the writer work; otherwise its own follow-up.
  • Interaction with RFC 0022 re-projection. Clustering rides the same compaction pass that re-projects promoted columns (§3.6). Confirm a promoted-set change between builds is correctly out of scope for RFC0036.4 (determinism is claimed for same-configuration rebuilds — §9.13’s phrasing — not across config changes), and that sorting keys read the current promoted set, matching re-projection.
  • Run format and fan-in cap F. Decided: sorted runs spill as Parquet in the data schema with spill-oriented properties (no dictionaries, no statistics, ZSTD-1), reusing the existing Reader/writer rather than a parallel Arrow-IPC codec path — a run’s bytes influence the output only through its decoded rows, so IPC’s cheaper encode doesn’t pay for a second read path. Fan-in F = 64 single-passes every realistic partition (§9.7’s band-scale case held 32 inputs) while capping worst-case phase-2 residency at F × one decoded batch. Small partitions skip spilling entirely: while total encoded input stays ≤ 256 MiB (SINK_TARGET_BYTES, the ingest seal target), the sort runs fully in memory — no larger than phase 1’s existing one-input bound.
  • The H4 wording amendment (§3.3): landed with this slice — docs/hazards.md H4 scopes the 128 MB–1 GB row-group target to ingest-side files and states the compacted threshold as the pruning-granularity knob; the file band and H4.4 file-based detection signals are unchanged.

8. References

  • docs/benchmarks.md §9.13 — the L6 window-browse loss table (runs #8–#17), run #17’s no-bloom-collapse diagnostic, the “write-side layout” lever naming, and the determinism note this RFC’s RFC0036.4 preserves; §9.24 — the authoritative run (k=100 = 1,931,911 B; latency floors 0.370/4.341 pass; the lever “parked on its own line”); §9.7 — D2/D3 at band scale (the 456.7 MiB consolidated file, 166.8 MiB/s).
  • RFC 0031 (comparative program) — §5’s L6 disposition (latency floor gated, storage-bytes published-not-gated) and the frozen- gate set RFC0036.5 must keep green; the #498 scoreboard line this RFC discharges.
  • RFC 0033 (cached template map) — the 187,904 B warm acquisition: the layout-independent floor §2.2 is built on.
  • RFC 0009 (compaction) — the manifest/CAS machinery §3.6 leaves untouched; RFC0009.5 input validation; the D2/D3 measures RFC0036.3 re-asserts. RFC 0022 — promoted attribute columns and the §3.4 re-projection pass clustering rides. RFC 0014 — the flush-policy defaults (ourios-server/src/receiver.rs:55–57) and their §7 knob deferral. RFC 0035 — the ingest concurrency model §3.6 keeps untouched, and RFC0035.5’s intra-partition row-order disclaimer that forces §3.2’s run-formation phase. RFC 0005 §3.5 — the row-group and file bands this RFC re-scopes for compacted output. RFC 0016 — the scanned/pruned counts RFC0036.2 asserts against.
  • Code (paths under crates/ourios-parquet/src/ unless noted): writer.rs:60, writer.rs:627, writer.rs:644 (the 128 MiB uncompressed rotation; no sorting_columns, no max_row_group_size anywhere), crates/ourios-server/src/receiver.rs:55–57 (seal policy), partition.rs:29–43 (PartitionKey — no service dimension), compaction.rs:184–308 (one-file-at-a-time streaming, the §3.2-preserved memory property at compaction.rs:228–234, sorted basenames at compaction.rs:267–268), reader.rs:57 (the streaming ParquetRecordBatchReader §3.2’s merge builds on).
  • docs/hazards.md H4 — the small-file problem: the file band (unchanged), the row-group band (amended, §3.3), and the “sustained … → RFC” escalation this RFC answers. CLAUDE.md §3.5 (no schema change — §3.4 here), §3.6 (local scratch runs are cache, not truth), §2 pillar #1 (pruning via footer reads — the property this RFC restores granularity to).

RFC 0037 — GenAI / structured-event logs


rfc: 0037 title: GenAI / structured-event log handling status: green author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-22 supersedes: — superseded-by: —

RFC 0037 — GenAI / structured-event log handling

Status: green (2026-07-23). All five §5 acceptance scenarios pass. Implementation landed in three slices — §3.1 event-keyed templates (#599), §3.2 fidelity + structured_body_bytes observability (#600), §3.3 group-by-promoted-attribute (#601) — carrying RFC0037.1 (miner rfc0037_1_* example + property tests), RFC0037.3 (miner rfc0037_3_* unit + tests/rfc0037_structured_body.rs integration), and RFC0037.4 (ourios-querier rfc0037_4_count_by_promoted_attribute). RFC0037.2 (structured-body reconstruction) and RFC0037.5 (absent-body parity) are covered by the standing reconstruction property (RFC 0024 generates structured bodies) and rfc0025_absent_body.rs respectively — see §6. validated waits on the v9 corpus (§3.4).

Direction resolved 2026-07-22 (maintainer): the §3.2 fork is decided — Option A (full fidelity + observability). Structured bodies are never truncated; the guard against hazard #2 is a structured_body_bytes metric plus a per-service alert, not a size cap. The opt-in structured_body_byte_limit (former Option B) is dropped and recorded as a rejected alternative (§4). The remaining §7 items are minor scoping defaults, not forks.

1. Summary

OpenTelemetry now models GenAI/LLM agent activity as log events: a LogRecord carrying an event_name (e.g. gen_ai.client.inference.operation.details) and a structured body — an AnyValue array/kvlist such as gen_ai.input.messages — rather than a string. Ourios already ingests, stores, and reconstructs these records correctly today (structured body → canonical JSON in the body column, event_name as a first-class column, RFC 0025 covering the absent-body event shape). This RFC does three things on top of that working base: (1) closes a hazard-#2 gap where structured bodies bypass the param_byte_limit cardinality guard entirely; (2) folds event_name into the structured-template key so distinct event types get distinct template_ids instead of collapsing to one (severity, scope) sentinel; and (3) extends the count … by surface to group by a promoted attribute column, so the canonical GenAI aggregation (“completions by gen_ai.request.model”) is expressible. Scope stays within §1 — these are logs, not a metrics or evaluation backend.

2. Motivation

Why now. The OpenTelemetry GenAI semantic conventions have moved to a dedicated semantic-conventions-genai repository and stabilized the “chat history as a log event” shape (gen_ai.input.messages / gen_ai.output.messages, the consolidated gen_ai.client.inference.operation.details event, opt-in via OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental). The opentelemetry-demo main branch adds an agent service (LangGraph ReAct

  • LLM calls) emitting exactly these records; the demo’s next release is the planned corpus/otel-demo-v9 capture (issue #546). When that corpus lands, Ourios should already have a specified, tested contract for the workload rather than discovering its behavior empirically.

Why at this layer. GenAI event bodies are wordy, structured, unique per request, and carry the payload operators most want to retain verbatim (“show me the exact prompt and completion”). That intersects three Ourios invariants/hazards directly:

  • Hazard #2 / §3.2 (parameter cardinality). A single gen_ai.input.messages array can be tens of kilobytes. The param_byte_limit overflow guard (crates/ourios-miner/src/overflow.rs) protects the string path only; structured bodies are stored whole (cluster.rs ingest_structured) with no size bound. This is an unguarded ingress for large blobs.
  • Invariant §3.3 (bit-identical reconstruction). Operators want the chat history back byte-for-byte. Any bounding of body size must either preserve fidelity or explicitly flag the record lossy — never silently truncate.
  • Hazard #6 (query DSL surface). The value of promoting a gen_ai.* attribute to a column is grouped counting; today promotion buys filtering only, and count … by rejects attribute-column group keys.

An OTLP-native logs backend that mishandles the AI-observability workload of 2026 misses the moment. This RFC makes the handling deliberate.

3. Proposed design

3.0 Current behavior (verified, unchanged)

The following already hold and are not changed by this RFC; they are recorded so the delta is unambiguous:

  • A non-string AnyValue body becomes Body::Structured (crates/ourios-core/src/otlp.rs) and is stored whole as canonical JSON in the nullable body column with body_kind = Structured (ordinal 1). The Drain tree is never walked for structured bodies.
  • event_name is captured end-to-end and stored as a nullable Utf8 column (crates/ourios-parquet/src/lib.rs, columns::EVENT_NAME).
  • RFC 0025 represents the event-shaped absent-body record (event_name + attributes, no body) as body_kind = Absent (ordinal 2), body = NULL.
  • A gen_ai.* resource/log attribute can be promoted to a pruned, full-operator, filterable column via storage.promoted_attributes.{resource,log} (RFC 0022), advertised in the ourios://query-schema resource (RFC 0032).

3.1 Structured-template key includes event_name

Today ingest_structured keys the per-tenant structured-template map on (severity_number, scope_name), so every GenAI event in one scope collapses to a single template_id regardless of event_name. Fold event_name into the key: (severity_number, scope_name, event_name). Consequences:

  • count … by template_id distinguishes …inference.operation.details from a tool-call event from an application log line in the same scope.
  • Row-group pruning on template_id (pillar #1) becomes selective for event types, not just severity/scope.
  • The change is additive to the template population; it re-bases RFC 0024 calibration for any corpus containing structured events (§6).

The template_id allocation stays deterministic and per-tenant; the confidence = 1.0, lossy_flag = false, empty-params invariants for structured records (RFC 0001 §6.1) are preserved.

3.2 Bounding the structured body against hazard #2

Structured bodies must not be an unguarded ingress, but §3.3 forbids silent truncation and the structured body is the payload the operator wants back verbatim. Resolved (Option A): never truncate; guard by observation, not by a cap.

  • No size cap, ever. A structured body is retained whole regardless of size. Fidelity (§3.3) is never at risk, and lossy_flag stays false for the structured path (RFC 0001 §6.1).
  • structured_body_bytes metric. A histogram observing the canonical-JSON byte length of every structured body, dimensioned by service so an operator can see which service emits large bodies. Instrumented via an OTel meter (Ourios’s self-observability convention), not a Prometheus client.
  • Per-service alert when the structured-body byte rate crosses a soft threshold, mirroring the existing §3.2 param-overflow-rate alert. This is the operational signal that a service is shipping oversized payloads — actionable at the source (the emitter), which is where the fix belongs.

Rationale for no cap: hazard #2’s failure mode is dictionary-encoding collapse on a column of otherwise-repeating values, and the body column is not dictionary-encoded — the Parquet writer disables the dictionary on body by design (crates/ourios-parquet/src/writer.rs §3.6: bodies are unbounded, high-entropy, and dictionary encoding is the wrong choice for them). So a large structured body has no dictionary to collapse; the hazard is structurally absent for this column. The remaining cost of a large body is raw storage size — an operational signal, not a correctness threat — and capping it would trade the operator’s payload (the thing they most want) for bytes. The write-side layout work (RFC 0036) already governs file/row-group sizing, so large bodies are bounded at the storage layer without ever discarding data.

docs/hazards.md §2 is amended to state that structured bodies are retained whole and guarded by the structured_body_bytes metric + alert (not a length cap) — closing the current silent gap in the written invariant, which reads as if the param_byte_limit covers all bodies.

3.3 Group a count by a promoted attribute column

Extend the count … by group surface so a GroupTerm may reference a promoted attribute column (service already works; generalize to resource.<key> / attr.<key> when the key is in the effective promoted set and its column is present in the scanned union schema). The compiler (crates/ourios-querier/src/compile.rs field_group_expr) currently rejects Field::Resource(_) / Field::Attr(_) group keys; allow them only when promoted, falling back to the same typed-NULL literal that service uses for partitions predating the promotion. A non-promoted attribute stays rejected for grouping (it has no pruned column and grouping by an unpruned JSON LIKE scan is a footgun) — the error message points the user at promotion, and the ourios://query-schema cost model already classifies the distinction.

This makes the canonical GenAI aggregation expressible: gen_ai.operation.name == "chat" | count by attr.gen_ai.request.model, bucket(1h). It reuses the L4 machinery RFC 0031 measures; the (bucket, group_key) comparison unit is unchanged.

3.4 Corpus & calibration

The v9 GenAI corpus (#546) is the representative corpus for validating this RFC’s miner and reconstruction behavior. Sequencing is unchanged from #546: port already done (#547), capture v9 when the demo releases, freeze, then a v9 calibration manifest (RFC 0024) that now accounts for event_name-keyed structured templates (§3.1). Until v9 exists, acceptance runs on a synthetic GenAI-shaped fixture (structured input.messages/output.messages bodies, event_name set, gen_ai.* attributes) checked into testdata/.

A real, available-now AI-agent source: Claude Code’s OTLP export. Claude Code — the agent authoring this project — can export OpenTelemetry over OTLP (metrics plus log events carrying token usage, cost, and tool activity). It is therefore a genuine, self-hosted AI-agent telemetry stream we can point at Ourios today, ahead of the v9 demo release, and it fits the existing dogfood posture (Ourios already exports its own logs via the OTLP bridge).

Claude Code emits its own event schema (its namespace, not the gen_ai.* semconv), so raw it exercises the structured-event shape — event-heavy, token-bearing, wordy — more than the exact promoted keys of §3.5. But that gap closes upstream, where it belongs: an intermediary OpenTelemetry Collector running OTTL (a transform processor, or the purpose-built gen_ai_normalizer processor) rewrites claude_code.* events into the gen_ai.* semconv before they reach Ourios. This is not a workaround — it is the architecturally correct placement. Ourios’s OTLP-only stance is that schema normalization is the Collector’s job, never an in-product parser; doing it in OTTL keeps that boundary clean and turns the dogfood stream into a gen_ai.*-conforming corpus that validates §3.3’s promoted keys, not merely the shape. It reuses the existing collector-interop harness (real otelcol-contrib → Ourios). Treated as an additional corpus, not a replacement for the v9 capture — the demo gives us instrumentation-native gen_ai.* with no transform in the path, which is the cleaner validation of the promotion set; the Claude Code stream gives us real AI-agent data now.

A true GenAI vertical slice (a separate ingest path + a gen_ai-typed Parquet schema + a GenAI-specific query surface) was considered and rejected. The attraction of a slice is typed, prunable columns for the scalar GenAI fields; but promotion (RFC 0022) already delivers exactly that as additive per-deployment config, so a slice would fork the write path, schema, compaction/retention, and every cross-cutting concern (tenancy §3.7, WAL §3.4, object-storage truth §3.6) — two products in one binary — while pinning an on-disk schema to a development-stability, freshly-relocated semconv (CLAUDE.md §3.5 caution). The residual work to get the slice’s value is small: carry the last few promotions the GenAI SIG has already identified as the correct low-cardinality dimensions, plus the three deltas in §3.1–§3.3. This RFC takes that path.

The recommended default promotion set mirrors the SIG’s own metric-dimension guidance — “safe as a metric dimension” is precisely “low-cardinality, safe to promote and group,” and the same source says to keep prompt/completion text and user IDs as payloads, not dimensions:

  • Promote (low-cardinality, group- and filter-friendly): gen_ai.operation.name (enum: chat, embeddings, execute_tool, invoke_agent, …), gen_ai.provider.name (enum: openai, anthropic, aws.bedrock, …), gen_ai.request.model, gen_ai.response.model, gen_ai.output.type (enum), and for agent workloads gen_ai.agent.name, gen_ai.tool.name, gen_ai.tool.type. These are the natural count … by keys enabled by §3.3.
  • Promote for filtering only, never group: gen_ai.conversation.id, gen_ai.response.id — high cardinality. Promotion gives a pruned equality filter (“find this one conversation”); grouping by them is nonsense. A reminder that §3.3’s “group by any promoted column” needs operator judgement — promotable ≠ groupable.
  • Never promote (body payload): gen_ai.input.messages, gen_ai.output.messages, gen_ai.system_instructions, gen_ai.tool.call.arguments/result, gen_ai.retrieval.documents — the content. Sensitive (the SIG flags PII), unique, large → structured body, filter-by-scan at most.

Two boundaries this set makes explicit:

  • Promotion is verbatim projection + dictionary encoding, not template mining. An attribute value is already the isolated discrete token that mining exists to extract from an unstructured body; it has no constant-plus-variable structure to collapse. Low-cardinality repetition is captured by the promoted column’s dictionary encoding (RFC 0022), which is the physical analogue of what a template_id is for a body. The miner is never invoked on attribute values.
  • Token counts are measures, not dimensions — and summing them is out of scope (CLAUDE.md §1). gen_ai.usage.input_tokens / output_tokens are integers you would sum or take percentiles of, not group by; and promotion projects string values only, so they are not promotion targets regardless. The headline “tokens/cost by model per hour” query is a numeric measure aggregation (SUM/AVG/percentile), which is the province of the OTel gen_ai.client.token.usage metric, not a logs backend. Ourios lets an operator find and filter by token count and count events by model (the L4 frequency class); it deliberately does not sum tokens. This boundary keeps §1 (“not a metrics backend”) intact.

4. Alternatives considered

  • Mine the structured body (walk the AnyValue tree, extract inner fields as params). Rejected: it re-derives what the emitter already structured, invites semantic template merges across message shapes (hazard #1), and couples our schema to a fast-moving semconv. Storing the canonical tree whole and promoting the few scalar attributes that matter is both safer and more faithful.
  • A dedicated gen_ai-typed body column / sub-schema, or a full GenAI vertical slice. Rejected as premature schema commitment (CLAUDE.md §3.5) and a fork of the “one stack, thin glue” thesis (§2); see §3.5 for the full rationale. body_kind = Structured + event_name + the recommended promoted scalar set already give queryability without pinning the on-disk format to a development-stability, freshly-moved semconv.
  • Truncate large structured bodies by default. Rejected: violates §3.3. Fidelity is unconditional (§3.2, Option A).
  • Opt-in structured_body_byte_limit with a lossy flag (former Option B). Considered and rejected (maintainer, 2026-07-22): even as an off-by-default knob it adds a lossy structured-body code path and a reconstruction branch for a case the metric + per-service alert (§3.2) already surface at the source. The emitter, not the store, is where an oversized payload is fixed; a store-side cap trades the operator’s payload for bytes that RFC 0036’s storage-layer sizing already bounds.
  • Group by non-promoted attributes too (JSON LIKE group key). Rejected: no row-group pruning, unbounded scan, silently expensive — exactly the DSL footgun hazard #6 warns against. Promotion is the gate.
  • A separate RFC per delta. The three deltas share one workload, one corpus, and one test fixture; splitting them triples process overhead for changes that land together. Gap 3 is the most separable and could be peeled off if review prefers.

5. Acceptance criteria (frozen 2026-07-23)

One scenario per invariant/hazard touched; each id is referenced from the test code so the mapping is greppable (docs/verification.md §2):

Scenario RFC0037.1 — event-keyed structured templates (§3.1).

  • Given two structured records in one (tenant, severity_number, scope_name) whose event_names differ
  • When they are mined
  • Then they receive distinct template_ids
  • And … | count by template_id separates them into distinct groups.

Scenario RFC0037.2 — structured-body reconstruction fidelity (§3.3 invariant).

  • Given a structured GenAI body (an AnyValue array/kvlist)
  • When it is stored and rendered back from Parquet
  • Then the canonical JSON round-trips byte-for-byte
  • And lossy_flag = false (a property test over generated bodies).

Scenario RFC0037.3 — unbounded fidelity + observability (§3.2 / hazard #2).

  • Given an arbitrarily large structured body
  • When it is mined and stored
  • Then it round-trips byte-for-byte and is never truncated (lossy_flag = false)
  • And the structured_body_bytes metric observes its canonical-JSON length, dimensioned by service.

Scenario RFC0037.4 — grouped count by a promoted attribute (§3.3 / hazard #6).

  • Given gen_ai.request.model promoted to a column
  • When … | count by attr.gen_ai.request.model, bucket(1h) runs
  • Then the (model, bucket) → count map (keys in by-list order) equals a brute-force baseline
  • And the same query against a non-promoted key is rejected with a promotion hint (never a silent unpruned scan).

Scenario RFC0037.5 — absent-body event parity (§3.5 / RFC 0025).

  • Given an event record with event_name and attributes but no body
  • When it is stored
  • Then body_kind = Absent and the body cell is NULL
  • And RFC 0025’s absent-body read-path parity is unbroken.

6. Testing strategy

Each scenario maps to a greppable test (docs/verification.md §2):

  • RFC0037.1 (§3.1): ourios-miner cluster.rs rfc0037_1_event_name_distinguishes_structured_templates (example) and rfc0037_1_structured_key_is_the_whole_template_identity (proptest over arbitrary (severity, scope, event_name) tuples), plus snapshot.rs structured_template_record_without_event_name_restores_as_none (the #[serde(default)] migration).
  • RFC0037.2 (structured-body reconstruction): RFC 0024’s ourios-* property tests generate structured AnyValue bodies and assert they round-trip canonical-JSON equalcanonical::decode_any_value on the rebuilt bytes equals the original AnyValue (decoded value equality; string bodies round-trip bit-identically, structured bodies by decoded equality). Byte-for-byte retention of the stored canonical JSON is pinned directly by rfc0037_3_structured_body_retained_byte_for_byte (slice B). Plus the miner RFC0001.9 canonical-body round-trip (rfc_internal.rs) and the Parquet Structured-row round-trips in rfc0025_absent_body.rs / rfc0021_arrow_upgrade.rs.
  • RFC0037.3 (§3.2 fidelity + observability): ourios-miner cluster.rs rfc0037_3_structured_body_retained_byte_for_byte (colocated unit, byte identity + non-lossy) and tests/rfc0037_structured_body.rs rfc0037_3_structured_body_unbounded_fidelity_and_observability (the structured_body_bytes histogram records the canonical-JSON length under the required ourios.tenant, plus the recommended ourios.service present for this service-bearing record, via the in-memory meter).
  • RFC0037.4 (§3.3 grouped count): ourios-querier tests/it/rfc0002_dsl.rs rfc0037_4_count_by_promoted_attribute — the (model, bucket) → count map (keys in by-list order) equals a brute-force oracle (RFC 0031 L4 shape); the non-promoted key is rejected with a hint naming the raw config key + sublist.
  • RFC0037.5 (absent-body parity): covered by RFC 0025’s rfc0025_absent_body.rs (body_kind = Absent, body cell NULL).
  • Calibration (deferred to v9): RFC 0024 manifest regenerated; C1/C2 per-service reconstruction over the GenAI corpus. This is the remaining work for validated.

7. Open questions

  • The fork (§3.2): cap or no cap? Resolved 2026-07-22 — Option A (full fidelity + structured_body_bytes metric + alert; no cap). Former Option B rejected (§4).
  • event_name in the string-path template key? No — scoped to the structured path only. String log records rarely carry event_name, and keeping the string Drain key unchanged minimises the calibration re-base and the blast radius. (Default decision; reversible if a corpus shows string-path event records in practice.)
  • Gap 3 (group-by promoted attribute) in-scope here? Yes — it is the queryability payoff for the promoted gen_ai.* columns this RFC motivates, and it reuses the RFC 0031 L4 unit rather than new machinery. It remains the most separable slice if review prefers to peel it into an RFC 0002 amendment.
  • Implicit promotion of any gen_ai.* keys? No — always deployment config (storage.promoted_attributes). Implicit promotion would pin the schema to a development-stability, recently-moved semconv (invariant §3.5 caution).
  • v9 timing (sequencing, not a fork): this RFC reaches green on the synthetic GenAI fixture (§3.4) and only validated once the demo releases and corpus/otel-demo-v9 is captured (#546).

8. References

  • OpenTelemetry GenAI semantic conventions (moved to open-telemetry/semantic-conventions-genai); gen_ai.input.messages / gen_ai.output.messages / gen_ai.client.inference.operation.details event; OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental.
  • Issue #546 — corpus/otel-demo-v9 readiness (k6 port #547; demo agent service).
  • RFC 0001 (template miner; §6.1 structured-record invariants), RFC 0022 (promoted attribute columns), RFC 0024 (calibration), RFC 0025 (absent-body representation), RFC 0031 (L4 grouped-count comparison unit), RFC 0032 (query-schema resource).
  • CLAUDE.md §1 (scope — logs only), §3.2 (parameter cardinality), §3.3 (bit-identical reconstruction), §3.5 (schema migration caution); hazards #1, #2, #6.

RFC 0038 — Self-tracing


rfc: 0038 title: Self-tracing — the OTel traces signal, disciplined to request scope status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-23 supersedes: — superseded-by: —

RFC 0038 — Self-tracing — the OTel traces signal, disciplined to request scope

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

Status: green (2026-07-24). All seven §5 acceptance criteria are implemented and pass: RFC0038.1 (request-scope spans + log correlation, #614/#615/#616/#617), RFC0038.2 (ingest O(1), #615), RFC0038.3 (spawn-boundary context, #617), RFC0038.4 (traces configured via the universal OTel SDK env vars — no bespoke Ourios surface — with the OTEL_TRACES_EXPORTER=none disable mapping tested), RFC0038.5 (loop guard, #614), RFC0038.6 (flush on shutdown, #614), RFC0038.7 (canonical GenAI/MCP span attributes with the genai-relocation live-check exemption, §3.6; exemption tracked by #622). §3.4 was amended to lean on the universal OTel env vars instead of a bespoke config-file sampler surface (maintainer decision, 2026-07-24).

1. Summary

Ourios dogfoods two of the three OpenTelemetry signals about itself — logs (via the tracing → OTLP appender bridge) and metrics — but not traces. The consequence is concrete: its own log records carry no trace_id / span_id, so a warning from the MCP handler cannot be correlated to the request that caused it. This RFC adds the traces signal, fulfilling CLAUDE.md §6.3 (“every RPC is traced”), which docs/roadmap.md records as deliberately deferred at the first milestone. The commitment is spans on request-scoped operations only — one per query, per MCP tool call, per OTLP Export batch, and per compaction sweep — and a hard rule that the per-record ingest hot path mints no spans. Trace correlation on logs follows for free, because the log-appender bridge stamps the active span’s ids onto every record it emits.

2. Motivation

Why now. A telemetry backend whose own logs cannot be trace-correlated is a credibility gap, and the missing signal was noticed in Ourios’s own dogfooded logs (an rmcp error line with empty trace_id/span_id). §6.3 has always required it; the deferral was a scope call, not a design decision.

Why at this layer. Traces are a process-global concern owned by ourios-telemetry, the single crate that holds the OTel SDK (RFC 0001 §6.8’s export-architecture split: library crates depend on the API only). Adding a SdkTracerProvider + a tracing-opentelemetry layer there is the one place the change belongs.

Why the discipline is load-bearing. Ourios’s thesis is query performance, and its ingest path processes records at high throughput. OpenTelemetry’s own guidance is unambiguous that per-item instrumentation on such a path is wrong: the Collector coding guidelines say to “avoid outputting logs per a received or processed data item … for such high-frequency events instead of logging consider adding an internal metric,” and the trace-span guidance restricts spans to operations that are significant, have duration, and involve out-of-process calls — explicitly not short in-process work or point-in-time occurrences. A span (and its context propagation) per log record would tax exactly the path the project optimises. So the RFC’s central act is drawing the line, defensibly, between request scope (spans) and record scope (metrics, which already exist).

3. Proposed design

3.1 The instrumentation boundary

There is zero span instrumentation in the tree today; the change is purely additive. The boundary:

Gets exactly one spanSignalAnchor
A logs query (POST /v1/query)server span, rootquerier.rs handle_query
Each MCP tool call (query_logs, list_templates, template_drift)span, child of rmcp’s own serve_inner spanmcp.rs #[tool] fns
One OTLP Export batch (gRPC or HTTP)server span at the shared choke pointreceiver/pipeline.rs ingest_bound
One compaction sweepinternal spancompactor.rs sweep tick
Never gets a span (metrics only — already present)
The miner per-record ingest / ingest_mined / ingest_structured
The encode-pool per-record emit_concurrent worker loop
The record-sink per-partition flush_* / drain_* (async, decoupled from the request)
Tenant fan-out’s per-ResourceLogs loop

The per-Export-batch span is the correct coarse boundary (OTel’s messaging convention blesses one “Receive/Process” span for a whole batch); it encloses fan-out + WAL commit + miner hand-off as a whole, at zero per-record cost. Within it, the WAL group-commit — the one genuinely I/O-bound, latency-bearing step (a batched fsync; hazard §3.4 WAL durability-vs-latency) — gets a single child span (commit wal, INTERNAL kind). It has duration and a meaningful boundary, which OTel’s guidance says makes it a span rather than an event (an event is a point in time and cannot carry the commit latency, which is the whole reason to instrument it). This is the trace’s one sub-span; the per-record loops below it stay bare. The record-sink flush is genuinely asynchronous — its work outlives the batch that produced it — so it correctly has no span; we do not thread batch context into the buffer to link flushes back (that is the throughput killer to avoid). Serialize/encode detail, if ever wanted, is a span event, not a span. Per-record observability stays in the metrics the hot path already emits.

Span boundaries coincide with the timing brackets metrics already measure (Instant::now()record_ok/err/record_sweep/WAL-commit timing), so a span is “the causal, parent-child view over the same points metrics already measure” — minimal new code.

3.2 The tracer, in the bootstrap

ourios-telemetry::init builds a SdkTracerProvider (OTLP SpanExporter, batch processor) alongside the existing meter and logger providers, under the same “build all fallible steps before installing globals” discipline, and installs it via global::set_tracer_provider. The subscriber registry gains a tracing-opentelemetry OpenTelemetryLayer next to the existing appender bridge and fmt layer. Binding tracing spans to OTel spans is what makes the ids exist; the log-appender bridge then stamps trace_id/span_id onto every emitted log record automatically — no per-call-site change for correlation.

Two hazards, both flagged for the implementer:

  1. The telemetry-induced-telemetry loop guard must extend to traces. The existing bridge already mutes the exporter’s own tonic/hyper/h2/ tower/opentelemetry* events; the trace layer needs the same filter, or the OTLP exporter’s transport spans feed back into the exporter.
  2. TelemetryGuard must flush the tracer on shutdown (SIGTERM / Drop / the subscriber-already-installed teardown branch), so batched spans are not lost on exit — the same treatment the logger provider gets.

3.3 Span context across the async boundary

The three non-MCP span sites hand work to a detached task — the gRPC/HTTP receivers tokio::spawn the ingest, and the compactor spawn_blockings the sweep. tokio::spawn does not propagate span context. Each span is therefore either opened inside the spawned callee (ingest_bound, the sweep body) or the spawned future is .instrument(Span::current())-wrapped at the call site. This RFC prefers opening the span inside the callee (one choke point, no per-transport duplication). This is the single highest-risk detail and carries its own acceptance scenario (RFC0038.3).

The MCP tool spans need no such care: rmcp already creates a serve_inner span around dispatch, which — once the trace layer exists — becomes their parent and starts exporting for free. The querier and OTLP paths have no such inherited root and get Ourios-created roots.

3.4 Configuration and sampling

Sampling is the second line of defense (the first is not minting hot-path spans). The default is parentbased_always_on — the OTel SDK default, and the right one here: OTel’s guidance says to consider sampling only above ~1000 traces/sec and to avoid it at “tens of small traces per second or lower,” which is where Ourios’s disciplined span count (per query / MCP call / Export batch / sweep — never per record) sits; and as a self-hosted, air-gapped binary there is no per-span vendor cost to manage. The one volume-sensitive span is the per-Export-batch one under heavy ingest, and the standard OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG knob (e.g. parentbased_traceidratio at 0.1) is the operator’s lever for exactly that — Export batches are independent root traces, so ratio-sampling them loses no cross-request correlation.

Lean on the universal OTel SDK env vars — no bespoke Ourios config. These env vars are the config contract operators already know; inventing a parallel Ourios surface for the same thing is drift and a second way to configure one knob. So Ourios configures traces entirely through the standard SDK vars and does not couple a unique config to them:

  • Sampler: Ourios does not call .with_sampler(...). The SDK resolves the sampler from OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG (any standard sampler name; default parentbased_always_on). Invalid values are logged and ignored by the SDK per the env-var spec — Ourios does not add its own validation or precedence layer.
  • Disable: the standard per-signal switch OTEL_TRACES_EXPORTER=none turns the traces pipeline off, restoring today’s logs-plus-metrics posture exactly (no tracer, no trace_id on logs). init() reads it directly — Ourios plays the “autoconfigure” role Go’s autoexport / Java’s autoconfigure play, since the Rust SDK’s manual exporter construction reads no exporter-selector var (#618). TelemetryConfig.traces_enabled (default on) remains a programmatic override on top; OTEL_SDK_DISABLED=true disables all three signals together.
  • Endpoint / transport: OTEL_EXPORTER_OTLP_ENDPOINT and the other OTEL_EXPORTER_OTLP_* vars, already read by the SDK exporter.

There is no telemetry.traces.* config-file section and no file-vs-env precedence: the SDK’s own env resolution is authoritative.

3.5 Span names and attributes

Names are fixed here (low cardinality, ids as attributes not names) and follow OTel’s span-naming guidance: the {action} {target} pattern, no static namespace prefix in the name (the ourios.* dotted style is for metrics, not spans; a span’s origin is the service.name resource attribute, so an ourios. prefix would be exactly the redundant static text the spec says to drop). The MCP tool spans adopt the GenAI convention’s execute_tool {tool.name} form, so Ourios’s own agent-facing tool calls interoperate with GenAI-aware backends. So the §5 contract is complete:

OperationSpan nameKind
Logs queryPOST /v1/query (HTTP {method} {route})SERVER
MCP tool callexecute_tool query_logs / execute_tool list_templates / execute_tool template_drift (GenAI execute_tool {tool.name})INTERNAL (child of rmcp serve_inner)
OTLP Export batchingest logsSERVER
WAL group-commitcommit walINTERNAL (child of the batch)
Compaction sweepsweep partitionsINTERNAL

Required attributes are low-cardinality and set at span start (so they are available to sampling): the query and MCP spans carry ourios.tenant (the query span also the standard http.request.method / http.route / http.response.status_code); the ingest-batch span carries the batch’s record count and the number of distinct tenants it fanned out to (counts, not ids); the sweep span carries the partitions/files swept. Tenant and other identifiers are attributes, never part of the span name, keeping names low-cardinality.

3.6 GenAI/MCP semantic-convention attributes on the tool spans

The MCP tool spans are Ourios’s agent-observability surface: an agent driving the /mcp tools should see them exactly as it sees any GenAI tool call. Each execute_tool {tool} span therefore carries the canonical OTel attributes — gen_ai.operation.name = execute_tool, gen_ai.tool.name (the tool), and mcp.method.name = tools/call, plus mcp.session.id recorded from the forwarded mcp-session-id header so an agent’s calls within one session correlate. The span name follows the GenAI {gen_ai.operation.name} {gen_ai.tool.name} form (execute_tool query_logs etc.); because #[tracing::instrument] requires a static name literal, the name and the two attributes are written separately per tool rather than one derived from the other, so the MCP-span unit test asserts both the name and the attribute values together — a drift between them fails the test.

These four attributes moved out of core semantic-conventions to the separate semantic-conventions-genai registry; in our pinned dependency (semconv v1.42.0) they survive only as deprecated “Moved to …” stubs, which weaver registry live-check reports as violations. weaver cannot take a second registry dependency (not yet implemented: Multiple dependencies is not supported yet), and v1.42.0 still ships the gen-ai/mcp model besides — so a second dependency would also collide on group ids. The live-check job therefore gates on a filtered violation count that exempts only the genai-relocation deprecation for the gen_ai.*/mcp.* namespaces; every other violation (including any other deprecation on those keys) still fails. Issue #622 tracks collapsing this into a single genai dependency once upstream deletes its v1.42 copies.

Driving an MCP call through live-check also surfaces rmcp’s own internal instrumentation (bare session_id / peer_info / notification fields on events at rmcp source lines) — non-semconv third-party noise, not Ourios signal. That is muted at the source, alongside the export-stack loop guard, in ourios-telemetry’s guarded_env_filter (rmcp=off); Ourios’s own execute_tool span (target ourios_server::mcp) is unaffected.

4. Alternatives considered

Correlation-only (a tracer that generates ids but exports no spans). The appender bridge needs only an active OTel span context to stamp ids, so we could install the tracer + layer but attach no span exporter — cheaper, and it fixes the reported symptom. Rejected as a half-step: once the tracer and layer exist, the exporter is a few lines more and delivers the actual traces signal §6.3 asks for; shipping ids that point at spans nobody can see is worse ergonomics than either extreme.

Full auto-instrumentation (span everything, sample hard). Wrap every function / the per-record path in spans and lean on a low sample ratio to control cost. Rejected: sampling reduces export volume but not span creation + context-propagation cost on the hot path, and it muddies traces with per-record noise that OTel’s own guidance says to model as metrics. The metrics already exist; duplicating them as spans is pure cost.

Do nothing / keep traces deferred. Rejected: it leaves §6.3 unmet and the self-logs uncorrelatable, and the deferral’s original rationale (first- milestone scope) has expired.

tracing’s trace_id via a non-OTel mechanism (e.g. a request-id field). Rejected: it would not interoperate with the OTel traces signal a user’s Collector expects, and Ourios’s whole posture is OTel-native.

5. Acceptance criteria

Scenario RFC0038.1 — request-scoped operations open exactly one span, and their logs carry the trace context. Given a server with traces enabled and an always-on sampler, When a logs query, an MCP query_logs call, a single OTLP Export batch, and a compaction sweep each execute, Then each produces the expected span(s): one server span for the query, one child-of-serve_inner span for the MCP call, one internal span for the sweep, and — for the Export — one server batch span with a single commit wal child span (and no further sub-spans), And any log record emitted within that operation carries the operation’s trace_id/span_id (the correlation the reported gap was about).

Scenario RFC0038.2 — the ingest hot path mints no per-record spans. Given traces enabled and an always-on sampler, When one Export batch of N records is ingested, Then the number of spans produced by the ingest path is bounded by the batch/commit structure and is independent of N (O(1) in the record count, not O(N)) — the miner, encode-pool, and record-sink inner loops create none — And the ingest-throughput benchmark shows no regression attributable to tracing beyond the per-batch span (a documented ceiling).

Scenario RFC0038.3 — span context survives the spawn boundary. Given the receiver’s tokio::spawned ingest and the compactor’s spawn_blockinged sweep, When each runs, Then the batch/sweep span is present and correctly parented (not orphaned), so records/log lines produced under it resolve to the batch’s trace — verified by asserting the emitted log’s trace_id equals the span’s (the tokio::spawn context-loss trap is closed).

Scenario RFC0038.4 — traces configure through the universal OTel SDK env vars, and disabling is the standard per-signal switch. Given the standard OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG and OTEL_TRACES_EXPORTER env vars (no bespoke Ourios config surface), When the sampler is left unset; set via env parentbased_traceidratio at a ratio; and OTEL_TRACES_EXPORTER=none, Then the default samples (root) traces (parentbased_always_on, the SDK default — Ourios does not override the sampler); the env ratio sampler exports the configured fraction (the SDK’s own resolution, which Ourios does not alter); and OTEL_TRACES_EXPORTER=none (honored by init()) installs no tracer and stamps no trace_id/span_id on log records — the observable, runtime logs-plus-metrics-only behaviour (no throughput change). (Sampler resolution and invalid-value handling are the SDK’s universal, upstream-tested behaviour; Ourios tests only its own mapping of OTEL_TRACES_EXPORTER=none to the disable path.)

Scenario RFC0038.5 — no telemetry-induced-telemetry loop. Given the OTLP span exporter’s own transport stack (tonic/hyper/…) emits spans/events, When traces are enabled, Then those exporter-internal spans are muted by the same loop-guard filter that mutes them for the logs bridge — exporting a span does not generate more spans about the export.

Scenario RFC0038.6 — spans flush on shutdown. Given a batch span processor with buffered spans, When the server shuts down (SIGTERM / TelemetryGuard::shutdown / Drop), Then the tracer provider is flushed alongside the logger and meter providers, and no acknowledged-window span is dropped on a clean exit.

Scenario RFC0038.7 — MCP tool spans carry the canonical GenAI/MCP attributes, and only their relocation is exempted from live-check. Given the /mcp tool surface with traces enabled, When an agent invokes a tool over an established MCP session, Then the execute_tool {tool} span carries gen_ai.operation.name = execute_tool, gen_ai.tool.name (the invoked tool), mcp.method.name = tools/call, and mcp.session.id (the caller’s session), And weaver registry live-check over the emitted telemetry reports no violation other than the sanctioned “moved to semantic-conventions-genai” deprecation for the gen_ai.*/mcp.* namespaces — every other drift still fails the gate (§3.6; the exemption’s removal is tracked by #622).

6. Testing strategy

Mapped to CLAUDE.md §6.2:

  • RFC0038.1 / .3 / .5 / .6 — integration tests in ourios-server / ourios-ingester using an in-memory span exporter (SDK test exporter): drive a query, an MCP tool call, an Export, and a sweep; assert span count/name/parentage and that a co-emitted log’s trace_id matches. A dedicated case asserts the spawn-boundary parentage (.3) and the loop-guard muting (.5), and a shutdown case asserts the flush (.6).
  • RFC0038.2 — a span-count assertion parameterised over batch size N (spans are O(1) in N), plus a criterion guard on the ingest (OTLP → WAL, WAL → Parquet) hot-path benchmarks confirming no per-record tracing cost — a regression there blocks merge (§6.2 benchmarks).
  • RFC0038.4 — a unit test over Ourios’s own mapping: OTEL_TRACES_EXPORTER → whether the traces pipeline installs (none → off; unset / otlp / any other → on). Sampler resolution (OTEL_TRACES_SAMPLER/_ARG) is the SDK’s universal, upstream-tested behaviour that Ourios no longer overrides — there is nothing Ourios-specific left to test there.
  • RFC0038.7 — the ourios-server MCP-span integration test asserts the gen_ai.*/mcp.* attributes (including the session id) on the emitted span; the live-check CI job proves emission-time semconv conformance, gating on the genai-relocation-filtered violation count so a real drift on any other attribute still fails (§3.6).

7. Open questions

  • Should the per-Export-batch span live on ingest_bound (single choke point, preferred) or on each transport handler (export/handle_logs)? §3.3 prefers the former; confirm no transport-specific attributes are lost.
  • tracing-opentelemetry version alignment with the pinned opentelemetry 0.x; confirm no version-skew with the appender/exporter crates before adding the dependency.

Future work (out of scope here). A reusable DataFusion → OTel instrumentation — per-operator / per-ExecutionPlan-node sub-spans, bridging DataFusion’s existing per-operator MetricsSet into the trace — would deepen the query span into an operator tree. It is a community-shaped component (a standalone datafusion-opentelemetry crate, most naturally offered to datafusion-contrib and announced to the OTel Rust ecosystem), best built for Ourios’s own query span first and then extracted upstream — the same dogfood-then-give-back path as Ourios’s opentelemetry-rust contributions. This RFC’s query span (§3.1) is exactly the parent such operator sub-spans would attach to, so nothing here blocks it and the boundary discipline (query scope, not ingest) already covers it.

8. References

  • CLAUDE.md §6.3 (Observability of ourselves — “every RPC is traced”); §6.2 (testing discipline, benchmarks block regressions).
  • docs/roadmap.md (traces “deliberately deferred”).
  • RFC 0001 §6.8 (export architecture: API-only library crates, SDK in ourios-telemetry).
  • RFC 0020 (configuration file — where the new telemetry.* section lands).
  • OpenTelemetry — defining spans (significant, has duration, out-of-process; not for short in-process work); Collector coding guidelines (no per-item logging/spans — use a metric); messaging spans (one Receive/Process span per batch; links over nested per-item spans); sampling and the OTEL_TRACES_SAMPLER environment surface.

RFC 0039 — Inbound trace-context propagation


rfc: 0039 title: Inbound trace-context propagation — SERVER spans continue the caller’s trace status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-25 supersedes: — superseded-by: —

RFC 0039 — Inbound trace-context propagation

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

Status: green — all six §5 scenarios have asserting tests, landed over four slices: the query arm and the propagator install (#627), the ingest spawn boundary on both OTLP transports (#628), the /mcp SERVER span with the tool spans nesting locally beneath it (#629), and the sampling regime (this slice). Two §3 design decisions were withdrawn during implementation and amended in place rather than silently followed — gRPC extraction moved out of the tower auth layer into the receiving handler (§3.3/§3.4), and the MCP arm gained a span, which §2’s original “no new spans” promise had ruled out.

1. Summary

Ourios’s request-scoped SERVER spans (RFC 0038) are currently created as trace roots: they never read the incoming W3C traceparent/tracestate, so a caller’s trace stops at the Ourios boundary. This RFC installs a global TraceContextPropagator and, at each ingress, extracts the caller’s opentelemetry::Context from the request carrier (HTTP headers / gRPC metadata) and attaches it as the current context around the span-producing future (FutureExt::with_context), so the root span inherits it as parent — not via set_parent, which fails on an already-entered #[tracing::instrument] span. The observable result: the ingest logs, POST /v1/query, and MCP tool spans join the caller’s distributed trace instead of starting a disconnected one, and a parentbased sampler honours the caller’s sampling decision. No new signal, no schema change — this completes the traces pillar that RFC 0038 established.

2. Motivation

The point of a SERVER-kind span is to be the server half of a client’s request: linked to the caller’s CLIENT/PRODUCER span through propagated context, so an operator can follow one trace from the application that emitted a log, through the OTLP exporter, into Ourios’s ingest path — or from a query client into Ourios’s querier. RFC 0038 built the spans but not the propagation, so today every Ourios span is a root. For a telemetry backend that sits inside someone else’s distributed system, that is the most consequential remaining gap in the traces signal: correlation-within-Ourios works (RFC 0038), but correlation-across-the-boundary does not.

This is deliberately a small, bounded change at the ingress layer. It touches the traces pillar (hence an RFC), and for the ingest and query arms it adds no new spans, no new attributes of consequence, and no on-disk change — it only sets the parent of spans that already exist.

Amendment (slice 3). The paragraph above originally promised “no new spans” for every arm. That does not survive contact with the MCP arm: the only spans /mcp had were the execute_tool <tool> spans, which are INTERNAL — a kind the tracing API defines as an operation “as opposed to an operations [sic] with remote parents or children” (the grammar slip is upstream’s; quoted verbatim). Parenting them straight to the caller, as §7 originally deferred, would contradict their own kind, and MCP tools/call is JSON-RPC over HTTP, where both conventions require the inbound server span’s kind to be SERVER. So the MCP arm adds one span — a SERVER span for the inbound /mcp request — with the tool spans nesting locally beneath it. No on-disk change; §3.3 bullet D and §3.5 carry the details, and this closes §7’s second open question.

3. Proposed design

3.1 The one global: a W3C propagator

Install the W3C Trace Context propagator once, in ourios-telemetry’s init(), alongside the existing provider installation:

#![allow(unused)]
fn main() {
opentelemetry::global::set_text_map_propagator(
    opentelemetry_sdk::propagation::TraceContextPropagator::new(),
);
}

This is unconditional (cheap, stateless) and independent of whether the traces pipeline is enabled — extraction is a no-op when no exporter is installed, and installing the propagator regardless keeps the ingress code uniform. baggage propagation is out of scope (§7).

3.2 The ingress map

Four ingress categories open a span on the request path — seven span-producing functions in all, since the MCP category is three tool functions plus the /mcp server span slice 3 adds (§3.3 bullet D). The count is stated so test coverage (RFC0039.1/.3/.6) omits no site. The carrier — where the incoming traceparent lives — is not always co-located with the span:

Sites are named by file and function, deliberately without line numbers — those rot on every touch of the surrounding code and have already been corrected twice in review.

#SpanSiteCarrier & where it is reachable
Aingest logs (gRPC)span in IngestPipeline::ingest_bound (pipeline.rs); entry LogsReceiver::export (grpc.rs)tonic MetadataMap, on the request in export itself (§3.4)
Bingest logs (HTTP)span in ingest_bound; entry handle_logs (http.rs)axum HeaderMap, a handle_logs extractor
CPOST /v1/queryhandle_query_traced, behind the handle_query wrapper (querier.rs)axum HeaderMap, a handle_query extractor
D1{method} /mcp (SERVER)mcp_server_span_traced, behind the mcp_server_span layer (mcp.rs)axum HeaderMap, in the layer — the remote carrier for the whole MCP category
D2execute_tool <tool> (×3, INTERNAL)the three _traced fns (mcp.rs), each via a thin #[tool] delegatenot the remote carrier: McpTraceContext in parts.extensions, the D1 span’s own context (§3.3 bullet D)

The mechanism is uniform (§3.3): extract the caller’s opentelemetry::Context and make it the current context around the span-producing future, so the span — a tracing root — inherits it as its OTel parent.

3.3 The mechanism: attach the context, do not set_parent

OpenTelemetrySpanExt::set_parent must be called before the span is entered. On an already-entered span — which every #[tracing::instrument] span is, for its whole body — it returns SetParentError::AlreadyStarted and the parent is silently not set. So propagation cannot set_parent from inside an instrumented fn. Instead it makes the extracted context current before the span is built; tracing-opentelemetry then parents a root span to Context::current(). The idiom is opentelemetry::trace::FutureExt::with_context(future, cx) — run the span-producing future under the extracted context. One contract, every site:

  • Query (C): an un-instrumented handle_query wrapper extracts cx from the request HeaderMap and awaits the instrumented handle_query_traced under it — handle_query_traced(..).with_context(cx).await. No tower layer: an earlier revision of this bullet proposed a shared PropagationLayer, but neither ingest arm can use one (their span is born past a tokio::spawn, below), which would leave the query arm as its sole beneficiary — see §4.
  • gRPC ingest (A): extraction happens in LogsService::export itself, from the request’s tonic MetadataMap (§3.4) — not in the tower auth layer, as an earlier revision of this section proposed. Extraction belongs with the handler that receives the call, which is both what OpenTelemetry prescribes for a service receiving upstream calls and what makes it testable: the RFC0039.3 harness drives export directly (no tower stack), so a layer-extracted context would leave the extraction itself uncovered. See the amendment note below.
  • The tokio::spawn boundary (A/B): the ingest logs span is born inside ingest_bound, after the spawn in export / handle_logs, which ambient context does not cross. So the handler extracts cx from its own carrier before the spawn, moves it into the spawned closure, and attaches it to the whole spawned block — async move { ingest_bound(...).await }.with_context(cx) — rather than to ingest_bound’s future alone. Wrapping the block is deliberate: it holds whether #[tracing::instrument] mints its span at call time or on first poll, so the span cannot be created outside cx. No ingest_bound signature change, no set_parent.

Amendment (slice 2). §3.3’s gRPC bullet and §3.4 originally routed extraction through the tower auth layer into a request extension. That was withdrawn during implementation for two reasons: it contradicted §6, whose RFC0039.3 test calls export directly and so would have exercised only the spawn hand-off and never the extraction; and it put a propagation concern inside a layer named for authentication. The OTel guidance is explicit that a service receiving upstream calls extracts in the receiving handler (“the one context on the wire becomes the parent of the new span the library creates”), and the OTel Demo’s own C++ gRPC service does exactly this with a GrpcServerCarrier over client_metadata(). The cost of the correction is the ~15-line MetadataExtractor that §3.4 had hoped to avoid.

  • MCP (D): two spans, because one cannot honestly do both jobs. A mcp_server_span layer, outermost on the /mcp router so it also covers an auth rejection, extracts the caller’s cx from the request headers and opens a SERVER span under it (D1) — named {method} /mcp via otel.name, since /mcp serves POST, GET and DELETE and the macro’s static name cannot vary. That span then publishes its own context as McpTraceContext in the request extensions, and each un-instrumented #[tool] delegate reads it back and runs self.<tool>_traced(...).with_context(parent).await (D2).

    Two details are load-bearing. The delegates attach the server span’s context, not the extracted remote one: that is what makes the tool spans children of a local span, keeping them legitimately INTERNAL. And the channel is the request extensions rather than ambient context, because rmcp dispatches the tool on a tokio::spawned task — verified, not assumed: with the hand-off removed the tool span lands in a freshly minted trace. The extensions are known to survive that hop because AuthBinding already travels the same route.

This is the same discipline RFC 0038.3 uses to carry work across tokio::spawn, applied here to the parent context — and it is one uniform contract, resolving the earlier draft’s split between an explicit parameter and a request extension.

3.4 The extractor shim

opentelemetry::propagation::Extractor is a two-method trait (get, keys). Two adapters are needed, both in receiver/propagation.rs:

  • HeaderExtractor<'a>(&'a http::HeaderMap) — the axum-side ingresses (OTLP/HTTP, the query API, the MCP tools), whose carrier is an http::HeaderMap.
  • MetadataExtractor<'a>(&'a tonic::metadata::MetadataMap) — the OTLP/gRPC ingress. gRPC metadata is HTTP/2 headers, but tonic models it as HeaderMap<MetadataValue>, not http::HeaderMap, and exposes no cheap &HeaderMap view, so it needs its own carrier. keys() offers only the ascii half: a binary (-bin) key can never resolve as a text-map entry.

Both resolve through the propagator installed in §3.1, e.g. global::get_text_map_propagator(|p| p.extract(&HeaderExtractor(headers))). opentelemetry-http ships an equivalent HeaderExtractor; the local pair avoids that dependency and keeps both carriers described in one place.

3.5 Dependency promotion (the one production-surface change)

The ingress code needs opentelemetry types in production (Context, propagation::Extractor, trace::FutureExt::with_context, global::get_text_map_propagator), but opentelemetry is a production dependency of ourios-ingester/ourios-server today only with the metrics feature. This RFC adds the trace feature to that existing dependency in both crates. The propagator install (opentelemetry_sdk::propagation::TraceContextPropagator, §3.1) stays in ourios-telemetry, which already depends on opentelemetry_sdk.

For the ingest and query arms that is the whole production-surface cost — one added feature flag on a crate already depended on — because the parenting rides the current-context bridge the tracing-opentelemetry layer already provides, with no set_parent call.

Amendment (slice 3). This section originally concluded that “tracing-opentelemetry is not needed in the ingress crates at all”. True of the ingest and query arms; not of MCP. The /mcp SERVER span must hand its own context to the tool handlers across rmcp’s dispatch spawn (§3.3 bullet D), and reading a tracing span’s OTel context requires OpenTelemetrySpanExt::context(). So ourios-server gains tracing-opentelemetry as a production dependency — at the same 0.33 pin ourios-telemetry already uses, so no new version enters the tree. The alternative was building the /mcp span through the raw OTel API, which hands back a Context directly and needs no new dependency, but would have made it the one Ourios span that is not a tracing span (no log correlation, and a pattern break — CLAUDE.md §5.4).

3.6 Sampling interplay

With a parent context attached, the SDK’s default parentbased_always_on sampler (RFC 0038 §3.4, resolved from OTEL_TRACES_SAMPLER) honours the caller’s sampled flag: a caller who sampled the trace propagates sampled=1 and Ourios records/exports its spans within that trace; a caller who did not propagates sampled=0 and Ourios’s spans are dropped, keeping the trace consistent end-to-end. This is desirable and is the reason to prefer a parentbased sampler as the default — it is what makes propagation meaningful. A request with no incoming context falls back to the root sampling rule unchanged (backward-compatible).

3.7 Traces disabled

with_context merely attaches an opentelemetry::Context for the duration of a future; it has no fallible surface and no Result to handle (contrast the set_parent design, which returned SetParentError — one reason to prefer the attach idiom). When traces are disabled the span carries no OTel layer, the attached context is inert, and nothing is exported — a no-op, not an error. No unwrap/expect is introduced (CLAUDE.md §6.1).

4. Alternatives considered

Do nothing (status quo — roots). Correlation within Ourios works; the cost is that no operator can follow a trace across the Ourios boundary. For a telemetry backend this is precisely the interesting join, so the gap is not acceptable long-term.

Extract at the shared ingest_bound span only, via ambient context. Fails: the carrier does not reach ingest_bound (its signature has no request), and tokio::spawn severs ambient context (§3.3). Extraction must happen in the handler.

A single set_parent call inside each instrumented fn. The obvious first design, and what an earlier draft proposed — but it does not work: OpenTelemetrySpanExt::set_parent returns AlreadyStarted on an entered span, and every #[tracing::instrument] span is entered for its body, so the parent is silently dropped (§3.3). The attach-the-context idiom (with_context) is the correct primitive and is what §3.3 adopts.

A shared PropagationLayer instead of per-handler extraction. Attractive on paper — one layer for every axum-side ingress — but it buys less than it looks. Neither ingest arm can use it: their span is born past a tokio::spawn, so they need the explicit with_context hand-off regardless (§3.3), and a layer that extracted for them would only duplicate what the handler must do anyway. That leaves the query arm as the sole beneficiary of a whole tower layer, for the two lines it already spends extracting directly. Per-handler extraction also keeps each site’s carrier visible at the site, which is where OTel puts it.

Adopt opentelemetry-http’s HeaderExtractor as a dependency. Reasonable, but it is one more crate for a ~10-line shim; the RFC inlines the extractor. If a tonic-metadata extractor is later needed, revisit.

5. Acceptance criteria

Scenario RFC0039.1 — a SERVER span continues an incoming trace. Given the traces pipeline enabled and the global TraceContextPropagator installed, When a POST /v1/query request and an OTLP Export (both gRPC and HTTP) each arrive carrying a valid W3C traceparent for trace T span S, Then the resulting POST /v1/query and ingest logs spans each have trace_id == T and parent span id == S (they are children of the caller’s span, not roots).

Scenario RFC0039.2 — no incoming context is a fresh root, unchanged. Given the same setup, When a request arrives with no traceparent, Then the span is a fresh root with a newly minted trace_id and no parent — identical to pre-RFC behaviour, and no error is raised.

Scenario RFC0039.3 — the extracted context survives the ingest spawn. Given the gRPC and HTTP OTLP receivers, whose ingest logs span is created inside a tokio::spawned ingest_bound, When a batch arrives carrying traceparent for trace T, Then the ingest logs span (and its commit wal child) resolve to trace_id == T — proving the parent context was extracted before the spawn and applied to the post-spawn span (the RFC 0038.3 boundary, for the parent context this time).

Scenario RFC0039.4 — the caller’s sampling decision is honoured. Given the default parentbased sampler, When a request carries traceparent with the sampled flag unset (-00), and separately with it set (-01), Then the unset case produces no exported span (the trace was not sampled upstream), and the set case exports the span within trace T — the parent decision governs, end to end.

Scenario RFC0039.5 — a malformed carrier is treated as absent. Given the propagator, When a request carries a syntactically invalid traceparent, Then extraction yields an empty context, the span becomes a fresh root (as RFC0039.2), and no panic or request error occurs.

Scenario RFC0039.6 — the MCP tool call joins the caller’s trace, correctly shaped. Given an MCP tools/call over /mcp carrying traceparent for trace T span S, When the tool executes, Then a {method} /mcp span of kind SERVER resolves to trace_id == T with parent span id == S; and the execute_tool <tool> span resolves to trace_id == T with its parent being that local server span — retaining kind INTERNAL, which the spec reserves for operations without remote parents. So an agent driving Ourios’s tools sees the whole exchange inside its own trace, without either span misrepresenting its kind.

6. Testing strategy

Mapped to CLAUDE.md §6.2:

  • RFC0039.1 / .2 / .5 / .6 — integration tests in ourios-server / ourios-ingester using the RFC 0038 scoped-InMemorySpanExporter harness: drive handle_query, handle_logs, and (global-tracer binary, per RFC0038.1 MCP arm) an MCP tools/call, each with an injected traceparent header, then assert SpanData.span_context.trace_id() / .parent_span_id(). The no-context and malformed-context cases assert a fresh, valid root and no error.
  • RFC0039.3rfc0039_3_ingest_propagation.rs (its own global-tracer binary, per RFC0028.2): call LogsReceiver::export and the HTTP router directly with a traceparent, and assert the ingest logs + commit wal spans carry the injected trace_id. It gets a binary of its own rather than extending rfc0038_3_spawn_boundary.rs — a process holds one tracer install, and that file owns the no-inbound-context case, so extending it would have meant editing a passing test’s assertions (CLAUDE.md §6.2).
  • RFC0039.6rfc0039_6_mcp_propagation.rs (likewise its own binary): handshake without a traceparent, then one tools/call with one, so a single run covers both the propagated and the root case. Asserts the SERVER kind and remote parent of the /mcp span, and that the execute_tool span is INTERNAL with that server span as its local parent.
  • RFC0039.4 — a sampler test: with OTEL_TRACES_SAMPLER=parentbased_always_on (default), inject -00 vs -01 traceparents and assert exported-span presence. The parent-based resolution itself is upstream SDK behaviour; the test covers Ourios’s wiring (that the extracted context reaches the sampler).
  • The extractor shims get a unit test (round-trip a traceparent through a HeaderMap and back to a SpanContext).

7. Open questions

  • Resolved (slice 2). FutureExt::with_context does re-attach the extracted context inside the spawned ingest_bound task, on both transports — rfc0039_3_ingest_propagation.rs passes, and fails with each arm’s span in a freshly minted trace when the two handler changes are reverted. Site A’s carrier is the tonic MetadataMap, read in export (§3.4 amendment); no request extension and no ingest_bound signature change are involved.
  • Resolved (slice 3): yes, the dedicated /mcp SERVER span is warranted — and required. This question framed an INTERNAL span with a remote parent as “valid but slightly unusual”. It is not valid: the tracing API defines INTERNAL as an operation “as opposed to an operations [sic] with remote parents or children”, and the concepts doc as one that “does not cross a process boundary” (both quoted verbatim — the grammar slip is upstream’s). Meanwhile tools/call is JSON-RPC over HTTP, and both the RPC and HTTP conventions state the inbound server span’s kind MUST be SERVER. So /mcp gains a SERVER span (§3.3 bullet D), the tool spans nest locally under it, and both kinds stay honest. Consequences recorded as amendments in §2 (the “no new spans” promise) and §3.5 (the tracing-opentelemetry dependency).
  • tracestate and baggage: tracestate rides along with TraceContext automatically; baggage propagation is explicitly out of scope here.
  • Response-side injection (Ourios as a client to object storage / a downstream) is a separate concern — not in this RFC (inbound only).

8. References

  • RFC 0038 (self-tracing) — the spans this RFC gives parents to; §3.3 (the tokio::spawn boundary), §3.4 (the sampler), RFC0038.3 (spawn-boundary test harness), RFC0038.7 (rmcp=off loop-guard).
  • CLAUDE.md §6.3 (observability of ourselves), §2 (the traces pillar via RFC 0038), §6.1 (no unwrap/expect in non-test code).
  • W3C Trace Context — https://www.w3.org/TR/trace-context/.
  • OpenTelemetry — context propagation; FutureExt::with_context (the attach idiom this RFC uses; note OpenTelemetrySpanExt::set_parent returns AlreadyStarted on an entered span, which is why it is not used).
  • OpenTelemetry span kinds — SpanKind (the normative definition of INTERNAL that §7’s second question turned on), and the RPC / HTTP server-span conventions, both of which state the kind MUST be SERVER.
  • Pinned: opentelemetry 0.32.0, opentelemetry_sdk 0.32.1, tracing-opentelemetry 0.33.0.

RFC 0040 — DataFusion operator instrumentation


rfc: 0040 title: DataFusion → OTel operator instrumentation — the query span as an operator tree status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-25 supersedes: — superseded-by: —

RFC 0040 — DataFusion → OTel operator instrumentation

Status: accepted (2026-07-28, maintainer sign-off). Terminal. No thesis-gate applies, so validated is vacuous (precedent: RFC 0008) and green was the last verification stage; the flip records maintainer approval of the design as binding. The ourios-df-otel crate remains deliberately extractable for upstream.

Status: green (2026-07-25). All six §5 acceptance criteria are implemented and pass: RFC0040.1 (operator span tree, transitively parented to the query span), RFC0040.2 (real, non-inverted wall-clock bounds), RFC0040.3 (normative datafusion.operator.* attributes, asserted as typed Value::I64), RFC0040.4 (untimed nodes skipped and re-parented, covered at the crate level against a real CooperativeExec), RFC0040.5 (span count tracks plan shape, not row count), RFC0040.6 (zero cost when unsampled — a plan double that panics if touched proves the walk never runs, both as a unit test and a criterion bench measuring ~530 ps/call). Landed across four slices: ourios-df-otel crate (#632), the datafusion.operator.* weaver registry entries (#633), ourios-querier wiring + integration tests (#634), and the RFC0040.6 guard (this slice). No thesis-gate applies (this RFC doesn’t touch a benchmarks.md §7 pillar), so green is the RFC’s terminal pre-accepted stage — maintainer flip is the only remaining step.

Verified live end-to-end against real ingested data (not just the test suite): a real query through ourios-server → a local OTel Collector → Jaeger v2 rendered the full 8-span operator tree with real row_groups_pruned/elapsed_compute/output_rows values.

1. Summary

The POST /v1/query span (RFC 0038) is flat: it times the whole query but shows nothing of where the time went. This RFC deepens it into an operator tree by emitting one OTel child span per ExecutionPlan node, reconstructed post-hoc from the finished physical plan. DataFusion 54 records genuine wall-clock StartTimestamp/EndTimestamp on every BaselineMetrics-backed operator, so the spans carry real bounds (not synthetic timings), with output_rows, elapsed_compute, output_bytes, and pruning counts as attributes. The logic lives in a new, dependency-light crate (ourios-df-otel) whose only deps are datafusion and opentelemetry — so it lifts cleanly to a standalone datafusion-opentelemetry for datafusion-contrib, the dogfood-then-give-back path RFC 0038 §7 named. This is a new crate (hence an RFC per CLAUDE.md §7) and extends the traces pillar (§5.1).

2. Motivation

Ask “why was this query slow?” and today’s trace answers only “it took 340 ms.” Every mature database instrumentation — the postgres client span with db.query.text and per-statement timing is the canonical example — lets an operator see the work decomposed. For a query engine, the natural decomposition is the physical plan: which operator scanned how many row groups, where pruning helped, which node dominated the wall clock. Ourios already reads this per-operator data (scan_stats/fold_metrics) but only rolls it up into aggregate QueryStats metrics — the per-node structure is discarded. This RFC keeps that structure as spans, turning the flat query span into the operator tree an engine’s trace should be.

It is also strategic. RFC 0038 §7 committed to building a reusable datafusion-opentelemetry component for datafusion-contrib — “built for Ourios’s own query span first and then extracted upstream.” This RFC is that build. Keeping the crate’s dependencies to datafusion + opentelemetry (no Ourios types) is what makes the extraction a lift, not a rewrite.

3. Proposed design

3.1 The timing source (the finding that shapes everything)

DataFusion 54 operators built on BaselineMetrics record a real StartTimestamp at stream construction and a real EndTimestamp on drain/Drop (datafusion-physical-plan-54/src/metrics/baseline.rs:75,135,175). These surface as MetricValue::StartTimestamp / EndTimestamp in the node’s MetricsSet, and MetricsSet::aggregate_by_name() reduces per-partition instances to earliest start / latest end (metrics/value.rs:915) — exactly the wall-clock interval a span needs. This is the crux: because the timestamps are genuine, the operator spans are truthful, not derived. ElapsedCompute (CPU-busy time) becomes an attribute, never the span’s timeline.

Two residual constraints, both benign for Ourios:

  1. Post-hoc. Metrics populate only after collect() returns. Every Ourios query path fully buffers via datafusion::physical_plan::collect (lib.rs:73; never execute_stream), so the plan is finished and its timestamps final when we read them. Spans are therefore built after the query, with explicit start/end — not opened live.
  2. Opt-in metrics. ExecutionPlan::metrics() returns None for operators that do not use BaselineMetrics (execution_plan.rs:492). A node with no timestamps is skipped (no span), so the tree shows the operators that actually carry timing; children of a skipped node re-parent to the nearest timed ancestor (or the query span).

3.2 The walk — reuse what already exists

accumulate_scan_stats (lib.rs:703) already recurses the physical plan tree: for each node it reads plan.metrics() and recurses over plan.children(). The span reconstruction is the same walk with a different fold — for each timed node emit a span instead of (in addition to) accumulating stats. The retained plan: Arc<dyn ExecutionPlan> is available at exactly the sites scan_stats is called today, before the Arc drops: lib.rs:1355 (count scan), :1422 (aggregate), :1492 (row materialize), drift.rs:191. The new crate exposes a single entry point:

#![allow(unused)]
fn main() {
// ourios-df-otel
pub fn record_plan_spans<T: opentelemetry::trace::Tracer>(
    plan: &dyn ExecutionPlan,
    parent: &opentelemetry::Context,
    tracer: &T,
)
where
    T::Span: Send + Sync + 'static;
}

The tracer is a generic T: Tracer, not &dyn Tracer: the Tracer trait has an associated Span type and is not object-safe as a bare trait object. Callers pass the global tracer (opentelemetry::global::tracer("ourios-df-otel"), a BoxedTracer) or any concrete tracer.

The where T::Span: Send + Sync + 'static bound is load-bearing, not decoration: threading a child span as the parent Context for recursion goes through Context::with_span, which requires it. Every real tracer (BoxedTracer, the SDK tracer) satisfies it, so no caller is affected — but the bare T: Tracer bound does not compile without it.

It walks plan, and for each node with StartTimestamp+EndTimestamp builds a child span (parent = its plan-parent’s span, root = parent) named by ExecutionPlan::name() (e.g. DataSourceExec, FilterExec, AggregateExec — low-cardinality, the operator kind).

3.3 Span emission — the raw OTel span builder (not #[instrument])

Backdated spans cannot come from #[tracing::instrument] (it starts “now”). The crate uses the OTel SDK span builder directly. with_start_time and end_with_timestamp take std::time::SystemTime, so the node’s DateTime<Utc> timestamps convert via SystemTime::from; end_with_timestamp takes &mut self:

#![allow(unused)]
fn main() {
let start: SystemTime = node_start.into();     // DateTime<Utc> -> SystemTime
let end:   SystemTime = node_end.into();
let mut span = tracer
    .span_builder(node.name().to_string())
    .with_kind(SpanKind::Internal)
    .with_start_time(start)
    .with_attributes(node_attributes(&metrics))
    .start_with_context(tracer, parent_cx);    // parent_cx = this node's parent span's context
// … recurse into children, passing this span's context as their parent …
span.end_with_timestamp(end);                  // &mut self; real EndTimestamp
}

Attributes are normative — the span contract is deterministic (types, units, and the no-match representation are fixed):

AttributeType / unitSource (MetricValue)
datafusion.operator.output_rowsint, rowsOutputRows
datafusion.operator.elapsed_computeint, nanosecondsElapsedCompute (Time::value() is ns)
datafusion.operator.output_bytesint, bytesOutputBytes
datafusion.operator.row_groups_prunedint, countscan PruningMetrics::pruned()
datafusion.operator.row_groups_matchedint, countscan PruningMetrics::matched()

Pruning is emitted as the two counts, never a ratio — a ratio is undefined when matched == 0 (a fully-pruned or non-scanning node); a consumer derives the ratio if it wants one. An attribute whose metric a node does not report is omitted, not zero-filled, so presence is meaningful.

Why a datafusion.* namespace and not db.* (OTel MCP consultation, 2026-07-25). The OTel semantic conventions define no convention for query-plan or per-operator spans: the whole db.* span convention describes a database client span — one application→database operation — not sub-operations inside an engine. Two consequences:

  • Reusing db.response.returned_rows for a plan node’s output rows would collide with its normative meaning (“the number of rows returned by the database operation as observed at the time the span ends”) — an operator’s output rows are not the operation’s returned rows. Per the project’s no-collision rule, these attributes take a distinct namespace.
  • datafusion.operator.* (not ourios.*) because the semantics are DataFusion’s, not Ourios’s — the crate is built to be extracted (§3.5), and an ourios.-prefixed attribute would be wrong the moment another project uses it. The prefix is the instrumented library, per OTel’s guidance on naming for third-party/library-specific attributes.

Ourios-side attributes (e.g. ourios.tenant) stay in the Ourios registry on the Ourios-owned spans, not on these. The datafusion.operator.* names still go through the weaver registry before landing (§7) so live-check validates them.

Backdated timestamps and SpanKind::Internal are spec-sanctioned, not a workaround (OTel MCP consultation, 2026-07-25). The trace API defines an explicit start-timestamp parameter precisely for spans “created” after their logical start already passed, and an explicit end timestamp likewise — the mechanism §3.3 uses (with_start_time/end_with_timestamp) is the documented path, not an abuse of the API. SpanKind::Internal is correct because these spans never cross a process boundary — doubly confirming the db.* rejection above, since the database CLIENT span kind is specifically for calls to a remote database process, which per-operator spans inside one query engine are not. One caution the same guidance surfaces and worth naming rather than silently overriding: OTel’s general span-authoring guidance discourages full spans for operations that don’t cross a process boundary and are individually short (suggesting span events instead), which is exactly what most operator nodes are. The decomposition itself — seeing where in the plan time went — is the stated goal of this RFC (§2), so the exception is deliberate, not an oversight.

3.4 Parenting into the query span

The operator spans must nest under POST /v1/query. That span is a tracing span (in ourios-server); the plan executes in ourios-querier. The parent opentelemetry::Context is obtained inside the querier via tracing::Span::current().context() (OpenTelemetrySpanExt) — the query span is current throughout run_query, including the post-collect reconstruction. This adds tracing-opentelemetry + opentelemetry(trace) as ourios-querier dependencies (parallel to RFC 0039’s promotion, and called out likewise). The querier then calls ourios_df_otel::record_plan_spans(&plan, &cx, &tracer) at the scan_stats sites.

No DataFusion type crosses any Ourios public boundary (H6): record_plan_spans is an internal side-effect on the retained plan; the query response and error surfaces are unchanged.

3.5 The new crate

crates/ourios-df-otel/ — deps datafusion (the pinned 54) and opentelemetry (trace) only, no ourios-* deps. #![deny(unsafe_code)]. This isolation is deliberate: it is what lets the crate lift to a standalone datafusion-opentelemetry for datafusion-contrib with no un-picking. The Ourios-specific wiring (getting the parent context, the call sites) stays in ourios-querier; the crate is pure “ExecutionPlan tree + parent context → spans.”

3.6 Cost discipline (RFC 0038’s boundary, honoured)

The reconstruction is O(plan nodes) — a handful per query — and runs once per query, after execution. It is not per-record and not per-batch (RFC 0038.2’s invariant). It is gated on the query span being recording and sampled: before walking, check parent.span().span_context().is_sampled() (the parent Context’s active span’s SpanContext), which is false both when traces are disabled (no OTel layer → an invalid, unsampled SpanContext) and when the sampler dropped this trace. An unsampled query skips the walk entirely, so the cost is zero on the sampled-out path (the default) and bounded-tiny on the sampled path. A criterion guard confirms no query-latency regression on the sampled-out path.

4. Alternatives considered

(b) True live spans by wrapping ExecutionPlan/RecordBatchStream. Insert a wrapping operator via a PhysicalOptimizerRule (SessionStateBuilder::with_physical_optimizer_rule) that opens a span in execute() and ends it when the stream drains. This captures true intra-operator concurrency/overlap that the post-hoc min/max bounds flatten. But it adds a per-poll wrapper to the hot execution path, complicates the collect-based flow, and re-derives timing DataFusion already records — all for concurrency detail few will read. Deferred: it is the natural next increment of the extractable crate, not the first cut. Post-hoc (a) already yields real bounds.

(c) One query span, plan as an attribute/event. Attach displayable(plan).indent() plus rolled-up metrics as attributes on the existing query span. Cheapest, and a fine fallback when traces are off — but it is a string blob, not a navigable operator tree, and defeats the “where did time go” goal (no per-operator timeline). Rejected as the primary design; the plan-text may still ride the query span as a supplementary attribute (§7).

Do nothing (flat query span). The query span still gives end-to-end latency and the aggregate pruning metrics. But the per-operator structure — already computed and thrown away — stays invisible, and the datafusion-contrib give-back never happens.

A module inside ourios-querier instead of a crate. Simpler in the tree, but couples the logic to Ourios and forfeits the extraction. The whole value is a datafusion+opentelemetry-only component; a crate is what encodes that.

Adopt an existing datafusion-contrib OTel crate if one now exists. None is referenced in-repo, and RFC 0038 treated this as greenfield — but the ecosystem moves. §7 makes “check datafusion-contrib for a current crate” a gate before building, to adopt-or-align rather than duplicate.

5. Acceptance criteria

Scenario RFC0040.1 — a query emits an operator span tree under its query span. Given traces enabled, the query span sampled, and a logs query that scans at least one Parquet file, When the query executes, Then at least one child span is emitted whose parent (transitively) is the POST /v1/query span, one per timed ExecutionPlan node, each named by the operator kind (DataSourceExec, FilterExec, …), forming the plan tree.

Scenario RFC0040.2 — operator spans carry real wall-clock bounds. Given the same, When the tree is reconstructed, Then each operator span’s start/end equals the node’s aggregated StartTimestamp/EndTimestamp (earliest-start / latest-end across partitions) — genuine wall-clock, within the parent query span’s interval, not derived from ElapsedCompute.

Scenario RFC0040.3 — the metric attributes are present and correct. Given an operator reporting output_rows, elapsed_compute, output_bytes, and (for the scan) pruning counts, Then its span carries those as attributes, equal to the values fold_metrics/aggregate_by_name reads for the same node — the span and the QueryStats metric never disagree about the same operator.

Scenario RFC0040.4 — nodes without metrics are skipped, not faked. Given an ExecutionPlan node whose metrics() is None (no BaselineMetrics), Then no span is emitted for it, and its children re-parent to the nearest timed ancestor (or the query span) — the tree never invents a timeline.

Scenario RFC0040.5 — O(plan), once per query; never per-record. Given a query returning N records, When it executes, Then the number of operator spans is bounded by the plan node count and is independent of N (RFC 0038.2’s invariant), and the reconstruction runs once after collect, not per batch or per row.

Scenario RFC0040.6 — zero cost when unsampled / traces off. Given traces disabled, or the query span not sampled, When a query executes, Then the plan walk does not run, no operator span is emitted, and the query-latency benchmark shows no regression attributable to this feature (the default, sampled-out path).

6. Testing strategy

Mapped to CLAUDE.md §6.2:

  • RFC0040.1 / .2 / .3 / .4 — integration tests in ourios-querier (or a ourios-df-otel test) over the scoped-InMemorySpanExporter harness: run a real query against a small fixture Parquet set with the query span current, then assert the exported spans’ names, parent linkage, start/end (against the plan’s own aggregate_by_name timestamps, read independently in the test so the assertion is not self-referential), and attributes. A synthetic plan with a metrics()-None node covers .4.
  • RFC0040.5 — a span-count assertion parameterised over N (records) asserting operator-span count is constant in N (the RFC 0038.2 shape), and a check that the walk is invoked once per query (a counter/mock).
  • RFC0040.6 — a criterion guard on the Parquet → query result hot-path benchmark confirming no regression on the traces-off / unsampled path; a unit test that the walk is skipped when the parent context is not sampled.
  • Attribute-name conformance rides the existing weaver registry live-check gate once the five datafusion.operator.* names are registered (§3.3, §7).
  • ourios-df-otel unit tests over hand-built MetricsSets: the MetricValue → attribute mapping, and the timestamp reduction.

7. Open questions

  • Attribute names — SETTLED via the OTel MCP (2026-07-25). There is no OTel convention for query-plan / per-operator spans; db.* describes database client spans (one app→DB operation). Reusing db.response.returned_rows per plan node would collide with its normative meaning, so the operator attributes take the datafusion.operator.* namespace (the instrumented library, not ourios.* — the crate is built to be extracted). Fixed in §3.3. Remaining mechanical step: register the five names in semconv/registry/ + weaver generate, so live-check validates them (see the RFC0038.7 precedent for how out-of-registry attributes fail the gate).
  • Crate name / extraction — SETTLED via a working prototype of each (2026-07-25). datafusion-contrib/datafusion-tracing does exist and was spiked directly rather than judged from its docs. It is alternative (b) (live spans via a PhysicalOptimizerRule), and on Ourios’s real multi-partition plans it has a production-blocking bug: RepartitionExec’s internal tokio spawns are not covered by the crate’s join-set tracer hook (DataFusion 54.0.0), so every operator span is silently dropped unless target_partitions(1) — not viable in production. Its attributes are also pretty-printed strings ("150.71µs", "64.0 KB") and a combined datafusion.node text dump, not the normative typed datafusion.operator.* table. The post-hoc ourios-df-otel design was spiked in parallel and passed all four of RFC0040.1–.4 against real multi-partition query plans with correctly-typed attributes. Building in-repo, per §3, is confirmed as the right call — not merely the absence of a competitor.
  • Querier OTel deps — SETTLED. §3.4’s tracing-opentelemetry + opentelemetry(trace) additions to ourios-querier are confirmed (mirrors RFC 0039’s dep-promotion precedent exactly); no Context-threading alternative needed.
  • The “show the query” attribute. Separately from the operator tree, should the query span carry the DSL statement and/or the displayable(plan) text? The OTel MCP consultation settles the naming if we do: db.query.text (stable) for the statement — carrying an explicit normative sanitization requirement (“non-parameterized query text SHOULD NOT be collected by default unless there is sanitization that excludes sensitive data, e.g. redacting literal values”) — and db.query.summary (stable, explicitly a low-cardinality grouping key) for a redacted shape. Note these are defined on database client spans while ours is a SERVER span, so the fit needs a deliberate call. Because the DSL can carry user literals, this interacts directly with the skip_all PII decision (RFC 0038 §3.5) and the H6 scrubbing rule — it stays out of this RFC and deserves its own (a sanitizing db.query.summary is the likely shape).
  • Live spans (option b). Left as the next increment of the extractable crate if intra-operator concurrency detail is ever needed.

8. References

  • RFC 0038 (self-tracing) §3.1 (the POST /v1/query span this nests under), §7 (the datafusion-opentelemetry future-work commitment), RFC0038.2 (the O(1)-in-records span-count invariant this RFC honours).
  • RFC 0021 (DataFusion/arrow upgrade) — the pinned DataFusion 54 whose BaselineMetrics timestamps make option (a) truthful.
  • RFC 0039 (inbound propagation) — the sibling traces-completeness RFC; the same dep-promotion pattern.
  • CLAUDE.md §7 (new crate = architectural commitment → RFC), §3 (H6: no DataFusion type crosses the query boundary), §6.3 (observability of ourselves), OTel-alignment rule (signal names via the OTel MCP + weaver).
  • DataFusion — ExecutionPlan::{name, children, metrics} (datafusion-physical-plan-54), MetricsSet::aggregate_by_name, MetricValue::{StartTimestamp, EndTimestamp, OutputRows, ElapsedCompute, OutputBytes, PruningMetrics}, BaselineMetrics.
  • OpenTelemetry — span builder with_start_time / end_with_timestamp (backdated spans); pinned opentelemetry 0.32 / tracing-opentelemetry 0.33.

RFC 0041 — Dashboard datasource plugins


rfc: 0041 title: Dashboard datasource plugins — Ourios as a Grafana / Perses source status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-25 supersedes: — superseded-by: —

RFC 0041 — Dashboard datasource plugins

Status: accepted (2026-07-28, maintainer sign-off). Terminal. No thesis-gate applies (validated vacuous, RFC 0008 precedent). The RFC0041.5 recorded deferral closed the same day: v0.6.0 shipped typed columns and the plugin repositories landed the latest matrix leg + wire-level sum e2e (§9.3). The Grafana datasource follow-up also shipped, in its own repository.

Status: green (2026-07-27, maintainer flip). RFC0041.1–.4 and the .6 capstone are verified — the plugins shipped in ourios-perses-plugin PRs #1–#6, and the committed FinOps dashboard (examples/perses/, #661) rendered unmodified against the live dogfood capture (§9). One recorded deferral: RFC0041.5 is deliberately partial0.5.0 is the declared minimum and the version CI exercises; the latest matrix leg and the wire-level sum e2e land with the next server release, the first to carry RFC 0042 typed columns (precedent for a recorded deferral at green: RFC 0005’s #[ignore]d sizing criterion). The Grafana datasource remains an ungated later follow-up. (Both since closed — the deferral per §9.3, the Grafana datasource in its own repository.)

(specified, same date: build now, Perses first, three plugins, separate repo — the maintainer decision this RFC asked for. What changed since drafted: RFC 0042 verified live spend aggregation (RFC0042.9), so a dashboard now charts money — the plugin became the demo artifact for the agent-FinOps direction rather than a generic API client.)

(Original drafted framing, 2026-07-25: §5/§6 were deliberately empty because the open question was not how but whether and where*; both hosts were spiked to a rendered dashboard first, so §3.1’s figures are measured, not guessed. The §3.4 severity finding shipped separately as RFC0002.21.)*

1. Summary

Ourios answers queries over HTTP (POST /v1/query, RFC 0016) in a logs DSL (RFC 0002) that was designed with dashboard authors as its primary audience. Neither Grafana nor Perses can consume it today: each needs a datasource plugin. At drafted this RFC asked which host — or whether the work belongs in this cycle at all; at specified both questions are resolved (§7): build now, Perses first — three plugins in the dedicated ourios-perses-plugin repository, capped by the committed FinOps dashboard (RFC0041.6) — with the Grafana datasource an ungated later follow-up.

Working spikes exist for both Grafana and Perses. Each renders real ingested logs in a real dashboard against the live querier. The Ourios-side work (query shape, field mapping, time-range injection) is identical across them and took ~20 minutes to port; effectively all the cost is in each host’s plugin system. Measured effort at log parity: 1–2 days for Grafana, 3–5 for Perses — and Grafana’s figure already includes time series, which Perses would need a further plugin for (§3.1).

2. Motivation

The query surface is stable and nothing consumes it but us. RFC 0002 (DSL) and RFC 0016 (query endpoint) are both green. The only clients today are curl, the MCP surface (RFC 0027), and the bench harness. An operator who wants a wallboard has no path that does not involve writing one themselves.

The DSL was built for this and the debt is already paid. RFC 0002 §3.6 names “Perses dashboard authors (declarative YAML/CRDs)” as the primary audience, and §4 P7 makes YAML-embeddability a first-class requirement, tested by RFC0002.10 (a property test asserting every well-formed query is a single-line scalar surviving a YAML round-trip). That constraint shaped the grammar. It buys nothing until a dashboard tool can actually issue the query.

The stated blocker has cleared. docs/roadmap.md §5 defers the Perses plugin with the rationale that “a Perses plugin queries a query interface that doesn’t exist yet”, gated on RFC 0031 close-out. RFC 0031 is accepted (2026-07-22) and the query API is green. The roadmap line now reads: “Its stated prerequisite (RFC 0031 close-out) is now met.”

Counter-motivation, stated plainly. This is a client, not the engine. Nothing in CLAUDE.md §2’s pillars moves. The MCP surface already gives an agent the same access a dashboard would give a human, and the agent-observability direction is arguably the more differentiated one. A reader should be able to conclude “not now” from this RFC as easily as “yes” — §7 puts that question first.

3. Proposed design

3.1 What the spikes established (measured, not estimated)

Both spikes ran against the live dogfood querier with real ingested telemetry and were driven to a rendered dashboard via headless Chromium.

GrafanaPerses
Renders Ourios log lines
Time series from count by bucket(w)✅ (same plugin)not built
Plugins for logs1 datasource2 (Datasource + LogQuery)
Plugins for logs and time seriesstill 13 (adds TimeSeriesQuery)
Backend languagenone (data proxy)none (frontend + CUE)
Schema languagenoneCUE, mandatory
Config validatedat renderat write time
Measured effort (log parity)1–2 days3–5 days

Findings that shape any real implementation:

  • Grafana needs one plugin for every frame type; Perses needs one per query kind. A single Grafana datasource returns logs, time series and tables — the response shape picks the frame. Perses splits LogQuery from TimeSeriesQuery, so the same coverage is a third plugin. The spikes built logs on both and time series on Grafana only, which is why the effort figures below are not directly comparable at equal scope: Perses at log parity is 3–5 days; adding its time-series plugin is more.
  • No backend component is required on either host. Grafana’s data proxy (plugin.json routes) and Perses’s datasource proxy both forward server-side, injecting x-ourios-tenant from datasource config. This removes the assumed Go backend and is most of the cost saving.
  • count by bucket(w) already returns RFC 3339 bucket keys, so the time-series path needs no server-side work. Verified rendering as a graph.
  • Grafana’s ISO range format parses as-is. range(2026-07-25T08:00:00.000Z, …) — milliseconds included — is accepted by the DSL. No translation layer.
  • Perses timestamps are seconds; Grafana’s are milliseconds. A silent 1000× error if assumed rather than checked.
  • Perses’s LogQueryStats.bytesExamined maps directly onto Ourios’s stats.bytes_read, so pillar #1’s pruning win surfaces in the UI for free. Grafana has no equivalent slot.

3.2 The Ourios-side mapping (host-independent)

This is the portable half — identical in both spikes:

Ourios (POST /v1/query)Dashboard field
records[].time_unix_nanotimestamp (÷1e6 → ms for Grafana, ÷1e9 → s for Perses)
records[].body.linelog line
records[].severity_text, else OTLP severity_number bandlevel
attributes[] + resource_attributes[], AnyValue-unwrappedlabels
aggregate[] with RFC 3339 keystime series
aggregate[] with other keystable
stats.bytes_readquery stats (Perses only)

The dashboard time range becomes a range(...) stage. Two properties a plugin author needs and should not have to infer:

  • The window is half-open. RFC 0002 §6.2 fixes range(from, to) as from <= effective < to, matching RFC 0010’s [from, to). Both Grafana’s and Perses’s pickers hand over an inclusive-looking to, so a row exactly on the upper bound is excluded — worth stating, because the alternative is each plugin quietly guessing and drifting apart.
  • A range the user wrote by hand wins. The injected stage is skipped entirely when the query already contains a range(...); silently overriding it would make the editor lie about what ran.

3.3 Where the plugin lives

A plugin is TypeScript; this is a Rust workspace. CLAUDE.md §7 pins the layout and makes a new component an architectural commitment. The plugin should therefore live in its own repository, not in crates/. That keeps this repo’s toolchain single-language and lets the plugin version against its host rather than against Ourios releases.

3.4 The severity gap — RESOLVED, shipped separately

This began as the one finding here that touched Ourios rather than a plugin, and it has since been decided and merged on its own: RFC0002.21 (RFC 0002 §6.1 amendment, PR #641). It is recorded here because the spike is what surfaced it, and because it is the clearest example of the kind of defect only a dashboard client exposes.

Confirmed live during the spike: a natural first query — severity >= trace — returned zero rows against real agent telemetry, because Claude Code’s GenAI events carry severity_number: 0, below trace, so every row group pruned. Through a dashboard that looks like a broken datasource.

Storing the 0 was never in question — it is what the source sent, and RFC 0018’s rule governs: the backend is a faithful witness, not a corrector. What was wrong was the comparison. The OTel Logs SDK drops a record on minimum_severity only when its SeverityNumber “is specified (i.e. not 0)”; unspecified records “bypass minimum severity filtering”. Ourios did the inverse. The data model sanctions the special case explicitly: “Special handling MAY be given to SeverityNumber=0 when it is used to represent an unspecified severity.”

Shipped semantics: a floor (>= / >) above 0 admits unspecified rows; a ceiling (< / <=) excludes them, so a predicate and its negation still partition; an explicit 0 threshold keeps ordinary numeric meaning, so severity > 0 still means “has a specified severity”. The rule is compiled into the predicate rather than applied after the scan, because it is a pruning-correctness matter and not only a UX one — a post-filter would have left the old min/max pruning in place and silently skipped whole files of unspecified rows.

Nothing here blocks or depends on the plugin decision, and the fix stands whether or not this RFC is ever implemented — which is why it shipped first.

4. Alternatives considered

Grafana first, Perses later (or never). Cheapest path to the most users, and the spike proves 1–2 days. Grafana is also where the comparative work already points (RFC 0031 benchmarks against Grafana Loki, so reviewers of that work already have Grafana running). Against: Perses is the roadmap item and the DSL’s stated primary audience, so shipping Grafana first is a deliberate reordering of a documented plan.

Perses first. Matches the roadmap and RFC 0002 §3.6’s primary audience, validates dashboards at write time, and surfaces pruning stats natively. But it is 2–3× the effort, and percli’s scaffolding is currently broken for query plugins: it cannot generate a LogQuery at all, omits #kind/ #selector from the datasource schema, and pins a CUE module (perses/perses/cue) that does not define #datasourceSelector, while the shipped plugins use a different one (perses/shared/cue). Each of those is a silent failure a newcomer loses hours to. The spike documents the fixes.

Both. The Ourios-side mapping ported in ~20 minutes, so the marginal cost of the second is mostly its host’s plugin system, not re-derivation. Still two artefacts to version, sign, and maintain against two moving APIs.

Grafana’s Infinity datasource (no plugin at all). Configure the existing generic JSON/HTTP datasource against /v1/query. Zero code, works today. Against: every panel hand-maps fields, there is no query editor, no schema awareness, and nothing to publish — it is a workaround an operator can already discover, not a project deliverable. Worth documenting in the guide either way.

A Loki-compatible query API. If Ourios spoke LogQL over Loki’s HTTP API, Grafana support would be free via the built-in datasource, and Perses’s Loki plugin would work too. This is the only option that gets both hosts for one piece of work. Against: it is an enormous surface to imitate faithfully, it would make Loki’s semantics a compatibility constraint on the DSL forever, and CLAUDE.md §1 says we are “not a Loki/Mimir/ClickHouse clone”. RFC 0031 uses Loki strictly as a benchmark target and never proposed API compatibility. Rejected, but recorded because it is the obvious “why not just…” question.

Do nothing. The MCP surface (RFC 0027) already lets an agent query Ourios, and docs/guides/agent-telemetry.md documents that loop. If the agent-observability direction is the differentiated one, a human wallboard may simply not be the constraint worth spending on this cycle. This is a live option, not a strawman — see §7.

5. Acceptance criteria

Written at the specified flip (2026-07-27), the §7 host question resolved: Perses first — three plugins in the dedicated ourios-perses-plugin repository — with the Grafana datasource an explicitly cheap follow-up this RFC does not gate on. Criteria RFC0041.1–.5 are satisfied by tests in the plugin repository’s CI (run against the released ourios-server container image, the collector-interop pattern inverted); RFC0041.6 by an artifact in this repository. The RFC ladder here tracks their aggregate state.

  • RFC0041.1 — datasource connection across both auth modes [RFC 0026]
    • Given a Perses instance with the OuriosDatasource plugin configured against a running ourios-server container
    • When the datasource health/connection path runs against a server in open mode (no auth section)
    • Then it succeeds with no credential configured
    • When it runs against a server with RFC 0026 enforcement on
    • Then a datasource carrying a valid bearer token for the configured tenant succeeds; one carrying no token surfaces the API’s 401; and one whose token does not cover the configured tenant surfaces the API’s 403 — each as a distinct, visible datasource error, never swallowed into a generic failure.
  • RFC0041.2 — log-panel parity with the RFC 0016 response
    • Given ingested fixture records
    • When a Perses log panel runs an RFC 0002 DSL statement through OuriosLogQuery
    • Then the rendered rows equal the RFC 0016 response — body, timestamp, severity, and service mapped per §3.2
    • And a DSL error surfaces as the panel’s error state carrying the API’s own message.
  • RFC0041.3 — time-series mapping under §6.3 bucket semantics [RFC 0002 §6.3, RFC 0042 §3.5]
    • Given fixture records spanning multiple bucket windows, including a record exactly on a window boundary
    • When a time-series panel runs count by bucket(w) and sum(attr.<k>) by attr.<group_k>, bucket(w) (aggregated numeric key <k>, series-label group key <group_k>) through OuriosTimeSeriesQuery
    • Then the series match the API’s aggregate groups under RFC 0002 §6.3’s bucket semantics — half-open, epoch-aligned UTC windows [k·w, (k+1)·w), the boundary record landing in the later bucket, keys the window start
    • And bucket keys render as timestamps, group keys as series labels, and NULL aggregate values as gaps — never zeros (the RFC 0042 §3.5 rule shown, not re-derived).
  • RFC0041.4 — query editors adapt via the runtime schema [RFC 0032]
    • Given a deployment’s ourios://query-schema document
    • When the query editors initialize
    • Then field and promoted-attribute suggestions (severity band names included) derive from that document, not from names hardcoded in the plugin.
  • RFC0041.5 — compatibility declaration, CI-exercised (partial at green — recorded deferral, see banner + §9.1)
    • Given plugin release metadata declaring its minimum ourios-server version
    • When the plugin repository’s CI runs
    • Then the e2e suite executes against exactly that image tag alongside latest
    • And a contract break fails the plugin’s gate — not a user’s dashboard.
  • RFC0041.6 — the committed FinOps dashboard renders [capstone]
    • Given the committed Perses dashboard definition in this repository — agent spend by model over time (sum(attr.cost_usd)), token throughput, and tool-decision mix — and a dogfood capture served by the local stack
    • When the dashboard is imported into a Perses instance with the plugins installed
    • Then every panel renders from the capture with no manual edits to the definition. This is the demo artifact the host decision was made for.

6. Testing strategy

Per CLAUDE.md §6.2, adapted to a TypeScript workspace: RFC0041.1–.5 are end-to-end tests in the plugin repository (Playwright or the Perses e2e harness against the GHCR ourios-server image; unit tests for the DSL request/response mapping), pinned to the criterion ids so the mapping stays greppable. RFC0041.6 is verified by rendering the committed dashboard against a dogfood capture — the same corpus discipline as RFC0042.9. The main repository’s CI is untouched: the contract surface it already gates (RFC 0016 shapes, RFC 0032 document, RFC0002.10 YAML-embeddability) is what the plugin builds on.

7. Open questions

  • Is this worth doing now? — RESOLVED yes (2026-07-27). What changed the calculus: RFC 0042 landed typed numeric promotion and RFC0042.9 verified live spend aggregation over MCP, so a dashboard now shows money, not just logs — the plugin became the FinOps demo artifact rather than a generic API client.
  • Which host — RESOLVED: Perses first (2026-07-27). Grafana wins the measured-effort comparison (§3.1), but Perses wins the posture that matters: Apache-2.0 + CNCF end-to-end (Grafana OSS is AGPL), dashboards-as-code fitting the GitOps/air-gapped story, and the §5 deferred-capabilities commitment the roadmap has carried from the start. Scoped to all three plugins (the FinOps dashboard needs time series). The Grafana datasource remains a cheap later follow-up and is not gated by this RFC.
  • §3.4 severity — RESOLVED (RFC0002.21, PR #641). Ourios’s floor semantics were the inverse of the OTel Logs SDK’s; floors now admit unspecified severity, ceilings exclude it, and the rule is compiled into the predicate so row-group pruning agrees with it. Shipped independently of this RFC’s decision.
  • Repository placement — RESOLVED: separate repo (2026-07-27), ourios-perses-plugin. The boundary is the stable public query surface (unlike the rejected intra-workspace splits, which cut private co-evolving internals); the toolchains, release cadences, and supply-chain postures are disjoint; and standalone plugin repositories are the host ecosystem’s convention. Drift is gated by RFC0041.5, adaptation by RFC0041.4. The FinOps dashboard definition stays in this repository (RFC0041.6).
  • Does a per-row id belong in the query response? Neither host gets one today; both spikes synthesize {ts}-{template_id}-{index}. Adequate for display, not stable across pagination, which matters for live tailing. Adding one is an RFC 0016 response-shape change.
  • Grafana log-volume histogram. Grafana currently derives the volume graph from returned lines only, noting the datasource “does not support full-range histograms”. Implementing getLogsVolumeDataProvider over count by bucket(w) would give a true full-range graph — the capability already exists and is verified. Small, high-value, but only if Grafana is chosen.

8. References

  • RFC 0002 (logs DSL) §3.6 (Perses dashboard authors as primary audience), §4 P7 + RFC0002.10 (YAML-embeddability, property-tested), §6.3 amendment (bucket(width) — the time-series path).
  • RFC 0016 (query-serving endpoint) — POST /v1/query, the surface a plugin consumes. RFC 0026 (tenant binding) — why every request carries a tenant.
  • RFC 0027 (MCP surface) / RFC 0032 (ourios://query-schema) — the existing programmatic client, and the introspectable schema a query editor could use for autocomplete.
  • RFC 0031 (comparative evaluation vs Grafana Loki) — the stated prerequisite, now accepted; note it uses Loki as a benchmark target, not an API contract.
  • docs/roadmap.md §5 — “The Perses datasource plugin — deliberately deferred (§5), not started. Its stated prerequisite (RFC 0031 close-out) is now met.”
  • CLAUDE.md §1 (“not a Loki clone”, “not a managed service”), §7 (layout / new-component commitment).
  • OpenTelemetry — Logs Data Model Comparing Severity (special handling of SeverityNumber=0 in comparisons is explicitly permitted), Severity Fields (a backend MAY interpret missing severity as INFO), and Logs SDK LoggerConfig (unspecified severity bypasses minimum-severity filtering).
  • Grafana — logs data frame contract, frontend data proxy.
  • Perses — plugin creation; the bundled Loki plugin is the reference for a LogQuery implementation.

9. Verification record

9.1 RFC0041.1–.5 — plugin repository (2026-07-27)

Delivered in ourios-perses-plugin PRs #1–#6: OuriosDatasource + OuriosLogQuery (.1/.2, unit + container e2e with the RFC 0026 auth matrix — 401 and 403 classified distinctly), OuriosTimeSeriesQuery (.3 — bucket detection positional, NULL scalar renders as a gap and never zero, series identity keyed on the group tuple), runtime schema suggestions over the MCP resource (.4, degrading to a plain field when unreachable). .5 was deliberately partial until v0.6.0 (the first release carrying RFC 0042 typed columns); §9.3 records its completion.

9.2 RFC0041.6 — the committed dashboard renders (2026-07-27)

examples/perses/agent-finops.json imported unmodified (percli-shaped API upsert) into Perses 0.53.1 with the built plugin archive installed, against the live dogfood capture (tenant agent-dogfood, RFC 0042 promotions from dogfood-config.yaml). All four panels rendered from the capture with no edits to the definition: spend by model (sum(attr.cost_usd) by attr.model, bucket(1h) — two series, claude-fable-5 plus claude-haiku-4-5, ~$391 across the day’s buckets), output-token throughput (Int64 class, ~40K/h peaks), tool-decision mix, and the event log (RFC0002.21 floor admitting unspecified-severity GenAI events). Idle hours draw as gaps, not zeros — the RFC 0042 null-propagation contract on screen. The panel time range arrives as a DSL range(...) stage (the request body carries only the query field), confirming the §3.2 mapping.

9.3 RFC0041.5 — deferral closed (2026-07-28)

v0.6.0 shipped typed columns, and both plugin repositories completed the matrix the same day (jensholdgaard/ourios-perses-plugin#9, jensholdgaard/ourios-grafana-datasource#4 — the Grafana datasource itself having shipped as the ungated follow-up): the e2e job runs the declared-minimum 0.5.0 image alongside latest, where the typed leg starts the fixture with map-form promotion entries (rejected by 0.5.0’s parser — the compatibility boundary the criterion pins) and verifies sum(attr.cost_usd) by attr.model, bucket(1h) over real Float64 columns with an all-NULL bucket staying null on the wire, plus the Int64 token sum. The matrix immediately proved its worth: RFC0002.21’s severity-floor change surfaced as a live behavioural difference between the legs, and the floor-dependent expectations are now leg-aware in both repositories. Validating the same release against the envykube consumer surfaced #664 (body == silently empty on template-mined records) — filed, not a plugin defect.

RFC 0042 — Typed numeric attribute promotion


rfc: 0042 title: Typed numeric attribute promotion (RFC 0022 amendment) status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-26 supersedes: — superseded-by: —

RFC 0042 — Typed numeric attribute promotion (RFC 0022 amendment)

Status: accepted (2026-07-28, maintainer sign-off). Terminal. No thesis-gate applies (validated vacuous, RFC 0008 precedent); RFC0042.9’s live verification on real spend was the closing evidence.

Status note (2026-07-27, green): RFC0042.1–.8 landed across the implementation slices (#649 writer, #650 config, #651 scan + no-coercion adapter, #652 predicates + aggregation, #653 compaction re-typing). RFC0042.9 verified live the same day: with the dogfood promotion set (#646) active, a fresh Claude Code capture (10 api_request events) answered sum(attr.cost_usd) by attr.model over the RFC 0027 MCP surface with real per-model spend from the typed Float64 column (claude-fable-5: 35.27878 USD / 10 requests, one row group scanned), and sum(attr.output_tokens) exercised the Int64 class alongside (2,881 tokens). Getting the capture to flow also surfaced and fixed a latent env bug (#654): per-signal OTLP endpoints are used as-is per spec, so the dogfood env needed explicit /v1/<signal> paths. accepted is a maintainer flip.

Enacts RFC 0022 §7.1, whose deferral clause — “deferred until a consumer demands it” — has been met: the agent-FinOps loop’s headline query is sum(attr.cost_usd) by attr.model, and cost_usd arrives as a double AnyValue, which RFC 0022 §3.1 projects as NULL. Discovered the honest way: a config-only promotion of the key (PR #646, now drafted awaiting this RFC) would have produced an always-NULL column and a silently empty sum — strictly worse than today’s promotion-hint error.

1. Summary

RFC 0022 promotes string-valued attributes to dedicated Utf8 columns; every other AnyValue variant projects NULL. This amendment adds two typed promotion classes — i64 and f64 — as a per-key type declaration in storage.promoted_attributes. A typed key projects to an OPTIONAL Int64/Float64 column named by the same DSL path, with min/max-statistics pruning for ordering predicates and direct (cast-free) RFC0002.17 scalar aggregation. Files whose column type does not match the declared type are handled by the same rule RFC 0022 §3.3 already defines for pre-amendment files: the column reads as absent. String promotion — the bare-string config entry — is byte-for-byte unchanged.

2. Motivation

Numeric attributes are the common OTLP emission (http.status_code as int; Claude Code’s cost_usd as double, its token counts as ints — verified against captured api_request events). Under RFC 0022 they are second-class twice over:

  1. Aggregation is impossible. RFC0002.17 scalar aggregates (sum/avg/min/max) require a promoted column and try_cast Utf8Float64. A numeric AnyValue projects NULL (RFC 0022 §3.1), so the column a numeric key would get is always-NULL and the aggregate silently returns nothing. The FinOps loop — a source querying its own spend back through the RFC 0027 MCP surface — dies on exactly this.
  2. Ordering never matches. attr.http.status_code >= 500 is typed-arm-only (RFC 0022 §3.3); on a NULL-projected column it matches no rows, silently.

The responsibility split this serves (maintainer direction, 2026-07-26): the source computes cost and stamps it on its own telemetry; Ourios stores it faithfully, gives it a column, and aggregates it; pricing tables and FOCUS-shaped output belong to consumers of the query surface. Typed promotion is the whole of Ourios’s share.

3. Proposed design

3.1 Type classes

Each promoted key carries a class, declared in config (§3.2):

ClassArrow / Parquet typeProjectsNULL when
string (default)Utf8 / STRINGstring AnyValuekey absent, or value not a string — RFC 0022 §3.1, unchanged
i64Int64 / INT64int AnyValuekey absent, or value not an int
f64Float64 / DOUBLEdouble or int AnyValue (int widened)key absent, or value neither
  • All typed columns are OPTIONAL and named by the DSL path exactly as in RFC 0022 §3.1 (attr.cost_usd). The class changes the column’s Arrow type, never its name.
  • f64 widens ints because sources are inconsistent (a zero cost may arrive as int 0); i64f64 is exact for |v| ≤ 2^53, and a key expected to exceed that belongs in i64. i64 does not narrow doubles — no silent truncation.
  • String-encoded numbers do not parse. A string "500" under an i64 class projects NULL. Parsing would smuggle in coercion ambiguity ("500" vs "5e2" vs " 500") that RFC 0022 §3.1’s “byte-faithful or NULL” rule exists to keep out; a source that stamps numbers as strings gets the string class and lexicographic semantics, documented as such.
  • Bool, bytes, array, kvlist classes are out of scope (§7).

3.2 Configuration

A list entry in storage.promoted_attributes.{resource,log} is either the RFC 0022 bare string (class string) or a typed mapping:

storage:
  promoted_attributes:
    log:
      - model                          # bare = string class, unchanged
      - { key: cost_usd, type: f64 }
      - { key: input_tokens, type: i64 }
  • Modeled as a two-variant entry (bare | {key, type}), rejecting unknown type values and duplicate keys across both spellings at startup — RFC 0020’s strict-parse posture.
  • type: string is legal and identical to the bare spelling.
  • The implicit resource.service.name promotion stays string and cannot be re-typed.
  • Rollout ordering is RFC 0022 §3.2’s, verbatim: a config carrying typed entries requires a binary at or above this RFC’s green; upgrade first, extend the config second.

3.3 Cross-file type conflict (the re-typing rule)

Files written under different promoted sets already coexist (RFC 0022 §3.4). This amendment adds a new coexistence case: the same column name with different physical types (a key promoted as string historically, re-declared i64 today — or vice versa).

Rule: a file whose column type differs from the currently declared class is read as if the column were absent from that file. This is the same class of behaviour RFC 0022 §3.3 assigns to pre-amendment files — column reads NULL, ==/!= fall through to the JSON arm where one exists, ordering and aggregation exclude those rows — so re-typing degrades exactly as adding a promotion does, and compaction converges history toward the current declaration as a side effect (§3.5).

Implementation note (binding): the scan must map per-file schemas onto the declared scan schema — DataFusion’s schema-adapter seam — casting nothing. A mismatched column is projected as NULL, never coerced; coercion would reintroduce the string-parse ambiguity this RFC’s §3.1 rejects.

3.4 Predicate compilation (RFC 0022 §3.3 amendment)

For a key of class i64/f64, the DSL literal must be numeric (the grammar already has numeric literals — confidence < 0.7); a string literal against a numeric-class key is a compile error with a hint naming the declared class.

  • Ordering (< <= > >=): typed arm only, P op v, prunable via row-group min/max statistics. Same shape as RFC 0022 ordering, now on a column whose statistics are numeric rather than lexicographic.
  • Equality (== !=) on i64: two arms, as RFC 0022 §3.3 — typed arm plus a JSON fallback arm for files where the column is absent or type-mismatched. Canonical integer formatting is unique, so the JSON arm is exact.
  • Equality on f64: typed arm only. JSON text carries no canonical float formatting (0.1 vs 1e-1), so a fallback arm would be wrong in both directions. Consequence, stated plainly: float equality never matches rows in pre-amendment or type-mismatched files. Float equality is a degenerate query regardless; ordering is the supported idiom.
  • Regex (=~ !~): compile error on numeric classes — regex over a number is a category error the string class already serves.

3.5 Aggregation, encodings, compaction, telemetry

  • RFC0002.17 scalar aggregates read a numeric-class column directly — no try_cast. The Utf8 try_cast path remains for string-class keys, unchanged. NULL cells stay excluded; sum over an all-NULL group returns NULL, not 0 (DataFusion semantics, now load-bearing: an unpromotable variant must not fabricate a zero cost).
  • Encodings (RFC 0005 §3.6 table extension): page index and statistics yes for both classes; bloom filter yes for i64 (equality is exact and index-backed), no for f64 (equality is discouraged — §3.4); dictionary encoding left to writer defaults.
  • Compaction (RFC 0009) re-projects rewritten rows with the current typed declaration, exactly as RFC 0022 §3.4 — including across a re-typing, which is how history converges. RFC0036.4 byte-identity holds within a fixed config, as today.
  • Telemetry: the existing ourios.storage.parquet.promoted.size instrument covers typed columns with no new names or attributes — the promoted-column-name attribute already identifies the column. No weaver-registry change.

3.6 Schema evolution (CLAUDE.md §3.5)

Additive OPTIONAL columns, same evolution class as RFC 0022 §3.4 / RFC 0018: pre-amendment readers see unknown columns and ignore them; this reader sees absent columns as NULL. No historical rewrite; the §3.3 rule is the migration plan for the one new conflict case. The attributes/resource_attributes JSON columns remain the source of truth; typed columns are projections, and the RFC 0017 read path never consumes them — OTLP fidelity untouched.

4. Alternatives considered

  • Stringify numerics into the existing Utf8 columns. No schema change, sum works via the existing try_cast. Rejected: float formatting makes == unreliable (the exact reason RFC 0022 §3.1 projects NULL today), lexicographic min/max statistics cannot prune numeric ordering ("9" > "10"), and the cast burns per-row CPU at query time forever.
  • Parse string-encoded numbers into typed columns. Helps sources that stamp "500". Rejected: coercion ambiguity (§3.1), and it makes projection behaviour depend on value content rather than variant — untestable by exhaustion over variants.
  • Type-suffixed column names (attr.cost_usd#f64) to make re-typing conflicts structurally impossible. Rejected: leaks the class into the on-disk schema forever and doubles the RFC 0022 §3.3 fallback surface; the schema-adapter rule handles the rare conflict without permanent naming debt.
  • Automatic type inference from observed values. Rejected: write-time inference makes the schema a function of traffic — irreproducible files, and hazard #5 with extra steps. The class is an explicit operator declaration, like promotion itself.

5. Acceptance criteria

  • RFC0042.1 (typed projection). Given a config promoting attr.cost_usd as f64 and attr.input_tokens as i64, when records carrying those keys as double/int AnyValues are ingested, then the written file carries OPTIONAL Float64/Int64 columns of those names holding the values, and the JSON attribute columns are byte-identical to an unpromoted run.
  • RFC0042.2 (projection totality). Given any AnyValue variant under each class, when projected, then the cell is the §3.1-table value or NULL — never a panic, never a coerced parse; in particular int widens into f64, double does not narrow into i64, and strings project NULL under both numeric classes.
  • RFC0042.3 (aggregation). Given a corpus with promoted numeric keys, when sum/avg/min/max(attr.<key>) by <group> runs, then results equal the oracle computed from the JSON attributes, records lacking the key are excluded, and an all-NULL group yields NULL, not 0.
  • RFC0042.4 (ordering + pruning). Given multi-row-group files with disjoint numeric ranges, when an ordering predicate on a typed key runs, then row counts match the JSON oracle and the RFC 0016 scanned/pruned counters show at least one pruned row group.
  • RFC0042.5 (absent and mismatched files). Given a scan spanning a pre-amendment file, a file with the key promoted as string, and a file with the current i64 declaration, when equality and ordering predicates and a sum run, then the query does not error, == on the i64 key answers correctly across all three files (JSON arm), and ordering/sum cover exactly the current-declaration file.
  • RFC0042.6 (config). Given bare, typed, and mixed entries, when the config parses, then bare entries behave identically to RFC 0022; and given an unknown type, a duplicate key across spellings, or a re-typed service.name, then startup fails with an error naming the offence.
  • RFC0042.7 (compile errors). Given a string literal compared against a numeric-class key, or a regex on one, then compilation fails with a hint naming the declared class; and float == compiles typed-arm-only.
  • RFC0042.8 (compaction re-projection). Given input files written under the string declaration for a key now declared i64, when compaction rewrites them, then output files carry the Int64 column projected from JSON truth, and RFC0036.4 byte-identity holds for repeated compaction under the fixed config.
  • RFC0042.9 (the consumer demand). Given a captured Claude Code corpus ingested under the dogfood promotion set (PR #646’s keys, typed), when sum(attr.cost_usd) by attr.model runs over the MCP surface, then it returns non-empty per-model spend matching the JSON oracle.

6. Testing strategy

  • Property tests (proptest): RFC0042.2 — arbitrary AnyValue variants × classes; projection is total and never coerces. RFC0042.3’s oracle comparison over generated numeric corpora (reusing the RFC 0024 envelope generators).
  • Unit: RFC0042.6 config parsing next to the RFC 0020 config tests; RFC0042.7 compile errors next to the RFC0002.17 tests.
  • Integration (querier): RFC0042.1, .3, .4, .5 over written fixtures, with the scanned/pruned counters as the pruning oracle (the RFC0022.5 pattern).
  • Integration (compaction): RFC0042.8 in compaction.rs’s existing re-projection suite.
  • Corpus: RFC0042.9 against the captured agent-telemetry corpus once recaptured under the typed dogfood set.

7. Open questions

  • Bool classattr.<key> == true for flags like cache_hit; cheap once the seam exists, no consumer yet.
  • Timestamp class — attribute-carried epoch times; wants its own design (unit ambiguity), not this RFC.
  • The remaining RFC 0022 §7 items (per-tenant sets, demotion, bloom sizing) are untouched by this amendment.

8. References

  • RFC 0022 — Queryable attribute columns (amended by this RFC; §3.1 string-only rule, §3.3 predicate arms, §7.1 reservation).
  • RFC 0002 §6 / RFC0002.17 — scalar aggregates over promoted columns.
  • RFC 0020 — configuration file schema (strict parse posture).
  • RFC 0009 / RFC 0036 — compaction re-projection, byte-identity.
  • RFC 0027 — the MCP surface the FinOps consumer queries through.
  • CLAUDE.md §3.5 (schema evolution), §4 hazards #2/#4/#5.
  • PR #646 (drafted) — the config change this RFC unblocks.

RFC 0043 — Derive event_name from the legacy event.name attribute


rfc: 0043 title: Derive event_name from the legacy event.name attribute at ingest status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-29 supersedes: — superseded-by: —

RFC 0043 — Derive event_name from the legacy event.name attribute at ingest

Status: accepted (2026-07-29, maintainer sign-off). Terminal. No thesis-gate applies (validated vacuous, RFC 0008 precedent); the flip records maintainer approval of the derivation as binding.

Status: green (2026-07-29). All seven §5 criteria pass: the derivation + boundary shapes landed in #668 (RFC0043.1–.4/.7, both encodings through the one materialize_record seam), and #669 closed .5 (attr-only records through the real miner into a real store, matched end-to-end by event_name ==) and .6 (the id-separation observable, criterion refined inline — the originally referenced counter does not exist as a distinct instrument). No thesis-gate applies; accepted is a maintainer flip.

(specified, same date: §5 criteria written and testable; the one fidelity question (§7) resolved in the design — the attribute is preserved verbatim, the field is derived, nothing is corrected.)

1. Summary

When an incoming LogRecord has no top-level event_name but carries an event.name attribute, ingest populates the stored record’s event_name from that attribute — and keeps the attribute itself byte-for-byte. This is the OTel spec’s own migration story executed at the backend: the event.name attribute is the legacy precursor of the top-level EventName field, and real sources (Claude Code, opencode-plugin-otel) still emit only the attribute. With the field populated, RFC 0037’s event-keyed templating engages for those sources and event_name == "…" becomes the idiomatic DSL filter the OTel events semconv prescribes (“when users query for a specific event name…”).

2. Motivation

The idiomatic query exists but the data never reaches it. The DSL already exposes event_name as a queryable field and the OTLP receiver already parses the wire field — but our two flagship GenAI sources predate the field and emit only the event.name attribute, so every such record stores event_name: NULL. Users fall back to body == (bitten by #664) or attribute-implicit grouping.

The events semconv points here, not at body. Semconv MUST NOT give body a value beyond a display message, and event identity queries are EventName’s job. This RFC makes the backend meet the spec’s intent for sources that haven’t caught up.

RFC 0037 gets its keying for free. Event-keyed templating (§3.1) activates on event_name presence; today it never engages for Claude Code/opencode events. Deriving the field turns their template handling from body-mining into the designed event-keyed path.

3. Proposed design

At the OTLP decode boundary (ourios-core OTLP conversion, both protobuf and JSON paths per the RFC0003.6 checklist):

  1. If LogRecord.event_name is set and non-empty, it wins. The event.name attribute, if also present, is stored untouched — no comparison, no correction, no flag (a mismatch between the two is source telemetry, and we preserve it; the read path returns both as received).
  2. If LogRecord.event_name is unset or empty and an event.name attribute is present with a non-empty string value, the stored record’s event_name is set to that string. The attribute remains in attributes verbatim — derivation, not a move. Non-string and empty-string event.name values derive nothing.
  3. Neither present → event_name stays NULL, exactly as today.

“Set” is defined identically for both encodings: protobuf cannot distinguish an absent string field from an empty one (proto3 default), and RFC0003.6 JSON may spell absence as a missing key, null, or "" — all three read as unset. An empty string is therefore never a value, on either side of the derivation, and the two decode paths cannot diverge (RFC0043.3/.7).

The invariant posture: the OTLP-fidelity rule (preserve / flag / never correct) is untouched because nothing received is altered or dropped — the derived field is additive, and a reader comparing stored attributes against the source’s export sees byte identity. Downstream (mining, RFC 0037 keying, the DSL field, the RFC 0032 query-schema document) all consume event_name unchanged; they simply see it populated for more sources.

4. Alternatives considered

  • Do nothing; teach body == instead. #664’s fix (RFC 0044) makes body == correct, but the semconv is explicit that event identity is the event name’s job; leaving the field NULL keeps the idiomatic query dead for the most important corpus and keeps RFC 0037’s keying inert.
  • Move the attribute into the field (hoist-and-drop). Violates the fidelity rule — the stored attributes would no longer match what the source exported.
  • Collector-side remapping (OTTL). Works per deployment, but every deployment must know to do it; the backend doing the spec’s documented migration once is strictly less operational surface. A deployment that remaps anyway hits rule 1 and nothing double-applies.

5. Acceptance criteria

  • RFC0043.1 — the wire field wins
    • Given a record with event_name set and a differing event.name attribute
    • When it is ingested and read back
    • Then the stored event_name is the wire field’s value
    • And the attribute is returned verbatim, unflagged.
  • RFC0043.2 — derivation from the attribute
    • Given a record with no event_name and an event.name string attribute
    • When it is ingested and read back
    • Then event_name equals the attribute’s value
    • And the event.name attribute is still present, byte-identical.
  • RFC0043.3 — both encodings
    • Given the RFC0043.2 record encoded as OTLP protobuf and as RFC0003.6 JSON
    • When each is ingested
    • Then both derive identically.
  • RFC0043.4 — non-string derives nothing
    • Given an event.name attribute whose value is not a string
    • When ingested
    • Then event_name stays NULL and the attribute is preserved.
  • RFC0043.5 — the idiomatic query works end-to-end
    • Given an ingested attr-only corpus (Claude Code-shaped fixture)
    • When event_name == "claude_code.api_request" runs
    • Then exactly the api_request records return.
  • RFC0043.6 — RFC 0037 keying engages, observably
    • Given three attr-only structured records: two sharing an event.name (with differing body content) and one with a distinct event.name (RFC 0037 §3.1 keys (severity, scope, event_name) for structured bodies; without derivation all three collapse into the one no-event sentinel)
    • When mined
    • Then the two same-name records carry the same template_id despite differing content
    • And the distinct-name record carries a different template_id — the separation is the externally visible proof the derived name reached the template key, since the sentinel would have merged all three. (Refined at implementation time: the originally referenced “event-keyed counter” does not exist as a distinct instrument; the id separation is a strictly stronger observable.)
  • RFC0043.7 — empty is never a value, in either encoding
    • Given records with (a) empty-string wire event_name plus an event.name attribute, (b) an empty-string event.name attribute only, and (c) JSON null event.name — each encoded as protobuf and as RFC0003.6 JSON where representable
    • When ingested
    • Then (a) derives from the attribute, (b) and (c) derive nothing, and the protobuf and JSON results are identical shape-for-shape.

6. Testing strategy

Unit tests at the decode boundary for .1–.4 (both encodings, per the RFC0003.6 checklist — struct round-trip alone is insufficient); integration through ingest→query for .5; a miner-level assertion for .6 against a Claude Code-shaped fixture. No new metrics: derivation is not an anomaly (the anomaly-telemetry rule reserves error.type on the existing counters for rejections).

7. Open questions

  • Fidelity vs. derivation — resolved in §3: derive additively, never mutate received data. The read path returns both.
  • Should the RFC 0032 query-schema document call out event_name availability per tenant (it is population-dependent)? Deferred — the field is already listed as queryable.

8. References

  • OTel Logs Data Model — EventName field; events semconv (§Body: “MUST NOT define a value for body except … display message”; §Event name: “when users query for a specific event name…”).
  • RFC 0003 (OTLP receiver) + the RFC0003.6 JSON checklist.
  • RFC 0037 (GenAI/structured-event logs) §3.1 event-keyed templates.
  • RFC 0044 (template-aware body equality) — the complementary half: this RFC gives event filtering its idiomatic path; 0044 makes the compatibility path correct. #664 is closed by 0044, not this RFC.

RFC 0044 — Template-aware body equality


rfc: 0044 title: Template-aware body equality — the two-arm compile for body == status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-29 supersedes: — superseded-by: —

RFC 0044 — Template-aware body equality (body == two-arm compile)

Status: accepted (2026-07-29, maintainer sign-off). Terminal. No thesis-gate applies (validated vacuous, RFC 0008 precedent). The one open §7 fork — substring/ordering/regex on body — survives acceptance as a follow-up decision, tracked in §7, not by reopening this RFC.

Status: green (2026-07-29). All nine §5 criteria pass, landed in five slices the same day: #671 (the plan-time matcher), #672 (the two-arm compile — #664 closed there), #673 (.6 with the alias-exclusion refinement), #674 (.7/.8 pruning fixtures), #675 (.9 as a generative property and every line of the committed §3.3 corpus). Three refinements implementation forced on the spec, each stated inline: the template arm is a plan-time candidate superset with exactness at scan time (separators/overflow are per-record); alias-class expansion is excluded as wrong, not just unneeded (§3.3); and equality carries no IS TRUE wrap — under a filter NULL ≡ false, and the wrap defeats row-group pruning (!= keeps IS NOT TRUE, where three-valued logic genuinely bites). No thesis-gate applies; accepted is a maintainer flip.

(specified, same date: closes #664; equality only, other body operators §7; the design reuses RFC 0042 §3.3’s two-arm pattern and is well-defined because CLAUDE.md §3.3 guarantees render fidelity.)

1. Summary

body == "literal" today compiles against the physical body column only. High-confidence mined records store body as NULL (the template carries the text), so the predicate silently matches nothing and the row group is pruned — a confident-looking wrong answer (#664). This RFC compiles body equality to a two-arm predicate: the physical-column arm (covering retained and lossy bodies, as today) OR a plan-time template arm that resolves the literal against the tenant’s template store — zero-parameter templates become a prunable template_id IN (…), parameterized templates become template_id == T AND param(i) == v…. Body stays a first-class queryable field, as the OTel ecosystem expects (OTTL’s log.body, stanza’s body-default paths), and correct empties stay cheap.

2. Motivation

Silent empties violate the project’s own ethos. The DSL fails loudly everywhere else (unpromoted group-by names its fix; unknown fields list §7). body == is the one accepted-but-wrong query — and it bites hardest on the GenAI corpus, whose bodies are event names and mine to zero-parameter templates with 100% confidence, i.e. the exact records whose physical body is always NULL.

Rejecting body equality is not an option. The OTel ecosystem treats body as the primary addressable content (Logs Data Model top-level field; OTTL and stanza both path into it, stanza even defaulting bare fields to body.*). A backend where body == errors out is the odd one out. (The idiomatic event filter is event_name — RFC 0043’s half — but the compatibility path must be correct, not absent.)

The machinery already exists. RFC 0042 §3.3 established the two-arm compile (typed arm OR stored-form arm). RFC 0033’s cached template map gives the querier plan-time template access. param(n) is an existing DSL accessor. Reconstruction is property-tested byte-identical (CLAUDE.md §3.3), which is what makes literal → (template, params) inversion sound.

3. Proposed design

3.1 The two arms

For body == L (string literal L):

  • Physical arm (unchanged): body_col == L. Covers low-confidence retention and lossy-reconstruction records (CLAUDE.md §3.1/§3.3 rules), prunable via existing column statistics.
  • Template arm (new): at plan time, match L against every template in the tenant’s map.
    • A zero-parameter template matches iff its rendered text equals L byte-for-byte → contribute its id to a template_id IN (…) disjunct. Prunable via template_id min/max + bloom.
    • A parameterized template matches iff L unifies with its token structure (anchored, whitespace-exact per §3.3 capture) → contribute template_id == T AND param(0) == v0 AND … with the implied parameter values. A literal may unify with several templates; each contributes a disjunct.
  • The compiled predicate is physical-arm OR template-arm. A row group is skipped only when both arms are impossible — so a literal that matches nothing anywhere still prunes everything (correct empties stay cheap, RFC0044.8).

3.2 != and three-valued logic

body != L compiles as the negation with explicit NULL handling: a mined record (physical body NULL) matches != iff its template-side value does not equal L; the physical arm’s NULL must not silently exclude mined records (the mirror image of the #664 bug). The typed != arm in RFC 0042 §3.3 (presence kept explicit) is the pattern.

3.3 Template-map freshness and versioning

  • The template arm resolves against the same tenant template map snapshot the query’s read path uses (RFC 0033) — the predicate can never be staler than the rendering the user sees.
  • The plan-time match covers template versions and renames inherently: the registry folds every (template_id, version)’s tokens from the audit stream, and unification checks every entry — a template re-created under a new id or widened to a new version across deploys contributes each id/version whose tokens still unify with the literal. Missing this recreates #664 one deploy later. (Refined at implementation time from “traverses versions and aliases”: expanding RFC 0007 alias classes here would be wrong, not just unnecessary — alias classes group templates whose shapes differ, and byte-equality must never admit a record whose own tokens do not render the literal. resolves_to(n) remains the query for shape-crossing equivalence.)

3.4 Structured bodies

A string literal never matches a structured body (RFC 0037): those records are excluded by both arms by construction, and the docs point at the structured accessors. No error — mixed corpora are normal.

3.5 Out of scope

Ordering (<, >=), substring, and regex against body are unchanged by this RFC (a pattern matches unboundedly many rendered forms; there is no bounded plan-time inversion). Whether those forms share a silent-miss today and what to do about it is §7 — explicitly not smuggled into this slice.

4. Alternatives considered

  • Reject body equality loudly. Honest and cheap, but diverges from ecosystem expectations (§2) and removes the natural compatibility query for event-named bodies. Rejected.
  • Always store the body column. Correct by brute force; destroys pillar #2’s economics and the pruning value the thesis rests on.
  • Scan-time reconstruction (render every row, compare). Correct but unprunable — every body query becomes a corpus scan, exactly what pillar #1 exists to avoid. The plan-time inversion keeps pruning.
  • Fix only via RFC 0043 (event_name). Handles the event corpus but leaves body == silently wrong for every other mined line — the bug class survives.

5. Acceptance criteria

  • RFC0044.1 — the #664 reproduction matches
    • Given an ingested record whose body mined to a zero-parameter template (physical body NULL)
    • When body == "<that exact body>" runs
    • Then the record returns, with the row group scanned not pruned.
  • RFC0044.2 — parameterized unification
    • Given records under a template with parameter slots
    • When body == runs with a literal equal to one record’s original line
    • Then exactly that record returns (template + implied params).
  • RFC0044.3 — retained bodies still match
    • Given a low-confidence record whose original body is retained
    • When body == runs with that body
    • Then it matches via the physical arm.
  • RFC0044.4 — != does not silently drop mined records
    • Given mined records with reconstructions ≠ L
    • When body != L runs
    • Then they all return despite NULL physical bodies.
  • RFC0044.5 — structured bodies are excluded, not errored
    • Given a mixed corpus with RFC 0037 structured bodies
    • When body == "<string>" runs
    • Then structured-body records are absent and the query succeeds.
  • RFC0044.6 — versions and renames contribute every matching id
    • Given records written under a template later re-created under a new id and under a widened version (the RFC 0010 drift shapes)
    • When body == runs with the rendered text
    • Then records under every id/version whose tokens render the literal return — and no record whose own tokens do not. (Refined at implementation time: alias-class expansion is excluded by design — see §3.3.)
  • RFC0044.7 — pruning still engages
    • Given a multi-file corpus where the matched template appears in a strict subset of row groups
    • When body == runs
    • Then row_groups_pruned > 0 and results are complete.
  • RFC0044.8 — correct empties stay cheap
    • Given a literal matching no template and no retained body
    • When body == runs
    • Then the result is empty and every row group was pruned.
  • RFC0044.9 — the reconstruction invariant, driven through the predicate [property]
    • Given every line of the mined corpus (the §3.3 property-test corpus)
    • When body == <original line> is compiled and evaluated
    • Then the originating record is found for every non-structured line — equality-through-templates is exactly as faithful as reconstruction itself.

6. Testing strategy

Unit tests for the plan-time matcher (zero-param, parameterized, multi-template unification, alias traversal); integration through ingest→query for .1–.8; RFC0044.9 as a proptest extension of the existing reconstruction property suite (per CLAUDE.md §6.2, reconstruction is always a property test — this drives the same invariant through the predicate path). Pruning assertions read the stats counters the query response already carries.

7. Open questions

  • Substring/ordering/regex on body — do they share the silent miss today, and if so: loud rejection, opt-in reconstruction scan, or leave documented? Follow-up RFC either way (§3.5).
  • Plan-time match cost telemetry — template counts are bounded (RFC 0023, C2), so the match is expected to be sub-millisecond; is a plan-phase duration attribute on the query span (RFC 0038) worth adding while instrumenting this?

8. References

  • #664 — the reproduction this RFC closes.
  • RFC 0042 §3.3 — the two-arm compile pattern (typed arm OR stored form) this design reuses for body.
  • RFC 0033 — the cached tenant template map (plan-time access).
  • RFC 0007 / RFC 0010 — alias storage and drift semantics the template arm must traverse.
  • RFC 0037 — structured bodies (§3.4 exclusion rule).
  • RFC 0043 — the idiomatic-path complement (event_name derivation); together they close the “how do I filter events” story.
  • CLAUDE.md §3.3 (bit-identical reconstruction) — the invariant that makes the inversion sound; §3.1 (retention) — the physical arm’s coverage; hazard #6 (DSL surface).
  • OTel — OTTL log.body paths; Collector stanza field defaults (body.*); Logs Data Model (body as top-level field).

RFC 0045 — Composite tenant derivation


rfc: 0045 title: Operator-configured composite tenant derivation status: superseded author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-08-17 supersedes: — superseded-by: RFC 0046

RFC 0045 — Operator-configured composite tenant derivation

Status: superseded by RFC 0046 (2026-08-17). The maintainer ruled the same day that tenancy does not reside in OTLP data — no resource attribute is ever a tenancy input — so the in-band composite derivation this RFC specified was the wrong model, not a bad rule. RFC 0046 (out-of-band tenancy) replaced §3.1–§3.4 (rule, epoch log, detector) in #702; the Store double-encoding fix of §3.2 and TenantId opacity survive. The registry entries the detector minted are deprecated, not deleted. Kept as the record of a green implementation and of the reasoning that led out of it.

(green, earlier the same day, before the ruling:)

Status: green (2026-08-17). All ten §5 criteria pass, landed in one implementation PR (#692) of six slices the same day as the spec (#689): the TenantRule key list + receiver.tenant config (.1/.2/.3/.4/.6); the rule-epoch log (.10, on the RFC0014.5 crash fixture); the divergence detector + ourios.receiver.tenant.divergences (.7/.9); the served-binary sequence over one store + WAL — default → composite → composite + token (.2/.3/.4/.5/.8); a Helm receiver.tenant passthrough. Two things implementation forced on the spec, both recorded inline: the Store double-encoded any tenant id with a reserved character (§3.2 — fixed as fix(parquet)!, one-shot prefix rename for legacy objects), and the detector compares a digest + length rather than the 128-byte preview (§3.4). No thesis-gate applies (validated vacuous, RFC 0008/0044 precedent); accepted is a maintainer flip.

(specified, same date: §5 criteria written and testable. Grounded in the tenancy concept discussion (#688): the maintainer-settled Q1–Q10 answers (Q1–Q7 in the issue body, Q8–Q10 raised and settled in its comment thread) are this RFC’s premises, restated in §2/§3 where they bind.)

1. Summary

Expose the tenant-derivation rule RFC 0001 §6.1 reserved: an ordered list of resource-attribute keys, configured by the operator, whose values join into the tenant id ([k8s.cluster.name, service.name]cluster1/fluxcd). The default stays [service.name], byte-identical to today. Rule changes have append-only epoch semantics — newly ingested data derives under the new rule, stored ids never change, nothing repartitions. A divergence detector watches for the misconfiguration this RFC exists to kill: one tenant whose records span multiple values of a higher-order key (two clusters silently merged) announces itself with a warning and a counter instead of corrupting quietly.

2. Motivation

service.name is not globally unique — by specification. The semconv: service.name is expected to be unique within the same namespace”; global uniqueness holds only for the service.namespace / service.name / service.instance.id triplet. In Kubernetes the collision is guaranteed, not incidental: service.name is calculated from k8s.deployment.name (the documented k8s-attributes chain), so two clusters running the same deployment — fluxcd in cluster1 and cluster2 — derive the byte-identical tenant and their telemetry merges into one partition. A cross-tenant data merge is the §3.7-class corruption this backend exists to prevent, and today it is silent.

The mechanism is already general — TenantRule::by_attribute(key) exists, derivation is per-ResourceLogs group with whole-export rejection when no tenant resolves — but ourios-server hard-codes TenantRule::service_name() and no config surface reaches it. The operator who knows about the collision cannot deploy around it.

Settled premises from #688 that bind here: tenancy is the fused isolation-and-partitioning unit (“the smallest blast radius of a credential”, Q1); tenancy metadata stays out of the OTLP data model — derivation interprets producer-describing attributes, and no bespoke in-band tenant stamp is ever trusted (Q2); the token remains the authority and the derived tenant remains a claim checked against it (Q3); tenant ids are opaque — no mechanical hierarchy (Q4); cross-tenant queries stay out (Q3); repartitioning is rejected (Q5).

3. Proposed design

3.1 Configuration

receiver:
  tenant:
    # Ordered resource-attribute keys; values join into the tenant id.
    # Default: [service.name] — today's behaviour, unchanged.
    rule: [k8s.cluster.name, service.name]
    # Keys watched for divergence (§3.4) when not already in `rule`.
    # Default: [k8s.cluster.name].
    watch: [k8s.cluster.name]
    # Upper bound on remembered (tenant, key) pairs (§3.4). Default: 10000.
    watch_capacity: 10000
  • An empty rule list is a startup configuration error. A duplicate key in rule is a startup configuration error. A key listed in both rule and watch is accepted and simply not watched (§3.4). watch_capacity must be an integer ≥ 1 — 0 and negative values are startup configuration errors (an operator who wants no watching sets watch: []).
  • Derivation is per-ResourceLogs group from Resource.attributes, unchanged in shape. Every key in rule is required: any group whose resource lacks a rule key, or carries it with a non-string or empty-string value, rejects the whole export — the existing RFC0003.4 posture, and the RFC 0043 rule that an empty string is never a value. Partial joins are explicitly rejected as a design (§4): a group missing k8s.cluster.name that silently derived plain fluxcd would recreate the exact collision this RFC exists to close.
  • watch keys are never required. A group that lacks a watch key, or carries it as a non-string or empty string, is simply not observed by the detector for that key; the export’s acceptance is decided by rule alone. The detector observes, it never enforces (§3.4).

3.2 The join is injective, and the single-key case is byte-identical

A single-key rule (including the default [service.name]) derives the tenant id as the attribute’s string value, verbatim — exactly what TenantRule::service_name() produces today. No escaping is applied: a service.name of a/b stays tenant a/b, 100% stays 100%, so existing storage paths and token bindings are untouched (RFC0045.6 covers both characters).

A composite rule (two or more keys) percent-encodes % (as %25) and / (as %2F) in each component value, then joins the components with /. For a fixed rule, distinct component tuples therefore produce distinct tenant ids: ("a", "b/c")a/b%2Fc and ("a/b", "c")a%2Fb/c cannot merge. Injectivity is a per-rule property — within one epoch exactly one rule is in force, so no two live resources can collide; the cross-epoch case is §3.3.

The tenant id remains an opaque string to every downstream consumer (auth, storage, query); the partition layer’s existing percent_encode_tenant makes any tenant id path-safe, so the on-disk layout needs no change. One latent defect on that path does need fixing, and this RFC owns it: the Store resolved keys with ObjectPath::from, which escapes the % of an already-encoded tenant a second time (a%2Fba%252Fb), so the local querier’s tenant_id=<enc> join and the compactor’s percent_decode_tenant never found such a tenant’s objects. Invisible while tenant ids were plain service.name values; unavoidable once ids carry /. Keys are parsed (stored verbatim) instead; RFC0045.2/.4 exercise the fix end-to-end.

Legacy objects. A pre-fix deployment whose service.name values contained any character outside the unreserved set (/, %, =, :, space, …) wrote that tenant’s objects under the doubly-encoded key. Those objects were already unreadable on the local backend and mis-attributed by the compactor’s percent_decode_tenant; on S3 they were readable only because the writer and the remote read path shared the same double encoding. No dual-read is built: this is a pre-release fix, no correct deployment could have depended on the layout, and a read-side fallback would have to live in every consumer forever. The fix ships as a conventional breaking change (fix(parquet)!) whose note names the one-shot migration — rename the tenant_id=<double-encoded> prefix to tenant_id=<encoded> (an object copy on S3, a directory rename locally). Tenants whose ids are unreserved throughout — every plain service.name — have identical keys before and after.

3.3 Epoch semantics

Derivation happens at ingest, once. A rule change (config edit + restart) affects newly ingested data only: stored files keep the tenant ids they were written under, no repartitioning, no rewrite, no epoch qualifier in the id or the storage key. Tenant identity is the id string and nothing else (opaque ids, Q4): if a later rule derives an id that an earlier rule also produced, those records are one tenant, intentionally — the same way they would be if the rule had never changed. Records whose ids differ across epochs (fluxcd before, cluster1/fluxcd after) are two tenants, each queryable under its own id. Nothing happens to old data — with one qualification, the WAL tail, which is the only place “derive once” needs a mechanism.

The WAL tail derives under the rule it was acknowledged under. Startup recovery (RFC 0001 §6.9 / RFC0014.5) replays every surviving WAL frame through the tenant fan-out — un-flushed frames, plus frames a floor-retained segment still holds. Re-deriving those under a changed rule would either abort startup (a rule key the old frames never carried) or, worse, silently re-tenant acknowledged records into the new epoch’s ids — a duplicate in cluster1/fluxcd for a record already stored under fluxcd, and a miner tree fed twice. So the receiver persists a rule-epoch log in the WAL root (tenant_rule_epochs.json — a sidecar like the checkpoint file, not a new WAL frame kind; written temp-file → rename → directory fsync): an ordered list of {rule, after} entries meaning “frames with offset > after derive under rule” (after: null = from the beginning). Replay picks each frame’s epoch by offset: the newest entry whose after lies strictly below the frame. On startup, after replay and before either listener is bound (so no frame can be acknowledged under the new rule until the entry is durable), if the configured rule differs from the newest entry’s rule, a new entry is appended with after = the highest offset replay delivered. Replay delivers every surviving frame, so a None there means the WAL holds no frames at all; the log then collapses to the single entry {rule, null} — nothing exists to attribute to earlier epochs — so only the first entry is ever unbounded. Every WAL offset is globally ordered (UUIDv7 segment, byte), so “which epoch” is one comparison.

Durability and validation. The sidecar is written as temp file → fsync(file)renamefsync(directory), the same sequence as the checkpoint file, so a crash leaves either the previous log or the new one, never a torn file. On load the log must be an object with a non-empty epochs array; every entry a valid rule (non-empty, no duplicate keys) and the first with after: null and every later one with a {segment: UUID, byte} offset; and successive after values non-decreasing (an equal boundary is allowed — the later entry wins for frames above it, which is what append order means). Anything else, and an absent epochs, aborts startup naming the file (corruption class, like a bad segment header). An absent file means one implicit epoch, {[service.name], null} — every pre-RFC WAL is that epoch, so the upgrade needs no migration. Entries are never pruned; a rule change is rare and the file stays a few lines.

Read-time tenant aliasing (query tenant X also reads legacy tenant Y through an explicit, audited mapping) is the named escape hatch if S7-style demand materializes; it is out of scope here.

3.4 The divergence detector

For each key in watch that is not part of rule: per (tenant, key), the receiver remembers the first observed value. When a later group for the same tenant carries a different value, the receiver emits a rate-limited warning naming the tenant, the key, and both values, and increments a counter. The S2 misconfiguration — two clusters merging into one tenant under a single-key rule — thereby announces itself on the first divergent batch instead of corrupting silently. Ingest is never rejected by the detector: it observes, it does not enforce (the operator may genuinely intend one tenant spanning clusters).

State bound. The detector’s memory is a map of at most receiver.tenant.watch_capacity (tenant, key) entries — default 10 000 — each holding the first-observed value. Admission is first-come: once the map is full, new (tenant, key) pairs are not admitted and are not watched; a single warning announces saturation (once per process lifetime), so an un-watched tenant is a known, logged condition rather than a silent one. No eviction — first-observed semantics have no meaningful “least recently used” entry, and evicting would only trade one blind spot for another. The bound is an entry count, not a byte budget: each entry holds the tenant id (a string storage already holds per partition), the watched key (operator config), a 64-bit digest and the length of the first value, and its ≤128-byte preview — so the memory ceiling is watch_capacity × (|tenant| + |key| + ~160 B). State resets on restart (documented; the detector is best-effort by design): the first value seen after a restart becomes the new baseline, so a divergence that straddles the restart is not announced — only a divergence observed within one process lifetime is.

Value representation. Only non-empty string values are observed (§3.1); non-string values never reach the detector, so nothing needs a serialization. Comparison is exact: the detector keeps a 64-bit digest plus the byte length of the first value and compares later values against both, so two values that share a long common prefix are still told apart. What is stored for display and logged is a preview bounded to 128 bytes — longer values are truncated at a UTF-8 boundary and marked with a trailing — which caps both the memory per entry and the log line. The warning is rate-limited per (tenant, key), so a persistently divergent tenant produces one line per rate window, not one per batch. Redaction is not applied: watch keys are operator-selected producer descriptors, and selecting a key opts its values into the operator’s own logs exactly as selecting a rule key opts them into tenant ids and storage paths.

The counter’s name and attributes are minted at implementation time through the semconv registry + weaver process (provisional: ourios.tenant.watch_divergence, attribute = the watched key; the tenant id rides the warning log, not the metric, for cardinality). The OTel-naming check happens then, per house rule.

3.5 Auth interaction — none

The derived tenant remains a claim checked against the token’s tenant set (RFC 0026 whole-batch binding; RFC 0029 resolution). Composite ids are opaque strings to that machinery. A token authorizing cluster1/fluxcd authorizes exactly that string; nothing about binding, rejection, or the 403 contract changes.

Rollout under a rule change follows from §3.3 and needs no mechanism: a token naming fluxcd keeps authorizing exactly fluxcd — the old-epoch tenant — and does not authorize cluster1/fluxcd. The operator issues (or extends) tokens naming the new ids before or with the restart; until then, exports deriving new ids are rejected by the unchanged binding check (RFC0045.8) rather than silently landing somewhere. Whether old tokens are revoked once the old-epoch data ages out is the operator’s call.

4. Alternatives considered

  • Collector-side remapping (OTTL rewriting service.name or stamping a synthetic attribute). Works per deployment, still available, but every deployment must know to do it, and rewriting service.name corrupts its semantics. The backend doing the composite once is less operational surface.
  • A bespoke trusted in-band tenant attribute. Rejected per #688 Q2: OTLP deliberately has no tenant field; an injected stamp trusted on arrival is routing metadata smuggled into the data model. (An operator may still point the rule at such an attribute — it is then a claim like any other, bound by the token.)
  • Skip-missing-keys joining. Rejected: a partial join silently reproduces the collision under exactly the conditions (heterogeneous resource attributes) where the operator most needs the strictness.
  • Mechanical hierarchy (prefix queries over cluster1/…). Deferred per Q4: the separator is a social convention; ids are opaque.
  • Repartitioning on rule change. Rejected per Q5: a data-rewriting migration for a config edit inverts the risk profile of the entire design.
  • Replaying the WAL tail under the new rule (no epoch log; document “drain before you change the rule”). Rejected: replay delivers floor-retained frames even after a clean shutdown, so the procedure cannot be made airtight, and the failure is either a startup abort or a silent re-tenanting of acknowledged data — the second is exactly the §3.7 class this RFC exists to close. Stamping the tenant id or rule into each WAL frame was the other option; it changes the RFC 0008 frame format for a once-per-deployment event, where a sidecar keyed by offset does not.

5. Acceptance criteria

Scenario ids RFC0045.<n>.

RFC0045.1 — config resolution. Given a config with no receiver.tenant section, When the server starts, Then derivation uses [service.name]; Given rule: [], Then startup fails with a configuration error; Given rule: [service.name, service.name], Then startup fails with a configuration error; Given watch_capacity: 0 (or a negative or non-integer value), Then startup fails with a configuration error.

RFC0045.2 — the S2 scenario end-to-end. Given rule: [k8s.cluster.name, service.name] and two exports whose resources share service.name: fluxcd but differ in k8s.cluster.name (cluster1, cluster2), When both are ingested and queried, Then two tenants cluster1/fluxcd and cluster2/fluxcd exist, each query returns only its own records, and no record is reachable from the other tenant.

RFC0045.3 — strict missing-key rejection. Given the composite rule and a group whose resource lacks k8s.cluster.name (or carries it as a non-string or empty string), When the export is ingested, Then the whole export is rejected with the same posture as today’s missing service.name, and nothing reaches the WAL.

RFC0045.4 — join injectivity. Given rule of two keys and two exports with component tuples ("a", "b/c") and ("a/b", "c"), When both are ingested, Then they land in two distinct tenants and each is queryable only under its own id.

RFC0045.5 — epoch semantics. Given records ingested under the default rule, When the server restarts with the composite rule and further records are ingested, Then the old records remain queryable under their original tenant, the new records under the composite tenant, and no stored file was rewritten; And Given a later epoch derives an id the earlier epoch also produced, Then a query for that id returns records from both epochs — one tenant, per §3.3.

RFC0045.6 — default regression. Given no receiver.tenant config, When the existing RFC 0003 tenancy suite runs, Then it passes unchanged — derivation is byte-identical to the pre-RFC behaviour; And Given a single-key rule and a service.name of a/b (and of 100%), Then the derived tenant is exactly a/b (100%) — no escaping on the single-key path.

RFC0045.7 — divergence detector. Given the default rule and default watch, When two exports share service.name but differ in k8s.cluster.name, Then a warning naming the tenant, key, and both values is emitted and the divergence counter increments; And Given uniform k8s.cluster.name values, Then no warning and no increment; And Given a group lacking k8s.cluster.name (or carrying it non-string or empty), Then the export is accepted and that group is not observed; And Given a divergent value longer than 128 bytes, Then the warning carries the value truncated at a UTF-8 boundary with a trailing ; And Given two values that agree on their first 128 bytes and differ after, Then the divergence is still detected and counted.

RFC0045.8 — auth binding unchanged. Given auth enabled with a token bound to cluster1/fluxcd and the composite rule, When an export deriving cluster2/fluxcd is presented under that token, Then the whole batch is rejected per the RFC 0026 contract, with unchanged telemetry.

RFC0045.9 — watch state bound. Given watch_capacity: 1 and two tenants that each later diverge on k8s.cluster.name, When both are ingested, Then the first tenant’s divergence is reported, the second tenant’s is not, the saturation warning is emitted exactly once, and every export is accepted.

RFC0045.10 — WAL tail keeps its epoch. Given records acknowledged under the default rule whose frames are still in the WAL — un-flushed after a crash, or retained after a clean shutdown — When the server restarts with the composite rule, Then recovery derives those frames under [service.name] — they land only in their original tenant, no duplicate exists in any composite tenant, startup succeeds even though the frames lack k8s.cluster.name, and the epoch log gains one entry; And Given no epoch log exists beside a pre-RFC WAL, Then replay behaves as a single [service.name] epoch; And Given an epoch log that is unparseable, has no entries, or whose after boundaries go backwards, Then startup aborts naming the file.

6. Testing strategy

Unit tests in ourios-ingester for the rule (single-key verbatim, composite encode + join, missing/empty/non-string rejection, injectivity pairs) and for the detector (first-value memory, divergence, watch-key absence, truncation, capacity admission); a proptest over component tuples asserting the composite join is injective for a fixed key count (RFC0045.4 in property form). Config resolution (RFC0045.1) as FileConfig unit tests. RFC0045.2/.3/.5/.8 as ourios-server integration tests through the served OTLP → query path, reusing the RFC 0003 / RFC 0026 harnesses; RFC0045.6 is the existing suite plus two rule-level cases. RFC0045.7/.9 assert on captured tracing output and the counter, in the pattern the RFC 0026 telemetry tests use. RFC0045.10 extends the RFC0014.5 crash/replay harness (ingest → kill before flush → restart with a different rule) plus unit tests for the epoch log’s parse (including the rejection cases), append, and by-offset lookup; the retained-after-clean- shutdown arm is the served-binary RFC0045.5 sequence, where the phase-1 frame (which carries the composite keys) must not reappear as a duplicate in the composite tenant after the rule change — a re-derivation at replay would put it there.

7. Open questions

  • Counter final nameourios.tenant.watch_divergence is provisional; minted through the semconv registry + weaver at implementation, with the OTel-MCP naming check.
  • Saturation visibility — a once-per-lifetime warning is the minimum; whether the admitted-entry count deserves a gauge is decided when the counter is minted (same registry pass).

8. References

  • #688 — the tenancy concept discussion; Q1–Q10 are this RFC’s premises (Q1–Q7 in the issue body, Q8–Q10 in its comment thread).
  • RFC 0001 §6.1 — the reserved tenant-derivation rule this RFC exposes.
  • RFC 0003 §6.3 / RFC0003.4 — per-ResourceLogs derivation and whole-export rejection.
  • RFC 0005 §3.4 — percent_encode_tenant, the path-safety layer.
  • RFC 0026 / RFC 0029 — whole-batch binding and token resolution the derived tenant is checked against.
  • RFC 0043 — the empty-string-is-never-a-value rule.
  • OTel semantic conventions, service.name — uniqueness scoped to service.namespace; k8s attribute derivation chain.
  • CLAUDE.md §3.7 — the multi-tenancy invariant this RFC defends.

9. Deferred (recorded, not built)

Read-time tenant aliasing (Q5 escape hatch); visibility classes within a tenant (Q8 — the query-rewrite layer); conversation-scoped erasure (Q9); the ReBAC/OpenFGA resolver (#688 spike: viable as a third AuthResolver, operational costs to weigh in its own RFC); mechanical hierarchy (Q4).

RFC 0046 — Out-of-band tenancy


rfc: 0046 title: Out-of-band tenancy — the credential names the tenant, the data never does status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-08-17 supersedes: RFC 0045 superseded-by: —

RFC 0046 — Out-of-band tenancy

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

Status: green (2026-08-17). All eleven §5 criteria pass, landed in one implementation PR (#702) the same day as the spec (#699): the 0x03 TenantOtlpBatch frame + codec (ourios-wal); the selector, one-export-one-tenant materialisation, binding-on-selector and the removal of derivation / the epoch log / the detector (ourios-ingester); the config-surface removal, docs, Helm, dogfood, kind smoke test and Collector interop header (ourios-server + surface). RFC0046.1/.2/.3/.7/.10 served-binary over both transports, .4 on the WAL crash shape, .5/.11 at the recovery driver, .6 as a 0x03 dimension over the RFC 0008 harnesses, .8 in the collector interop job, .9 by grep. Two things implementation forced on the spec, both recorded inline: reject names the frame kind (§3.3), and the gRPC non-ASCII caveat is enforced by MetadataValue::to_str (obs-text bytes are admitted, then refused as not-text). No thesis-gate applies (validated vacuous, RFC 0008/0044 precedent); accepted is a maintainer flip. RFC 0045 is superseded by this RFC as of this flip (its frontmatter says so).

(specified, same date: §5 criteria written and testable. Premise settled by the maintainer: tenancy does not reside in OTLP data — no resource attribute (service.name included) is ever a tenancy input. That is also the #688 OTel-docs finding — OTel’s own multi-tenancy is out-of-band: collector metadata routing + an auth extension, headers_setterX-Scope-OrgID — which the #688 strawman and RFC 0045 drifted from for a zero-config default. The finer-grained replacements — RFC 0003 §6.3 / RFC0003.3–.4 (fan-out) and RFC 0001 §6.1 Tenant derivation — are recorded in §3.2 and §3.4.)

1. Summary

The tenant an export lands in is chosen out of band, on the ingest request, never derived from the payload: an X-Ourios-Tenant header (HTTP) or x-ourios-tenant metadata entry (gRPC), required on every export, that must fall inside the credential’s tenant set — the exact contract the querier has enforced since RFC 0016/0026. One export = one tenant. Resource attributes describe the producer (service, cluster, agent) and become promoted columns and filters inside a tenant; they never partition storage. The WAL frame carries the tenant it was acknowledged under, so replay needs no derivation and RFC 0045’s rule-epoch log disappears. TenantId stays an opaque, coarse (org/team/workspace-scale) string — the storage partition, the template-tree scope and the credential’s blast radius remain one concept (#688 Q1) — and it is the object type a ReBAC resolver (RFC 0047) will bind to.

2. Motivation

Deriving the tenant from the data was the wrong model, not a bad rule. RFC 0045 solved the service.name collision with a better key, but every derived tenant is still a function of producer descriptors: k8s.cluster.name and service.name say what emitted the record, not who owns it. That has three costs the composite rule cannot remove:

  • Two sources of truth for “what is a tenant.” With an authority model (RFC 0026 tokens today; a relationship graph tomorrow — RFC 0047) the tenant is an object with owners; a derivation rule mints tenants from whatever the producer chose to send. Every mismatch between the two is a silent isolation gap, and the derived id corresponds to nothing in the graph.
  • The producer controls its own tenancy. A misconfigured or hostile emitter picks its tenant by choosing attribute values, bounded only by the token set — the credential should pick, the data should not.
  • OTel says so. Resource attributes are the entity model’s description of the producer; multi-tenancy in the Collector is metadata (X-Scope-OrgID, headers_setter, batch-by-tenant metadata + auth extension) — never an attribute. Aligning keeps Ourios a drop-in target for the Collector’s existing tenant routing.

The maintainer’s ruling (2026-08-17) makes this the model: service.name is to be totally unrelated to tenancy. This RFC applies it and retires the derivation machinery.

3. Proposed design

3.1 The tenant selector

Every OTLP export names its tenant out of band:

TransportCarrierAbsent / emptyNot in the credential’s set
OTLP/HTTPX-Ourios-Tenant request header400 Bad Request403 Forbidden
OTLP/gRPCx-ourios-tenant request metadataINVALID_ARGUMENTPERMISSION_DENIED

The rule is the querier’s (RFC 0016 §3.3, RFC 0026 §3.3), applied verbatim to ingest: the header is required on every export, in open mode too (there is no default tenant — RFC0003.4’s “never invent a tenant” posture, kept), and when auth is on the value must be a member of the resolved binding’s tenant set (a wildcard * set admits any non-empty value). A single-tenant credential does not make the header optional: one rule for both roles, nothing implicit; a Collector sets it once (otlphttp.headers / headers_setter).

One selector, one canonical value. Exactly one selector per request: a repeated X-Ourios-Tenant header or a repeated x-ourios-tenant metadata entry — even with equal values — is rejected 400 / INVALID_ARGUMENT before authorization and before any WAL work, so no two layers can ever see different selections. The value is normalised once, at extraction, and that one string is what authorization compares (byte-exact against the binding set), the WAL records, storage encodes and queries match: the raw bytes must be valid UTF-8; ASCII whitespace is trimmed at both ends; the result must be non-empty, at most 256 bytes, and contain no control characters (U+0000U+001F, U+007F). Anything else is 400 / INVALID_ARGUMENT. Transport caveat, stated rather than hidden: gRPC ASCII metadata can only carry visible ASCII, so a tenant id with non-ASCII characters is reachable over OTLP/HTTP, the querier and MCP but not over OTLP/gRPC (a non-ASCII value there is INVALID_ARGUMENT); operators who want gRPC everywhere keep ids ASCII. Nothing else is validated: TenantId stays opaque, and storage path-safety is percent_encode_tenant (RFC 0005 §3.4).

3.2 One export, one tenant

The per-ResourceLogs fan-out (RFC 0003 §6.3, RFC0003.3) is retired: every record in the export carries the selected tenant. Missing service.name is no longer a rejection — it is an absent (NULL) promoted column, exactly as any other absent promoted attribute (RFC 0022; the “always promoted” rule means the column exists, not that the value must). RFC0003.4’s shape survives as “no tenant selector ⇒ whole export rejected”; its trigger moves from the payload to the request.

service.name, k8s.cluster.name, gen_ai.* and every other resource or log attribute are what OTel says they are: descriptions of the producer and the event, queryable (service == "fluxcd"), promotable (RFC 0022/0042), sortable inside the tenant (RFC 0036) — never a partition key.

3.3 The WAL frame carries the tenant

Replay today re-derives tenants from the raw request; with no derivation there is nothing to re-derive from, so the acknowledged frame must record its tenant. RFC 0008 §6.2 reserves kind > 0x02 for exactly this: a new frame kind

kind = 0x03  TenantOtlpBatch
payload = u16 (little-endian) tenant byte length
        ‖ tenant bytes (UTF-8, as validated in §3.1)
        ‖ ExportLogsServiceRequest protobuf bytes (as OtlpBatch)

Everything else about the frame (header, CRC, _pad, torn-tail rules, group-commit fsync, checkpoint/retain semantics) is unchanged; RFC0008.x criteria hold as written because they are payload-agnostic. Replay validates the tenant prefix before touching the protobuf — a zero length, a length above 256, a length running past the payload, or invalid UTF-8 is a SinkRejected invalid-payload failure at the recovery driver (the class a CRC-valid frame with an undecodable protobuf already has: loud, startup-aborting, not RFC0008.5 corruption, since the frame’s own integrity check passed) — then materialises the request under the tenant and feeds the miner as before. The RFC 0045 rule-epoch log is deleted, not migrated.

Legacy frames and downgrade. A kind = 0x01 (OtlpBatch) frame has no recorded tenant. Per the maintainer’s persisted-layout ruling (nothing pre-production is preserved) replay does not guess: encountering one aborts startup with an error naming the frame’s offset and the remedy (drain the WAL under the previous version, or delete it). 0x01 stays a valid kind byte on the wire — rejected by this binary as unsupported for replay, never as corruption — so RFC0008.5’s corruption classification is untouched. The reverse direction is unsupported by construction: a binary predating this RFC reads 0x03 as an unknown kind, which RFC 0008 §6.2 already classifies as corruption (FrameError::UnknownKind → halt). That is the documented behaviour of the old reader, not a contradiction of the sentence above; the operator procedure for downgrading across this RFC is the same as for upgrading — drain the WAL first, or delete it.

3.4 What RFC 0045 leaves behind

RFC 0045 pieceFate
TenantRule / composite derivation, receiver.tenant.ruleRemoved. No derivation exists.
Rule-epoch log tenant_rule_epochs.jsonRemoved (the frame carries the tenant). An existing file is ignored and may be deleted.
Divergence detector + receiver.tenant.watch{,_capacity}, ourios.receiver.tenant.divergences, the two events, ourios.tenant.watch.*Removed. “One tenant spans several clusters” is a legitimate ownership shape once the credential picks the tenant; the ownership topology belongs to the graph (RFC 0047), not to a heuristic. Registry entries are deprecated, not deleted (semconv rule).
Store::resolve parse fix (RFC 0045 §3.2)Kept. Layout-correctness, independent of the tenancy model.
TenantId opacity, percent_encode_tenant, per-tenant partitioning and template treesKept.
Helm receiver.tenant passthroughRemoved (the section is gone).

3.5 Auth interaction

RFC 0026’s whole-batch binding check becomes a header check: selector ∉ binding set ⇒ 403/PERMISSION_DENIED, ourios.ingest.batches with error.type = permission_denied and the ingest_denied audit event, all unchanged; the per-ResourceLogs walk that produced the same result from derived tenants is retired. RFC 0029 (OIDC) and RFC 0027 (MCP binding) are untouched — they resolve the set; this RFC changes only where the selection comes from on ingest. RFC 0047 will add a third resolver (OpenFGA) behind the same seam, which is why the selector must be an opaque TenantId and nothing more.

3.6 Collector interop

The reference Collector pipeline sets the header statically (exporters.otlphttp.headers.X-Ourios-Tenant) or per-request via the headers_setter extension from inbound context — the same shape as Loki’s X-Scope-OrgID. The interop test moves from “tenant derived from service.name” to “tenant set by the exporter”; a pipeline that omits the header gets a 400 it can see.

4. Alternatives considered

  • Keep derivation, add out-of-band as an override (header wins when present, else derive). Rejected: two sources of truth is the problem, not a feature; every “else” branch is a silent path.
  • Header optional for single-tenant credentials. Rejected for uniformity: the querier requires it, a Collector sets it once, and “the token implies the tenant” is one more implicit rule to document and test.
  • A trusted in-band tenant attribute (ourios.tenant set by the Collector). Rejected as in #688 Q2: OTLP has no tenant field; routing metadata smuggled into the data model, and any producer can set it.
  • Tenant in the WAL segment header instead of per frame. Rejected: a segment interleaves exports from many tenants under group commit; per-frame is the only correct granularity, and it costs 2 + |tenant| bytes.
  • Replay legacy 0x01 frames under [service.name]. Rejected per the persisted-layout ruling; keeping the derivation code alive only for a replay path nobody in production has would preserve the model this RFC retires.

5. Acceptance criteria

Scenario ids RFC0046.<n>.

RFC0046.1 — selector required, both transports. Given open mode and an export without a tenant selector, When it is sent over OTLP/HTTP (over OTLP/gRPC), Then it is rejected with 400 (INVALID_ARGUMENT) naming the header, and nothing reaches the WAL; And Given the same export with X-Ourios-Tenant: acme (x-ourios-tenant metadata), Then it is accepted and every record lands in tenant acme.

RFC0046.2 — binding check. Given auth enabled with a token bound to [acme], When an export selects acme, Then it is accepted; When it selects globex, Then the whole export is rejected 403 (PERMISSION_DENIED), ourios.ingest.batches{error.type=permission_denied} increments and the ingest_denied audit event names the token and tenant (the RFC0026.7 surface, unchanged); Given a * token, When an export selects any non-empty tenant, Then it is accepted.

RFC0046.3 — one export, one tenant; service.name is just an attribute. Given an export whose ResourceLogs carry service.name fluxcd, checkout, and none at all, When it is sent with selector acme, Then all records are queryable under acme only, service == "fluxcd" returns exactly the first group, the record without service.name has a NULL promoted service column and is returned by a tenant-wide query, and no other tenant exists.

RFC0046.4 — WAL frame carries the tenant. Given exports acknowledged under selectors acme and globex whose records lack service.name entirely — acknowledged meaning the 0x03 frame was appended and fsynced (WAL-before-ack, unchanged) — When the receiver is SIGKILLed before any Parquet flush and restarted, Then replay lands every record in the tenant it was acknowledged under, no record is lost, no record moves tenant, and every replayed frame is kind = 0x03.

RFC0046.5 — legacy frames abort loudly. Given a WAL holding a kind = 0x01 frame, When the receiver starts, Then startup aborts naming the offset and the remedy, and the frame is not classified as corruption (RFC0008.5’s classification is unchanged).

RFC0046.6 — RFC 0008 invariants hold for the new kind. Given the RFC0008.4/.5/.7/.8/.10 scenarios, When their harnesses additionally exercise 0x03 frames, Then every invariant and expected outcome is unchanged (torn tail, corruption, checkpoint/retain, group commit, recovery driver are payload-agnostic) — the criteria are not edited, the harnesses gain a frame-kind dimension.

RFC0046.7 — selector hygiene. Given selectors acme (whitespace), `` (empty), one of 257 bytes, one containing a control character, and a request carrying the selector twice (equal values), When exported, Then the first is accepted as acme and every other case is rejected 400 / INVALID_ARGUMENT before any WAL append; And Given a selector containing /, % and an interior space, Then it round-trips: the export is accepted, stored under tenant_id=<percent_encode_tenant> and queryable under the same header value; And Given a non-ASCII selector, Then it is accepted over HTTP and rejected INVALID_ARGUMENT over gRPC.

RFC0046.8 — Collector interop. Given the reference otelcol-contrib pipeline exporting over TLS + OIDC with X-Ourios-Tenant set on the exporter, When it ships a batch, Then the records are queryable under that tenant; And Given the header removed from the pipeline, Then the Collector logs the receiver’s 400.

RFC0046.9 — derivation is gone. Given the codebase, Then no TenantRule, fan_out, RuleEpochs, DivergenceWatch or receiver.tenant.* config remains; ourios.receiver.tenant.divergences, the two events and ourios.tenant.watch.* are deprecated in the registry; and the RFC 0003 / RFC 0026 / RFC 0045 tests that asserted the retired behaviour are replaced by the criteria above, each replacement named in the PR (CLAUDE.md §6.2 — a contract change made explicit).

RFC0046.10 — querier and MCP unchanged. Given the RFC 0016 / 0026 / 0027 query and MCP suites, When they run, Then they pass unchanged — the read side already was out-of-band.

RFC0046.11 — malformed 0x03 payloads. Given CRC-valid 0x03 frames whose tenant prefix has zero length, a length above 256, a length past the payload end, or invalid UTF-8, When replayed, Then each is a SinkRejected invalid-payload failure naming the offset — startup aborts — and none is classified as RFC0008.5 corruption.

6. Testing strategy

Unit tests in ourios-ingester for selector extraction (header/metadata, trim, empty, length) and for the 0x03 frame codec (round-trip, tenant prefix bounds, decode of a 0x01 frame → the unsupported-for-replay error). RFC0046.1/.2/.3/.7 as ourios-server served-binary tests through both transports and the querier (reusing the RFC 0045 harness shape); RFC0046.2’s telemetry half in the RFC0026.7 harness-exempt binary. RFC0046.4 on the RFC0014.5 crash fixture with two tenants and no service.name. RFC0046.5/.6/.11 in ourios-wal (it/): the existing RFC 0008 harnesses gain a 0x03 dimension (invariants unchanged) and the recovery driver’s prefix validation gets its own rejection cases. RFC0046.8 in the CI-only collector interop job. RFC0046.9 is a git grep in the PR description plus the compile.

7. Open questions

All four resolved (recorded 2026-08-28; three by RFC 0048, one by the semconv extraction — this RFC was flipped accepted while they still read open, which the resolve-never-waive rule of the ladder sweep does not allow):

  • Selector length boundRFC 0048 §3.1 pinned it at 1–128 bytes, tighter than the 256 B floated here, and made it the one tenant grammar every boundary applies at extraction (MAX_SELECTOR_BYTES = MAX_TENANT_BYTES in the receiver).
  • Non-ASCII tenant ids over gRPCcaveat accepted, then dissolved: RFC 0048 §3.1’s grammar admits only ASCII graphic characters (minus :, #, /) at every boundary, so HTTP and gRPC now reject the same inputs. No -bin carrier is needed; parity is total because the grammar, not the transport, is the bound.
  • Deprecation window for the RFC 0045 registry entries — kept deprecated, as leaned. ourios.tenant.watch.key / .first_value carry deprecated: {reason: obsoleted} in the shared ourios-semconv registry, noting they were never emitted by a released version.
  • RFC 0003 §6.3 text — amended in place, as leaned: §6.3 opens with a “Superseded by RFC 0046 (2026-08-17)” banner.

8. References

  • #688 — the tenancy concept discussion; the OTel-docs finding that multi-tenancy is out-of-band (comment 1, point 2), and the OpenFGA resolver spike (scratch/openfga-spike.md).
  • OpenFGA assistant review of the two-layer model (scratch/openfga-ai-review-2026-08-17.md) — tenant as coarse object, never per-conversation; the 2-step planner pattern RFC 0047 adopts.
  • RFC 0045 — the in-band composite rule this RFC supersedes; its Store fix survives.
  • RFC 0003 §6.3, RFC0003.3/.4 — the fan-out and rejection this RFC replaces.
  • RFC 0008 §6.2 — frame kinds, reserved range, RFC0008.4/.5 classification.
  • RFC 0016 §3.3, RFC 0026 §3.2–3.4, RFC 0027, RFC 0029 — the query-side contract ingest now mirrors; the resolver seam RFC 0047 extends.
  • RFC 0022 — service.name always promoted (column exists; value may be NULL).
  • OpenTelemetry Collector headers_setter extension and otlphttp exporter headers; Loki X-Scope-OrgID as prior art.
  • CLAUDE.md §3.4 (WAL-before-ack), §3.7 (multi-tenancy), §6.2 (tests are specifications).

9. Follow-on (recorded, not built here)

RFC 0047 — ReBAC resolver and graph-fed visibility. OpenFGA as a third AuthResolver producing the same (name, tenant-set) binding; the relationship graph fed asynchronously from stored GenAI columns (gen_ai.conversation.id, user.hash, gen_ai.agent.id); enforcement inside a tenant by query rewrite at plan time (Check tenant-wide first, bounded ListObjects otherwise, never per-record); content-vs-metadata classes as separate relations mapped to column masking; erasure via read-then-delete tuples riding the compaction rewrite. Nobody in the OTel ecosystem has connected these dots yet: the GenAI semconv marks every content attribute as PII-laden and the platform blueprint marks multi-tenant compliance “help wanted” — the coarse-tenant + graph-visibility split is the answer to both, and this RFC is its prerequisite.

RFC 0047 — ReBAC resolver and graph-fed visibility


rfc: 0047 title: ReBAC resolver (OpenFGA) and graph-fed visibility inside a tenant status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-08-17 supersedes: — superseded-by: —

RFC 0047 — ReBAC resolver and graph-fed visibility

Status: accepted (2026-08-21, maintainer sign-off). Terminal. As for RFC 0048, validated is vacuous — the ladder gates it on benchmarks.md §7 (compression, query latency, reconstruction) and this RFC is an authorization surface whose whole path is inert unless auth.openfga is configured, which the benchmark harness never does. The maintainer therefore advances it from green (RFC 0008 precedent). RFC 0048 is accepted alongside it and amends §3.1 (one tenant grammar, no percent-encoding), §3.3 (identity keys as configuration; the contextual-tuple carrier sealed and bridge (b) rejected), §3.6 (an operator front door for erasure, a backfill pass, and the deadline assumption made observable) — read the two together. The surviving §7 questions below are follow-up decisions, not reasons to reopen.

Status: green (2026-08-18). All twelve §5 criteria pass: RFC0047.1–.3 (the layer-1 resolver), .4–.8 (the planner two-step, masking, bounded enumeration), .9 (the MCP tool gate), .10–.11 (the graph emitter and erasure) on the served binary against a real OpenFGA container (openfga-resolver CI job) with the in-tree model, and .12 gating CI. The RFC0047.5 request-carried contextual-tuple arm is struck — rejected by RFC 0048 §3.5 (the carrier is sealed); the erasure request channel is the §3.6 slice-4 decision. Prerequisite: RFC 0046 (out-of-band tenancy, green) — the tenant is an opaque, coarse, credential-selected object, which is exactly the object type this RFC binds the authorization graph to. Grounded in the #688 OpenFGA spike (resolver seam holds, p50 1.4 ms), two OpenFGA-assistant reviews (§8) and the agent-scale ListObjects spike recorded in §10, whose central finding — the 1000-object cap is silent — shapes §3.4.

1. Summary

Add OpenFGA as a third AuthResolver (beside static tokens and OIDC) and, on top of the same coarse tenants, a visibility layer inside a tenant driven by a relationship graph that is fed from the telemetry itself. Layer 1 (unchanged from RFC 0046): the tenant is the storage partition, the template-tree scope and the credential’s blast radius, resolved once per session into the existing AuthBinding { name, tenant-set }. Layer 2 (new): the OpenTelemetry GenAI identifiers already stored as promoted columns — gen_ai.conversation.id, user.hash / enduser.pseudo.id, gen_ai.agent.id, gen_ai.workflow.name — are graph nodes; enforcement is query rewrite at plan time, never per-record checks: Check(principal, can_read_content, tenant) first, then Check(can_read_metadata, tenant) (tenant-wide with content masked), and only for scoped principals a bounded, streamed ListObjects(conversation) — filtered and counted per tenant — that becomes an IN (…) predicate over the promoted column, failing closed past an explicit bound or an incomplete stream. Content and metadata are separate relations, mapped to column masking. Agents are first-class principals; MCP tools are graph objects (can_call). Tuples are written asynchronously from stored data by a tenant-scoped emitter riding the compaction pass; erasure removes them the same way. Nobody in the OpenTelemetry ecosystem has connected these dots: the GenAI semconv marks every content attribute as PII-laden and the OTel platform blueprint marks multi-tenant compliance “help wanted” — coarse out-of-band tenants plus graph-fed visibility over the GenAI node ids is the answer to both.

2. Motivation

The facts we need are relationships, and they are already in the data. “Alice participated in conversation C”, “agent A acted in C”, “team T owns cluster K”, “C belongs to tenant X”: these are written when telemetry is ingested (or when an admin grants ownership), not computed from request attributes. Every question the product asks — may this principal read these conversations, may this agent read only its own, may FinOps read the spend metadata but no prompt content, may this collector write into this tenant — is a graph traversal with hierarchy (tenant → conversation → participant), per-resource sharing and reverse lookup (“which conversations may I read”): the three canonical ReBAC indicators. RBAC-with-scopes cannot express “only conversations I took part in”; ABAC/policy engines evaluate attributes at request time and would need the graph reconstructed as attributes on every request. ReBAC is the model; OpenFGA is the engine we spiked and reviewed.

Why now, and why on RFC 0046. With in-band derivation (RFC 0045) the tenant was a function of producer descriptors and corresponded to nothing in an authority graph — two sources of truth for “what is a tenant”. RFC 0046 made the tenant an opaque object chosen by the credential; that object is the graph’s tenant type, so layer 1 needs no translation and layer 2 can hang everything off parent: [tenant].

Why the split matters. Tenants must stay coarse (org/team/workspace): they are Parquet partitions and template trees, and one per user or conversation is hazard #4 (small files) plus RFC 0023 memory in one move. Fine-grained visibility therefore cannot be more tenants; it must be enforcement inside a tenant — which the graph gives us without touching storage.

The community gap. GenAI logs are chat-history stores; the semconv says so on every content attribute. Multi-tenant compliance is “help wanted” in the OTel platform blueprint, and the ReBAC engines’ agent-authorization guidance stops at “authorize the tool call”. Feeding an authorization graph from the observability data it protects — conversation, user, agent identifiers as nodes — closes the loop between “who may query this telemetry” and “what the telemetry says happened”, and is a contribution avenue once validated here (RFC 0046 §2 / #688 Q7).

3. Proposed design

3.1 Layer 1 — the resolver

A third AuthResolver, openfga, behind the RFC 0029 seam. Configuration:

auth:
  openfga:
    api_url: http://openfga.auth.svc:8080
    store_id: 01M07RYMXRDW4ND5M7XQV04W8R
    authorization_model_id: 01M07RZE9RHPVPTYCV22RX0TDA   # pinned; empty = latest
    api_token: ${env:OURIOS_OPENFGA_TOKEN}                 # ${env:…} only, RFC 0026 rule
    session_ttl_secs: 60
    consistency: minimize_latency    # or higher_consistency (bypasses OpenFGA's cache)

At session establishment (bearer resolved by the static or OIDC path first — OpenFGA does not authenticate, it authorizes) the resolver maps the credential to a principal: a static token → service_account:<name>, an OIDC subject → user:<sub> unless the token carries the configured agent claim (auth.oidc.agent_claim, e.g. ourios_principal_type=agent) → agent:<sub>. It then issues ListObjects(principal, can_query, tenant) and ListObjects(principal, can_write, tenant) (bounded, streamed — tenant sets are small by construction) and produces the same AuthBinding { name, tenant-set } RFC 0026 enforcement consumes; a principal with no queryable and no writable tenant is unbound (401-class). can_query is the binding capability, deliberately distinct from the read capabilities: it holds for tenant-wide content readers, tenant-wide metadata readers, and scoped principals (scoped_reader — a participant, actor or delegate on some conversation in the tenant, §3.2/§3.3), so a participant like user:bob or an agent like agent:bot binds the tenant and reaches the planner, where the two-step (§3.4) decides which rows. Binding never grants reading: can_read_content / can_read_metadata / can_write stay separate relations, and the model tests assert that scoped and metadata-only principals hold can_query without tenant-wide content. The binding is cached per credential for session_ttl_secs, fail-closed: an OpenFGA error or timeout during resolution is a 503 on the query side and UNAVAILABLE/503 on ingest, never an open door, and never a stale grant past the TTL. Revocation latency ≡ TTL; higher_consistency bypasses OpenFGA’s own cache after writes. Ingest authorization is exactly RFC 0046 §3.5: the out-of-band selector ∈ the resolved write set.

Composition with the credential’s own tenant list (slice 1 decision). The graph is authoritative for what a principal may touch, and a credential’s own list — a static token’s tenants, an OIDC tenant_claim — can only narrow it, never widen it: the binding’s read set is credential ∩ ListObjects(can_query), the write set credential ∩ ListObjects(can_write). With auth.openfga configured the OIDC tenant_claim becomes optional (the graph binds; a token without one binds exactly the graph’s sets), and a static token declares tenants: ["*"] to defer to the graph entirely. Every OpenFGA round-trip is bounded by auth.openfga.request_timeout_secs (default 5 s) so fail-closed has a deadline. A token whose group claim exceeds the contextual-tuple cap fails resolution with a named warning log and an unauthenticated (401-class) session — a credential defect, not an upstream outage.

Token claims as contextual tuples. An OIDC token’s group claim (auth.oidc.groups_claim, e.g. Dex groups) is passed to the resolver’s ListObjects calls as contextual tuples team:<group>#member@<principal> — OpenFGA’s documented pattern for claims-carried membership — so team membership needs no synchronisation pipeline; only the stable edges (team#owner/reader on tenants) are stored tuples written by operators. Contextual tuples cap at 100 per request (a token with more groups than that fails resolution closed, named error), are never persisted, and their effect lives exactly as long as the session — the same session_ttl_secs bound as everything else, so a group revocation in the IdP is honoured on the next resolution.

3.2 The authorization model

Kept in-treedeploy/openfga/model.fga and deploy/openfga/ store.fga.yaml land with this RFC, and CI’s openfga-model job runs fga model validate + fga model test on them (a required check, the way semconv/registry is gated by weaver) — so the model is a tested artefact from the day it is specified, not prose:

model
  schema 1.1

type user
type agent
type service_account

type team
  relations
    define member: [user, service_account, team#member]

type tenant
  relations
    define owner: [user, service_account, team#member]
    define writer: [service_account, team#member]
    define reader: [user, service_account, team#member]
    define metadata_reader: [user, service_account, team#member]
    define scoped_reader: [user, agent]            # data-derived binding (§3.3)
    define can_write: writer or owner
    define can_read_metadata: metadata_reader or reader or owner
    define can_read_content: reader or owner
    define can_query: can_read_metadata or scoped_reader   # session binding (§3.1)

type conversation
  relations
    define parent: [tenant]
    define participant: [user]
    define actor: [agent]
    define delegate: [agent]                       # acts on behalf of a participant
    define can_read_metadata: participant or actor or delegate or can_read_metadata from parent
    define can_read_content: participant or actor or delegate or can_read_content from parent

type tool
  relations
    define parent: [tenant]
    define caller: [user, agent, service_account, team#member]
    define can_call: caller or can_read_content from parent

Reviewed shape (OpenFGA assistant, both reviews): tenant as the object every resource belongs to; team#member nesting; permissions as can_*; X from parent inheritance; agents as first-class principals with explicit, revocable delegation (delegate) rather than copied permissions; content vs metadata as separate relations (not CEL conditions — those stay for time-boxed grants if ever needed); one OpenFGA store per deployment. Cluster and service ownership (cluster, service types from the review) are deferred to a follow-up: with RFC 0046 they are producer descriptors inside a tenant, and no v1 scenario needs them as objects.

3.3 Feeding the graph from the data

Tuples of the data-derived layer are written by an emitter in the compaction sweep (RFC 0009 §3.2 — the one place that already walks every tenant’s Parquet, promoted columns included), tenant-scoped, idempotent and batched (≤100 tuples per transactional Write, chunked non-transactionally; duplicates ignored):

From promoted column(s)Tuple
every distinct gen_ai.conversation.id in tenant Tconversation:T/<id>#parent@tenant:T
(gen_ai.conversation.id, user.hash or enduser.pseudo.id)conversation:T/<id>#participant@user:<hash> and tenant:T#scoped_reader@user:<hash>
(gen_ai.conversation.id, gen_ai.agent.id)conversation:T/<id>#actor@agent:<id> and tenant:T#scoped_reader@agent:<id>

Object ids are tenant-prefixed (conversation:T/<id>) in every tuple the emitter writes and every id the planner reads: OpenFGA object ids are opaque strings in one store, so the same raw conversation id in two tenants must be two objects; the parent tuple carries the tenant edge and the planner strips the prefix when it builds predicates (§3.4). A pure naming rule, held in one place (TenantObjects in the core openfga module) that both the emitter and the planner call. Slice-2 decision: the tenant segment is percent-encoded for / and % (conversation:<enc(T)>/<id>), so a tenant containing / can never alias another tenant’s conversation (a + b/c-1 vs a/b + c-1); the raw conversation id follows verbatim. A tenant that cannot itself be an object id (:, #, whitespace, > 256 bytes) has no graph objects at all — every graph question about it fails closed (403 tenant_unaddressable, naming the rule).

The binding tuple (tenant:T#scoped_reader@<principal>) rides along with every conversation grant so the principal can bind the tenant at session establishment (§3.1). It is idempotent like the rest, and stale is safe by construction: a scoped_reader whose last conversation grant was erased (§3.6) still binds the tenant, but layer 2 then enumerates nothing and — absent the self fast path — the query returns no rows. The sweep garbage-collects such tuples best-effort; correctness never depends on it. Ownership tuples (tenant#owner/writer/reader/metadata_reader, team#member, tool#caller, conversation#delegate) are administrative: written by operators through OpenFGA’s own API/CLI, never by Ourios — and a delegate grant on conversation:T/<id> MUST be paired by the operator with tenant:T#scoped_reader@agent:<id> for the same reason (documented next to the model; the .fga.yaml fixture shows the pair).

Slice-4 decisions (implemented). The emitter lives in the ingester (graph_emitter), fed from both hooks: the compaction sweep observes every input row it decodes (once, before any drop) and the receiver’s PublishCoordinator derives tuples from every batch it publishes and sends them off the flush path once the batch is durable. The conversation key is the column bound in visibility.objects (attr. or resource. stripped); the user keys are user.hash and enduser.pseudo.id, the agent key gen_ai.agent.id; a value that cannot be an object id is skipped. It also writes the per-tenant tool:T/<name>#parent@tenant:T objects (§3.5), so operators grant caller only. Writes are on_duplicate = ignore (OpenFGA ≥ 1.10, per the OpenFGA assistant), so a resend is a no-op and no read-then-diff pass exists; the sweep sends what it derived after the blocking pass, in ≤ 100-tuple batches, counted on ourios.graph.tuples{ourios.graph.tuple.operation}. Data stored before the graph was configured is fed the next time its partition is rewritten (compaction) — a backfill sweep is a follow-on (§9).

Freshness. A conversation is invisible to fine-grained principals until its tuples land (seconds after the next sweep; the emitter is also invoked on the receiver’s flush cadence for the tenants it flushed). Two bridges, both in the planner: (a) the self fast path — a user: principal (and only a user: principal; agents and service accounts never get it) always gets <self_principal_column> == <subject> as an additional OR-predicate without consulting the graph, where <subject> is the principal id with its user: prefix stripped: user:bob matches rows whose promoted attr.user.hash is bob. The path presumes the deployment’s self_principal_column carries the same identity the OIDC subject does (the RFC0047.5 fixture emits user.hash = <sub>); a deployment whose hashes are not subjects leaves self_principal_column unset and the fast path is disabled — never a mismatched comparison; (b) contextual tuples — a request may carry {conversation:T/<id>#participant@<principal>} for ids it just created, passed on Check/ListObjects and never persisted. Tenant-wide readers (the FinOps/operator case) never wait: they resolve at layer 1.

Bridge (b) is rejected (RFC 0048 §3.5; slice 2 had deferred it). A contextual tuple carried by the request is asserted by the very principal it grants: any scoped caller could name any conversation id and read it — a self-granted escalation the graph never checked. Contextual tuples are an application-trusted input (the group claim, minted by the IdP, is one); a caller-supplied one is not. Until a trusted carrier exists (a signed claim from the producer, or the emitter’s flush-cadence hook closing the gap), freshness bridges are the self fast path (a) — verified against the stored user.hash — and the emitter cadence. The RFC0047.5 contextual arm was deferred with this question in §7; RFC 0048 §3.5 closes it by rejection — the client API’s ContextualTuples newtype has the group claim as its only constructor, so the bridge is unrepresentable.

3.4 Layer 2 — query rewrite at plan time

For a query over tenant T by principal P, the planner runs the two-step:

  1. Check(P, can_read_content, tenant:T) (cached with the session, TTL as §3.1). Allowed ⇒ the tenant partition predicate only — no enumeration, no masking, no change to today’s plan.
  2. Otherwise Check(P, can_read_metadata, tenant:T). Allowed ⇒ the tenant partition predicate plus column masking — the tenant-wide metadata reader (user:fin, RFC0047.8) sees every row with the content columns masked, never an empty result: the configured content columns (auth.openfga.visibility.content_columns, default the GenAI content attributes gen_ai.input.messages, gen_ai.output.messages, gen_ai.system_instructions, gen_ai.tool.call.arguments, gen_ai.tool.call.result and body) are projected as NULL, and a query that filters or aggregates on a masked column is rejected (403, named column) rather than answered from data the principal may not read.
  3. Otherwise the principal is scoped (it bound the tenant through scoped_reader, §3.1): StreamedListObjects(P, can_read_content, conversation) — the streamed variant, never plain ListObjects: the spike showed the plain call returns HTTP 200 with 1000 objects and no truncation marker for a principal over 100 000 conversations (13 ms) — a planner that enumerated with it would emit a wrong predicate silently. The stream is global to the principal (OpenFGA enumerates objects, not objects-within-a-tenant), so the planner filters each streamed id by the T/ prefix and counts only tenant-T ids toward auth.openfga.visibility.max_objects (default 10 000): another tenant’s grants can cost stream time but can never exhaust T’s bound. The stream MUST be consumed to completion (or until T’s bound is hit) within auth.openfga.visibility.list_timeout (default 2s, below the server’s own deadline — see the config note); reaching the bound fails the query closed with a named error (“visibility set exceeds N objects in tenant T; ask for tenant-wide read”), and hitting the timeout before the stream ends fails closed too (“visibility enumeration incomplete”) — a partial tenant set is never accepted as a predicate. The surviving ids (prefix stripped) become attr.gen_ai.conversation.id IN (…) — over the promoted column, so RFC 0022/0042 pruning still applies — OR’d with the §3.3 self fast path and any contextual-tuple ids. A principal with an empty set and no fast path gets an empty result, not an error.
  4. Scoped metadata-only grants do not exist in the v1 model (every conversation-level relation — participant, actor, delegate — grants content on that conversation), so there is no fourth branch today. If a future model adds one, the branch is: enumerate can_read_metadata conversations exactly as in step 3 and apply step 2’s masking to the result — recorded here so the shape is settled before it is needed.

Configuration binds object types to columns explicitly — nothing is inferred:

auth:
  openfga:
    visibility:
      objects:
        - type: conversation
          column: attr.gen_ai.conversation.id
      self_principal_column: attr.user.hash          # the §3.3 fast path
      content_columns: [body, attr.gen_ai.input.messages, attr.gen_ai.output.messages]  # replaces the default set
      max_objects: 10000        # tenant-T ids only (§3.4 step 3)
      list_timeout_ms: 2000     # MUST stay below server_list_objects_deadline_ms (3000)
    server_list_objects_deadline_ms: 3000   # the server's OPENFGA_LIST_OBJECTS_DEADLINE

list_timeout_ms is deliberately below OpenFGA’s own OPENFGA_LIST_OBJECTS_DEADLINE (server default 3 s, which bounds the streamed call too): the client-side timeout must be the one that fires, so an incomplete enumeration is always detected here and failed closed, never ended quietly by the server. Startup validation rejects a list_timeout_ms that is not below the configured server deadline (auth.openfga.server_list_objects_deadline_ms, default 3000).

Per-record Check calls in the scan path are never performed (the architectural line from the first spike, confirmed by both reviews).

Slice-2 decisions (implemented). Durations are milliseconds (list_timeout_ms, server_list_objects_deadline_ms) like every other knob; objects[].type accepts only conversation in v1 (the one bindable type) and columns must be attr./resource. promoted names; the two Checks cache with the session TTL, the enumeration never does; masking renders body as {"kind":"masked"} and a masked attribute as "value": null (the OTLP unset value) — a reader can tell withheld from absent; template-level surfaces (drift, list_templates, template_drift) need tenant-wide content read (403 visibility_scoped) because templates are mined from bodies; the branch taken is recorded on ourios.query.visibility{ourios.query.visibility.branch} and the request span (the MCP tool spans carry the same field), so RFC0047.4’s “no enumeration” is a counter assertion; an explicit content_columns list replaces the default set and may not be empty (masking is never silently disabled). The self fast path is user: principals only, and principal ids are validated as object ids (a sub with :/#/whitespace is a 401-class credential defect, not a 503).

3.5 MCP tools as objects

Every RFC 0027 tool (query_logs, list_templates, template_drift, the FinOps trio) is a tool:<name> object per tenant (tool:T/<name>#parent@ tenant:T). Before dispatch the MCP server issues Check(P, can_call, tool:T/<name>); a tenant-wide reader can call everything, a narrowly scoped principal only what it was granted (caller). Time-boxed grants are the one place CEL conditions belong (a temporal_grant condition on caller) — recorded, not built in v1. The tool call’s own data access then goes through §3.4 like any query, so an agent calling query_logs reads exactly its own conversations.

Slice-3 decisions (implemented). The gate runs after the RFC 0026 tenant binding and the §3.4 two-step: a principal on the tenant-wide branch may call every tool without a round-trip (that is what the model’s can_call: caller or can_read_content from parent says, and it does not depend on an operator having written the tool#parent tuple); every other graph-bound principal needs an explicit caller grant, checked per call with the session’s contextual group tuples and never cached — a revoked grant is honoured on the next call. The denial names the tool (permission denied: tool template_drift is not callable by this principal in tenant acme). Template-level tools additionally require tenant-wide content read (§3.4 slice-2 rule), so a caller grant on list_templates / template_drift for a scoped principal passes the gate but not the content rule. The tool:T/<name>#parent@tenant:T tuples are written by the emitter for every tenant it sweeps (slice 4), so operators grant caller only.

3.6 Erasure (Q9)

Conversation-scoped erasure (RFC 0045 §9 / #688 Q9) rides the compaction rewrite that removes the rows: the same pass reads the object’s tuples (Read by object) and deletes them in ≤100 chunks — no wildcard delete exists — so a deleted conversation is unreachable and unlisted. Tuple deletion follows the Parquet rewrite, never precedes it (a dangling tuple is harmless; a dangling row is a leak).

Slice-4 decisions (implemented). The RFC left the request channel open; the choice is a durable marker object in the storeerasure/tenant_id=<enc>/conversation=<enc> (the partition percent-encoding; body {"phase":"rows"}) — because the object store is the source of truth (CLAUDE.md §3.6), it needs no new network surface or credential, and an operator can write it with the tooling they already have (ourios_ingester::compactor::request_erasure in-process). The sweep’s blocking pass rewrites every hour partition of the tenant through the same compaction rewrite with the conversation’s rows dropped (a single-file partition is rewritten too), advances the marker to {"phase":"tuples"} once every partition rewrote cleanly, and only then — in the async phase, after the blocking pass — reads the object’s tuples and deletes them in ≤ 100-tuple batches (on_missing = ignore), writes the new conversation_erased audit event (RFC 0005 §3.7 kind 9, carrying partitions_rewritten / rows_dropped / tuples_deleted) after the sweep’s compaction events, and removes the marker. A sweep interrupted between the phases retries only the tuple deletion; an unreachable graph leaves the marker and retries next sweep. Without a bound conversation object a marker is recorded as a sweep error, never silently dropped. The tuple deletion loops Read → delete until a Read returns empty — at most 8 delete rounds plus one confirming read: a paginated Read is not a snapshot (OpenFGA assistant, 2026-08-18), so a tuple the flush-cadence feed writes concurrently is swept up by the next round; an object still non-empty after the rounds is EraseIncomplete — the marker stays in the tuples phase and the next sweep retries. Erasure covers the rows durable at the rewrite; rows ingested for the same conversation id afterwards are new data (with their tuples) — consistent by construction — and the flush-cadence emit is a fire-and-forget within the same process that completes in milliseconds and is never retried, so a batch published before a later sweep’s rewrite cannot land tuples after that sweep’s erasure; a dangling tuple is in any case harmless. The same review confirmed the object-naming scheme has no documented pitfall; the served-binary test writes tenants containing / and % to a real v1.11.1 and asserts Read returns the object ids byte-for-byte and the encoded prefix filters the stream. The server-side deadline behaviour on streamed-list-objects remains undocumented (a source-level question); the client-side list_timeout_ms below the server deadline stays the fail-closed pattern.

3.7 Operational posture

Fail-closed everywhere OpenFGA is consulted (resolution, planner, tool gate); one store per deployment; the model id pinned in config and bumped by PR (the .fga.yaml tests run against the pinned model in CI); OpenFGA availability is an operational cost the deployment opts into by configuring auth.openfga at all — static tokens and OIDC keep working without it, and a deployment that wants coarse tenants only never touches this RFC.

4. Alternatives considered

  • RBAC with scopes (tenant:read, tenant:content). Covers layer 1 — it is what static tokens already are — but cannot express “only conversations I took part in” or “only my agent’s runs” without a role per principal per conversation. Rejected for layer 2, kept as what the static/OIDC resolvers remain.
  • ABAC / policy engine (OPA, Cedar) inside the app. Attribute-time evaluation; the relationships would have to be reconstructed as request attributes on every query, i.e. the graph rebuilt per call. Rejected as the primary model; OPA stays the right tool for infra-layer policy (admission, mesh) if ever needed — outside this RFC.
  • Per-record Check in the scan. Millions of calls per query; the availability coupling RFC 0029 §4 rejected for introspection. Rejected — the planner is the only enforcement point.
  • Fine-grained tenants (one per conversation/user). Breaks storage (hazard #4) and RFC 0023 memory; the multi-tenant guidance says keep tenants coarse. Rejected.
  • Plain ListObjects with a big cap. Silent truncation makes it a correctness hole, not a tuning knob (spike 2). Rejected in favour of the two-step + streamed, bounded, fail-closed enumeration.
  • Deriving tuples at ingest (in the receiver hot path). Adds an external write to the WAL-before-ack path. Rejected: the compaction pass already walks the data, and the freshness bridges (§3.3) cover the lag.
  • A second source of truth for tenants (graph + derived). Removed by RFC 0046; this RFC depends on there being exactly one.
  • One OpenFGA store per tenant (to make ListObjects tenant-constrained natively instead of the §3.4 prefix filter). Rejected: the multi-tenant guidance is one store with a tenant object (shared teams, one model version, one migration), and per-store provisioning would have to follow every RFC 0046 tenant’s lifecycle. The prefix filter + per-tenant counting is the cost of the single store, paid in the planner.

5. Acceptance criteria

Scenario ids RFC0047.<n>. Integration tests run against a real OpenFGA container (testcontainers, like Dex for RFC 0029) with the in-tree model.

RFC0047.1 — resolver binding. Given auth.openfga configured and tuples granting user:alice reader on tenant:acme and service_account:collector writer on tenant:acme, When alice’s OIDC bearer establishes a session, Then her binding’s read set is {acme} and write set empty; And When the collector’s token establishes one, Then its write set is {acme}; And Given user:bob with only a participant tuple on conversation:acme/c-1 plus its paired tenant:acme#scoped_reader binding tuple, and user:fin with only metadata_reader on tenant:acme, When each establishes a session, Then each binding’s read set is {acme} (they reach the planner) while Check(can_read_content, tenant:acme) is false for both; And Given a principal with no tuples, Then the session is unbound (401-class) — never an empty-but-open binding.

RFC0047.2 — ingest binding through the resolver. Given the collector above, When it exports with selector acme, Then it is accepted; with selector globex, Then 403/PERMISSION_DENIED (RFC 0046 §3.5, RFC0026.7 telemetry unchanged).

RFC0047.3 — fail closed. Given the resolver configured and OpenFGA unreachable (or timing out), When any principal establishes a session or a cached session’s TTL expires, Then queries answer 503 and ingest 503/UNAVAILABLE, nothing is admitted, and ourios.auth.resolutions counts error.type = upstream_unavailable.

RFC0047.4 — two-step, tenant-wide reader. Given alice with can_read_content on tenant:acme and 10 000 conversations in acme, When she queries, Then the plan carries only the tenant predicate, no ListObjects/stream call is issued (asserted on the OpenFGA request log / a counter), and every row is returned.

RFC0047.5 — two-step, participant. Given user:bob participant of conversations acme/c-1 and acme/c-2 only (tuples, with the binding tuple), When bob queries true on tenant acme, Then exactly the rows of c-1/c-2 return; And Given a further conversation c-9 whose rows carry attr.user.hash = bob (the principal’s subject, prefix stripped) but no tuple yet, Then those rows also return (self fast path); And (struck — rejected by RFC 0048 §3.5; the carrier is sealed) Given a contextual tuple for c-10 on the request, Then c-10’s rows return too; And Given bob is also participant on globex/c-1 (another tenant), Then that id never appears in the acme predicate.

RFC0047.6 — agent as principal. Given agent:bot actor on 500 conversations and agent:other actor on 500 different ones, When bot queries, Then exactly its 500 conversations’ rows return and none of other’s; And Given bot is also delegate on alice’s conversation c-7, Then c-7 returns for bot; And When the delegation tuple is deleted, Then (after the TTL) c-7 no longer returns.

RFC0047.7 — bounded enumeration fails closed, per tenant. Given visibility.max_objects: 100 and an agent actor on 150 conversations in acme, When it queries acme, Then the query is rejected with the named bound error, no partial result is returned, and the plain (capped) ListObjects is never used — the streamed call is what the OpenFGA log shows; And Given instead an agent actor on 50 conversations in acme and 150 in globex, When it queries acme, Then the query succeeds with exactly the 50 (only tenant-acme ids count toward the bound); And Given visibility.list_timeout shorter than the stream (a stalled fake), Then the query fails closed with the incomplete-enumeration error, never a partial predicate.

RFC0047.8 — metadata without content. Given user:fin with metadata_reader on tenant:acme only (no conversation tuples), When fin queries sum(attr.cost_usd) by attr.model, Then it succeeds over every row of the tenant (the §3.4 step-2 branch — the tenant predicate with masking, no enumeration issued); When fin selects or filters on body or attr.gen_ai.input.messages, Then rows carry NULL for those columns and a filter on them is 403 naming the column.

RFC0047.9 — tool gate. Given agent:bot with caller on tool:acme/query_logs only, When it calls query_logs, Then the call proceeds (and §3.4 scopes its data); When it calls template_drift, Then the MCP error is a permission denial naming the tool.

RFC0047.10 — emitter. Given a tenant whose Parquet holds records with gen_ai.conversation.id, user.hash and gen_ai.agent.id, When the compaction sweep runs, Then the parent, participant and actor tuples exist in OpenFGA with tenant-prefixed ids, a second sweep writes nothing new (idempotent), and tuple writes are chunked ≤100.

RFC0047.11 — erasure removes tuples after rows. Given a conversation erased through the compaction rewrite, When the pass completes, Then no tuple for that object remains and the object is absent from every principal’s ListObjects; And the tuple deletion is ordered after the Parquet rewrite (asserted on the sweep’s audit events).

RFC0047.12 — model tests gate CI. Given deploy/openfga/model.fga and store.fga.yaml, When CI runs fga model validate and fga model test, Then a model that breaks a documented assertion (e.g. an actor reading a conversation it did not act in) fails the job.

6. Testing strategy

Unit tests: principal mapping (token/OIDC/agent claim), the planner’s predicate composition (two-step branch, IN construction with prefix stripping, self fast path OR, contextual tuples, masking projection, bound → error) against a mocked resolver. Integration (ourios-server it/, testcontainers openfga/openfga, CI-only like the Dex job): RFC0047.1–.9 through the served binary with the in-tree model; RFC0047.10/.11 through the compaction sweep against the same container. .fga.yaml (RFC0047.12): one check/list_objects block per §5 relationship claim, run in a semconv- style CI job with the pinned CLI. Property test: for random tuple sets the planner’s returned row set equals the naive “rows whose conversation ∈ ListObjects” oracle (RFC 0024 style).

7. Open questions

  • Principal mapping for agents — the agent_claim name/value convention; whether an agent’s token may also carry a delegating user (act claim, RFC 8693) to mint delegate automatically.
  • Object id namespacing — prefixing, one store. Shipped and kept: conversation:<T>/<id> in a single store per deployment. RFC 0048 §3.1 removed the cost that made it look expensive (the tenant segment is verbatim now, not percent-encoded) and pinned the byte budget it implies.
  • max_objects default — 10 000, shipped. The bound is about predicate size and plan cost, not OpenFGA (spike 2 streamed 100k in 0.5 s). No deployment has hit it; a principal that would is the one the visibility_bound refusal tells to ask for tenant-wide read.
  • Where the emitter runs — both, plus a backfill. The flush cadence and the compaction sweep each feed it (§3.3 as proposed), and RFC 0048 §3.4 adds a one-off graph backfill for history that predates the graph. All three paths are additive and idempotent, so running them together needs no coordination; only erasure is fenced.
  • Request-carried contextual tuples (§3.3 bridge b) — deferred in slice 2 (a caller-asserted participant tuple is a self-grant); rejected by RFC 0048 §3.5, which also names the only trusted carriers. RFC 0048 further takes over the tenant id grammar (removing the §3.3 percent-encoding), the identity keys as configuration, the erasure front door and a backfill pass.
  • OpenFGA MCP for design time — a docs assistant, not a store MCP. The OpenFGA maintainers enabled a documentation assistant at https://openfga.mcp.kapa.ai (openfga/openfga discussion #560); it is hosted by kapa.ai, not by OpenFGA — an external, design-time dependency, exactly like the OpenTelemetry docs MCP this project already consults. It receives natural-language questions only: no store credentials, no tuples, no tenant or conversation ids, and it is never on a request path. Adopted 2026-08-19 and already load-bearing — it established that the 256-byte cap covers the whole type:id string, which exposed a real defect (#713) — with its answers verified against the cited docs or a real container before anything lands. A community store MCP (evansims/openfga-mcp) stays unadopted; it would touch a real store, so it stays out of both design time and runtime for now.

8. References

  • #688 — concept discussion (Q1–Q10 strawman) and the OpenFGA resolver spike report (seam holds, p50 1.4 ms).
  • Two OpenFGA-assistant reviews (2026-08-17, maintainer-run, working notes not in-tree; every load-bearing answer is restated where it applies): model shape, ListObjects cap/deadline, two-step pattern, contextual tuples, ≤100 tuples/Write, no wildcard delete, single store, agents/delegation, MCP tool authorization (tool / can_call), ReBAC vs RBAC/ABAC.
  • §10 — the agent-scale ListObjects spike: silent 1000 cap on plain ListObjects; streamed 5000 in 50 ms, 100k in 0.5 s; Check ~1 ms.
  • RFC 0046 — out-of-band tenancy (prerequisite); RFC 0026/0029 — the binding and resolver seam; RFC 0027/0032 — MCP tools and query schema; RFC 0022/ 0042 — promoted columns the predicates target; RFC 0009 — compaction sweep the emitter rides; RFC 0024 — the oracle-style property test.
  • OpenFGA docs: Multi-Tenant SaaS, AI Agent Authorization, Agents as Principals, MCP Authorization, Task-Based Authorization, Relationship Queries (caveats), Contextual Tuples, Consistency; openfga/agent-skills (in-tree at .claude/skills/openfga).
  • OpenTelemetry GenAI semantic conventions (content attributes flagged as sensitive; gen_ai.conversation.id); the OTel platform blueprint’s multi-tenant “help wanted”.
  • CLAUDE.md §3.7 (multi-tenancy), §4 hazard #4 (small files — why tenants stay coarse).

9. Follow-ons (recorded, not built here)

A backfill sweep that feeds the graph from data stored before the graph was configured (today only a rewrite re-derives); an operator-facing erasure surface over the store marker (a CLI verb / MCP tool); cluster/service ownership as graph objects (from the reviewed model) once a scenario needs them; time-boxed grants (temporal_grant CEL condition on tool#caller / conversation#delegate); an upstream write-up for the OTel community once this is validated in a real deployment (#688 Q7).

10. Evidence — agent-scale ListObjects spike (2026-08-17)

Throwaway openfga/openfga run (memory backend, defaults OPENFGA_LIST_OBJECTS_MAX_RESULTS=1000, 3 s deadline); model = the §3.2 DSL. Seed: tenant:acme, 100 000 conversation:acme/c-i with parent tenant:acme, agents bot50 / bot500 / bot5000 as actor on that many conversations, user:alice tenant reader, user:bob participant of one conversation. Seeding via /write in 100-tuple chunks: 74.8 s (~1 300 tuples/s single-threaded).

CallResultLatency
Check(alice, can_read_content, tenant:acme) — the two-step gateallowedp50 1.1 ms / p95 1.4 ms
Check(agent, can_read_content, tenant:acme)falsep50 0.8 ms
Check(agent, can_read_content, conversation:acme/c-3)allowedp50 2.1 ms
Check(collector, can_write, tenant:acme) — ingest bindingallowedp50 1.0 ms
ListObjects(bot50, conversation)502.3 ms
ListObjects(bot500, conversation)5002.5 ms
ListObjects(bot5000, conversation)1000 (silently capped, HTTP 200)3.4 ms
ListObjects(bob, conversation)11.6 ms
ListObjects(alice tenant-wide, conversation) over 100k1000 (silently capped, HTTP 200)13 ms
StreamedListObjects(bot5000)50000.05 s wall
StreamedListObjects(alice tenant-wide)100 0000.53 s wall

What this decided:

  1. The cap is silent — 200 with 1000 objects and no truncation marker. A planner that enumerated for a tenant-wide reader would build a wrong IN() (1 % of the tenant) with no error, so the two-step in §3.4 is a correctness requirement, not an optimisation.
  2. Scoped sets are cheap (2–3 ms for 50–500) but a principal with more than 1000 conversations still hits the cap → the scoped path uses StreamedListObjects, filters to the tenant, and fails closed at max_objects (§3.4 step 3) rather than emitting a giant IN().
  3. Session-time checks are ~1 ms → TTL-cached fail-closed resolution is free at the RFC 0029 seam (as the first spike found).
  4. Write throughput at 100 tuples/Write is adequate for an emitter riding the compaction pass; a burst of new conversations lags seconds, which contextual tuples and the §3.3 self fast path cover.

The harness was a throwaway script; nothing from it lands in-tree.

RFC 0048 — Graph operational surfaces


rfc: 0048 title: Graph operational surfaces — tenant id grammar, identity keys, erasure and backfill status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-08-18 supersedes: — superseded-by: —

RFC 0048 — Graph operational surfaces

Status: accepted (2026-08-21, maintainer sign-off). Terminal. No thesis-gate applies, so validated is vacuous here and the maintainer advances the RFC directly from green (RFC 0008 precedent, restated in RFC 0044): the ladder’s validated stage gates on benchmarks.md §7 — compression, query latency, reconstruction — and these are authorization and operational surfaces. The graph path is inert unless auth.openfga is configured, which the benchmark harness never does, so no gate moves; the tenant grammar adds one bounded check at request boundaries. Every §7 question is answered — the last two (grammar strictness, the graph erasures output shape) were settled by what shipped and are recorded there with their decisions — so this RFC closes with none outstanding.

Status: green (2026-08-20). All eight §5 criteria pass: RFC0048.1 (tenant.rs grammar table + the RFC 0046 end-to-end OTLP arms + rfc0048_grammar querier/MCP arms + config/claim unit tests), RFC0048.2 (rfc0048_2_verbatim_tenant_on_a_real_graph, real OpenFGA: 256-byte object accepted and Read byte-for-byte, 115 skipped, / id fits), RFC0048.3 (emitter/config/parse unit tests + the served-binary startup errors + the per-list promoted check), RFC0048.4 (rfc0048_4_erase_and_erasures_verbs + the completion event asserted at the compactor), RFC0048.5/.8 (rfc0048_5_8_backfill_and_fence_end_to_end, real OpenFGA: --from boundary, idempotent re-run, no Parquet rewrite, refusal-leaves-no-lock, sweep defers under the lock then completes), RFC0048.6 (the sealed ContextualTuples carrier + wire-shape fakes), RFC0048.7 (rfc0048_7_list_deadline_event_at_startup). Container scenarios run in the openfga-resolver CI job. §7’s deadline question is settled by measurement (silent clean EOF — see §7). Implementation: #714–#718. Prerequisites: RFC 0046 (green), RFC 0047 (green). This RFC closes the operational gaps the RFC 0047 implementation (#705–#710) had to fill on its own — each of them a decision that belongs in a spec, with a criterion, rather than in a slice’s commit message.

1. Summary

RFC 0047 specified the authorization model well and left four operational surfaces unspecified; the implementation chose for each and recorded the choice as a “slice decision”. This RFC promotes those choices into contract — or replaces them where the implementation’s choice was a workaround. Four changes: (1) a tenant id grammar shared by every boundary (selector, token config, OIDC claim, graph object ids) that makes the graph’s percent-encoding unnecessary; (2) the graph’s identity keys (user, agent) become configuration next to the conversation column, with today’s semconv keys as defaults; (3) an operator-facing erasure surface over the RFC 0047 store marker (a CLI verb to request and to list, plus a completion signal), and the marker made the only channel; (4) a backfill pass that feeds the graph from data stored before the graph was configured. It also formally rejects the request-carried contextual-tuple bridge RFC 0047 §3.3(b) deferred, and pins the list_timeout_ms / server-deadline coupling as observable at startup.

2. Motivation

The RFC 0047 slices surfaced five things a reviewer of the spec could have asked and did not (the retrospective is in the #710 discussion):

  • Two grammars for one identifier. RFC 0046 made the tenant an opaque string (visible text, ≤ 256 bytes, no control characters); RFC 0047 then put it inside OpenFGA object ids, where :, # and whitespace are illegal and / is the conversation separator. The implementation reconciled them with percent-encoding of the tenant segment (conversation:<enc(T)>/<id>) plus a per-tenant “cannot be a graph object → fail closed” branch. Encoding is a smell that says the grammar should have been constrained upstream: a tenant that cannot name a graph object is not a tenant this system can authorize.
  • Asymmetric configuration. The conversation column is explicit (visibility.objects[].column, “nothing is inferred”) while the user and agent keys (user.hash / enduser.pseudo.id, gen_ai.agent.id) are constants in the emitter. A deployment whose producers carry identity under enduser.id or a bespoke key cannot use the graph.
  • No operator surface for erasure. RFC 0047 §3.6 says how an erasure runs but not how it is requested; the implementation invented a durable marker object (erasure/tenant_id=<enc>/conversation=<enc>) reachable only from inside the process (request_erasure). An operator today writes an object into the bucket by hand. That is a workable primitive and the wrong front door.
  • No backfill. The emitter feeds the graph from the flush cadence and from every row compaction rewrites; data stored before the graph was configured is fed only if something rewrites its partition. A deployment that turns the graph on over existing history has scoped principals who see nothing until an unrelated compaction happens by.
  • A bridge that was a hole. RFC 0047 §3.3(b) let the request carry conversation:T/<id>#participant@<principal> as a contextual tuple — a self-grant. Slice 2 deferred it; this RFC rejects it and names the only trusted carriers.

None of these change the model or the two-step; they are the surfaces around them.

3. Proposed design

3.1 Tenant id grammar (amends RFC 0046 §3.1)

A tenant id is 1–128 bytes of ASCII graphic characters (0x210x7E, i.e. printable ASCII excluding space) with :, # and / further excluded — Rust’s char::is_ascii_graphic minus three characters. Every boundary applies the same rule, once, at extraction: the OTLP selector (X-Ourios-Tenant / metadata, RFC 0046), the querier header and MCP tenant argument, auth.tokens[].tenants, the OIDC tenant_claim values, and the OpenFGA tenant:<T> object. A value outside the grammar is 400 / INVALID_ARGUMENT at the request boundaries and a startup error in configuration.

Consequences, in order:

  • The RFC 0047 percent-encoding of the tenant segment goes away: conversation:<T>/<id> and tool:<T>/<name> with T verbatim; the / separator is unambiguous because T cannot contain it, and the raw conversation id follows (it may contain /). TenantObjects keeps the one-place naming rule and drops encode_tenant_segment; the InvalidTenant branch becomes unreachable from the request boundaries and stays only as the library’s own guard.
  • RFC 0046’s “non-ASCII reachable over HTTP but not gRPC” caveat disappears — the grammar is ASCII everywhere.
  • The 256-byte selector bound tightens to 128: OpenFGA caps the full object string (conversation:<T>/<id>) at 256 — the proto constraint is ^[^\s]{2,256}$ on the whole type:id string (openfga/openfga discussion #302; the docs AI confirms the cap includes the type prefix and the colon) — so a 128-byte tenant leaves 256 − 13 (conversation:) − 128 − 1 (/) = 114 bytes for the conversation id. Whether the cap counts bytes or characters is not documented; the grammar here is ASCII-only, so the two coincide and the question cannot bite. Pre-production, this is a ! change with no dual-read (feedback: break persisted layouts pre-production); the percent-encoding of the storage path (data/tenant_id=<enc>, RFC 0005 §3.4) is untouched — it is a path rule, not a grammar.
  • Conversation ids keep the object-id grammar, not the tenant grammar: 1 byte or more of ASCII graphic characters excluding : and # (so / is allowed — a raw gen_ai.conversation.id may contain it), and the full conversation:<T>/<id> string ≤ 256 bytes. One function (TenantObjects::conversation_fits) is the rule; the emitter skips a row whose id does not fit, the planner never sees such an id (the graph cannot hold it), the erasure path erases its rows and deletes zero tuples, and the CLI (§3.3) applies the tenant grammar to --tenant and this object-id grammar to --conversation.

3.2 Identity keys as configuration (amends RFC 0047 §3.3)

auth:
  openfga:
    visibility:
      objects:
        - type: conversation
          column: attr.gen_ai.conversation.id
      identities:                       # RFC 0048 — who is in the conversation
        user_columns: [attr.user.hash, attr.enduser.pseudo.id]   # default
        agent_columns: [attr.gen_ai.agent.id]                    # default
      self_principal_column: attr.user.hash

identities.user_columns / agent_columns name the promoted columns (attr. / resource.) whose values become user:<v> and agent:<v> principals in the emitter’s tuples (participant + binding, actor + binding, exactly as RFC 0047 §3.3). Defaults are today’s constants — the OpenTelemetry semantic-convention keys — so a deployment that says nothing gets the same graph. Every listed column must be a promoted column (startup error otherwise, the RFC 0047 §3.4 rule); a value that cannot be an object id is skipped as today. self_principal_column must be one of user_columns (the fast path compares the subject to a column that also mints participant, or it compares nothing).

3.3 Erasure surface (amends RFC 0047 §3.6)

The RFC 0047 store marker stays the durable primitive and the only channel — the compactor acts on markers and nothing else — and gains an operator front door:

ourios-server graph erase   --tenant acme --conversation c-7      # writes the marker
ourios-server graph erasures [--tenant acme]                       # lists pending markers + phase

Both are ourios-server subcommands (clap, RFC 0004 style). They resolve the same storage config as the daemon, so they work against local and S3 stores alike, and both refuse a tenant or conversation id outside the §3.1 grammar — and they boot the same telemetry stack: an operator verb is a short-lived CLI program, the shape OpenTelemetry’s semantic conventions for CLI programs cover, so the run is wrapped in a callee span named after the executable (INTERNAL, with process.executable.name, process.pid, process.exit.code, and error.type + an error status when that code is non-zero; process.command_args is not recorded — the convention says not to without sanitisation, and a verb’s arguments carry tenant and conversation ids). The universal OTel env vars stay the only control surface (OTEL_SDK_DISABLED=true, OTEL_*_EXPORTER=none); a bespoke flag would duplicate them. The stderr fmt mirror is installed either way, so a verb’s structured events reach the operator even when nothing is exported, and Shutdown (which includes ForceFlush) drains before the process exits — configuration resolves inside that span, so a malformed section is a recorded non-zero exit rather than an untraced one. The contract begins after the §3.1 grammar check: an off-grammar --tenant or --conversation is refused before any telemetry (or filesystem) work, so it has no span and no process.exit.code by design — the CLI convention describes an executing program, and that invocation never became one. erase is idempotent (create-if-absent, RFC 0047 §3.6). No HTTP or MCP surface: an erasure is an operator action against the store of record, not a tenant-facing request; an admin API is a later RFC if a scenario needs one. Completion is observable three ways, all existing: the marker disappears (graph erasures), the conversation_erased audit event lands (RFC 0005 §3.7 kind 9), and ourios.graph.tuples{ourios.graph.tuple.operation="delete"} counts. The compactor’s sweep additionally logs one structured event per completed erasure naming tenant, conversation, rows dropped and tuples deleted.

3.4 Backfill (amends RFC 0047 §3.3)

ourios-server graph backfill --tenant acme [--from 2026-08-01T00:00:00Z]  # one-off, resumable

Reads every data partition of the tenant — --from (RFC 3339, UTC) selects partitions whose hour start ≥ from, a half-open [from, ∞) on the partition key, so a whole hour is either in or out — offers every row to the emitter, and writes the derived tuples in ≤ 100- tuple idempotent batches — the same code path as the sweep’s observer, driven over all partitions instead of the ones being rewritten. It never rewrites Parquet. Resumable by construction (every write is idempotent); progress is one structured event per partition and ourios.graph.tuples. Runs as a subcommand, not a daemon mode, so it cannot be left on by accident. Its telemetry is the CLI-program contract above: the progress events reach stderr always and OTLP when the env vars say so, and the tuples it writes land on ourios.graph.tuples like the sweep’s.

Backfill and erasure exclude each other. Idempotent writes alone do not make backfill safe beside an erasure: a partition read before the erasure and written after it would recreate the erased conversation’s tuples. So the two hold each other off through the store: backfill first checks for pending erasure markers and refuses to start when any exists for the tenant (“erasures pending for acme; run again after the next sweep”) — leaving no lock behind — then creates (create-if-absent) a lock marker backfill/tenant_id=<enc> (the RFC 0005 §3.4 path encoding, like the erasure marker) and re-checks the erasure markers once more under the lock, removing the lock and refusing if one appeared in between; the sweep’s erasure pass skips a tenant whose backfill lock exists (recorded in the sweep report, retried next sweep); backfill removes its lock on completion, and graph backfill --unlock --tenant T clears a lock a crashed run left behind (the operator’s call, logged). Both markers are listed by graph erasures.

3.5 Contextual tuples — the trusted carrier (amends RFC 0047 §3.3)

The request-carried bridge is rejected. Contextual tuples reach the graph from exactly one carrier in v1, trusted by construction: the OIDC group claim (team:<group>#member@<principal>, minted by the identity provider — RFC 0047 §3.1). The client API enforces it: check and streamed_list_objects take a ContextualTuples newtype whose only constructor is the group-claim path (OpenFgaResolver::group_tuples, which validates every group as an object id and applies the 100 cap) — no caller can hand the client an arbitrary tuple. Freshness for a conversation whose tuples have not landed is the self fast path (data- verified) and the flush-cadence emit (seconds). RFC 0047 §3.3(b) and the corresponding RFC0047.5 arm are struck; RFC 0047 §7’s open item closes.

3.6 Deadline coupling made visible (amends RFC 0047 §3.4)

list_timeout_ms must stay below OpenFGA’s OPENFGA_LIST_OBJECTS_DEADLINE. The server cannot observe that setting; it can make its own assumption loud. At startup, when auth.openfga is configured, the server logs one structured event (ourios.server.graph.list_deadline) carrying the client list_timeout_ms and the declared server_list_objects_deadline_ms, and the RFC 0047 startup rejection stays. Operators who change the server’s deadline have one line to grep for.

4. Alternatives considered

  • Keep percent-encoding, leave tenants opaque. Works (proven against v1.11.1, #710), but every future object type pays the encoding and every reader of a tuple sees %2F. A grammar is one rule; an encoding is a rule plus a decoder in every consumer. Rejected.
  • Constrain tenants only where the graph is configured. Two grammars again, chosen at runtime — exactly the confusion this RFC removes.
  • An admin HTTP endpoint for erasure. A new authenticated surface with its own authorization question (“who may erase?”) for one operator verb; the marker + CLI keeps erasure an act against the store of record. If a self-service tenant erasure is ever needed, that is a scenario for its own RFC.
  • Backfill as a compactor mode. A long-running flag that must be turned off is the wrong shape for a one-off; a subcommand is.
  • Signed request-carried contextual tuples. Would restore bridge (b) safely, but needs a producer-side signer and a key distribution story for a bridge whose window the flush-cadence emit already covers. Rejected for v1; noted in §9.

5. Acceptance criteria

Scenario ids RFC0048.<n>.

RFC0048.1 — one tenant grammar, every boundary. Given tenant ids acme, a/b, a:b, a b, a#b, a 129-byte id and a non-ASCII id, When each is presented as the OTLP selector (HTTP and gRPC), the querier header, the MCP tenant argument, an auth.tokens[].tenants entry and an OIDC tenant_claim value, Then acme is accepted everywhere and every other value is rejected everywhere with the same named reason (400 / INVALID_ARGUMENT at request boundaries, a startup error in config, an unverifiable token for the claim).

RFC0048.2 — no encoding, one byte budget. Given the grammar, When the emitter and the planner name a conversation, Then the object is conversation:<T>/<id> with T verbatim (asserted against a real OpenFGA: write, Read byte-for-byte, streamed prefix filter), and TenantObjects has no encoding step; And Given a 128-byte tenant, Then a 114-byte conversation id fits and a 115-byte one is skipped by the emitter (never sent), and an id containing / fits.

RFC0048.3 — identity keys are configuration. Given identities.user_columns: [attr.enduser.id] and identities.agent_columns: [attr.bot.name], When rows carrying those attributes are swept, Then the graph holds participant/actor tuples for those values and none for user.hash / gen_ai.agent.id; And Given no identities block, Then today’s defaults apply unchanged (RFC0047.10 still passes); And Given a non-promoted column or a self_principal_column outside user_columns, Then startup fails naming the key.

RFC0048.4 — erasure has a front door. Given the daemon’s storage config, When ourios-server graph erase --tenant acme --conversation c-7 runs, Then the marker exists (graph erasures lists it in the rows phase), a second erase is a no-op, and the next sweep completes RFC0047.11 unchanged; When the sweep completes, Then graph erasures lists nothing and one structured completion event names tenant, conversation, rows dropped and tuples deleted; And Given an id outside the grammar, Then the verb refuses it before touching the store.

RFC0048.5 — backfill feeds history. Given a tenant with N sealed partitions written before auth.openfga was configured and a scoped principal who is a participant in them, When the principal queries, Then it sees no rows; When graph backfill --tenant T runs, Then the graph holds the RFC0047.10 tuples for every partition (writes ≤ 100 per batch), the principal sees exactly its rows, a second run writes nothing new, and no Parquet file was rewritten; And Given --from at an hour boundary, Then partitions whose hour starts at or after it are fed and earlier ones are not.

RFC0048.6 — the request bridge is gone. Given a scoped principal and a request that attempts to carry a contextual participant tuple for a conversation it holds no grant on, When it queries, Then no such tuple is sent to the graph (asserted on the fake’s request log) and the rows do not return; And Given the client API, Then check / streamed_list_objects accept only the ContextualTuples newtype and its sole constructor is the validated group-claim path (a compile-time property, exercised by the resolver tests); And RFC 0047 §3.3(b) and the RFC0047.5 arm read as struck.

RFC0048.7 — the deadline assumption is loud. Given auth.openfga configured, When the server starts, Then one ourios.server.graph.list_deadline event carries list_timeout_ms and server_list_objects_deadline_ms, and (RFC 0047) a list_timeout_ms not below the deadline is still a startup error.

RFC0048.8 — backfill and erasure exclude each other. Given a pending erasure marker for tenant T, When graph backfill --tenant T runs, Then it refuses before reading any partition, naming the pending erasure, and leaves no backfill lock behind (graph erasures lists only the erasure); And Given a backfill lock for T, When a sweep runs with an erasure marker for T, Then the erasure is skipped and reported (not advanced), and after the lock is removed the next sweep completes it; And Given a backfill that finished, Then its lock is gone and graph erasures lists neither.

6. Testing strategy

Unit: the grammar as one function with a table test (each boundary calls it — the test asserts every boundary routes through it, RFC0048.1); config validation for identities (RFC0048.3); TenantObjects without encoding (RFC0048.2). Integration (ourios-server it/, the RFC 0047 container harness): RFC0048.2 and RFC0048.5 against a real OpenFGA; RFC0048.4 and RFC0048.8 by spawning the subcommands against a temp store and asserting the markers, the listing, the refusal and the sweep’s completion/skip events; RFC0048.6 on the fake (request log) plus the newtype’s constructor visibility; RFC0048.7 on the served binary’s stdout/log. The RFC 0047 container tests keep passing unchanged except for the encoding assertions, which flip to the verbatim form.

7. Open questions

  • Streamed deadline truncation is silent — measured. Against a real openfga v1.11.1 run with OPENFGA_LIST_OBJECTS_DEADLINE=1ms and 3 000 matching tuples, streamed-list-objects returned 127 results and ended with HTTP 200 and a clean EOF — no error frame (2026-08-20, the §3.6 implementation’s experiment). So the fail-closed property rests exactly where RFC 0047 §3.4 put it: the client timeout is strictly below the declared deadline, so on any stream that would outlive the real deadline the client cuts off first (Incomplete, fail closed) — provided the declaration does not overstate the server’s real flag, which is what the §3.6 startup event exists to make loud. Original finding: the docs confirm OPENFGA_LIST_OBJECTS_DEADLINE applies to the streamed endpoint (and …_MAX_RESULTS does not — the reason §3.4 of RFC 0047 chose it), but say nothing about how deadline expiry ends the stream: an NDJSON error frame (which the client already turns into a fail-closed refusal) or a clean EOF (which the client reads as complete — a truncated scoped set would then present as the full one; visibility only narrows, never widens, but the partiality is silent). The §3.6 implementation must pin this empirically: run a real server with a deliberately tiny deadline and assert what arrives on the wire. (Docs-AI consult, 2026-08-19; settled by the experiment above.)

  • Grammar strictness — shipped tight, deliberately. 128 bytes of ASCII graphic minus :, #, /. No deployment has asked for more, and the asymmetry decides it: loosening later is additive (old ids stay valid), tightening later is not. Revisit only with a named deployment that cannot express its tenant id — a ! change either way, and pre-production that is cheap (feedback: break persisted layouts pre-production).

  • graph erasures output shape — human lines now, --json when something needs it. Shipped as one line per pending marker (and per backfill lock), which is what an operator reading a terminal wants; no tooling consumes the verb yet, and adding --json later is additive. The machine-readable channels already exist for automation: the conversation_erased audit event, the ourios.compaction.erasure.completed log event, and ourios.graph.tuples.

  • Backfill and the receiver’s flush cadence — the flush emit and backfill only ever add the same idempotent tuples, so no exclusion is needed there (unlike erasure, §3.4). Confirmed: the emitter is the graph’s only writer (flush cadence, sweep, backfill — all additive and idempotent; deletes run only in the fenced erasure pass), and operators write administrative tuples on other object types.

8. References

  • RFC 0046 §3.1 (selector normalisation), RFC 0047 §3.1/§3.3/§3.4/§3.6 and the slice-1..4 decision paragraphs; #705–#710 (the implementation and the review threads that surfaced these); the OpenFGA assistant answers of 2026-08-18 (Read is not a snapshot; no server-side ListObjects scoping; object-id limits).
  • RFC 0004 (CLI shape), RFC 0005 §3.4 (storage path encoding, unchanged), RFC 0005 §3.7 kind 9 (conversation_erased).
  • CLAUDE.md §3.6 (object storage is the source of truth — why the erasure primitive stays a store marker), §3.7 (multi-tenancy).

9. Follow-ons (recorded, not built here)

Signed request-carried contextual tuples (a producer-side signer would restore RFC 0047 §3.3(b) safely); a tenant-facing self-service erasure API if a scenario needs one; a cross-process erasure fence should a second writer to the same store ever exist.

RFC 0049 — Agent delegation and the act claim


rfc: 0049 title: Agent delegation — refusing silent impersonation via the RFC 8693 act claim status: specified author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-08-21 supersedes: — superseded-by: —

RFC 0049 — Agent delegation and the act claim

Status: specified (2026-08-21). §5 criteria written and testable. Prerequisites: RFC 0026 (OIDC, accepted), RFC 0047 + RFC 0048 (the graph, both accepted). This RFC closes a gap left by those RFCs: Ourios derives its principal from sub alone, so a standards-compliant delegation token authenticates as the subject, discarding the actor — impersonation, which RFC 8693 exists to distinguish from delegation.

1. Summary

RFC 8693 defines the act (actor) claim: a token whose subject is one party and whose acting party is another. Ourios’s OIDC verifier never inspects it (crates/ourios-core/src/auth/oidc.rs: the principal is sub, and agent: versus user: is decided solely by the configured agent_claim). A deployment that turns on token exchange therefore gets silent impersonation: an agent presenting a valid delegation token authenticates as the human, inherits every one of that human’s grants, and no signal anywhere records that an agent was involved. Nothing is malformed and nothing is misread — the actor is simply dropped.

Three changes, in the order they matter:

  1. A token carrying act is refused by default (401, named reason), because a principal Ourios cannot represent must never be silently downgraded to one it can.
  2. An opt-in actor mode maps the principal to the current actor — never the subject — so a delegation token grants exactly what the agent itself holds, and the delegating subject travels as attribution only.
  3. act never writes to the graph and never becomes a contextual tuple. The group claim stays the single contextual carrier (RFC 0048 §3.5). A blanket actdelegate expansion is rejected here on the record, so nobody reaches for it later.

2. Motivation

2.1 What the specification actually says

RFC 8693 separates two things that look alike:

With impersonation, “A is given all the rights that B has within some defined rights context and is indistinguishable from B in that context.” With delegation, “principal A still has its own identity separate from B, and it is explicitly understood that while B may have delegated some of its rights to A, any actions taken are being taken by A representing B.”

Discarding act collapses the second into the first. The RFC also bounds what a consumer may believe: a chain of delegation nests act claims, but “the consumer of a token MUST only consider the token’s top-level claims and the party identified as the current actor by the act claim. Prior actors identified by any nested act claims are informational only.” And on the risk: “Any time one principal is delegated the rights of another principal, the potential for abuse is a concern.”

2.2 Why this is not hypothetical

Ourios already treats agents as first-class principals (RFC 0047 §3.1) and ships an MCP surface (RFC 0027) whose callers are agents by construction. Token exchange is how an agent platform obtains a credential for a user’s session; the moment a deployment’s IdP issues one, our resolver reads sub and hands the agent the human’s visibility — the exact outcome layer 2 exists to prevent. OWASP’s LLM06 (Excessive Agency) names the mitigation in the same terms: “Track user authorization and security scope to ensure actions taken on behalf of a user are executed on downstream systems in the context of that specific user, and with the minimum privileges necessary.”

2.3 What the ecosystem recommends instead

OpenFGA’s agent guidance is the shape this project already follows — “‘on behalf of’ is not the same as ‘as.’ A well-modeled agent has its own identity, inherits only the permissions it actually needs, and can be revoked independently of the user it serves” — and its current recommendation for actual delegation is task-based authorization: agents start with zero permissions and receive narrowly scoped, optionally session-bounded grants, with a contextual tuple binding the calling agent to the task. That is a successor to this RFC (§3.5), not its content.

3. Proposed design

3.1 act is a refusal by default

The OIDC verifier inspects the validated claim set for a top-level act member. When present and the deployment has not opted in (§3.2), verification fails with each surface’s existing unauthenticated path — 401 on the querier, /mcp and OTLP/HTTP ingest, a trailers-only UNAUTHENTICATED (grpc-status 16) on OTLP/gRPC — carrying the stable kind delegation_unsupported and error.type = "unauthenticated" on ourios.auth.resolutions. The message names the claim and the config key that enables handling, so an operator meets a diagnosis rather than a mystery.

Refusal — not “ignore and continue as the subject” — is the point: the token asserts something about authority that this server does not implement, and RFC 0047’s posture is that an unanswerable authorization question fails closed.

3.2 actor mode: the principal is the actor, never the subject

auth:
  oidc:
    delegation: reject        # default; `actor` opts in

delegation changes nothing for a token without an act claim: in either mode such a token keeps today’s mapping — the principal is the top-level sub, typed by agent_claim — so enabling the mode cannot alter ordinary authentication. The rules below apply only when act is present.

Under delegation: actor a token carrying act authenticates as the current actor:

  • the principal id is the actor’s sub (the act object’s sub member); a missing or non-string sub inside act is a refusal;
  • the principal type applies the existing rule to the act object — agent: when the configured agent_claim appears inside it, else user:;
  • nested act claims are ignored for authorization (RFC 8693’s MUST); only the outermost actor decides the principal;
  • the token’s own sub (the delegating party) grants nothing. It is not consulted, not intersected, not unioned. An actor with no grants is refused or scoped exactly as that actor would be without the token.

The guarantee is precise, and narrower than “a delegation token can only reduce access”: the subject’s grants are never inherited. What the bearer sees is exactly what the actor sees authenticating alone — which may be less than the subject’s access, and may be more where the actor holds grants the subject does not. The property that matters is that a delegation token is never a way to acquire someone else’s visibility; it is only ever a way to authenticate as the actor, with attribution attached.

3.3 Attribution, not authority

An accepted delegation records the delegating subject on the request span and the auth audit event — attribution, so an operator can answer “who was this agent acting for?” — and nowhere else. It is never a principal, never part of a predicate, never a graph object. The attribute name is minted through semconv/registry/ with the OTel naming rules applied (the project’s standing rule: query the OTel MCP and check for a semconv collision before adding any name), so the exact key is settled at implementation, not asserted here.

3.4 act never reaches the graph

No tuple — persisted or contextual — is ever derived from the act claim. (The OIDC group claim remains the system’s one claim-derived tuple and is unchanged: request-scoped team:<group>#member through the sealed carrier.) RFC 0048 §3.5 sealed the contextual carrier so that its only constructor is the validated group-claim path; this RFC adds nothing to it. Concretely, the rejected design is actconversation:T/<id>#delegate@agent:A for every conversation the subject participates in: act carries no resource scope, so expanding it into a per-conversation grant over the subject’s whole footprint is impersonation reached by a longer route — precisely what §2.1 distinguishes, and what LLM06’s minimum-privilege guidance forbids.

3.5 Where real delegation goes (not built here)

If a deployment needs an agent to read a user’s conversations, the answer stays an explicit, revocable grant: today the delegate relation an operator or user writes on a specific conversation (RFC 0047 §3.2); tomorrow, if the scenario justifies it, OpenFGA’s task-based pattern — a task (and optionally session) object, grants scoped to it, and a contextual tuple binding the calling agent to the task so a task cannot be replayed by a different agent. That is a separate RFC with its own producer story; naming it here is what stops actdelegate being rediscovered as a shortcut.

3.6 One identity per agent (deployment requirement)

OWASP’s Non-Human Identities Top 10 lists NHI9 NHI Reuse — “sharing identities across multiple services or agents, complicating attribution” — and NHI5 Overprivileged NHI. Ourios cannot detect sharing: a fleet behind one sub is one principal, so per-agent revocation and attribution both quietly disappear, and every grant is held by all of them. This RFC therefore requires the rule be written into the authentication guide beside agent_claim when it is implemented: one subject per agent identity. It is a deployment contract the server cannot enforce.

4. Alternatives considered

  • Keep today’s behaviour (ignore act). This is the vulnerability: a valid delegation token becomes full impersonation of the subject with no record of the actor. Rejected.
  • Accept, warn, and continue as the subject. A warning nobody reads does not change who the query ran as. Rejected.
  • Mint delegate tuples from act. §3.4. Rejected on the record.
  • Treat act as a contextual delegate (request-scoped, not persisted). Better than persisting, still wrong: the grant is unscoped, so it is impersonation for the life of the request. Rejected.
  • Build task-based authorization now. The right destination (§3.5), but it needs a task/session model, a producer contract for creating tasks, and a consent story. Deferred; this RFC makes the unsafe interim behaviour impossible rather than shipping the full feature.

5. Acceptance criteria

Scenario ids RFC0049.<n>. Eight criteria (RFC0049.1–.8).

RFC0049.1 — a delegation token is refused by default. Given auth.oidc configured without delegation, When a token carrying a top-level act claim is presented to the OTLP receiver (HTTP and gRPC), the querier and /mcp, Then each refuses with the surface’s unauthenticated status (401 / UNAUTHENTICATED), the stable kind delegation_unsupported, a message naming the act claim and the auth.oidc.delegation key, and one ourios.auth.resolutions increment with error.type = "unauthenticated"; And no data is read, written or acknowledged.

RFC0049.2 — the actor is the principal, and the subject grants nothing. Given delegation: actor, a subject user:alice holding tenant-wide reader and an actor agent:bot holding nothing in the tenant, When a token with sub = alice and act.sub = bot queries, Then the principal is agent:bot, the visibility branch is the one agent:bot would get alone (scoped or refused — never alice’s tenant-wide read), and alice’s grants are not consulted.

RFC0049.3 — only the outermost actor counts. Given delegation: actor and a token whose act nests a further act (alicebotinner), When it is presented, Then the principal is the outermost actor (bot) and the nested actor is ignored for authorization — including when the nested act is itself malformed, which never affects the outcome; And Given a top-level act that is not an object (null, a string, a number, an array), or an object whose sub is absent, not a string, empty, or not a valid object id, Then the token is refused with the same unauthenticated path as RFC0049.1 — a malformed delegation is never downgraded to the subject.

RFC0049.4 — the actor’s principal type follows the same rule. Given delegation: actor and agent_claim: ourios_principal_type=agent, When the act object carries that claim/value, Then the principal is agent:<act.sub>; And when it does not, Then it is user:<act.sub>.

RFC0049.5 — attribution without authority. Given an accepted delegation, Then the delegating subject appears on the request span and the auth audit event under the registry-minted attribute, And it never appears as a principal, in a visibility predicate, or as a graph object (asserted on the fake’s request log, as RFC0048.6 does).

RFC0049.6 — act never reaches the graph. Given any delegation token in either mode, Then no Write is issued and no contextual tuple other than the group-claim tuples is sent (the sealed ContextualTuples carrier gains no new constructor).

RFC0049.7 — the knob is validated. Given auth.oidc.delegation: <anything else>, Then startup fails naming the key and the accepted values.

RFC0049.8 — a token without act is untouched. Given delegation: actor and a token carrying no act claim, When it authenticates on any surface, Then the principal is the top-level sub typed by agent_claim, exactly as with delegation: reject and exactly as before this RFC — enabling the mode changes nothing for ordinary tokens.

6. Testing strategy

Unit: the verifier’s claim handling as a table (absent act, top-level act in each mode, nested act, malformed act, the agent_claim inside act) next to the existing OIDC tests; config validation for the knob. Integration (ourios-server it/): RFC0049.1 across the three surfaces on the served binary with the existing issuer fixture; RFC0049.2 and .4 against the fake graph, asserting the branch and the request log; RFC0049.5 on the span exporter and the audit sink. No OpenFGA container test is required — the authorization model does not change, which is itself the point of §3.4.

7. Open questions

  • may_act. RFC 8693’s companion claim states that a party is authorized to become an actor. It is an authorization-server concern; is there any value in Ourios reading it (for instance to refuse an act the issuer never sanctioned), or is that double-checking the IdP’s own job?
  • Per-tenant delegation mode. delegation is deployment-wide as specified. A deployment that trusts token exchange for one tenant and not another would need it per credential or per tenant — no scenario asks for it yet.
  • Task-based authorization (§3.5). Its own RFC when a scenario arrives: what creates a task, who consents, how expiry is expressed (OpenFGA conditions vs a TTL on the object).

8. References

  • RFC 8693 — OAuth 2.0 Token Exchange: act (§4.1), may_act (§4.4), delegation versus impersonation (§1.1), security considerations (§5).
  • OWASP Top 10 for LLM Applications 2025, LLM06 Excessive Agency — minimum privileges, acting in the context of the specific user, human-in-the-loop for high-impact actions.
  • OWASP Non-Human Identities Top 10 (2025) — NHI5 Overprivileged NHI, NHI9 NHI Reuse.
  • OpenFGA modelling guides: AI agent authorization, Modeling agents as principals, Task-Based Authorization (the §3.5 successor pattern).
  • RFC 0047 §3.1 (principal mapping), §3.2 (delegate), RFC 0048 §3.5 (the sealed contextual-tuple carrier).

RFC 0050 — Upstream-derived templates


rfc: 0050 title: Upstream-derived templates — accepting log.record.template and reconciling with semconv status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-08-24 supersedes: — superseded-by: —

RFC 0050 — Upstream-derived templates

Status: accepted (2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing in docs/benchmarks.md §7; elsewhere validated is vacuous for this surface (RFC 0008 precedent).

Status: green (2026-08-25). All nine §5 criteria pass, landed as seven implementation PRs over two days (#730 grammar/alignment + provenance, #731 modes + audit + counter, #733 config, #734 vocabulary pair, #735 drift provenance, #736 served mixed stream, #737 real drainprocessor pass) plus two coverage closers (#738, #740). RFC0050.1 rfc0050_1_default_mines_as_if_unannotated_and_stores_verbatim (identical ids and leaves, field-for-field record equality modulo the annotation itself — the record stream plus identical config determines every miner-derived column and the file layout — and the attribute stored verbatim); .2 rfc0050_2_adoption_uses_the_upstream_string (unit) and the collector-interop job’s drainprocessor_annotates_and_ourios_adopts (a real otelcol-contrib 0.159.0 drain pass, adoption discriminated by the persisted template_adopted audit event); .3 at the cluster and through the served binary (rfc0050_3_served_mixed_stream.rs); .4 the grammar/alignment table in upstream.rs plus the rfc0050_properties.rs render-equals-body proptests (construction and ingest level); .5 rfc0050_5_ceiling_stops_adoption_interning + rfc0050_5_byte_limit_rejects_before_parsing, with the counting clause pinned on ourios.miner.upstream_template.processed by the rfc0050_processed_counter metric binary (success error.type-free; byte_limit / grammar / template_ceiling each counted); .6 both convergence orders, the drift set (rfc0050_6_drift_reports_the_provenance_set), the single-audit-event assertion, and the cross-provenance alias binding; .7 the semconv registry entries + the weaver live-check job; .8 rfc0050_read_path.rs + the DTO omit-when-unresolvable tests; .9 rfc0050_9_observe_associates_without_touching_the_clustering. §6’s drainprocessor arm is delivered as the interop scenario (a live processor rather than a pre-annotated corpus file — recorded in §6, with the corpus-scale harness run named as available follow-on work). No thesis-gate applies (validated vacuous — RFC 0008/0044/0046 precedent); accepted is a maintainer flip. Two §7 outcomes recorded inline: rejections ride ourios.miner.upstream_template.processed (§3.2, #732), and log.record.template stays a local semconv definition until upstream lands semantic-conventions#1283/#2064 (§3.6).

(specified, 2026-08-24: §5 criteria written and testable. Prerequisites: RFC 0001 (the miner, accepted), RFC 0023 (bounded template memory, green), RFC 0010 (drift), RFC 0007 (aliases). Touches pillar 2 (§2 of CLAUDE.md) and invariants §3.1 (no silent merges), §3.2 (parameter cardinality) and §3.3 (bit-identical reconstruction), so it is an RFC rather than a patch.)

1. Summary

OpenTelemetry now templates logs before they reach a store. The collector-contrib drainprocessor (alpha, logs) runs the same Drain algorithm Ourios’s pillar-2 miner runs and annotates each record with log.record.template — plus optional log.record.template.parameter.<name> and log.record.template.wildcards. Its README states the attribute “aligns with the proposed OTel attribute in semantic-conventions#1283 and #2064”.

Today Ourios ignores that entirely: the attribute is stored as an ordinary log attribute and the miner re-derives its own template from the body. A deployment that templates upstream therefore pays for clustering twice and ends with two clusterings that can disagree — and the one Ourios keeps is the less portable of the two, because template_id is tenant-local while the string converges across instances.

This RFC makes the upstream template usable and reconciles the two worlds:

  1. Accept it — per deployment, as a three-mode dial whose default changes nothing: ignore is today’s behaviour; opt-in observe keeps mining exactly as today but records the upstream string on the mined template’s registry entry, making disagreement between the two clusterings queryable; opt-in adopt takes a record’s log.record.template as its template instead of mining the body (§3.2).
  2. Reconcile it — an adopted template is interned in the same registry, gets the same template_id key, and records its provenance, so drift (RFC 0010), aliases (RFC 0007) and the bounded budget (RFC 0023) all keep working across both origins (§3.3, §3.5).
  3. Align the vocabulary, and carry both identities — the portable string under the convention’s name (log.record.template) and the local key under ours (ourios.template.id), because they answer different questions and a consumer that has only one is stuck (§3.6).

Nothing about template_id itself changes: it stays the Ourios-derived, tenant-local key the Parquet layout and the pruning path are built on.

2. Motivation

2.1 The interface our thesis rests on is being standardised

Pillar 2 says log lines collapse to (template_id, params) at ingest. That was a bet that someone must do the collapsing and the store is the natural place. The bet is half-won: the ecosystem agrees templates matter, and has started producing them one hop earlier. A store that cannot consume an upstream template is, from the operator’s point of view, insisting on redoing work their pipeline already did.

2.2 Two unlinked clusterings are worse than either

If the Collector templates and Ourios re-mines, a deployment gets two independent trees over the same corpus with different thresholds and different masking rules. Filtering rules written against log.record.template upstream do not select the same rows as template_id == N downstream. Nobody can tell which is “the” template for a line, and the drift query answers a question about only one of them. The problem is not that two clusterings exist — it is that nothing connects them. Linked (§3.2 observe), the pair is richer than either alone: the upstream string gives our template a portable identity, and disagreement between the two trees is itself a signal.

2.3 The portable identity is the string, and we do not expose one

template_id is a local key: an RFC 0023 eviction, a re-mint or a second store gives a different id for the same shape — which is exactly why RFC 0010 makes drift a first-class query and RFC 0007 adds aliases. The drainprocessor documents the complementary property for the string: it “converges to the same value across instances given the same configuration and log patterns”. Adopting the convention gives Ourios something it currently lacks: a template identity that means the same thing outside one tenant of one store.

3. Proposed design

The positioning, before the mechanics. The miner is the foundation and stays the foundation: it is always present, it is the component that carries every guarantee this RFC relies on — reconstruction (§3.4 is the miner’s alignment machinery), confidence scoring, the RFC 0023 budget, the corpus gates — and Ourios remains fully functional, byte for byte, with zero upstream templating: no Collector, no processor, nothing in front of it. Upstream templates are an input Ourios can leverage, never a dependency, and the coupling is exactly one attribute read at ingest. The three modes below form a dial, not a migration: ignore (no leverage), observe (leverage the string, keep our clustering), adopt (leverage the clustering too). A deployment can sit on any of them indefinitely.

3.1 The attribute Ourios reads, and the grammar it must be in

log.record.template (string) on the log record. When present, usable (below) and adoption is enabled, it is the record’s template. The companion attributes are read only as described in §3.4; neither is required.

The convention does not define a syntax, so this RFC does. Upstream deliberately split the question out: #2064 proposed a log.record.template.syntax attribute and the SIG deferred it, which leaves log.record.template a bare string that might be Drain output (user <*> logged in), printf (User %s logged in), message-templates (User {user} logged in) or an f-string. Guessing between them is how a store silently mis-parses a line, so v1 accepts exactly one shape and refuses the rest:

  • Wildcards. <*>, and <name> for a named mask token (the drainprocessor’s masking_rules emit these). Each matches exactly one token under the miner’s own tokenisation — a maximal run of bytes containing no Unicode whitespace, whitespace being every codepoint char::is_whitespace accepts, exactly as ourios-miner’s tokenizer defines it — so alignment is deterministic and needs no backtracking.
  • Literals. Every other byte is literal and must match the body byte for byte. Matching is over UTF-8 bytes: no normalisation, no case folding, no whitespace collapsing.
  • Rejected outright (never adopted, mined instead): any other placeholder syntax (%s, {}, {name}, $var), two adjacent wildcards (ambiguous split), a wildcard that would match zero tokens, a literal segment that does not appear in the body in order, and any template that leaves body bytes unconsumed at the end.
  • Repeats are positional. The same mask name appearing twice yields two parameters in template order; the drainprocessor’s own first-match-wins collapsing of parameter.<name> is exactly why its parameter attributes are a cross-check and not the source (§3.4).

When the upstream syntax attribute lands, accepting a second syntax is an additive change to this section.

3.2 Three modes: ignore, observe, adopt

miner:
  upstream_templates: ignore   # default; `observe` and `adopt` opt in
  • ignore (default) — today’s behaviour exactly: the attribute is an ordinary attribute, the miner mines the body, and the Parquet bytes for a given corpus are unchanged. The default must stay this way: adopting silently would change every template_id in a live store and move the corpus gates, which is a migration, not a default.
  • observecoexistence: leverage the string, keep our clustering. The miner mines every record exactly as under ignore — same template_ids, same Parquet bytes for the data columns, same corpus-gate numbers — and when a record also carries a log.record.template that passes the byte cap and the §3.1 grammar, that string is recorded on the mined template’s registry entry as an upstream association. The registry gains, per template, a bounded set of associated upstream strings (default 4; overflow is counted, not stored — a template attracting many distinct upstream strings is a cardinality signal, not data worth keeping). Nothing is adopted, no reconstruction gate runs, and the clustering decision stays Ourios’s. What it buys: every mined template acquires a portable identity for §3.6’s surfaces, and disagreement between the two trees becomes queryable — two upstream strings mapping to one mined template (their tree is coarser here), or one upstream string spread over several mined templates (ours is finer), each a concrete place where thresholds or masking rules differ. observe is also the migration on-ramp: run it, look at the associations, then decide whether adopt is even worth it for this deployment.
  • adopt — a record carrying a usable log.record.template (§3.1 grammar, §3.4 reconstruction) skips the Drain tree; its template is the upstream string. A record without the attribute is mined as before, so a mixed stream works and no producer is forced to change. Even here the miner is not idle standby: it is the fallback for every rejection in §3.1/§3.4 and the sole engine for unannotated records — adopt narrows when the tree is consulted, never whether it exists.
miner:
  upstream_template_byte_limit: 8192   # UTF-8 bytes; 0 disables all upstream-template handling

The string is bounded before any work is done on it. max_templates (RFC 0023) bounds interned templates and param_byte_limit (CLAUDE.md §3.2) bounds extracted parameters — neither bounds the inbound attribute, so without a cap a 10 MiB “template” would be tokenised and aligned before anything rejected it. A value longer than upstream_template_byte_limit (UTF-8 bytes, default 8 KiB) is not parsed, not aligned and not adopted: the record is mined instead, and the rejection is counted so a misbehaving producer is visible rather than silently absorbed. The counter is a dedicated ourios.miner.upstream_template.processed (the OTel processor .processed shape: one counter for successes and failures, error.type present only on rejection — byte_limit, grammar, alignment, template_ceiling), not the existing ourios.miner.parse_failures: that metric means “this line failed to parse and its body was retained”, which is false for a record whose attribute was rejected but whose body then mined cleanly — overloading it would corrupt the corpus-gate semantics the §3.1 counters carry. (Amended at implementation time from an earlier draft that named the parse-outcome metric, per the OTel recording-errors convention.)

3.3 An adopted template is a first-class template

It is interned in the tenant’s registry exactly like a mined one and receives a template_id from the same space; the Parquet schema does not change (RFC 0005 §3.5 — no migration, and no per-row provenance column).

Provenance is a set on the registry entry, not a single value, over three origins:

OriginMeaningTrust
minedOurios’s Drain tree derived itinference; confidence scored as today
upstream_deriveda clustering processor derived it (drainprocessor)inference made elsewhere, same failure modes
producer_declaredthe emitting library’s own message templateground truth — the developer wrote it

Two notes on that taxonomy. First, the same string can legitimately arrive both ways: mined on Monday, adopted on Tuesday. A single-valued field would then depend on ingest order, the later value overwriting the earlier, so drift and audit answers would differ by replay order. A set, unioned monotonically, is order-independent, and §5 tests both directions. Second, Ourios cannot always tell upstream_derived from producer_declared: the attribute key is the same either way, because the convention (#1283/#2064) was drafted for producer-declared templates and the drainprocessor reuses the name for derived ones. Until upstream distinguishes them, both record as upstream_derived; §7 tracks it.

The set is what lets everything downstream stay honest:

  • RFC 0023 budget — adopted templates count against max_templates like any other. A tenant at its ceiling stops interning new upstream templates and falls back to mining (or NO_TEMPLATE), so a producer emitting a unique template per record cannot grow memory without bound. §3.2 of CLAUDE.md in spirit: an untrusted-shaped input must not become unbounded cardinality.
  • RFC 0010 drift — the drift query reports the provenance set, so an operator can see a shape whose template changed because the upstream processor changed, not because our tree moved.
  • RFC 0007 aliases — an alias may bind a mined template to an adopted one, which is the migration path for a deployment that turns adoption on over existing history.
  • §3.1 no silent merges — adoption is a clustering decision made elsewhere, so it emits the same audit event a merge does, naming the provenance.

3.4 Reconstruction decides usability (invariant §3.3)

Ourios’s contract is that render(template, params) equals the original line byte for byte, or the row is flagged lossy and the body retained. An upstream template is a claim about the line, produced by a different tokenizer with its own masking rules — the drainprocessor itself documents that a mask spanning whitespace makes template and body impossible to align position-by-position, and skips its parameter attributes when that happens.

So adoption is conditional on reconstruction, checked per record — after the §3.2 size cap has already rejected an oversized string, so none of the work below is proportional to an unbounded input:

  1. Align the upstream template against the body to recover the parameters and the inter-token separators. log.record.template. wildcards, when present, is used as a cross-check, never as the source of truth — the store verifies rather than trusts.
  2. If the alignment reproduces the body byte for byte, adopt: the row carries the upstream template with its parameters and separators, confidence 1.0.
  3. If it does not, do not adopt silently. The row falls back to mining, and if mining also cannot reconstruct it, the existing lossy-flag + body-retention path applies unchanged.

Parameter values remain subject to the per-parameter byte limit that governs mined parameters — param_byte_limit (CLAUDE.md §3.2, default 256, measured in UTF-8 bytes of the extracted value) — and an overflowing value spills to the body column exactly as a mined one does. Adoption changes where a parameter came from, never what bounds it.

3.5 What stays local

template_id remains Ourios-derived and tenant-local. It is the Parquet column, the pruning key and the DSL field, and nothing in this RFC makes it portable — the portable identity is the string. The glossary and docs/architecture/otlp-log-format.md say so explicitly so the flat namespace stops implying otherwise.

3.6 Vocabulary alignment — carry both, and say which is which

The two identifiers answer different questions and neither substitutes for the other: the string says what shape this line has and means the same thing in any store; the id says which row group to read and means nothing outside this tenant. A consumer holding only the id must make a second call to learn the shape (which is what docs/guides/agent-telemetry.md tells operators to do today); a consumer holding only the string cannot use the pruning path. So where Ourios names a template as attributes, it emits the pair:

AttributeValueWhy this name
log.record.templatethe template stringthe convention (§3.1); a vendor name for a concept the ecosystem is standardising is exactly what CLAUDE.md’s alignment rule forbids
ourios.template.idthe u64 local keyvendor-namespaced because it is vendor-specific — no convention exists or should
ourios.template.versionthe template’s versionthe companion the drift/alias machinery needs (RFC 0007/0010)

Note the shape of the id’s name: ourios.template.id, not ourios.template_id. OTel’s naming rules put a property under its object with a dot (*{object}.{property}) and explicitly warn against {object}_{property} “if this object could have other properties” — a template has at least a version, so the dotted form is the one that extends. It also matches the namespace the registry already uses for ourios.miner.template.count.

Which surfaces carry them:

  • Telemetry Ourios emits about itself (a query span that pinned a template, miner events): the pair above, registered in semconv/registry/ and exercised by the weaver live-check gate. One registration nuance is load-bearing: log.record.template cannot be a weaver ref:, because the attribute is not in the upstream registry to reference — verified against the registry, whose log namespace today holds only log.iostream, log.file.*, log.record.original and log.record.uid. It is therefore a local definition in our registry: development stability, a note naming #1283/#2064 as the tracked proposal, and the explicit caveat that the entry pre-adopts a proposed name exactly as the drainprocessor (itself an OTel component) does. Writing a log.* name in a vendor registry is otherwise something OTel’s naming guidance warns against; the tracking note is what makes it pre-adoption rather than squatting, and the entry collapses to a ref: the day the proposal lands.
  • Records returned by the query API: the string is added beside the existing template_id / template_version response fields, so a consumer stops needing the second list_templates call. It is not injected into the record’s attributes array — RFC 0018’s fidelity rule is that the read path returns the attributes ingest stored, and a template Ourios derived was never one of them. Ourios-derived data stays in Ourios-derived fields.
  • A stored record re-exported over OTLP (no such surface today): the same rule decides it. An adopted template may go back out as log.record.template because the producer sent it; a mined one may not be presented as though the producer had, and would need an explicit derived-data marker. §7 tracks that if the surface ever exists.

API surfaces that return a template string as data (the template registry, list_templates, the drift query) keep their field names — they are not OTel attributes, and renaming them is a query-contract break with no upside. The DSL keeps template_id for the same reason (RFC 0002 contract), now documented as the local key it is.

3.7 Named arguments may already be attributes

Issue #2064’s accepted direction is that a named placeholder does not get a log.record.template.parameter. prefix: {user.id} is emitted as a top-level attribute user.id, explicitly so that templates reuse existing semantic conventions (API Request by {http.request.method} {url.full} by user {user.id}). Positional placeholders keep log.record.template.parameter.<index>.

To be explicit about scope: a template written in that syntax ({user.id}) is outside the v1 grammar — §3.1 rejects {name} placeholders, so such a record is never adopted in v1; it is mined, which is the safe fallback, and accepting message-template syntax is the additive §3.1 extension gated on the upstream syntax attribute. What this section is about survives that rejection: the record’s arguments arrive already stored as attributes — possibly promoted ones (RFC 0022) that the DSL can filter and aggregate on directly — while the miner independently extracts the same values into params. That stores them twice: once as an attribute column, once in the params list.

v1 does not deduplicate. The params list is what render consumes, and invariant §3.3 (bit-identical reconstruction) is worth more than the bytes saved — Parquet’s dictionary encoding absorbs most of the duplication anyway. But the interaction is worth naming now, because the tempting optimisation (drop params that duplicate an attribute) would quietly make reconstruction depend on the attribute set surviving unchanged, which RFC 0018’s fidelity rule guarantees for stored attributes but not for a projection. §7 records it as a measurement to take once real producer-declared traffic exists.

4. Alternatives considered

  • Ignore upstream templates permanently. Today’s behaviour. Cheap, and wrong once a deployment runs the drainprocessor: two clusterings, the portable one discarded.
  • Adopt by default. Every template_id in a live store changes the moment a producer adds the attribute, corpus gates move, and the change arrives without an operator asking for it. Rejected — opt-in with an alias path (§3.3) is the migration-safe shape.
  • Trust the upstream parameters/wildcards verbatim. Faster, and it breaks invariant §3.3 the first time a mask spans whitespace: the store would emit a template it cannot render back to the line. The wildcards stay a cross-check (§3.4).
  • Replace the miner with the drainprocessor. Would make Ourios depend on a Collector in the ingest path and give up the corpus gates and confidence scoring that pillar 2’s correctness rests on. The miner stays; upstream templates are an input, not a substitute.
  • Only ignore and adopt, no middle mode. The first draft of this RFC. It forces a false choice — no leverage at all, or hand the clustering to a component we do not run — and gives a deployment no way to evaluate upstream quality before committing. observe is the coexistence the design is actually after: the miner stays the foundation, the upstream string is leveraged as identity and as a comparison signal, and adoption becomes a decision made on evidence.
  • Emit ourios.template.string instead of the conventional name. Inventing a vendor name for a concept the ecosystem is standardising is exactly what CLAUDE.md’s OTel-alignment rule exists to prevent.

5. Acceptance criteria

Scenario ids RFC0050.<n>, RFC0050.1–.9.

RFC0050.1 — the default changes nothing. Given upstream_templates unset and a corpus whose records carry log.record.template, When it is ingested, Then the attribute is stored as an ordinary attribute like any other, and the result is byte-identical to the same corpus ingested by the pre-RFC build — the comparison is against today’s behaviour on the same input, not against a different corpus with the attribute stripped, which would of course differ by that attribute’s own bytes. Every template_id, every miner-derived column and the file layout are unchanged.

RFC0050.2 — adoption uses the upstream string. Given upstream_templates: adopt and records carrying log.record.template, When they are ingested, Then each record’s template is the upstream string, two records sharing a string share one template_id, the Drain tree gains no leaf for them, and the registry entry’s provenance set contains upstream_derived.

RFC0050.3 — a mixed stream works. Given adopt and a stream where only some records carry the attribute, Then annotated records adopt and unannotated ones are mined, in one tenant, with both provenances visible in the registry and both queryable by template_id.

RFC0050.4 — the grammar and reconstruction gate adoption (invariant §3.3). Given adopt, When a record carries a template outside the §3.1 grammar — %s, {}, {name}, $var, adjacent wildcards, a literal segment absent from the body, trailing unconsumed body bytes — Then it is not adopted and the record is mined as if the attribute were absent; And when a grammatical template cannot be aligned byte for byte (a mask spanning whitespace, a template from a different line), Then likewise, and if mining also cannot reconstruct the row it is flagged lossy with the body retained. For every adopted row, render(template, params, separators) equals the original body byte for byte — asserted as a property test over the corpus, with alignment matching UTF-8 bytes and each wildcard consuming exactly one token.

RFC0050.5 — both bounds hold, and the string is bounded first. Given adopt, a max_templates of N and a producer emitting a unique log.record.template per record, When 10·N records are ingested, Then the tenant’s template count never exceeds N, memory stays bounded, the overflow path is the documented fallback (mining, then NO_TEMPLATE), and the ceiling is observable on the existing miner metrics; And Given a record whose log.record.template exceeds upstream_template_byte_limit, Then it is rejected before tokenisation or alignment — no work proportional to its length — the record is mined instead, and the rejection is counted.

RFC0050.6 — provenance is a set, and order cannot change it. Given a template string that arrives mined first, adopted second, and the same string in a second tenant adopted first, mined second, Then both registry entries end with the same provenance set {mined, upstream_derived} and one template_id each — the answer does not depend on ingest order; And the RFC 0010 drift query reports the set, an RFC 0007 alias can bind a mined template to an adopted one, and adoption emits the CLAUDE.md §3.1 audit event naming the template and its origin.

RFC0050.7 — the vocabulary is the convention’s. Given any Ourios telemetry that names a template string as an attribute, Then the key is log.record.template, the name resolves in semconv/registry/, and the weaver live-check reports no violation for it; And Given the local key on the same signal, Then it is ourios.template.id (with ourios.template.version), registered as vendor attributes.

RFC0050.8 — the read path carries both, without inventing attributes. Given a stored record whose template is known, When it is returned by the query API, Then the response carries the template string beside the existing template_id / template_version fields — so no second list_templates call is needed — And the record’s attributes array is byte-identical to what ingest stored (RFC 0018 fidelity): no log.record.template is injected into a record whose producer did not send one, and one that was sent survives the round trip unchanged.

RFC0050.9 — observe leverages without touching the clustering. Given upstream_templates: observe and a corpus whose records carry valid upstream templates, When it is ingested, Then every template_id, every miner-derived column and the corpus-gate numbers are identical to the same corpus under ignore — the clustering is untouched — And the registry’s mined entries carry the associated upstream strings; And Given records mapping two upstream strings onto one mined template and one upstream string across two mined templates, Then both disagreement shapes are visible in the registry; And Given more distinct upstream strings for one template than the association bound, Then the set stays at the bound and the overflow is counted.

6. Testing strategy

Unit: the alignment routine (template ⇄ body) as a table — exact match, mask spanning whitespace, template from a different line, a template longer than the body, parameters over param_byte_limit. Property (proptest): for any adopted row, render equals the original body, or the row is lossy with the body retained (RFC0050.4). Real drainprocessor output: delivered as the collector-interop CI scenario — otelcol-contrib runs the drain processor over a repetitive stream and exports the annotated records to a served Ourios in adopt mode, asserting adoption (discriminated by the persisted template_adopted audit event), verbatim claims against the a-priori converged template, and byte-identical reconstruction (RFC0050.2/.4). (As specified this arm was an RFC 0024 harness run over a pre-annotated corpus file; the delivered form exercises the same path against a live processor instead — a corpus-scale adopt run through the RFC 0024 harness remains available as follow-on work if adoption quality ever needs corpus-gate numbers.) The ceiling and byte-limit bounds and their counting are unit-pinned (rfc0050_5_* plus the rfc0050_processed_counter metric binary). Integration: a mixed stream through the served binary (RFC0050.3); the byte-identical default asserted by mining the same bodies with and without the annotation and requiring field-for-field record equality modulo the annotation itself (RFC0050.1); observe asserted by diffing its miner-derived output against ignore’s on the same corpus (RFC0050.9). The weaver live-check job covers RFC0050.7.

7. Open questions

  • Final attribute name. semantic-conventions #1283 / #2064 are open; log.record.template is what collector-contrib ships today. If the convention lands renamed, this RFC’s registry entry is the one place to change for telemetry — and OTel schema files formally describe attribute renames, so the rename will arrive with a machine-readable transformation to follow. The stored data side is decided policy, not a design gap, and stays open here only until the upstream name lands: - Pre-production (the current posture): a rename is a !-marked breaking change and old files are simply regenerated. No dual-read, no migration tooling — the standing rule for persisted layouts before a production deployment exists. - Post-production: old files keep the old column and readers already tolerate absent/unknown columns (§3.5 schema-evolution invariant), so nothing breaks on read. Query-side, the promoted-attribute configuration gains an alias entry (old name → canonical) resolved at planning time — the same shape as template aliases (RFC 0007, hazard #5) — with the OTel schema file as the authoritative mapping rather than one we invent. Physical convergence rides compaction re-projection (RFC 0022), which rewrites old files under the new promoted column as they are compacted anyway; no dedicated migration tool. To be precise about what re-projection may touch, because RFC 0018’s fidelity rule is at stake: a promoted column is a physical projection derived from the stored attributes, and only that projection converges to the canonical name. The record’s logical attributes array — the key and value the producer actually sent — is preserved byte for byte through every rewrite, old key included; a consumer reading attributes back always sees what was ingested, while the alias entry maps queries under either name onto the one canonical column. Nothing is built ahead of need: the only trigger for any of this is the upstream convention actually landing.

  • Trust boundary — bounded, then verified. Resolved in §3.2 and §3.4: upstream_template_byte_limit caps the string before any parsing (RFC 0023’s budget bounds interned templates, not inbound bytes), the §3.1 grammar rejects everything it cannot parse unambiguously, and reconstruction decides adoption. The attribute is operator-pipeline data, but none of that trust is load-bearing.

  • Telling declared from derived. #1283/#2064 drafted log.record.template for producer-declared message templates (log.info("Message {}", p)) — ground truth. The drainprocessor reuses the name for a derived one — an inference. The key is the same, so §3.3 records both as upstream_derived, and a producer-declared template gets less confidence than it deserves. The deferred log.record.template.syntax (#2064) would incidentally settle it, since Drain output and a message template are different syntaxes; worth raising there rather than inventing a marker.

  • Duplicate arguments (§3.7). Once producer-declared traffic exists, measure what fraction of params duplicates a stored attribute under #2064’s named-argument rule, and decide whether deduplication is worth making reconstruction depend on the attribute set.

  • Contributing upstream. Both issues are open and quiet (last activity 2026-02; #1283 since 2024-07, #2064 triage:accepted: needs-sig), and neither addresses derived templates at all — the drainprocessor took the name for a concept the convention was not drafted for. Two things this project has that the discussion lacks: the declared-versus-derived distinction above, and the reconstruction property — that a template is only safe to rely on if render(template, args) reproduces the line byte for byte, which is invariant §3.3 here and is property-tested over a real corpus (RFC 0024). Worth contributing rather than only consuming; per feedback: ai-disclosure-at-top-when-posting- externally, any post is maintainer-approved first.

8. References

  • collector-contrib drainprocessorlog.record.template, log.record.template.parameter.<name>, log.record.template.wildcards; masking rules; the whitespace-spanning-mask alignment caveat that §3.4 turns into a usability gate.
  • semantic-conventions#1283, #2064 — the proposed template attribute the processor tracks.
  • RFC 0001 (miner), RFC 0005 §3.5 (schema stability), RFC 0007 (aliases), RFC 0010 (drift), RFC 0023 (bounded template memory), RFC 0024 (property + corpus testing).
  • CLAUDE.md §2 pillar 2, §3.1–§3.3.

RFC 0051 — ourios-serving crate extraction


rfc: 0051 title: ourios-serving — shared serving infrastructure out of the ingest crate status: green author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-08-27 supersedes: — superseded-by: —

RFC 0051 — ourios-serving: shared serving infrastructure out of the ingest crate

Status: green (2026-08-28). All seven §5 criteria pass, landed as two implementation PRs: #762 (the ourios-serving crate

  • the four receiver-module moves, the deprecated shims, and the RFC0051.1 layering gate) and #763 (the OIDC/OpenFGA client moves + the RFC0051.2 manifest gate). Both gates were demonstrated red against pre-move main before going green (§9). RFC0051.3/.4/.5: workspace suite 1446/1446 with the RFC 0026/0027/0029/0030/0039 scenarios inside; the RFC 0047 container suite and collector-interop CI legs green on both PRs. RFC0051.6 recorded in §9 with an honest caveat (the cascade win applies to querier-only consumers, not the dual-role server binary). RFC0051.7’s shipping half holds (the shims exist, annotated); the deletion is tracked by #764. validated is vacuous for a placement RFC (RFC 0008 precedent); accepted is a maintainer flip.

(red, 2026-08-28: the RFC0051.1/.2 gates written first and shown failing on pre-move main — 8 source offences + 5 more found in tests by the hardened scanner, and 4 offending manifest lines.)

(specified, 2026-08-27, maintainer sign-off on the §7 decisions: §3.2 resolved to Option A — one ourios-serving crate; the OIDC client moves with the OpenFGA client (core ends reqwest-free); the ourios_ingester::receiver::* re-export shims get one deprecation release.)

(drafted, 2026-08-27: Wave 3 of the structural review, epic #745.) Touches no §2 pillar and no §3 invariant directly; §3.7 (multi-tenancy) constrains the extraction — every moved surface keeps its tenant parameter exactly. Prerequisites: RFC 0026/0027 (auth), RFC 0029 (OIDC), RFC 0030 (TLS), RFC 0046/0047 (out-of-band tenancy, ReBAC).

1. Summary

A new crate ourios-serving takes the role-independent serving plumbing — the auth resolver, TLS settings and reloading acceptors, and trace-context propagation — out of ourios-ingester, and takes the reqwest-backed OpenFGA and OIDC clients out of ourios-core. The move is placement-only: every auth decision, TLS handshake, header name, metric, audit event and error text stays bit-identical. After it, the querier role no longer compiles the ingest pipeline to get an auth check, and the foundational-types crate carries no HTTP stack.

2. Motivation

Two placements survived from the era when the receiver was the only serving surface:

  1. Serving plumbing lives in the ingest crate. AuthResolver / AuthBinding (receiver/auth.rs, 660 lines), TlsSettings (receiver/tls.rs, 226), the reloading TLS acceptors (receiver/tls_serve.rs, 456) and trace-context propagation (receiver/propagation.rs, 219) — 1 561 lines of role-independent serving infrastructure — sit under ourios-ingester. The querier role consumes all of it (ourios-server’s querier.rs, mcp.rs, visibility.rs import from ourios_ingester::receiver::*), so a querier-only deployment compiles and links the entire ingest pipeline to get an auth check and a TLS acceptor. LISTENER_QUERIER being defined inside the receiver’s TLS module is the one-line picture of the problem.

  2. HTTP clients live in the foundational-types crate. ourios-core carries the OpenFGA HTTP client (auth/openfga/client.rs, 1 628 lines) and the OIDC verifier (auth/oidc.rs, 1 078 lines), both reqwest-backed behind the openfga / oidc features. Core is depended on by every crate in the workspace; an HTTP client with TLS stack is the heaviest possible payload to put there, and feature-unification means most builds pay for it. “Shared types, tenant, IDs, errors” (§7 of CLAUDE.md) was never meant to include a REST client.

Both smells have a compile-feedback cost (RFC 0028’s thesis: slow feedback is a velocity killer) and a conceptual one: the dependency arrows point across roles instead of down to shared infrastructure.

Non-goals

  • No behaviour change anywhere. This is a placement RFC.
  • The receiver’s pipeline (ingest_bound, encode pool, WAL coupling) stays in ourios-ingester — only role-independent serving plumbing moves.
  • No public-API redesign of the moved modules (the tower AuthLayer for HTTP and the TenantDenied error split are separate Wave 3 items, deliberately sequenced after this move).

3. Proposed design

3.1 What moves

A new crate crates/ourios-serving receives, as pure moves:

FromToContents
ourios-ingester/src/receiver/auth.rsourios-serving/src/auth.rsAuthResolver, AuthBinding, AuthError, token/OIDC resolution
ourios-ingester/src/receiver/tls.rsourios-serving/src/tls.rsTlsSettings, ALPN constants
ourios-ingester/src/receiver/tls_serve.rsourios-serving/src/tls_serve.rsreloading acceptors, LISTENER_* labels, handshake metrics
ourios-ingester/src/receiver/propagation.rsourios-serving/src/propagation.rsW3C trace-context extraction (RFC 0039)
ourios-core/src/auth/openfga/client.rsourios-serving/src/openfga.rsthe OpenFGA HTTP client
ourios-core/src/auth/oidc.rs (client half)ourios-serving/src/oidc.rsJWKS fetch + verification

ourios-ingester re-exports the moved receiver modules for one release (pub use ourios_serving::… as …) so downstream paths keep compiling; the re-exports carry #[deprecated] and are removed in the following breaking release (pre-production posture — see the “break persisted layouts” precedent, but source-level paths get one deprecation cycle because the Helm chart’s users may pin git deps).

3.2 What stays, and the shape fork

Stays in ourios-core: the config typesOpenFgaConfig, OIDC issuer/audience config, AuthConfig — which ourios-server’s resolver and ourios-config need without any I/O. After the move ourios-core has no reqwest dependency and no openfga/oidc cargo features; the features migrate to ourios-serving.

The fork (maintainer decision):

  • Option A (recommended): one crate ourios-serving with modules auth, oidc, openfga, tls, tls_serve, propagation. One new crate in §7’s layout; the dependency diamond is ingester → serving, server → serving, (querier stays serving-free — its role wiring lives in ourios-server). The name stretches slightly over the OpenFGA client (the graph emitter in the ingester writes tuples through it), but one crate is the smaller architectural commitment, and a later ourios-authz split remains cheap because the module boundaries land clean now.
  • Option B: two cratesourios-authz (auth, oidc, openfga) and ourios-serving (tls, tls_serve, propagation). Honest names, two arrows per consumer, two new crates in §7. Choose this only if the naming stretch of Option A is judged to matter more than the crate-count budget.

New-crate rule (§7 of CLAUDE.md): this RFC is the required justification. The §7 layout list gains the chosen crate(s) when the RFC is accepted — recorded here so the CLAUDE.md line edit rides the acceptance rather than a separate waiver.

3.3 Dependency rules after the move

  • ourios-serving depends on ourios-core (types), ourios-config, ourios-telemetry, ourios-semconv — never on ourios-ingester, ourios-querier or ourios-parquet.
  • ourios-ingester and ourios-server depend on ourios-serving. ourios-querier does not (its role wiring in ourios-server does).
  • §3.7 tenancy: every moved function keeps its tenant parameter and semantics byte-for-byte; the move is git-verifiable as pure relocation (function bodies unchanged).

4. Alternatives considered

  • Leave it: the querier role keeps linking the ingest pipeline; every auth/TLS touch keeps recompiling ourios-ingester and everything above it. Rejected by the review’s evidence (receiver.rs is the #3 churn file; worst-case warm check 24.5 s).
  • Move serving plumbing into ourios-server: makes the binary crate a library for the ingester (inverted again) and defeats role-scoped compilation. Rejected.
  • Feature-gate the receiver modules inside ourios-ingester: features don’t fix dependency direction and multiply the CI matrix. Rejected.

5. Acceptance criteria

Scenario ids RFC0051.<n>, RFC0051.1–.7.

RFC0051.1 — the querier role sheds the ingest crate. Given the workspace after the move, When ourios-server, ourios-querier and their tests are searched for any of the moved modules’ path fragments — ourios_ingester::receiver::auth, ourios_ingester::receiver::tls, ourios_ingester::receiver::tls_serve, ourios_ingester::receiver::propagationor for the moved names formerly re-exported at the receiver root (AuthBinding, AuthError, AuthResolver, GraphIdentity, authenticate_bearer, HeaderExtractor, MetadataExtractor, extract_context, TlsSettings) reached through any ourios_ingester:: path, Then no match exists, And the querier role’s modules build against ourios-serving only. (The nested-path and root-re-export forms are both in scope — a brace-import literal alone would miss ourios_ingester::receiver::AuthResolver::static_only(..)-style call sites.)

RFC0051.2 — core carries no HTTP stack. Given ourios-core/Cargo.toml after the move, When inspected, Then it declares no reqwest dependency and no openfga/oidc feature, And cargo tree -i reqwest lists only the chosen serving crate (and dev-dependencies) as its workspace entry points.

RFC0051.3 — pure move, proven by the standing suites. Given the moved modules, When their own test suites (auth resolver, TLS reload, propagation) and the RFC 0026/0027 (enforced tenancy + MCP binding), RFC 0029 (OIDC), RFC 0030 (TLS + mTLS + reload) and RFC 0039 (propagation) acceptance suites run, Then every scenario that passed before the move passes after it, with only import paths changed in the test code.

RFC0051.4 — end-to-end interop unchanged. Given the collector-interop CI job (a real otelcol-contrib exporting over TLS + OIDC), When it runs against the moved crate layout, Then it passes unchanged.

RFC0051.5 — graph surfaces follow the client. Given the OpenFGA client in its new home, When RFC 0047’s real-container suite (12 scenarios) and the graph emitter / erasure paths run, Then all pass, And both paths import the client from the serving crate, not from ourios-core.

RFC0051.6 — compile feedback improves measurably. Given a one-line edit to the moved auth.rs, When warm cargo check -p ourios-server and -p ourios-querier are timed before and after the move, Then the after-times are recorded in §9, And the querier-role path no longer rebuilds ourios-ingester.

RFC0051.7 — the shims die on schedule. Given the deprecated ourios_ingester::receiver::* re-exports in the release the move ships in, When the next breaking release is cut, Then the re-exports are deleted, And a follow-up issue created at acceptance time tracks that removal.

6. Testing strategy

Per CLAUDE.md §6.2, mapped to the §5 ids:

  • RFC0051.1/.2 — mechanical gates: a grep assertion (CI step or a test over the source tree) and a cargo tree -i reqwest check; no new test code.
  • RFC0051.3 — the existing unit + integration suites of the moved modules, renamed paths only; the RFC 0026/0027/0029/0030/0039 scenario tests run unmodified. No test is weakened or deleted (§6.2 “tests are specifications”).
  • RFC0051.4/.5 — the existing testcontainers CI jobs (collector-interop, the RFC 0047 OpenFGA container suite) — end-to-end behaviour pins.
  • RFC0051.6 — a recorded measurement (script + numbers into §9), not a CI gate: wall-clock is machine-dependent; the dependency claim (querier path not rebuilding the ingester) is the assertable half, via cargo build --timings unit lists.
  • RFC0051.7 — release-process checklist item plus the tracking issue; not automatable before the release exists.

7. Open questions

All resolved at specified (2026-08-27, maintainer sign-off):

  • §3.2 shape — Option A: one ourios-serving crate. A later ourios-authz split stays cheap because the module boundaries land clean now.
  • The OIDC client moves with OpenFGA — it is what empties reqwest out of core (RFC0051.2).
  • Deprecation window for the ourios_ingester::receiver::* re-exports — one release, then deleted in the next breaking release (RFC0051.7).

8. References

  • Epic #745 — the 2026-08-27 structural review (method + line-level evidence; Wave 3 item 1 is this RFC).
  • RFC 0026 (auth & tenant binding), RFC 0027 (MCP surface), RFC 0029 (OIDC bearer layer), RFC 0030 (TLS/mTLS listeners), RFC 0039 (trace-context propagation) — the behaviour contracts the moved modules implement; their §5 suites are this RFC’s no-regression pins.
  • RFC 0046 / RFC 0047 — out-of-band tenancy and the ReBAC resolver; the OpenFGA client’s consumers on both the ingest (graph emitter, erasure) and serving (visibility) sides.
  • RFC 0028 — the build-feedback program; the compile-cost argument and the “modules change zero compilation units” rule this RFC’s crate split deliberately escapes.
  • CLAUDE.md §3.7 (multi-tenancy constraint on every moved surface), §7 (crate layout — gains the new crate on acceptance).

9. Validation

  • RFC0051.1 — red on pre-move main: 8 offending source sites in ourios-server/src; the shipped gate (a path-segment scanner, not a line grep — hardened in review to see brace-grouped and multi-line imports) then found 5 more in ourios-server/tests. Green from #762 on; self-tests pin six catch shapes and three legitimately-ingester-owned passes.
  • RFC0051.2 — red on pre-move main: 4 offending manifest lines (jsonwebtoken, reqwest, oidc =, openfga =). Green from #763 on; exact TOML-key matching.
  • RFC0051.3 — workspace nextest 1446/1446 after #763 (serving 11, ingester 158 — its 8 moved inline tests plus 2 formerly-feature-gated resolver tests now count in serving — server 167, querier 260); only import paths changed in test code.
  • RFC0051.4/.5 — the collector-interop and RFC 0047 real-container CI jobs green on both implementation PRs; the graph emitter and erasure paths import the client from ourios_serving::openfga.
  • RFC0051.6 — measured 2026-08-28 (M-series laptop, isolated target dirs, warm build then a one-line auth.rs edit): cargo check -p ourios-server before 1.37 s (rebuilds ingester + server) vs after 1.82 s (serving + ingester + server); cargo check -p ourios-querier after the same edit: 23.5 s before vs 22.3 s after — but both querier numbers are a measurement artifact, not signal: a solo -p ourios-querier invocation resolves a different feature unification than the combined warm build and recompiles the DataFusion stack on both sides of the move. The meaningful querier fact is structural: ourios-querier has no ourios-ingester edge in its unit graph before or after (cargo build --timings unit lists), and the auth edit therefore never touches it. Caveat recorded as measured: the dual-role server binary still rebuilds the ingester on an auth edit — the ingester itself now depends on ourios-serving — so the criterion’s “querier-role path no longer rebuilds ourios-ingester” holds structurally (ourios-querier has no ingester edge; the RFC0051.1 gate keeps it that way) rather than as a warm-check delta for ourios-server. The wins this RFC actually delivers are the dependency direction, core’s freedom from the HTTP stack, and the cascade cut for any future querier-only binary.
  • RFC0051.7 — the shims landed annotated in #762 (modules and root re-exports both #[deprecated]); deletion was tracked by #764. Outcome (maintainer decision, 2026-08-28): deletion accelerated to before any release shipped the shims — under the pre-production posture the one-release window was belt-and-braces for hypothetical external consumers, and 0.10.0 now ships the ourios_serving paths only (published 0.9.0 artifacts predate the move entirely, so no published release ever carried the old paths in deprecated form). With the shims gone the compiler enforces the boundary everywhere; the RFC0051.1 gate remains as the regression-proof for server/querier source.

Template mining in Ourios


title: “Template mining in Ourios: what Drain says, what it leaves out, and what we commit to” speaker: Jens Holdgaard Pedersen drafting-assistance: Claude target-duration: 45 minutes audience: engineers familiar with log backends but not the Drain paper companion-rfc: docs/rfcs/0001-template-miner.md created: 2026-04-24

Template mining in Ourios

A lecture manuscript. Prose is written for spoken delivery; figures are sized to lift onto slides.


Abstract

Log storage at scale has a compression problem that looks unsolvable when you squint at it. A terabyte of raw log lines is mostly repetition — the same twenty-odd templates interleaved with ever-changing parameters — but commodity byte-level compressors like zstd cannot see that structure. They see bytes. Template mining is the layer that turns the repetition into a first-class citizen before any byte codec runs, and the algorithm we use — Drain, published in 2017 — is so simple it fits on one slide.

But the paper is ten pages long, and a production log backend needs answers to at least six questions the paper does not answer. Those unanswered questions are not implementation details. They are the difference between a search engine that tells the truth and one that quietly conflates a login event with a logout event because the two lines shared enough token structure to merge. This lecture is about those six questions, the commitments Ourios makes in response, and the honesty contract those commitments form with the user.

Thesis

Drain is not a log parser. Drain is a tree. What makes it safe to put into production is everything we build around the tree — the confidence scoring, the merge auditing, the body retention, the reconstruction property — none of which appear in the paper.

Hold on to that sentence. Every figure in this talk exists to defend it.

Learning objectives

By the end of this lecture you should be able to:

  1. Draw the Drain parse tree from memory and walk a log line through it.
  2. Name the six gaps between the published algorithm and a production log backend, and state the Ourios invariant that fills each gap.
  3. Explain why bit-identical body reconstruction is a property test and not a unit test.
  4. Defend the thesis above against a critic who says “just use zstd.”

Outline

§TopicMinutes
1Motivation: where the compression comes from5
2The paper: Drain as published10
3Worked example: a line walks the tree5
4What the paper does not say8
5The Ourios extensions8
6The honesty contract: reconstruction5
7What is still open2
Questions2

1. Motivation: where the compression comes from

I want to start with a number, because the number is what makes this whole project coherent. Operators of large log deployments — people running Loki, Elasticsearch, proprietary SIEMs — consistently report that their raw log volume compresses by somewhere between fifty and two hundred times when it lands in a structured backend. That compression does not come from zstd. If you zstd a day of raw logs you get maybe ten times. The rest — the factor of five to twenty on top of the byte codec — comes from noticing that your logs are not really text at all.

They are a program output. The program has maybe two thousand printf-style call sites. Each call site fires somewhere between a few hundred and a few million times a day, always with the same template and different parameters. A log line that reads

ERROR db connection failed for user 42 after 3 retries

is not a string. It is a tuple. It is template number, say, 847, plus the parameters (42, 3). The template itself appears once per deployment. The parameters appear once per event. If you store the template once and the parameters inline, you have already compressed the log before you have compressed a single byte.

This is not a theoretical claim. It is how every serious log backend built in the last decade actually works under the hood. What differs between backends is how they recover the templates. You can ask developers to annotate them at compile time — SLF4J’s structured logging, OpenTelemetry’s log records — but the reality of a heterogeneous deployment is that you inherit a pile of logs from Python scripts and Go services and JVM apps and legacy C++ daemons, and the only common substrate you have is the emitted text.

So you mine the templates online, from the text, as the logs flow. That is what Drain does.

2. The paper: Drain as published

The Drain paper — He, Zhu, Zheng, and Lyu, ICWS 2017 — introduces a single data structure and one algorithm that walks it. The data structure is a tree with a fixed depth. The algorithm is: preprocess the line, walk the tree from root to leaf, decide at the leaf whether this line matches an existing log group or opens a new one. That is the whole paper. Ten pages.

Let me draw the tree.

Figure 1 — The Drain parse tree

Drain parse tree: a root node branching to three length-group children (len=5, len=7, len=11); each length child branches further to token-prefix children keyed on the first token; each prefix node points to a list of leaf log groups.

Three levels matter here. The root has a child per distinct token count — Drain assumes that two log lines of different length are probably from different call sites, and this is empirically true often enough to use as a cheap first-level filter. Below the length node sits a chain of prefix nodes — one per token, up to a configured depth. At depth two, as drawn, the tree branches on the first token of the line. If the depth were three you would also branch on the second token, and so on. The paper defaults to depth three or four; the deeper you go, the more precise the partition but the more groups you end up with.

At the bottom of each prefix chain is a leaf. A leaf is not a single template. It is a list of templates — what the paper calls log groups — each with its own parameter positions. When a line arrives at a leaf, Drain compares it against each log group in the leaf by token-wise similarity, picks the best match if the similarity exceeds a threshold, and either merges the line into that group or, if no group is similar enough, opens a new group.

The similarity function is where the arithmetic lives. It is simply the fraction of positions where the template and the line have the same token — wildcards count as matches. So if a leaf contains the template ERROR db connection failed for user <*> and a line arrives reading ERROR db connection failed for user 42, every token matches — the wildcard absorbs the 42 — and similarity is 1.0. A different line, ERROR db connection timeout for user 7, matches six of seven tokens — connection matches, but timeout does not equal failed — so similarity is about 0.86. If the threshold st is 0.7, both lines land in the same group; the template widens to ERROR db connection <*> for user <*>. If the threshold is 0.9, only the first line matches; the second opens a new group.

That is Drain. That is the whole thing. I am not hiding complexity. The paper is short because the algorithm is short.

3. Worked example: a line walks the tree

Let us walk one line through concretely so the abstraction has weight.

Figure 2 — Walking ERROR db connection failed for user 42

Line: "ERROR db connection failed for user 42"

Step 1 — preprocess
    tokens: ["ERROR", "db", "connection", "failed",
             "for", "user", "42"]
    length: 7

Step 2 — walk
    root          →  len=7 node
    len=7         →  tok₀="ERROR" branch
    tok₀="ERROR"  →  leaf L₇

Step 3 — compare at leaf L₇
    candidate A: "ERROR db connection failed for user <*>"
                 similarity = 7/7 = 1.00   ← best

    candidate B: "ERROR db pool exhausted for user <*>"
                 similarity = 5/7 = 0.71

Step 4 — decide
    threshold st = 0.7
    similarity(A) ≥ st   →  assign to group A
    param extracted: ["42"]
    template unchanged (already fully general at that slot)

Result
    template_id  = hash("ERROR db connection failed for user <*>")
    params       = ["42"]

Pause on step three. The whole engine is visible here. Every decision Drain makes — whether to match, whether to widen, whether to open a new group — is a function of that similarity score and that one threshold. Lift the threshold and you get more, narrower templates. Lower it and you get fewer, more abstract templates that absorb lines they arguably should not absorb.

That single scalar is the most important knob in the whole system. Remember the thesis: what makes it safe to put into production is everything we build around the tree. We are about to talk about what the paper does not say about the threshold, and about much else.

4. What the paper does not say

I want to go through this carefully, because these are the questions that become bugs in production if you skip them.

4.1 It does not say what the threshold should be for your corpus

The paper reports empirical results on a handful of public corpora with thresholds around 0.4 to 0.7. These are the datasets the authors had access to — HDFS, BGL, Apache, OpenSSH. Your corpus is not one of those. The right threshold for an application that emits heavily templated, well-structured log lines is different from the right threshold for an application that concatenates stack traces and request payloads into each line.

This is not a criticism of the paper. This is a reminder that the paper reports that there exists a sweet spot, not what it is for you. In Ourios we default to a strict threshold — at least 0.7 — and expose it as tenant-configurable, and we gate any reduction below 0.7 behind an RFC. That last part matters. There is always an engineer who, when templates look noisy, wants to lower the threshold to “clean things up.” What they are actually doing is forcing unrelated templates to merge. A strict default plus a gate keeps that pressure from silently drifting the system toward wrong.

4.2 It does not say what to do when similarity is close but not above threshold

Drain is a classifier with two classes: match, and no-match. In practice there is a third case that matters deeply to a log backend. Imagine a line that matches the best candidate at 0.65 when the threshold is 0.7. What do you do? The paper says: open a new group. The paper is right that this is the safe default, but it is wrong that this is a complete answer. In a log backend the user has a specific question: was this line produced by the same code as that template? If you opened a new group because similarity was 0.65, you have told the user “these are different” — but you only know that with 0.65 confidence, not 1.0 confidence. A query that asks “show me all events from template X” will miss this line even though it came from the same call site, probably.

Ourios handles this with a three-zone model.

Figure 3 — The three-zone confidence model

Three-zone confidence model: a horizontal axis from 0 to 1 with dashed verticals marking the floor and the threshold. The axis splits into three labelled zones — parse_failed (retain body, count error), lossy match (retain body and template, set lossy_flag), and clean match (template plus params only; body optional).

Three zones, three behaviours. Above the threshold, the happy path: store the template id and the parameters. Below the threshold but above the floor — what I am calling the lossy zone — store the template id, the parameters, and the original body, and raise a flag on the row so the reader knows not to trust reconstruction against this row. Below the floor, parse failed altogether: store only the body, increment parse_failures_total, and move on.

The floor is the second most important knob in the system. Set it too low and you never see parse failures — everything is technically a match, just a bad one. Set it too high and you throw away useful partial matches. A reasonable default sits around 0.3. The point is that the three-zone model exists at all, because without it the backend is lying to the user in the lossy zone.

4.3 It does not say what to do when parameters are enormous

The paper implicitly assumes parameters are short variable bits — numbers, hostnames, UUIDs. In production a parameter slot may capture an entire stack trace, a request body, a base64 payload. If you put a megabyte of stack trace into a parameter, Parquet’s dictionary encoding collapses. File sizes explode. Query latency degrades. The backend’s whole value proposition evaporates for that column.

The Ourios answer is a per-parameter byte limit — 256 bytes by default — with overflow behaviour that is explicit rather than clever. When a parameter exceeds the limit, the original value spills into the body column of the row, the params slot gets a short truncation marker, and a counter increments. Per-service alerts fire when more than 1% of rows hit overflow. The ceiling on the limit is 1 KiB; above that we would rather open an RFC than silently accept larger values.

This is the kind of rule that looks ugly on a whiteboard and is invisible in a paper but saves the storage format from a class of tail-latency failure that is otherwise impossible to diagnose in production.

4.4 It does not say whether to preserve whitespace

The paper talks about tokens. Tokens are a convenient abstraction and they are also a lossy abstraction. When you tokenise connection failed — two words separated by three spaces — into ["connection", "failed"], you have thrown away the three spaces. Later, when an operator opens the UI and asks “show me what was actually logged,” and you reconstruct from template plus parameters, you produce connection failed — one space. You have lied. Quietly, in a way that the user will only notice if they happen to be debugging a whitespace-sensitive format.

This is the invariant in CLAUDE.md §3.3 — bit-identical body reconstruction — and it is stricter than it sounds. It says: for every line we ingest, either we can reproduce the original byte stream exactly from what we stored, or we have flagged the row as lossy. No in-between. The miner either captures the inter-token whitespace as part of the template, or it gives up honestly and keeps the body.

4.5 It does not say how templates evolve over time

A service ships a new version. The log format changes — a new field appears, an old one goes away, word order shifts. The template tree you built from last month’s logs no longer matches this month’s logs cleanly. The paper has nothing to say about this; it assumes a static tree.

Real deployments are never static. Ourios needs a template versioning story: what changes cause a new template version vs. a new template, what aliases hold between old and new templates, and how a query that says “template X” either resolves across versions or surfaces the drift explicitly to the user. This is hazard 5 in docs/hazards.md and it is genuinely hard — hard enough that the RFC has it as an open question rather than a solved problem.

4.6 It does not say anything about multi-tenancy

The paper describes one tree. A log backend serves many tenants whose logs cannot cross-pollinate: tenant A’s login template must not end up merged with tenant B’s logout template just because they share token structure. This is CLAUDE.md §3.7, and it is the invariant that says the tree is not one tree — it is one tree per tenant — and every code path that touches data carries a tenant id. Retrofitting this after the fact is more expensive than building it in at the start; the RFC makes it foundational.

Figure 4 — Gaps to invariants

What the paper doesn’t sayOurios invariant (CLAUDE.md)
What threshold to pick§3.1 — strict default ≥ 0.7, RFC gate below
What to do in the lossy zone§3.1 — three-zone model, body retained under threshold
What to do with huge parameters§3.2 — 256 B limit, overflow to body, 1% alert
Whether whitespace is preserved§3.3 — bit-identical reconstruction or lossy flag
How templates evolve§3.5 — versioning, aliases, drift detection
How tenants are isolated§3.7 — one tree per tenant, tenant id on every path

This is the table to internalise. Everything else in the design descends from these six lines.

5. The Ourios extensions: the record shape and the merge policy

Let me show you what a mined record looks like in Ourios, because it makes the invariants concrete.

Figure 5 — The Ourios log record

The Ourios log record: a 3×3 grid of fields. Row 1: tenant_id, template_id, template_version. Row 2: params[], body?, confidence. Row 3: lossy_flag, timestamp, service. tenant_id is highlighted as the partition key, confidence and lossy_flag are highlighted as honesty-contract fields.

Every field on that diagram is a commitment:

  • tenant_id is present on every row, not on every file — the partitioning is a separate question. We never trust the file to tell us the tenant; we trust the row.
  • template_id is the identity of a template within a tenant. The same text in two tenants yields two different ids. This is deliberate — it means a query never needs to join across tenants to resolve identity.
  • template_version lets a template’s representation change over time while the logical identity persists.
  • params are length-bounded per 4.3 above.
  • body? is present whenever the lossy-or-fail zone fired, and optionally always, as a tenant-configurable choice. Paying the storage cost of always keeping the body buys perfect reconstructability; most tenants will not want to pay it, and the default should be “only when needed.”
  • confidence is the scalar the three-zone model was defending.
  • lossy_flag is the boolean the reader checks before trusting template-based rendering.

Now the other piece the paper does not address — merging.

Drain as published merges templates implicitly. When a line matches an existing log group but its tokens differ at some positions, the template at those positions becomes a wildcard. The template has widened. This is a merge. The paper does not call it that and does not audit it.

In Ourios, every widening event that crosses a configurable threshold of semantic change fires a merge audit event — a structured record with the old template, the new template, the tenant, the timestamp, and the reason. The audit event is a first-class citizen: it goes to the same storage, it is queryable, and there is a metric merges_total that dashboards the rate.

Why does this matter? Because the horror story for a template miner is a silent merge that crosses a semantic boundary. user logged in <*> and user logged out <*> differ in one token. Depending on your threshold, they can merge into user logged <*> <*>, and now a query for the login event returns logout events too. The user will not know this has happened unless we tell them. The audit event is how we tell them.

Strict defaults plus visible audits plus a merge-rate metric are not paranoia. They are the shape of “we are not going to let this system lie to you silently.”

6. The honesty contract: reconstruction as a property

We have seen confidence scoring, length limits, whitespace capture, versioning, tenancy, merge auditing. There is one more piece that ties them together, and it is less a design and more a claim we make to the user.

Figure 6 — The reconstruction invariant

\[ \begin{aligned} &\forall\, \mathtt{line} \in \mathtt{corpus}: \\ &\quad \mathtt{reconstruct}(\mathtt{mine}(\mathtt{line})) \equiv \mathtt{line} \\ &\quad \lor\;\; \mathtt{mine}(\mathtt{line}).\mathtt{lossy\_flag} = \mathtt{true} \end{aligned} \]

In English: for every log line we ingest, either we can reproduce the exact bytes the customer’s application wrote, or we flag the row so the reader knows not to claim we can.

This is not a design decision. It is a property. It is what we prove on every CI run. The test is:

for every line in testdata/corpus/ :
    record = mine(line)
    if record.lossy_flag == false :
        assert reconstruct(record) == line

If that assertion ever fails, the backend is lying, and that PR does not merge.

The reason this is a property test and not a unit test is that the set of log lines we care about is the power set of our token vocabulary, and we cannot write unit tests against a power set. What we can do is assemble a corpus — real, anonymised log lines from real applications — and run the property against every line in the corpus on every build. proptest lets us go further: it generates synthetic adversarial inputs that stress the whitespace capture, the tokeniser, the length limits, and the merge policy, looking for a counterexample. When it finds one, we have learned something real.

The reconstruction property is the single honesty contract between this system and its operators. Everything else in the design — the confidence model, the body retention, the merge audit — is in service of making this property defensible.

7. What is still open

I am going to close with the things I do not yet know, because if this lecture ended with a polished answer it would be a marketing pitch and not a lecture.

  • Threshold on real corpora. We have said “strict default, at least 0.7.” The paper’s sweet spot is corpus-dependent. Until we run Ourios on meaningful corpora we do not know whether 0.7 is merely safe or also good.
  • Masking placement. Drain3 does regex-based masking — IPs, UUIDs, numbers — before the tree walk. This improves template stability dramatically but it also couples the tree to a set of regex rules that are inherently wrong at some edges. Where exactly that masking happens — pre-tree, post-tree, both, neither — is an open design question.
  • Binary and malformed input. Log lines are not always valid UTF-8. They are not always text. A mature miner has a story for what happens when the input is simply not parseable into tokens. We do not yet have that story written down.
  • Template identity across versions. The versioning story in §4.5 needs an alias mechanism and a drift query surface. Neither is designed yet.

These four items are in docs/rfcs/0001-template-miner.md under Open Questions, and the RFC cannot move to accepted until they are resolved.

Thesis, restated

Drain is not a log parser. Drain is a tree. What makes it safe to put into production is everything we build around the tree — the confidence scoring, the merge auditing, the body retention, the reconstruction property — none of which appear in the paper.

If you take one thing away from this lecture, take that sentence. The tree is a reasonable default partition function over log lines. The system around it is the product.

Questions

Prompts for the Q&A segment. Seed these into the room if the audience is quiet.

  1. Why not use an LLM-based parser instead of Drain?
  2. Why is reconstruction a property test and not a unit test — can you give an example of a bug that a unit test would miss?
  3. How does the merge audit scale when a single deployment produces a high merge rate — does the audit stream itself need to be templated?
  4. If a tenant configures a threshold below 0.7, how is that audited as a policy event?
  5. What happens to the template tree when a service is sunset and its templates go cold?

References

  • He, P., Zhu, J., Zheng, Z., Lyu, M.R. Drain: An Online Log Parsing Approach with Fixed Depth Tree. ICWS 2017.
  • Drain3 (IBM): https://github.com/logpai/Drain3
  • LogPAI benchmark suite: https://github.com/logpai/logparser
  • Ourios: CLAUDE.md §2.2, §3.1–§3.3, §3.5, §3.7, §4, §6.2, §6.3
  • Companion RFC: docs/rfcs/0001-template-miner.md