Ourios
οὔριος — 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: 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. For history, the 2026-05 finding on OTLP’s log data model vs. the miner’s view of it records why the ingest contract changed; its gaps are resolved.
- 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 / Thenacceptance 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 withOURIOS_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--configis 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
# RFC 0030: native TLS per listener (omit a block for plaintext).
# Keys: cert_file + key_file (required together), client_ca_file
# (mTLS), min_version ("1.2" | "1.3"), reload_interval_secs.
grpc_tls:
cert_file: /etc/ourios/tls/tls.crt
key_file: /etc/ourios/tls/tls.key
client_ca_file: /etc/ourios/tls/ca.crt
reload_interval_secs: 60
http_tls:
cert_file: /etc/ourios/tls/tls.crt
key_file: /etc/ourios/tls/tls.key
# 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
http_tls: # RFC 0030; also covers /mcp
cert_file: /etc/ourios/tls/tls.crt
key_file: /etc/ourios/tls/tls.key
mcp:
enabled: false
template_map:
refresh: # RFC 0060: warm template maps off the query path
enabled: true
interval: 5m # idle time between passes; ms/s/m/h, at least 10s
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)
| Variable | Meaning |
|---|---|
OURIOS_STORAGE_BACKEND | local (default) or s3 |
OURIOS_BUCKET_ROOT | local-backend store root |
OURIOS_S3_BUCKET / OURIOS_S3_REGION / OURIOS_S3_ENDPOINT / OURIOS_S3_PREFIX | S3 addressing |
OURIOS_S3_ACCESS_KEY_ID / OURIOS_S3_SECRET_ACCESS_KEY / OURIOS_S3_SESSION_TOKEN | S3 credentials |
OURIOS_RECEIVER_ENABLED / OURIOS_RECEIVER_GRPC_ADDR / OURIOS_RECEIVER_HTTP_ADDR | receiver role |
OURIOS_WAL_ROOT | WAL directory (receiver) |
OURIOS_RECEIVER_ENCODE_WORKERS | concurrent encode pool size (RFC 0035; default: all cores) |
OURIOS_TEMPLATE_IDS_ALLOW_BOOTSTRAP / receiver.template_ids_allow_bootstrap | authorise the one-time template-id bootstrap over existing data, for the upgrade to RFC 0059 only (default off); see the bootstrap’s cost |
OURIOS_QUERIER_ENABLED / OURIOS_QUERIER_HTTP_ADDR / OURIOS_QUERIER_DEFAULT_WINDOW_SECS | querier role |
OURIOS_QUERIER_MCP_ENABLED | the /mcp agent surface (RFC 0027) |
OURIOS_COMPACTION_ENABLED / OURIOS_COMPACTION_INTERVAL_SECS | background compactor |
Auth configuration is file-only — there are deliberately no
OURIOS_AUTH_* variables; token values reach the file through
${env:…} references.
The template-id bootstrap
The upgrade to RFC 0059
starts one receiver with receiver.template_ids_allow_bootstrap set. That
start reads the footer of every data and audit *.parquet file during
startup recovery, before the receiver’s listeners open (and, in a process
that also runs the querier, before the querier’s), so it accepts no logs
until it ends:
- Cost. One footer read per file, 16 at a time, so about the store’s file count divided by that concurrent read rate. Let compaction catch up first on a store with many small files.
- Progress. The start, progress at least every 30 seconds, and the end
are printed on stderr whatever the logs exporter, and logged as
ourios.receiver.template_ids.bootstrap.progressandourios.receiver.template_ids.bootstrapped. - Interruptions. SIGTERM or SIGINT during the scan stops it between reads and exits cleanly with nothing written. The next start scans again from the beginning.
- Done. Remove the setting only once the receiver has logged
ourios.receiver.template_ids.bootstrappedor writtensnapshots/TEMPLATE_IDS_SEATEDunder its WAL root.
Listener TLS
TLS is file-only too (RFC 0030);
without --config every listener speaks plaintext. Each listener takes
its own block — receiver.grpc_tls, receiver.http_tls,
querier.http_tls — with the same keys:
| Key | Meaning |
|---|---|
cert_file | PEM certificate chain; required together with key_file to enable TLS |
key_file | PEM private key |
client_ca_file | optional; PEM CA — set it to require and verify client certificates (mTLS) |
min_version | optional; "1.2" (default) or "1.3" |
reload_interval_secs | optional; positive integer — re-read the files on this interval and swap in changed material (unset: never reload) |
Setting any other key without the cert_file / key_file pair is a
startup error, as is an unreadable or malformed PEM file. Paths accept
${env:…} substitution like any other value. See the
authentication guide for how TLS composes
with bearer auth.
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 ondistroless/cc. -static(:<version>-static) — static musl binary ondistroless/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 barescratch, 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: the key values.yaml settings, the
topology diagram, S3 credentials and per-role IAM, the compactor
topology, and probes. 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.
Warning: by default a chart install runs unauthenticated and over plaintext (RFC 0026 open mode), and its install notes say so. Keep both Services inside a trusted network boundary until you turn on authentication.
The chart’s auth values render authentication
into the receiver and querier config: static tokens, OIDC, and the
OpenFGA tenant binding. receiver.tls and querier.tls turn on
listener TLS and mTLS, and querier.mcp.enabled serves /mcp. Token
values and the OpenFGA API token come only from existing Secrets
(secretKeyRef, injected as env and referenced as ${env:…}), and
certificates mount read-only from kubernetes.io/tls Secrets, so no
secret reaches the rendered ConfigMap. The chart
README
has the values and examples.
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:
Check(principal, can_read_content, tenant)allowed → the tenant predicate only (today’s plan).- else
Check(can_read_metadata, tenant)allowed → every row, with the configuredcontent_columnsreturned as null (bodyas{"kind":"masked"}); a query that filters or aggregates on one of them is403 column_forbidden, naming the column. - else the principal is scoped (bound through
scoped_reader): its readable conversations are enumerated through the streamedListObjects— filtered to this tenant, at mostmax_objectstenant ids, withinlist_timeout_ms— and becomeattr.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
A plaintext listener exposes bearer tokens in transit. Every listener can serve TLS
natively, and mTLS when given a client CA
(RFC 0030); alternatively,
terminate TLS in front (ingress, service mesh, or an L4 proxy). TLS is
opt-in per listener and file-only — there are no OURIOS_* TLS
variables:
receiver:
grpc_tls: # OTLP/gRPC (:4317)
cert_file: /etc/ourios/tls/tls.crt
key_file: /etc/ourios/tls/tls.key
client_ca_file: /etc/ourios/tls/ca.crt # optional: require client certs (mTLS)
min_version: "1.3" # optional: "1.2" (default) or "1.3"
reload_interval_secs: 60 # optional: re-read the files on this cadence
http_tls: # OTLP/HTTP (:4318) — same keys
cert_file: /etc/ourios/tls/tls.crt
key_file: /etc/ourios/tls/tls.key
querier:
http_tls: # query API and /mcp (:4319) — same keys
cert_file: /etc/ourios/tls/tls.crt
key_file: /etc/ourios/tls/tls.key
cert_fileandkey_fileare PEM paths and must be set together; every other key requires them. The files are read and validated at startup, so an unreadable or malformed PEM fails startup naming the block and the path.client_ca_fileturns on mTLS: a handshake without a client certificate that chains to this CA is refused before it reaches the auth layer. mTLS admits the connection; it does not bind a tenant — bearer auth still runs on every request.reload_interval_secs(a positive integer; unset means never reload) re-reads the files on that interval and swaps in changed material for new handshakes, so rotating certificates (cert-manager,spiffe-helper) need no restart. A reload that finds unreadable or invalid files logs an error, countsourios.receiver.tls.reload_failures, and keeps serving the last good certificate.
With an auth section configured, any listener left without a *_tls
block gets one startup warning naming it (bearer credentials over
plaintext) — not an error, because TLS may terminate at a fronting
proxy. On Kubernetes, the Helm chart sets these keys, and the auth
section above, from Secret references; see the chart README’s
Authentication and TLS.
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
-
Every error or worse in the last hour:
severity >= error | range(-1h, now) -
Everything the
checkoutservice logged in the last 15 minutes:service == "checkout" | range(-15m, now) -
Errors from
checkoutwhose body mentions a timeout:service == "checkout" and severity >= error and contains(body, "timeout") -
How many log lines each service produced today, busiest first:
true | range(-24h, now) | count by service | sort count desc -
Error count per template, to find the noisiest failure shape:
severity >= error | count by template_id | sort count desc | limit 20 -
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 -
Lines matching a regular expression (matched against the body; add
^/$yourself if you want anchoring):matches(body, "user [0-9]+ locked out") -
Everything with a given trace id, across all services:
trace_id == "4bf92f3577b34da6a3ce929d0e0e4736" -
Warnings and errors, excluding a known-noisy service:
severity >= warn and service != "vacuum-daemon" -
Lines carrying a specific attribute value (any OTLP attribute is addressable, promoted or not):
attr.decision == "deny" | range(-6h, now) -
Kubernetes-style resource lookup — one pod’s logs:
resource["k8s.pod.name"] == "checkout-7d4b9f-x2m8p" | range(-30m, now) -
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 -
Login-failure count per five-minute bucket — a rate over time:
template_id == 42 | range(-3h, now) | count by bucket(5m) -
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) -
Structured OTel events by name (RFC 0043/0044):
event_name == "gen_ai.client.inference.operation.details" | limit 50 -
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 LogQL | You 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 Insights | You write in the Ourios DSL |
|---|---|
fields @timestamp, @message | limit 20 | true | 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 service | true | count by service |
stats sum(cost_usd) by model | true | sum(attr.cost_usd) by attr.model |
sort @timestamp desc | limit 10 | true | sort ts desc | limit 10 |
parse @message "user * logged in from *" as user, ip | stats count(*) by user | template_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-agentcost 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_idor an attribute, notseverity. These events carryseverity_number 0(unset), soseverity >= tracesilently excludes them. Usetemplate_id == N(findNwithlist_templates) or a promoted attribute, or the match-alltrue. - 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.modelcolumn to group on. CLAUDE_CODE_ENABLE_TELEMETRY=1alone exports nothing — theOTEL_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.nameis producer metadata you can filter on, not the tenant). To scope to one run, promotesession.id(add it topromoted_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/avgaggregations.
Check a store for template-id collisions
Before v0.12.0, a receiver whose miner snapshot was discarded could rebuild its template-id counter below ids it had already published (#898). The next new template could then take an id that existing rows and audit events already bind to a different template. v0.12.0 (RFC 0059) stops this from happening again, but it does not repair a store where it already happened.
Run this check once on every store written by a release before
v0.12.0. A logged snapshot discard (the
ourios.receiver.snapshot.discarded event, e.g. unknown_version on an
upgrade or restore_failed) is the most visible trigger. A missing
snapshot after WAL frames were reclaimed, or a replaced local root, can
cause the same collision without logging a discard, so the absence of
that event is not evidence of a clean store. The check is read-only.
The continuous signal
The querier also reports collisions itself. Each time it folds
a tenant’s audit stream into the template map, it checks whether one
(template_id, version) is bound to two different templates. For every
such pair it finds, it emits:
- the
ourios.template_map.binding.conflictedlog event (WARN), carryingourios.tenant,ourios.template.idandourios.template.version. The event never carries the template texts, because they can contain user data. - the
ourios.template_map.lookup.anomaly = binding_conflictattribute on that acquisition’sourios.template_map.lookupsdata point. Alert on any increase of that series.
Two identical bindings, such as a re-emitted event, are not a conflict. Neither is a widened template (same id, new version), and neither is an adoption riding an existing leaf, because it restates the leaf’s template exactly. Detection changes nothing about what is served: rows still render against the last binding.
The check runs only when the querier folds the audit stream. A query
answered from a fresh cached template map (lookup outcome hit) does
not fold, so a tenant whose audit stream stops changing goes quiet once
its map is cached (#928). The next audit file the tenant writes triggers a
fold and the signal again.
The signal names the colliding pairs. It does not list the rows that render ambiguously, and it does not show the texts. For that, and for stores written before v0.12.0, use the queries below.
Historical damage assessment
The template map and list_templates keep the last binding per
(template_id, version), which hides the earlier one, and the drift
query never shows template text. So the check reads the Parquet files
directly with DuckDB.
Setup
Give DuckDB read access to the object store:
CREATE SECRET (TYPE s3, KEY_ID '…', SECRET '…', REGION '…',
ENDPOINT 'host:port', URL_STYLE 'path');
In every query below, replace s3://BUCKET/PREFIX with the store’s
bucket and storage.s3.prefix. For a local store, use the store root
path in place of the s3:// URL.
The queries
1. Within a tenant: one (template_id, version) bound to more than
one template text. This is the corrupting case: rows carrying that id
render against the wrong text.
SELECT tenant_id, template_id, new_version AS version,
count(DISTINCT new_template) AS texts,
list(DISTINCT new_template) AS templates,
min("timestamp") AS first_bound, max("timestamp") AS last_bound
FROM read_parquet('s3://BUCKET/PREFIX/audit/**/*.parquet', union_by_name = true)
WHERE event_type IN ('template_created', 'template_widened',
'template_type_expanded', 'template_adopted')
GROUP BY ALL
HAVING count(DISTINCT new_template) > 1
ORDER BY tenant_id, template_id, version;
2. Across tenants: one template_id created under more than one
tenant. This breaks RFC 0001 §6.1’s cluster-wide uniqueness, but it
doesn’t corrupt rendering, because each tenant’s registry is separate.
SELECT template_id, list(DISTINCT tenant_id) AS tenants
FROM read_parquet('s3://BUCKET/PREFIX/audit/**/*.parquet', union_by_name = true)
WHERE event_type IN ('template_created', 'template_adopted')
GROUP BY template_id
HAVING count(DISTINCT tenant_id) > 1
ORDER BY template_id;
3. The rows affected by query 1. These are the rows that render ambiguously.
WITH bound AS (
SELECT tenant_id, template_id, new_version AS version
FROM read_parquet('s3://BUCKET/PREFIX/audit/**/*.parquet', union_by_name = true)
WHERE event_type IN ('template_created', 'template_widened',
'template_type_expanded', 'template_adopted')
GROUP BY ALL
HAVING count(DISTINCT new_template) > 1
)
SELECT r.tenant_id, r.template_id, r.template_version,
count(*) AS rows, min(r.observed_time_unix_nano) AS first_observed,
max(r.observed_time_unix_nano) AS last_observed
FROM read_parquet('s3://BUCKET/PREFIX/data/**/*.parquet', union_by_name = true) r
JOIN bound b ON r.tenant_id = b.tenant_id AND r.template_id = b.template_id
AND r.template_version = b.version
GROUP BY ALL
ORDER BY ALL;
These queries were validated against the #898 reproduction, a discarded tenant re-minting after its WAL was reclaimed:
- query 1 reports the tenant’s colliding id with two texts;
- query 2 reports the id shared by two tenants;
- query 3 reports the affected rows.
Reading the results
- Query 1 is empty: no rendering was corrupted.
- Query 1 has rows: the listed
(tenant_id, template_id, version)binds more than one text. Rows carrying it (query 3) render against whichever binding the registry folds last. The other binding’s original lines are still available wherever the body was retained (CLAUDE.md §3.1 and §3.3), but rendered output for those rows is ambiguous. Record the affected time range fromfirst_observedandlast_observed, and open an issue if the data matters. - Only query 2 has rows: ids repeat across tenants, but rendering is correct.
Caveats
- An adoption that reuses an existing leaf restates the leaf’s template text byte for byte. The miner adopts onto a leaf only when the upstream template’s tokens equal the leaf’s, and both texts are written from the same tokens. So an adoption is not a false positive in query 1.
- The
data/**glob in query 3 can include files that compaction has replaced but not yet removed, so a row may be counted twice. - The audit scans in queries 1 and 2 (and query 3’s
boundCTE) must cover a tenant’s whole audit history: the two bindings of one id can be days apart, and a day-narrowed glob would see only one of them and report no collision. You may narrow the audit glob to a single tenant’s prefix (audit/tenant_id=<tenant>/**), never by day. Only thedata/**glob in query 3 may be narrowed to the days query 1 reported (first_boundtolast_boundand later).
OTLP log format — what crosses the wire vs. what Ourios consumes today
Historical — resolved. This is a dated investigation finding (2026-05-13), kept as the record of why the ingest contract changed; it does not describe the current architecture. Every gap it lists was closed: the RFC 0001 §6.1 amendment moved the miner onto the OTLP record shape, and the miner now ingests
&OtlpLogRecord(crates/ourios-miner/src/cluster/mod.rs), not a raw&str. RFC 0003 (OTLP receiver) and RFC 0018 (OTLP log-spec compliance) are bothaccepted. “Today” below means 2026-05-13.
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:
| Field | Type | Notes |
|---|---|---|
time_unix_nano | fixed64 | Event time at the source; 0 = unknown |
observed_time_unix_nano | fixed64 | When the collector saw it; required once observed |
severity_number | enum | Normalised TRACE..FATAL with sub-levels (1–24) |
severity_text | string | Source’s original level string |
body | AnyValue | The log content. Not necessarily a string. |
attributes | KeyValue[] | Per-occurrence structured context |
dropped_attributes_count | uint32 | Truncation indicator |
flags | fixed32 | Lower 8 bits = W3C trace flags |
trace_id | bytes (16) | Trace correlation |
span_id | bytes (8) | Span correlation |
event_name | string | Identifier 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:
tokenize(raw)splits on Unicode whitespace (crates/ourios-miner/src/tokenize.rs).mask(tokens)runs UUID / IPv4 / NUM rules over the resulting&strslice (crates/ourios-miner/src/mask.rs).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:
- 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.
- Treat structured Body as not-mineable. Store it verbatim
in the
body?column withlossy_flag = false(it’s an explicit structured value, not a lossy reconstruction); the miner emits atemplate_idof “structured body” and the query path knows to readbody?directly. Simpler, gives up templating for those records. - Mine inner string fields. If
bodyis akvlist_valuewith a"msg"field, minemsgas 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:
| Add | Type | Rationale |
|---|---|---|
time_unix_nano | u64 | B1 gate; required column |
observed_time_unix_nano | Option<u64> | OTLP has both |
severity_number | u8 | Operator queries; template key |
severity_text | Option<String> | Source’s original level |
attributes | KeyValue[] | The structured params we currently miss |
resource_attributes | KeyValue[] | service.name etc. |
scope_name | Option<String> | Template-key candidate |
scope_version | Option<String> | Diagnostic / drift detection |
trace_id | Option<[u8; 16]> | Trace correlation |
span_id | Option<[u8; 8]> | Trace correlation |
flags | u32 | W3C trace flags |
event_name | Option<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)? bodyrepresentation. AnyValue → what does the miner see? (Per §3.5 above.)tenant_idderivation. 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:
-
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. -
Draft RFC 0003 — OTLP receiver. Cover (a) the wire-decode layer (
tonicfor gRPC,axum/hyperfor HTTP/protobuf, against the officialopentelemetry-protocrate); (b) theOtlpLogRecord → MinerClustermapping; (c) thetenant_idderivation rule; (d) the WAL-before-ack sequencing under the new structured shape (§3.4); (e) build-vs-depend evaluation (tonic+ hand-roll vs. embedding therotelRust collector vs. running the OTel Collector out-of-process and forwarding). -
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
- OTLP
logs.proto: github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/logs/v1/logs.proto - OTLP
common.proto(AnyValue, KeyValue): github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/common/v1/common.proto - OpenTelemetry Logs Data Model spec: opentelemetry.io/docs/specs/otel/logs/data-model
- RFC 0001 §6.1 (current record schema):
docs/rfcs/0001-template-miner.md - Glossary entry OTLP (the load-bearing commitment):
docs/glossary.md
Last updated: 2026-05-13.
Hazards
Referenced from
CLAUDE.md§4 (“Before any change to the hot path, re-readdocs/hazards.md”) and §10 (“When in doubt: 1. Readdocs/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_flagis not set) / parse failure (< floor, retain body, increment counter).lossy_flagis reserved for the H7 case (genuine tokenizer / preprocessing failure wherereconstruct(record) != ingested_bytesis possible); it is not a low-confidence signal. Seedocs/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_totalcounter: spike on stable input → service-version change or threshold drift.body_retention_ratiogauge: rising → input shifted or threshold is too tight.confidence_p01histogram 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
bodycolumn; theparamsslot is replaced by a truncation marker (length + hash, no original payload). - Counter increments on overflow.
Detection.
params_overflow_ratioper 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, green — 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 guards it by observation, not truncation: the miner records
every structured body’s canonical-JSON byte length on the
ourios.miner.structured_body.size histogram (the RFC’s
structured_body_bytes), dimensioned by (tenant, service)
(crates/ourios-miner/src/metrics.rs; asserted by
crates/ourios-miner/tests/rfc0037_structured_body.rs). The per-service
alert on that distribution is an operator rule, not shipped in this
repo; RFC 0036’s write-side row-group/file sizing bounds 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 ofX. - 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_versionemits 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 = Xandtemplate_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 = trueon the row. There is no third option. - Reconstruction is a property test against the testdata
corpus: for every non-lossy row,
reconstruct(record) == ingested_bytesexactly. 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_ratiogauge: 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_receivedper 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 two amendments recorded at the bottom of this file — to
docs/rfcs/README.mdandCLAUDE.md— have been applied: the RFC process now carries the five-stage maturity model and the §5 Acceptance criteria section, andCLAUDE.mdhas §5.6 Verification process. Those sections are kept as the record of what changed and why.
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 <*>anduser 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.1is the first scenario defending hazard H1.§3.<n>.<m>— invariant-rooted;§3.4.2is the second scenario defendingCLAUDE.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.3for 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 applied
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:
| Stage | What exists | Gate to next |
|---|---|---|
| Drafted | RFC §§1–4 and §§7–8 filled; §§5–6 may be stubbed | Peer review of design |
| Specified | §5 acceptance criteria written, scenarios numbered | Review: do the criteria cover every invariant and hazard the RFC touches? Are they testable in principle? |
| Red | Test stubs compile, are tagged #[ignore], and fail with todo!() (or equivalent) when run | Implementation begins |
| Green | All §5 criteria pass; unit + property + corpus tests green | Validation against representative inputs |
| Validated | Thesis-gates in benchmarks.md §7 pass on representative corpora | Maintainer 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> -- --ignoredand watches thetodo!()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 testskips 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, andcargo test -- --include-ignoredexits 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:
- 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.
- No
refactorstage 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 was status: draft when this example
was written; it has since moved through every maturity stage and is
accepted. 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 <*>anduser 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
bodycolumn contains the original line bytes- And the row carries
lossy_flag = false(the flag is reserved for tokenizer / preprocessing failure perdocs/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 inCLAUDE.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.
Applied amendment — docs/rfcs/README.md
Historical record: the text below is the amendment as proposed; it has since been applied.
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:
- Acceptance criteria — normative scenarios, one per invariant or hazard the RFC touches. Format: structured prose with
Given / When / Then / Andleading clauses; each scenario carries an id of the formH1.1,§3.4.2, orRFC<NNNN>.<m>, referenced from the test code so the mapping is greppable. Seedocs/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:
- Drafted — PR opened with status
drafted. Sections §§1–4 and §§7–8 are filled. Discussion happens in PR review.- 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.
- Red — test stubs exist and fail. Implementation may begin.
- Green — all acceptance criteria pass; unit + property + corpus tests green.
- Validated — thesis-gates in
docs/benchmarks.md§7 pass on representative corpora. Maintainer flips status toaccepted.A regression detected after
Validatedeither reopens the RFC (if a criterion is invalidated) or spawns a tuning RFC perbenchmarks.md§7 (if a thesis-gate degrades). Seedocs/verification.md§3.
The earlier superseded and rejected entries remain unchanged.
Existing RFC frontmatter
RFC 0001 and RFC 0002 carried status: draft when this amendment was
proposed. The amendment PR renamed both to status: drafted so the
maturity model applies uniformly, with no content change to the RFCs
themselves at that step. Both have since reached accepted.
Applied amendment — CLAUDE.md
Historical record: the text below is the amendment as proposed; it has since been applied.
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.mddefines 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.
How the amendments were applied
The body of this document is the verification process spec. The two amendments above have been applied; this section records what the applying PR carried:
docs/rfcs/README.md—status:value list, new §5 Acceptance criteria in Required sections with renumbering, lifecycle rewrite,draft→draftedrename in RFC 0001 and 0002.CLAUDE.md— new §5.6 Verification process.
Both were planned to land in a single PR. RFC 0001 then got a §5 Acceptance criteria applied as the first concrete use of the process — the worked example in §6 of this document was the target shape, and applying it was expected to surface specificity gaps in RFC 0001’s existing design. That surfacing is the point.
This document is listed in docs/SUMMARY.md under the Architecture
header, as the amendment PR required.
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 registers no custom logical nodes or UDFs: the
DSL compiles to stock DataFusion expressions — resolves_to(<id>)
expands to a template_id IN (…) list over the alias set, and body
rendering happens after execution from the read-time template
registry (RFC 0017) — so DataFusion is treated 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 0001
§6.7).
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. See template_id: a u64 the miner allocates
from one cluster-wide counter in first-observation order, so it is
monotonic within each tenant and never shared across tenants
(RFC 0001 §6.1, “Template identity”). Not a hash of the template
string — RFC 0001 rejects content hashing as a tenant-isolation leak.
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 fromdocs/rfcs/0001-template-miner.md§8. Flat-file, living document, parallel todocs/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).
Current status (2026-09-27). The 2026-06-14 summary above still holds for the four thesis-gates, but predates two later programs. RFC 0031 (
accepted) added comparative gates against Grafana Loki, frozen in its §7: the first authoritative run passed all 11 frozen gate decisions (§9.24) and the post-RFC-0036 rerun showed no regression (§9.26). RFC 0034 (accepted) re-scoped D1 from a per-core rate to a per-node capacity on baseline hardware (§5), which the §9.23 asserting soak passes. §7 remains the thesis-gate summary; §9 is the dated record, newest entry last.
0. How to read this document
Every goal below carries two labels.
- Scope —
thesis-gate,tuning-goal, ordiagnostic.- A
thesis-gatefailing on representative corpora means a pillar (CLAUDE.md§2) is wrong. The response is an RFC, not a sprint. - A
tuning-goalfailing means the design is sound but the implementation needs work. The response is a PR. - A
diagnosticis measured and recorded but gates nothing — it characterises a property or guards against regression. A1 was reclassified here by RFC 0011 (accepted); see §2.
- A
- Bar —
must-win,should-win,stretch, orinformational.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— adiagnostic’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.ymldownloadsHDFS_v1.zipfrom the official Zenodo record (record 8196385, DOI10.5281/zenodo.8196385, md5-pinned in the workflow), uses the extractedHDFS.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 (thetestdata/corpus/README.mdanonymisation 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.”
- LogHub HDFS_v1 is the first of these wired in, as a
bench-time-fetched corpus for the query gates:
- Self-collected (deferred): at least one anonymised corpus per
target deployment archetype. Proposed set:
- Structured Java/Spring service (well-templated, low entropy).
- Go service under Kubernetes (heterogeneous, mid entropy).
- 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-winnumbers are quoted against this baseline; scaling to larger hardware is a separate question. The realised baseline (thebaseline-8vcpu-32gibhardware 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’svalidated. 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 tobytes(raw_corpus) / bytes(zstd_compressed_corpus). - Target: Ourios ratio ≥ 3× 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 -lon 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’srfc0007_1_*tests. Thecriterionbenchcrates/ourios-bench/benches/b1.rsadds the wall-clock ratio: ab1/syntheticgroup (controlled pruning instrument vs. an in-processzstdcat | grepreference) and ab1/real-corpusgroup (setOURIOS_B1_CORPUS_DIRSto 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.ymlonci-runner— indicative only. The authoritative numbers are thebaseline-8vcpu-32gibrun 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’srfc0007_2_*test — for a fixed result the scanned row groups + bytes stay flat as the corpus grows. Thecriterionbenchcrates/ourios-bench/benches/b2.rsadds the wall-clock view: ab2/syntheticgroup (result held constant, corpus scaled 1×/10×/50×) and ab2/real-corpusgroup over real corpora (setOURIOS_B2_CORPUS_DIRSto a comma-separated list of corpus dirs; skipped when unset, since the corpora aren’t committed). Both loader formats feed it: the OTLP/JSONcorpus/otel-demo-v*releases and the bench-time-fetched plain- text LogHub HDFS_v1 (§1). Run withcargo bench -p ourios-bench --bench b2. CI runs land via.github/workflows/query-bench.ymlonci-runner— indicative only. The authoritative numbers are thebaseline-8vcpu-32gibrun 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.nameon 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 2× 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
paramsslot 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 — theCLAUDE.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 Nwith N = cores) through one shared WAL/commit stream, with WAL fsync batched at 100 ms (theCLAUDE.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_idchanged 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:
| # | Goal | Failing means |
|---|---|---|
| A1 | Compression ≥ 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 |
| B1 | Predicate queries ≥ 10× faster than zstdcat | grep | Parquet statistics pillar not delivering |
| B2 | Template-exact queries scale with result size, not corpus size | Inverted-index-collapse thesis is wrong in practice |
| C1 | 100% bit-identical reconstruction on non-lossy rows | Honesty contract with user violated |
| C2 | Template count plateaus sub-linearly | Drain 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):
| corpus | size | ourios | zstd-19 | A1 delta |
|---|---|---|---|---|
| v1 | 30 MiB | 15.5× | 33.3× | 0.465 |
| v2 | 136 MiB | 21.5× | 32.3× | 0.666 |
| v3 | 272 MiB | 23.4× | 32.3× | 0.725 |
| v4 | 547 MiB | 24.6× | 32.4× | 0.758 |
Codec sweep (v4 = 547 MiB, ourios ZSTD level varied):
| ourios ZSTD | ourios | A1 delta |
|---|---|---|
| 3 (prod default) | 24.6× | 0.758 |
| 9 | 26.2× | 0.808 |
| 15 | 26.4× | 0.816 |
| 19 | 26.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).
| corpus | size | run | ourios | zstd-19 | A1 delta |
|---|---|---|---|---|---|
| v5 | 1,042,274,219 B | 27370641352 | 26.3× | 31.7× | 0.828 |
| v6 | 1,034,615,505 B | 27373716667 | 26.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:
| corpus | rows | RGs scanned | ourios bytes | reference bytes (zstd) | ourios | reference | speedup |
|---|---|---|---|---|---|---|---|
| v5 | 11 | 3/6 | 326,102 | 1,403,025 | 6.14 ms | 245.5 ms | 40.0× |
| v6 | 28 | 5/6 | 764,082 | 1,455,912 | 8.50 ms | 258.5 ms | 30.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).
| corpus | size | ourios | zstd-19 | A1 delta |
|---|---|---|---|---|
| v1 | 30 MiB | 14.6× | 33.3× | 0.439 |
| v2 | 136 MiB | 19.9× | 32.3× | 0.615 |
| v3 | 272 MiB | 21.4× | 32.3× | 0.665 |
| v4 | 547 MiB | 22.5× | 32.4× | 0.693 |
| v5 | 994 MiB | 23.8× | 31.7× | 0.751 |
| v6 | 987 MiB | 23.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).
| corpus | rows | RGs scanned | ourios bytes | reference bytes (zstd) | ourios | reference | speedup |
|---|---|---|---|---|---|---|---|
| v5 | 11 | 3/6 | 326,102 | 1,403,025 | 5.86 ms | 200.27 ms | 34.2× |
| v6 | 28 | 5/6 | 764,082 | 1,455,912 | 8.03 ms | 203.87 ms | 25.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):
| corpus | rows returned | RGs scanned | bytes | latency |
|---|---|---|---|---|
| v4 | 89,382 | 5/5 | 5,514,033 | 6.84 ms |
| v5 | 168,487 | 6/6 | 6,785,714 | 9.57 ms |
| v6 | 168,313 | 6/6 | 6,801,255 | 9.69 ms |
| hdfs-v1 | 1,723,232 | 14/14 | 16,523,421 | 30.19 ms |
Windowed 1-hour template-exact (the gate’s shape: result roughly constant as the corpus grows):
| corpus | corpus rows | rows returned | RGs scanned | bytes | latency |
|---|---|---|---|---|---|
| v4 | 735,377 | 12,854 | 1/5 | 1,674,718 | 4.39 ms |
| v5 | 1,367,532 | 17,632 | 1/6 | 1,857,999 | 5.07 ms |
| v6 | 1,360,040 | 11,750 | 1/6 | 1,592,279 | 4.19 ms |
| hdfs-v1 | 11,175,629 | 28,207 | 1/14 | 1,737,852 | 5.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.
| gate | result | verdict |
|---|---|---|
| A1 | ourios 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) |
| C1 | 1.000000 — 11,175,578 / 11,175,578 non-lossy rows bit-identical; lossy ratio 4.6e-06 (51 rows) | PASS |
| C2 | end 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.
| gate | result | verdict |
|---|---|---|
| C1 | 1.000000 — 11,175,578 / 11,175,578 non-lossy rows reconstruct bit-identically; lossy ratio 4.6e-06 (51 rows) | PASS |
| C2 | end 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.
| measure | result | verdict |
|---|---|---|
| D2 compaction throughput | 32 files (485.2 MiB) → 1 in 2.91 s = 166.8 MiB/s; 153,600 rows conserved | keeps up — single-partition / single-threaded, ≫ any per-partition seal rate, so the backlog drains |
| D3 small-file size band | output 456.7 MiB — IN the 256 MiB–2 GiB band; 0% of live files < 128 MiB (target < 5%) | PASS |
| B2-post query latency | template 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).
| bench | median | throughput |
|---|---|---|
wal_append/batch — OTLP→WAL append + fsync (the WAL-before-ack unit) | 372 µs | 10.5 MiB/s |
sink_write/1000 — WAL→Parquet emit + flush (RFC 0014) | 2.64 ms | 379 K rec/s |
sink_write/10000 | 12.24 ms | 817 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.
| corpus | A1 (ourios vs zstd-19 → delta) | C1 reconstruction | C2 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) — PASS | ratio 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) — PASS | ABSTAIN (< 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.
| bench | result | timing | pruning |
|---|---|---|---|
b1/synthetic | 2000 rows | ourios 2.93 ms vs zstd-grep ref 118 µs | pruned 1/2 row groups, read 7.8 KB |
b2/synthetic/{2k,20k,100k} | result held constant | 2.13 / 4.67 / 11.32 ms | sub-linear in corpus size |
b2/real-corpus/HDFS (template 1, ubiquitous) | 1.72 M rows | 30.8 ms | 14/14 row groups (no prune — template is everywhere) |
b2/real-corpus/HDFS windowed 1 h | 28,207 rows | 6.1 ms | 13/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.
| corpus | rows | RGs scanned | ourios bytes | reference bytes (zstd) | ourios | reference | speedup |
|---|---|---|---|---|---|---|---|
| v5 | 11 | 3/6 | 324,773 | 1,403,025 | 8.10 ms | 282.06 ms | 34.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.
| bench | result | timing | pruning |
|---|---|---|---|
b2/real-corpus/corpus/v4 (template 45) | 89,382 rows | 8.71 ms | 5/5 row groups (full span) |
b2/real-corpus/corpus/v5 (template 8) | 168,487 rows | 12.37 ms | 6/6 row groups (full span) |
b2/real-corpus/corpus-window-1h/v4 | 12,854 rows | 5.46 ms | 1/5 — 4 row groups pruned by the time window |
b2/real-corpus/corpus-window-1h/v5 | 17,632 rows | 6.71 ms | 1/6 — 5 row groups pruned by the time window |
b2/synthetic/{2k,20k,100k} | result held constant | 2.17 / 4.77 / 13.61 ms | sub-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):
| bench | result | timing | pruning |
|---|---|---|---|
b2/synthetic/{2k,20k,100k} | result held constant | 2.72 / 6.33 / 19.7 ms | sub-linear in corpus size |
b2/real-corpus (1.1 GiB subset) | windowed 1 h → 1 row | 6.31 ms | 5/6 row groups pruned |
b2/real-corpus (1.8 GiB subset) | template 56199 → 55,751 rows full-span; windowed 1 h | windowed 6.31 ms | 10/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.
| corpus | rows | RGs scanned | ourios bytes | reference bytes (zstd) | ourios | reference | speedup |
|---|---|---|---|---|---|---|---|
| HDFS_v2 | 24,030 | 54/80 | 19,284,044 | 548,344,798 | 116.76 ms | 13.545 s | 116× |
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).
| bench | result | timing | pruning |
|---|---|---|---|
b2/real-corpus windowed 1 h | 78 rows | 5.60 ms | 79/80 row groups pruned by the time window (21 partitions) |
b2/real-corpus full span | 56,234,257 rows | 124.70 ms | 80/80 scanned (count over the dominant class) |
b2/synthetic/{2k,20k,100k} | result held constant | 1.92 / 3.70 / 10.3 ms | sub-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:
| service | lines | end templates | C2 |
|---|---|---|---|
| cart | 2,756,331 | 2 | ratio 1.000 PASS |
| recommendation | 971,490 | 17 | abstain (< 1 M) |
| currency | 597,259 | 1 | abstain (< 1 M) |
| ad | 486,726 | 3 | abstain (< 1 M) |
| kafka | 136,790 | 14,608 | abstain (< 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:
| service | templates | 1 h-window query | row groups pruned |
|---|---|---|---|
| cart | 2 | 3.66 ms | 48 / 49 |
| kafka | 14,608 | 3.40 ms | 48 / 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.
(2026-09-27: the stock config promotes eighteen resource attributes
to stream labels. This corpus triggers three of them, which discriminate
it by service. See §9.31, which records the set and why no number below
changes.)
Hardware. ci-runner — indicative, 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:
| run | workflow run id | delta under test |
|---|---|---|
| #8 | 29171354194 | honest-metric baseline (§3.6 amendment wired) |
| #9 | 29174022848 | + single-pass count/materialize scan (#485) |
| #10 | 29174342843 | + late materialization (#486) |
| #11 | 29186113326 | L3 diagnostic: Loki 0-rows, pre-salvage panic — no counted numbers |
| #12 | 29188179299 | + L3 trace pair (#487/#488) |
| #13 | 29189430335 | L3 diagnostic recurrence (on the #489 branch): L3 timed out; the salvaged report’s other pairs are counted where tabulated |
| #14 | 29190408893 | + trace_id/span_id blooms (#489; pre-merge on the PR branch, since merged) |
| #15 | 29192897795 | + L1 template pair (#492; pre-merge, since merged) |
| #16 | 29199815903 | + 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) |
| #17 | 29203804795 | + selective-resource diagnostic pair, fixed picker (#493; pre-merge, since merged) |
| #18 | 29210202343 | + 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.
| run | ourios bytes | loki storage-side | loki processed | storage | processed |
|---|---|---|---|---|---|
| #15 | 1,358,683 | 104,825,428 | 2,468,065,726 | 77.2× | 1,816.5× |
| #16 | 1,358,683 | 105,191,956 | 2,469,772,352 | 77.4× | 1,817.8× |
| #17 | 1,358,683 | 105,579,510 | 2,474,713,321 | 77.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.
| run | ourios config | ourios bytes | loki storage-side | loki processed | storage | processed |
|---|---|---|---|---|---|---|
| #12 | no bloom — trace_id column scanned corpus-wide | 72,935,984 | 102,835,803 | 2,419,117,783 | 1.41× | 33.2× |
| #14 | + trace_id/span_id blooms (#489) | 4,812,668 | 105,353,837 | 2,476,749,585 | 21.9× | 514.6× |
| #15 | reproduction | 4,812,668 | 102,133,866 | 2,404,486,169 | 21.2× | 499.6× |
| #16 | reproduction | 4,812,668 | 104,656,570 | 2,456,853,969 | 21.7× | 510.5× |
| #17 | reproduction | 4,812,668 | 105,251,547 | 2,465,855,695 | 21.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):
| run | lever | ourios bytes (count + mat + reg) | loki storage | loki processed | storage | processed |
|---|---|---|---|---|---|---|
| #8 | baseline | 4,270,091 (609,498 + 3,146,731 + 513,862) | 2,880,784 | 89,184,711 | 0.67× | 20.9× |
| #9 | single-pass scan (#485) | 3,660,593 (0 + 3,146,731 + 513,862) | 3,158,323 | 98,114,703 | 0.86× | 26.8× |
| #10 | late materialization (#486) | 2,549,129 (0 + 2,035,267 + 513,862) | 2,751,834 | 85,261,718 | 1.08× | 33.4× |
| #12 | reproduction (no L2 delta) | 2,549,129 | 2,779,800 | 86,255,901 | 1.09× | 33.8× |
| #13 | reproduction | 2,549,129 | 3,349,897 | 98,253,343 | 1.31× | 38.5× |
| #14 | reproduction | 2,549,129 | 3,224,893 | 100,044,070 | 1.27× | 39.2× |
| #15 | reproduction | 2,549,129 | 2,688,942 | 83,216,895 | 1.05× | 32.6× |
| #16 | reproduction | 2,549,129 | 2,673,545 | 82,919,233 | 1.05× | 32.5× |
| #17 | reproduction | 2,549,129 | 3,224,528 | 100,198,466 | 1.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.
| run | pair | ourios bytes | loki storage-side | loki processed | storage ratio | processed ratio |
|---|---|---|---|---|---|---|
| #8 | k=100 | 5,094,790 | 16,250 | 63,595 | 0.003 fail | 0.012 fail |
| #8 | k=2000 | 9,736,285 | 72,524 | 1,809,523 | 0.007 fail | 0.186 fail |
| #10 | k=100 | 2,257,867 | 16,250 | 63,595 | 0.007 fail | 0.028 fail |
| #10 | k=2000 | 4,528,429 | 72,524 | 1,809,523 | 0.016 fail | 0.40 pass |
| #17 | “ad” k=100 (diagnostic) | 1,757,489 | 31,616 | 687,043 | 0.018 fail | 0.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):
| pair | ourios p50 | loki p50 | ratio (>1 = Ourios faster) |
|---|---|---|---|
| severity (1 row) | 82.0 ms | 875.0 ms | 10.7× |
| L3 trace (9 rows) | 74.6 ms | 24,101.9 ms | 323× |
| L1 template (2 rows) | 75.7 ms | 23,321.5 ms | 308× |
| window k=100 | 40.2 ms | 13.8 ms | 0.34 |
| window k=2000 | 85.9 ms | 294.8 ms | 3.43 |
| selective-resource k=100 | 38.8 ms | 51.2 ms | 1.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 viaingester.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 main — M_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.
- 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. M_L2stays frozen-deferred — §7’s unfreeze condition (the RFC 0033 warm measurement on the headline corpus) was not met.- The lever is an artifact encoding amendment (
format_version2, 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) | completeness | storage-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 }:
| gate | verdict |
|---|---|
| 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):
| measure | value | bar | verdict |
|---|---|---|---|
| sustained rate | 359,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 / max | 103.05 / 157.27 / 172.68 / 227.94 ms | p99 ≤ 200 ms | latency bar PASS |
| per-core rate | 24,999 lines/s/core (4 workers) | ≥ 100 000 lines/s/core (§D1) | D1 FAIL as normalized |
| D2 backlog | max 1 partition, 60 compactions over 185 samples, final 0 (returned to zero) | bounded, drains in-window | D2 PASS |
| WAL at last sample | 397 segments, 53,146,830,018 B | — | disk 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 load | achieved | ack p50 / p99 | D2 backlog |
|---|---|---|---|
| 200,000 lines/s | 86,132 lines/s | 5,951 / 6,262 ms | max 1 partition, drained — PASS |
| 400,000 lines/s | 85,879 lines/s | 5,968 / 6,309 ms | max 1 partition, drained — PASS |
| 800,000 lines/s | 85,911 lines/s | 5,972 / 6,319 ms | max 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).
| arm | node capacity | ack p50 / p99 (at saturation) | D2 |
|---|---|---|---|
| BEFORE (main) | 82,100 lines/s | 6,183 / 7,023 ms | PASS |
| AFTER (prototype) | 132,289 lines/s | 3,830 / 4,629 ms | PASS |
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.
| measure | value | bar | verdict |
|---|---|---|---|
| achieved rate | 99,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 / max | 95.68 / 117.00 / 153.63 / 723.70 ms | p99 ≤ 200 ms at the sustained rate | latency bar PASS |
| D2 backlog | max 8 partitions, 480 compactions over 116 samples, final 0 (returned to zero) | bounded, drains in-window | D2 PASS |
| per-core rate | 12,490 lines/s/core | diagnostic, 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. (The Loki stream labels this corpus triggers
are recorded in §9.31.) 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):
| pair | ourios bytes | loki storage / processed | gate verdicts | latency |
|---|---|---|---|---|
| L1 template (2 rows) | 1,032,727 | 101,021,242 / 2,397,510,168 | storage PRIMARY 97.82× pass; processed 2,321.5× context | 90.44× |
| L3 trace (9 rows) | 4,486,712 | 101,021,242 / 2,397,510,168 | storage PRIMARY 22.52× pass; processed 534.4× context | 77.99× |
| L2 severity (1 row) | 2,223,173 | 2,754,940 / 85,310,490 | processed PRIMARY 38.37× pass; storage floor 1.239× vs 11/10 pass | 3.77× |
| L4 frequency (1,197 rows) | 47,995,205 | 172,776,288 / 4,086,073,975 | processed PRIMARY 85.14× pass; storage floor 3.60× vs 11/10 pass | — |
| L6 window k=100 | 1,931,911 | 16,250 / 63,595 | latency floor 0.370 pass (≥ 1/3); storage-loss diagnostics published, not gated | 0.37 |
| L6 window k=2000 | 4,202,473 | 72,524 / 1,809,523 | latency floor 4.341 pass | 4.34 |
| selective-resource “ad” k=100 (diagnostic) | 1,431,533 | 31,657 / 239,072 | floor-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.
| measure | result (sorted, this run) | §9.7 reference (unsorted, baseline-8vcpu-32gib) | verdict |
|---|---|---|---|
| D2 compaction throughput | 32 files (452.9 MiB) → 1 in ~3.2 s = ~138 MiB/s (137–144 over three runs); 153,600 rows conserved | 166.8 MiB/s | keeps 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 band | output 452.7 MiB — IN the 256 MiB–2 GiB band; 0% of live files < 128 MiB (target < 5%) | 456.7 MiB, 0% < 128 MiB | PASS (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):
| gate | this 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.98 | 4.341 | PASS |
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.
| store | survivors / groups | materialization bytes | query scanned |
|---|---|---|---|
| before — one unsorted ingest file (128 MiB groups) | 1 / 1 | 100,520,155 | 1 (the whole file) |
| after — compacted, §3.1-sorted (32 MiB groups) | 2 / 6 | 70,018,075 | 2 |
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.
| threshold | compacted file (on disk) | row groups | window materialization (survivors / bytes) | scanned / pruned | window rows |
|---|---|---|---|---|---|
| 16 MiB | 65,376,242 B (62.35 MiB) | 5 | 1 / 15,370,891 B (14.66 MiB) | 1 / 4 | 3,000 |
| 32 MiB | 64,856,419 B (61.85 MiB) | 3 | 1 / 30,346,974 B (28.94 MiB) | 1 / 2 | 3,000 |
| 64 MiB | 64,856,419 B (61.85 MiB) | 3 | 1 / 30,346,974 B (28.94 MiB) | 1 / 2 | 3,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.
| store | survivors / groups | materialization bytes | query scanned / pruned | rows |
|---|---|---|---|---|
| before — single unsorted ingest file | 1 / 1 | 3,806,306 | 1 / 0 (whole file) | 10,110 |
| after — compacted, §3.1-sorted @ 2 MiB | 1 / 7 | 731,521 | 1 / 6 | 10,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.
| store | survivors / groups | materialization bytes | query scanned / pruned | rows |
|---|---|---|---|---|
| before — single unsorted ingest file | 1 / 1 | 3,812,581 | 1 / 0 (whole file) | 9,984 |
| after — compacted, §3.1-sorted @ adaptive (floored to 1 MiB) | 1 / 21 | 500,537 | 1 / 20 | 9,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).
9.31 Results — 2026-09-27 (in-repo, deterministic) — which Loki stream labels the corpus/otel-demo-v8 replay triggers (#800)
Purpose. RFC0031.10’s machine-check (rfc0031_10_loki_label_allowlist)
established that the comparative Loki config, stock grafana/loki:3.5.3
(digest sha256:3165cecce301…), promotes eighteen resource attributes
to stream labels (LOKI_LABEL_ALLOWLIST), not the one earlier prose
implied. What Loki indexes on a given run, though, depends on which of
those eighteen keys the corpus carries, and no §9 comparative entry
recorded that. This entry records it for corpus/otel-demo-v8, the
corpus §9.13, §9.15, §9.24 and §9.26 name and the comparative-bench.yml
dispatch default the other comparative entries (§9.14–§9.18) ran on. It is
a property of a frozen,
digest-pinned corpus, not a performance measurement, so hardware does not
apply.
Method. A full scan of the release asset logs.jsonl.gz from
corpus/otel-demo-v8 (sha256
c594448171b79a0e4c7272b08b94dd8656fc56ffaea6b56bea2bd4df5b99016c,
matching the release’s recorded digest): 690,355 LogsData lines, one
ResourceLogs each. Every resource attribute key was counted and every
allowlisted key’s values were tallied. As a cross-check, one real record
per distinct resource shape was pushed over OTLP into the digest-pinned
image running LOKI_DISPATCH_FLAGS. Loki’s /loki/api/v1/labels returned
exactly the three names below, and loki_ingester_streams_created_total
read 6.
Result — 3 of 18 allowlisted labels, 6 streams.
| Loki label | resource attribute | on ResourceLogs | distinct values |
|---|---|---|---|
service_name | service.name | 690,355 (all) | 5 (recommendation, ad, currency, cart, kafka) |
service_namespace | service.namespace | 690,355 (all) | 1 (opentelemetry-demo) |
service_instance_id | service.instance.id | 220,761 | 4 (one each on ad and cart, two on kafka) |
None of the fifteen other allowlisted keys (deployment.environment*,
cloud.*, k8s.*, container.name) appears anywhere in the capture.
The capture is not Kubernetes-attributed. The remaining keys it carries
(service.version, host.name, container.id, os.*, process.*,
telemetry.*) are outside the allowlist and are not promoted.
stream (service_name, service_instance_id) | ResourceLogs |
|---|---|
recommendation, — | 322,725 |
ad, one id | 162,823 |
currency, — | 146,869 |
cart, one id | 34,549 |
kafka, id 1 | 16,462 |
kafka, id 2 | 6,927 |
Reading — no recorded comparative number changes. Of the three
triggered labels, service_namespace is constant and service_instance_id
is determined by service_name everywhere except kafka, which it splits
into two streams. Loki’s index therefore discriminates the corpus exactly
as finely as service_name alone, plus that one kafka split. Every LogQL
selector the harness issues is either {service_name=~".+"} (L1, L3, L4:
all streams) or {service_name="<svc>"} (L2, the window browses, the
selective-resource diagnostic). No query can select on the other two
labels, so they give Loki no pruning the queries could use. The runs used
this stock config unchanged; #797 changed only the declaration of the
allowlist, not a Loki flag or the image. So every comparative ratio in
§9.13–§9.18, §9.24 and §9.26 stands. Read it as a comparison against a
Loki whose index discriminates this corpus by service, which is what the
stock config yields on this capture, not as a comparison against a
multi-dimensional Kubernetes-label index. A corpus carrying k8s.*
attributes would give Loki more streams and is a different measurement.
Recording going forward. The dispatch run now derives this inventory
on its corpus push pass and prints it as a loki stream labels: line in
the report block. It also writes the inventory to comparative-results.json
under loki_stream_labels, so each future run records which labels its
Loki had.
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-09-27 — a status reconciliation, not a new milestone. §3’s ladder is regenerated from every RFC’s
status:front matter, RFC 0001 through RFC 0057: the 2026-08-25 completed-backlog batch flip moved most of thegreenrows toaccepted, and RFC 0007 and RFC 0009 areaccepted, notvalidated. Since the prior entry, tenancy moved out of band (RFC 0046, superseding RFC 0045) and gained graph-fed visibility and its operational surfaces (RFC 0047, RFC 0048); upstream-derived templates (RFC 0050) and theourios-servingextraction (RFC 0051) landed; DataFusion 55 arrived (RFC 0021 phase 2a, #773). The open arc is WAL reclamation (RFC 0052,red, slices A–D1 merged), with backpressure, unwind safety, publication frontiers and audit durability drafted behind it (RFC 0053–0056).Prior entry — 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 →
greenin two days (#647–#653), closing with RFC0042.9 verified on live telemetry: the agent queried its own spend over MCP — thesum(attr.cost_usd) by attr.modelquery returned 35.28 USD across 10 requests from the typedFloat64column. 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 wentspecified→greenthe same date: build now, Perses first — the three plugins shipped in the dedicatedourios-perses-pluginrepository (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’slatestmatrix leg waits for the next server release (the first with typed columns); Grafana remains an ungated follow-up. The unreleased breaking changes onmainsince 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 byourios-df-otel— a crate carrying onlydatafusion+opentelemetry, kept extractable for upstream. RFC 0037 (GenAI / structured-event logs) isgreen. v0.5.0 shipped. §3’s ladder now covers RFC 0001 through RFC 0041. RFC 0041 (dashboard datasource plugins) wasdraftedwith §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
mainbehind 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 tovalidated(acceptedis 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-serverbinary + Helm chart), multi-tenancy-at-runtime is partially landed (auth + tenant binding shipped via RFC 0026accepted; 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 theL1/L3/L6classes 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 →
specified→red(first shipping-milestone spine;storemodule skeleton + §5 stubs landed); RFC 0009 (background compaction) flipped tovalidated(RFC0009.7 D2/D3/B2-post measured onbaseline-8vcpu-32gib, §9.7); RFC 0005 (Parquet storage) and RFC 0010 (audit-stream / drift queries) flipped togreen(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 toaccepted(maintainer sign-off). RFC 0001 reachedvalidatedfirst (C1/C2 pass authoritatively on thebenchmarks.md§1 baseline hardware, §9.6; A1 is diagnostic per RFC 0011); RFC 0008’svalidatedis 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 landedourios-parquetend-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.
| Gate | What it measures | Why it matters |
|---|---|---|
| B1 | Predicate-pushdown query latency on time/template/tenant filters | Pillar 1 (footer reads + min/max stats skip row groups) actually skips |
| B2 | Template-exact query latency (where template_id = X) | Pillar 2’s template_id column is a usable index, not a curiosity |
| C1 | Bit-identical reconstruction rate over the corpus | The hardest invariant (CLAUDE.md §3.3) holds in practice, not just in unit tests |
| C2 | Template-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-09-27)
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:
| Gate | Result | Source |
|---|---|---|
| B1 predicate-pushdown | PASS — 34.2× / 25.4× vs zstdcat | grep at ~1 GB, exact row-count agreement | §9.4 |
| B2 template-exact | PASS — windowed latency flat across 0.57→1.04 GB; flat on HDFS_v1 (11.2 M rows, 1/14 row groups) | §9.4 |
| C1 reconstruction | PASS — 1.000000 on HDFS_v1 (11.2 M lines, authoritative) | §9.6 |
| C2 template convergence | PASS — 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 (read from each RFC’s status: front matter on
main, 2026-09-27):
| RFC | Area | Status |
|---|---|---|
| 0001 | Template miner | accepted |
| 0002 | Query DSL | accepted |
| 0003 | OTLP receiver (gRPC + HTTP) | accepted |
| 0004 | Configuration policy | accepted |
| 0005 | Parquet storage | accepted |
| 0006 | Bench harness | accepted |
| 0007 | Querier (DataFusion + logs DSL) | accepted |
| 0008 | WAL | accepted |
| 0009 | Background compaction | accepted — RFC0009.7 D2/D3/B2-post measured authoritatively on baseline-8vcpu-32gib (benchmarks.md §9.7) |
| 0010 | Audit-stream / drift queries | accepted |
| 0011 | A1 re-scope | accepted |
| 0012 | meta: CLAUDE.md §2 pillar-#2 wording | accepted |
| 0013 | Object storage (S3-compatible) | accepted |
| 0014 | Ingest write path: record sink and flush policy | accepted |
| 0015 | Fuzzing harness: cargo-fuzz + ClusterFuzzLite CI | accepted |
| 0016 | Query-serving endpoint: HTTP query API over the logs DSL | accepted |
| 0017 | Read-time template registry & query-row rendering | accepted |
| 0018 | OTLP log-spec compliance amendments | accepted |
| 0019 | Storage-backend selection (local vs S3) | accepted |
| 0020 | Server configuration file (YAML + env-var substitution) | accepted |
| 0021 | Coordinated DataFusion / Arrow upgrade | green — phase 2a landed (DataFusion 55 / arrow 59 / parquet 59, #773); phase 2b (object_store 0.14) and dropping the collect_statistics = false row-group-pruning workaround wait on DataFusion 56 (draft #846), and the upstream fix does not yet cover the RFC 0044 body == shape |
| 0022 | Queryable attribute columns (RFC 0005 amendment) | accepted |
| 0023 | Bounded template memory (RFC 0001 amendment) | accepted |
| 0024 | OTLP-envelope property testing (RFC 0006 amendment) | accepted |
| 0025 | Absent-body representation (RFC 0005 amendment) | accepted |
| 0026 | Authentication and tenant binding (ingest + query) | accepted |
| 0027 | MCP query surface (agent-facing read tools) | accepted |
| 0028 | Build-feedback program (test-harness + workspace decomposition) | accepted |
| 0029 | OIDC bearer layer (issuer-agnostic, Dex-validated) | accepted |
| 0030 | TLS/mTLS on the data-plane listeners | accepted |
| 0031 | Comparative evaluation against Grafana Loki | accepted — the first fully authoritative run (benchmarks.md §9.24) passed all 11 frozen gate decisions; losses published per §5 |
| 0032 | Query-schema and cost-model resource for the MCP surface | accepted |
| 0033 | Cached template-map artifact | accepted |
| 0034 | D1 re-scope: per-node ingest-throughput bar | accepted — satisfied by the §9.20–§9.23 measurement series |
| 0035 | Ingest concurrency (ordered mining, concurrent encode/publish) | accepted |
| 0036 | Write-side layout (compaction-time service/time sort) | accepted |
| 0037 | GenAI / structured-event log support | green |
| 0038 | Self-tracing (OTel traces for Ourios itself) | accepted |
| 0039 | Inbound trace-context propagation | accepted |
| 0040 | DataFusion operator instrumentation | accepted |
| 0041 | Dashboard datasource plugins (Grafana / Perses) | accepted — Perses plugins shipped in ourios-perses-plugin; Grafana an ungated follow-up |
| 0042 | Typed numeric promotion (RFC 0022 amendment) | accepted |
| 0043 | Derive event_name from the legacy event.name attribute | accepted |
| 0044 | Template-aware body equality (body == two-arm compile) | accepted |
| 0045 | Operator-configured composite tenant derivation | superseded by RFC 0046 |
| 0046 | Out-of-band tenancy (the credential names the tenant) | accepted |
| 0047 | ReBAC resolver (OpenFGA) and graph-fed visibility | accepted |
| 0048 | Graph operational surfaces (tenant grammar, identity keys, erasure, backfill) | accepted |
| 0049 | Agent delegation and the RFC 8693 act claim | specified |
| 0050 | Upstream-derived templates (log.record.template) | accepted |
| 0051 | ourios-serving: shared serving infrastructure out of the ingest crate | green |
| 0052 | WAL reclamation and quiesce recovery | red — slices A1 (#820), A2 (#821), B (#823), C (#828) and D1 (#831, the publication barrier) merged; four slice-D stubs and slices E–F remain |
| 0053 | WAL backpressure | drafted |
| 0054 | Publish unwind safety | drafted |
| 0055 | Publication frontiers and tenant settlement | drafted |
| 0056 | Audit-sink durability on permanent write failure (RFC 0005 amendment) | drafted |
| 0057 | meta: move CLAUDE.md to AGENTS.md | drafted |
Crates — fourteen workspace crates, thirteen of them product crates
(ourios-core, -config, -miner, -wal, -parquet, -ingester,
-querier, -server, -serving, -bench, -semconv, -telemetry,
-df-otel; the fourteenth, -testgen, is dev-only):
ourios-miner— the Drain-derived miner, RFC 0001accepted:(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 0008accepted: 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_idbloom filter).ourios-ingester— RFC 0003accepted: the OTLP gRPC + HTTP receiver with WAL-before-ack, out-of-band tenancy (RFC 0046: the credential names the tenant, one export is one tenant), 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 / RFC 0002accepted: 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 0006accepted: drives the A1/B1/B2/C1/C2 measurements over OTLP-Demo + LogHub corpora, records results tobenchmarks.md§9, and (RFC 0031) runs the comparative dispatch against a real Loki container.ourios-df-otel— RFC 0040accepted: a post-hocExecutionPlan→ OTel span-tree walk, backdating each operator span from itsBaselineMetricstimestamps. Its runtime dependencies aredatafusionandopentelemetryalone — noourios-*crate among them — so it stays extractable as an upstream contribution.ourios-serving— RFC 0051green: the plumbing both roles share — bearer authentication and tenant binding, the OIDC and OpenFGA clients, listener TLS/mTLS, and inbound trace-context extraction — extracted fromourios-ingesterso the querier role no longer depends on the ingest crate.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 inourios-perses-plugin, the RFC0041.6 dashboard is committed in this repo and verified rendering. What remains is RFC0041.5’s recorded deferral (thelateste2e leg + wire-levelsum, unblocked by the next server release, tracked in the plugin repo) and the Grafana datasource as an ungated follow-up. RFC 0040 →— done (2026-07-28), alongside RFC 0041 and RFC 0042: all three flippedacceptedacceptedon maintainer sign-off.- WAL reclamation and its follow-ons — RFC 0052 is
redwith slices A1–D1 merged (#820, #821, #823, #828, #831); the remaining slice-D stubs, the timer/telemetry slice E and the crash/soak slice F are open. RFC 0053 (backpressure), RFC 0054 (publish unwind safety), RFC 0055 (publication frontiers) and RFC 0056 (audit durability) aredraftedand depend on it. - RFC 0049 (agent delegation, the
actclaim) isspecified; RFC 0057 (meta:CLAUDE.md → AGENTS.md) isdrafted. - Scattered §7/§9 open items on already-
green/acceptedRFCs (e.g. the recurring D1/D2 soak cadence now that the harness has shipped (§9.19/§9.23); RFC 0021’s phase 2b and thecollect_statisticspruning workaround, now waiting on DataFusion 56 (#846) since DataFusion 55 landed in #773; 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::ingestviasim_seq(replaces the exact-matchHashMapplaceholder). widenstep +template_widenedaudit emission + type-expansion +template_type_expandedaudit + 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_flagsemantics per §6.6. - Per-parameter byte-limit check +
OVERFLOWmarker + forced body retention. MinerCluster::ingestconsumes a structuredOtlpLogRecord(per RFC 0001 §6.1 as amended), not a raw&str. Thebody_kind = String/body_kind = Structuredfork 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.mdH4 — 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 OTLPLogsDatatest data into a stream ofOtlpLogRecords, hands them to the miner, writes Parquet, runs the A1/B1/B2/C1/C2 measurements, and reports numbers that go intobenchmarks.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 OTLPLogsData(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 (accepted; 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.
| Capability | Why deferred for MVP | Status |
|---|---|---|
Write-ahead log (ourios-wal) | Corpus replay is bounded and reproducible; durability is irrelevant for thesis-proving | Landed — 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 3 | Landed — RFC 0003 accepted: gRPC + HTTP receivers, WAL-before-ack; tenancy out of band per RFC 0046 |
| Snapshot mechanism (RFC 0001 §6.9) | Corpus runs from cold start; replay budget moot | Landed — part of RFC 0008 (accepted), v2 restore format |
| Full §6.8 telemetry surface | One or two metrics suffice for the bench; the §3.1.2 mandatory set is a production observability concern | Landed — OTel meters + OTLP metric exporter (RFC 0018 accepted); 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 gives request-scoped spans on ingest/query//mcp/sweep, RFC 0039 continues an inbound caller’s trace rather than starting a new one, and RFC 0040 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 UX | Landed — RFC 0002 accepted, 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 it | Partially landed — authentication + enforced tenant binding (RFC 0026), out-of-band tenancy where the credential names the tenant and the data never does (RFC 0046, superseding RFC 0045), ReBAC graph-fed visibility inside a tenant (RFC 0047), and the tenant id grammar, erasure and backfill surfaces (RFC 0048) are all 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 chart | Bench is a binary in ourios-bench; full deployment shape is shipping concern | Landed — 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” line | Landed (the plugin half) — RFC 0041 accepted (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
(accepted) 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:
- Summary — 3–5 sentences. The commitment, not the rationale.
- Motivation — why this change now, and why at this layer.
- Proposed design — precise enough that two engineers would produce the same implementation.
- Alternatives considered — one paragraph each. “I have not heard of it” is not acceptable.
- Acceptance criteria — normative scenarios, one per invariant
or hazard the RFC touches. Format: structured prose with
Given / When / Then / Andleading clauses; each scenario carries an id of the formH1.1,§3.4.2, orRFC<NNNN>.<m>, referenced from the test code so the mapping is greppable. Seedocs/verification.md§2. - Testing strategy — mapped to
CLAUDE.md§6.2; references the §5 scenario ids and names the technique (proptest, corpus,criterion) for each. - Open questions — everything unresolved, as a checklist.
- References — paper citations, related RFCs,
CLAUDE.mdsections 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.
- Drafted — PR opened with status
drafted. Sections §§1–4 and §§7–8 are filled. Discussion happens in PR review. - 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.
- Red — test stubs exist and fail. Implementation may begin.
- Green — all acceptance criteria pass; unit + property + corpus tests green.
- Validated — thesis-gates in
docs/benchmarks.md§7 pass on representative corpora. Maintainer flips status toaccepted.
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 perdocs/rfcs/README.md). Reachedvalidatedthe same day on the evidence below;acceptedrecords the maintainer’s final sign-off on the template-mining pillar. Thedocs/verification.md§3 /docs/rfcs/README.mdladder reservesvalidatedfor 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 — abovebenchmarks.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 thebenchmarks.md§1 baseline hardware: C11.000000, C2 a 40-template plateau (diagnostic local runbenchmarks.md§9.5, authoritativebaseline-8vcpu-32gibrerun §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
greenfirst (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:
acceptedis the maintainer’s final sign-off (docs/rfcs/README.md). NB the A1 re-scope’s own RFC (RFC 0011) is stilldrafted; 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 / Thenscenarios — the contract — grouped by parent (hazard, invariant, RFC-internal). §6 is the precise specification theourios-minercrate 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.mdsections 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 ofNtokens, 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 <*>anduser 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
bodycolumn 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_numberis part of the template key (no INFO/ERROR silent merge)
- Given two
OtlpLogRecords with identicalbody_kind = Stringbodies and identicalscope_name, butseverity_number = 9(INFO) andseverity_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_idcovering both severity buckets- (Operationalises the §6.1 Template-key composition commitment that
severity_numberis part of the key regardless ofbody_kind.)
Scenario H1.5 —
scope_nameis part of the template key (no cross-scope silent merge)
- Given two
OtlpLogRecords with identicalbody_kind = Stringbodies and identicalseverity_number, butscope_name = Some("lib.auth")andscope_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_idcovering both scopes- And a third record with
scope_name = Noneshares atemplate_idwith 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
Paramentry hastype_tag = OVERFLOWcarrying(length, sha256_prefix)instead of the original value- And the
bodycolumn contains the original line bytes regardless oflossy_flag- And
ourios.miner.params.overflow(attributesourios.tenant,ourios.service) increments
Scenario H2.2 — Per-service overflow rate above 1% raises an alert
- Given the
ourios.miner.params.overflow.utilizationgauge (attributesourios.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
iinto<*>- Then the leaf’s
template_versionbecomesV + 1- And an audit event with
event_type = template_widenedis 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
shasslot_types[s] = {NUM}- When an attach maps a typed parameter of
type = STRinto slots- Then
slot_types[s]becomes{NUM, STR}- And
template_versionincrements- And an audit event with
event_type = template_type_expandedis emitted naming the slot and the newly-addedParamType
Scenario H5.3 — Drift query returns templates that gained a version in window
- Given the
template_auditevent stream containstemplate_widenedandtemplate_type_expandedevents 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
rwherer.lossy_flag = false,reconstruct(r) == r.ingested_bytesholds 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_flagistrue- And the record’s
bodycolumn 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
bodycolumn 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 positionivia 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_bytesholds
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 generatedourios_semconvconstants) that the miner registers on theourios.minermeter when it is constructed (theourios.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-
paramline, 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 thereAmendment (2026-10-17, #1051, maintainer-approved) — per-tenant zero points. “No synthetic zero-traffic points” is replaced by §6.8’s per-tenant zero rule. The scenario gains:
- And after one clean line for a tenant the miner has not seen before,
ourios.miner.merges,ourios.miner.parse_failures,ourios.miner.params.overflowandourios.miner.template.version_changeseach carry a0point for that tenant, on exactly the attribute sets §6.8 names- And no zero point carries
ourios.service, and everyourios.miner.template_change/ourios.miner.parse_failure.reasonvalue on a zero point is a member of that registry enum- And a second line for the same tenant adds no zero point
- And a later real measurement with the identical attribute set (e.g. a merge, which never carries
ourios.service) counts on the series its zero point opened, while a measurement carryingourios.servicecounts on its own(tenant, service, …)series
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
256bytes
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 afterS- 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
Sreaches the miner (no double-apply — the v1 hazard that gated restore)- Amended by RFC 0059 (2026-10-05). Through the recovery driver, the equality holds up to an injective renaming of ids first minted in tail replay (RFC0059.9).
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 atX > S, and a WAL whose surviving segments start aboveSbut retain every frame aboveX(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 aboveX)- 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
Sand 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
MinerClusteringesting 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.mdE2.)
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_idfor that template differs from tenant B’stemplate_id- And no
template_idis 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
ExportLogsServiceRequestcarrying twoResourceLogswhoseResource.attributesresolve 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
LogRecordunderResourceLogs[0]is mined under tenant A- And every
LogRecordunderResourceLogs[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.countincrements 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_failuresincrements)- And an audit event with
event_type = template_widening_rejected_degeneraterecords 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,andother=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
simSeqagainst the best candidate is0.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
simSequnderthreshold = 0.5would yieldconfidence == 1.4(the ratio reframes scale-invariantly across tenants)
Scenario RFC0001.5 — Bare
template_id = Xspans 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_idis stable across widenings of one leaf)
Scenario RFC0001.6 — Bare
template_id = Xdoes 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 == Xare returned; rows withtemplate_id == Yare 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
Vwhere a single attach both introduces a new wildcard slot AND introduces a previously-unseenParamTypeinto 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_widenedevent for the new wildcard, immediately followed by atemplate_type_expandedevent 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.confidencehistogram for some(ourios.tenant, ourios.service)- When the miner’s meter is collected via an SDK in-memory reader
- Then
ourios.miner.confidence.p50andourios.miner.confidence.p01(attributesourios.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.p01become 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 = Structuredshort-circuits to a structured-template id
- Given an
OtlpLogRecordwhosebodyisBody::Structured(AnyValue)(any non-StringAnyValuevariant 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
bodycarries the Ourios canonical body encoding of thatAnyValue(per the §6.1 encoding rule)- And
paramsandseparatorsare empty- And
confidence == 1.0(the §6.1 sentinel)- And
lossy_flag == false
Scenario RFC0001.10 —
time_unix_nanois preserved verbatim from the wire
- Given an
OtlpLogRecordwithtime_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_000returns the row- (Gates
docs/benchmarks.mdB1 — time-range queries — by making the underlying column measurable.)
Scenario RFC0001.11 —
severity_number = 0andscope_name = Noneare distinct key buckets
- Given four
OtlpLogRecords with identicalbody_kind = Stringbody, 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 = Nonebucket with anyscope_name = Some(_)bucket- (Locks the §6.1 explicit edge-case rules:
0 = UNSPECIFIEDis 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
Twith two distinct leavesAandB(A < B) and no existing alias set- When an operator asserts
Bis an alias ofA- Then an
alias_assertedaudit event is durably recorded on the §6.4 stream under the §3.4 WAL-before-ack barrier before the assertion is acknowledged, namingtenant_id = T, the anchorrepresentative_id = A,member_ids = [B], theactor, and thetimestamp— 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 isA(the smallest member)
Scenario RFC0001.13 —
resolves_to(rep)returns all members and excludes non-members
- Given tenant
Twhose alias map records the set{A, B}(per RFC0001.12) and an unrelated leafCin no set- When the querier compiles
template_id.resolves_to(A)for tenantT- 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
T1whose alias map records{A, B}and tenantT2that has the sametemplate_idsAandBbut no alias assertion- When the querier compiles
template_id.resolves_to(A)once forT1and once forT2- Then for
T1it expands to{A, B}- And for
T2it 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
Twhose alias map records the equivalence class{A, B}(A < B, soAis the derived canonical)- When an operator retracts member
A— the canonical / smallest member — from the class- Then an
alias_retractedaudit event is durably recorded (same WAL-before-ack barrier and field shape as RFC0001.12) whose asserted set namesA(here asrepresentative_id, the operator’s anchor) plus an emptymember_ids, and theactor- And after the projection rebuilds,
Ais removed from the class, leaving{B}— a single member, which is no longer an alias set, soresolves_to(A)expands to exactly{A}andresolves_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
minof 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
Twith leafZand no alias assertion namingZ- When the querier compiles
template_id.resolves_to(Z)- Then the predicate expands to exactly
{Z}— identical to the base-member behaviour and to baretemplate_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 Drain | Ourios 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
LogRecordshape — the project’s stated ingest contract perdocs/glossary.md(entry OTLP: “we do not invent our own format”). The investigation that surfaced the gap isdocs/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): thebody.kindfork is at the top of the algorithm, the descent step incorporates the §6.1 template-key tuple, and theMinerCluster::ingestsignature now takes a structuredOtlpLogRecordrather 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:
| Field | Rust type (informal) | Source | Purpose |
|---|---|---|---|
tenant_id | TenantId | derived from Resource.attributes | Multi-tenant scoping [§3.7]; default rule below |
template_id | u64 | miner-allocated | Cluster-wide unique; see “Template identity” |
template_version | u32 | miner-allocated | Increments on widening; see “Template version” |
OTLP-derived columns (faithful to opentelemetry-proto):
| Field | Rust type (informal) | OTLP source | Purpose |
|---|---|---|---|
time_unix_nano | u64 | LogRecord.time_unix_nano | Event time at source; 0 = unknown. Required for thesis-gate B1 (time-range queries) |
observed_time_unix_nano | Option<u64> | LogRecord.observed_time_unix_nano | Collector observation time |
severity_number | u8 | LogRecord.severity_number | OTLP 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_text | Option<String> | LogRecord.severity_text | Source’s original severity string |
scope_name | Option<String> | InstrumentationScope.name | Library/module emitter; part of the template key (see below) |
scope_version | Option<String> | InstrumentationScope.version | Drift / debugging |
attributes | Vec<KeyValue> | LogRecord.attributes | Per-occurrence structured context |
dropped_attributes_count | u32 | LogRecord.dropped_attributes_count | Truncation indicator |
resource_attributes | Vec<KeyValue> | Resource.attributes | Source identity (service.name, host.*, etc.) |
trace_id | Option<[u8; 16]> | LogRecord.trace_id | Trace correlation |
span_id | Option<[u8; 8]> | LogRecord.span_id | Trace correlation |
flags | u32 | LogRecord.flags | Lower 8 bits = W3C trace flags |
event_name | Option<String> | LogRecord.event_name | Identifier 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_nanoParquet column —time_unix_nanowhen non-zero, elseobserved_time_unix_nano, else0— following the OTLP logs data model’s recommendation (“UseTimestampif it is present, otherwise useObservedTimestamp”). 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 wiretime_unix_nanois stored verbatim including0— 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:
| Field | Rust type (informal) | Source | Purpose |
|---|---|---|---|
body_kind | BodyKind | derived from LogRecord.body | Discriminator: String | Structured (see “Body representation”) |
body | Option<String> | LogRecord.body | UTF-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. |
params | Vec<Param> | from masking | One entry per <*> slot. Always empty when body_kind = Structured |
separators | Vec<Separator> | from tokenize | tokens.len() + 1 entries. Always empty when body_kind = Structured |
confidence | f32 | miner-derived | simSeq / threshold at attach time. 1.0 (sentinel) when body_kind = Structured |
lossy_flag | bool | miner-derived | True 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 }.ParamTypeis one ofIP, UUID, NUM, HEX, TS, PATH, STR, OVERFLOW.STRis 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);OVERFLOWcarries(length: u32, sha256_prefix: [u8; 8])instead of the original value (§6.5).params.len() == count(<*> in template), always (in thebody_kind = Stringbranch); §6.2 enforces this when a widening introduces new wildcard slots.Separatoris a small inline byte string (typically 1–3 bytes in practice). Encoding in Parquet is an implementation detail that does not affect this RFC.KeyValuemirrors the OTLPKeyValuemessage: akey: Stringand avalue: AnyValue.AnyValueis a discriminated union overstring | bool | int | double | bytes | array | kvlist. StoringAnyValuefaithfully in Parquet (rather than flattening to a string) is what keeps query expressions likeattributes["client.address"] = "10.0.0.1"typed.BodyKindis a two-variant enum (String,Structured) — not the fullAnyValuediscriminator. The body column carries the encodedAnyValuepayload;body_kindis 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 = String—LogRecord.bodyisAnyValue::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_flagare populated per the existing semantics.body_kind = Structured—LogRecord.bodyis any otherAnyValuevariant (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 thebodycolumn; no template is mined, noparams/separatorsare emitted.template_idis 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 onetemplate_id. The leaf the id points at carries theStructuredmarker and an emptybody_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 red →
green 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 RFCstatuschange here; the encoder isourios-core’sotlp::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
doubleprecision implicit, anddecode(encode(x))drifted 1–2 ULP for ~12% of arbitrary finitef64. Investigating #130 located the loss on the decode side, not the encoder: the emitter already produces shortest-round-trip digits (serde_json’s Ryuf64formatter —with-serdeadds no custom double formatter), butserde_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’sfloat_roundtripfeature, declared load-bearing inourios-core), so thef64round-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 asnull— bytes that do not decode back; that pre-existing gap is explicitly out of scope here and stays open. No RFCstatuschange.
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/uint64values as decimal strings (proto3 JSON’s canonical emit form; decoders accept a JSON number or string);doublevalues as JSON numbers in shortest-round-trip form, decoded with correctly-rounded float parsing —decode(encode(x))is bit-exact for every finitef64(#130; see the 2026-06-11 amendment above);bytesas base64;KvlistValueandArrayValueelement 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_jsonserialisation 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 onetemplate_id. This intentionally forfeits structured-body shape clustering — the rationale is that the structured-Body branch’s value comes from the faithful preservation ofattributesand the canonically-encodedbody, not from grouping similarAnyValueshapes. Operators who need shape-level clustering can opt into a futurebody_shape_fingerprintcolumn (a stable hash over theAnyValue’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_numberis part of the key becauseINFOandERRORversions 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 onetemplate_idwould surface either as the other on query, which is a[§3.1]“no silent merges” violation in disguise. The OTLP-spec-validseverity_number = 0(UNSPECIFIED) is a distinct key value, not coalesced with any specified severity.scope_nameis part of the key because the same body text emitted from two different instrumentation scopes (myapp.loginvsmyapp.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_attributesare NOT part of the key. They identify who sent the record (service, host, k8s pod), not what event was emitted. Thetenant_idderivation (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 samemyapp.logintemplate from two replicas ofservice.name = apiis the same template.event_nameis not in the key today but is reserved as a candidate addition. RFC 0001 stays at the OTLP-canonical severity+scope key; promotingevent_nameinto 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.”
Amended by RFC 0059 (2026-10-05, #898). Uniqueness is now a durable guarantee backed by a template-id high-water in object storage. Ids are monotonic per allocator only, not dense, and not monotonic across receiver replicas.
Amended by RFC 0059 (2026-10-06). “Never reused or reassigned” now has a boundary:
- an id still bound by stored data or audit is never reissued;
- an id whose data and audit retention or erasure removed may be reissued after an authorised re-bootstrap (RFC 0059 §3.7, RFC0059.19). Nothing stored binds it, so that is harmless.
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
LogRecordrather than a raw&str, in line with the §6.1 amendment. The algorithm now opens with thebody.kindfork from §6.1’s Body representation:AnyValue::Stringruns the Drain mining steps (the prior algorithm, preserved verbatim below); every otherAnyValuevariant 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 onMinerClusterbecomesingest(record: &OtlpLogRecord); pre-amendment callers wereingest(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_widenedortemplate_type_expandedaudit events, do not contribute toourios.miner.merges, and never carryparams/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
ParamTypeis not already in that slot’sslot_types[slot]set. Type expansion incrementstemplate_versionand emits atemplate_type_expandedaudit event (§6.4). - A single attach can trigger both wildcard-widening and
type-expansion in the same leaf; in that case
template_versionincrements twice and two audit events are emitted, in that order. - The leaf’s
template_versiononly increments on widening or type-expansion, not on a clean attach. Structured-Body leaves are never widened or type-expanded; theirtemplate_versionstays 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).bodyis retained unconditionally;lossy_flagfollows 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_failuresincrements; 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, attributesourios.tenant,ourios.service): increments per overflow.ourios.miner.params.overflow.utilization(gauge, attributesourios.tenant,ourios.service): rolling overflow rate. Alert at> 0.01per 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
Reconstructionsignal (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 (returnbodyverbatim, do not callreconstruct). The clean-path read-time template lookup (a registry mapping(template_id, template_version) → tokensat read time) is explicitly out of scope and deferred to the querier’s reader-materialisation story (RFC 0007). RFC 0001 staysspecified; 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_bytescap 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_idis stable across every widening of that leaf (§6.1, “Template identity”); onlytemplate_versionadvances. So a literal predicatewhere template_id = Xalready returns rows from every version of leaf X by construction — no alias resolution required. To pin to a single structural snapshot, querywhere (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 aswhere template_id.resolves_to(X); barewhere template_id = Xdoes 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_retractedaudit events, the per-tenant alias-map projection the querier reads, and §5 scenarios RFC0001.12–RFC0001.16. The RFC re-enters the ladder atspecifieduntil 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_ids — representative_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_retractedpersist asevent_kind4 / 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 thesemconv/registry/weaver registry alongside the compaction set (RFC 0009 §3.6) and consumed through the generatedourios-semconvconstants. The table below lists the registry names. Instrument kinds are unchanged from the original set; theconfidence.p50/confidence.p01gauges remain in-process views of theconfidencehistogram (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}.mdplus the weaver registry policies): counters drop the Prometheus_totalsuffix and take singular{annotation}units; the two fraction-of-total gauges use the conventional.utilizationsegment (unit1) rather than a bespoke.ratio; and the per-line elapsed-time histogram isourios.miner.duration(UCUMs), the conventional segment for a discrete operation’s elapsed time, notlatency.
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 lightweightopentelemetryAPI crate and resolve instruments throughglobal::meter("ourios.<subsystem>"). No SDK, no OTLP, no transport dependency in a library crate.Amendment (2026-10-12, #963, maintainer-approved) — versioned instrumentation scopes. Instrumented crates resolve instruments through
ourios_core::telemetry::meter("ourios.<subsystem>")(and spans throughourios_core::telemetry::tracer), a thin wrapper over the global provider that sets the workspace release as the scope version, so every series and span carriesotel.scope.version. A provider-bound site passesourios_core::telemetry::scope(..)tometer_with_scope/tracer_with_scope. The wrapper is API-only:ourios-coredepends on theopentelemetryAPI crate and no SDK, so the split above is preserved. The workspaceclippy.tomldisallows the unversionedglobal::meter/global::tracerandMeterProvider::meter/TracerProvider::tracer. -
A new
ourios-telemetrycrate owns the heavy deps — theopentelemetry_sdkandopentelemetry-otlpcrates (the upstream package names, underscore and hyphen respectively) plus the OTLP transport. It exposes aninit()that builds the OTLP pushMeterProvider(periodic-reader export, interval configurable), installs it as the process-global provider, and returns a guard whoseshutdown()flushes pending metrics on exit. The binary (ourios-server) callsinit()once at start-up; benches and integration tests call the same entry point or substitute an in-memory reader. Adding this crate extends theCLAUDE.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.
Amendment (2026-10-17, #1051, maintainer-approved) — per-tenant zero points. A counter that has seen no event exports nothing, so a dashboard could not tell “zero for this tenant” from “not wired”. The rule above is narrowed. Every exported point still carries only valid attribute values: no sentinel, and every
requiredattribute present.
- Trigger. The first time a
MinerClustersees a tenant, before that line’s own measurements, it records0once for that tenant on each of the four lazily-created counters:ourios.miner.merges,ourios.miner.parse_failures,ourios.miner.params.overflowandourios.miner.template.version_changes.
- “Sees” means any record the cluster ingests, WAL replay included.
- Each new cluster (a restart) records the points again. Adding
0to a cumulative counter changes no value.- Attribute sets.
ourios.tenant(required): the tenant.ourios.service(recommended) is omitted. A tenant’s services are open-ended, so the dimension has no natural first value. Omitting arecommendedattribute is valid, and the zero point is the tenant-level series. Lines without aservice.namealready count on that series.- A
requiredclosed-enum attribute cannot be omitted and must not take a sentinel, so the counter records one zero point per member of the registry enum:
ourios.miner.mergesgets one perourios.miner.template_changemember (template_widened,template_type_expanded);ourios.miner.parse_failuresgets one perourios.miner.parse_failure.reasonmember (below_floor,empty_line,tokenizer_failure,degenerate_widening,line_too_long,template_ceiling,id_reservation_failed).ourios.miner.params.overflowandourios.miner.template.version_changesrecord one point each, onourios.tenantalone.- Series. A zero point opens the tenant-level series for its attribute set, and only a measurement with the identical set adds to it. A real parse failure or overflow on a line that carries
ourios.servicecounts on its own(tenant, service, …)series, exactly as before. The zero point marks the tenant-level series live and is not merged with service series, so summing overourios.servicestill gives the tenant total.- Cost. The rule adds eleven series per tenant, a number fixed by the registry enums, not by traffic. The histograms and the observable gauges are unchanged: they already export on a tenant’s first line.
- Scope.
ourios.miner.upstream_template.processedand the alias counters (§6.7) are outside the rule, as they are outside §3.1.2.
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):
| Metric | Instrument kind | Attributes | Source invariant / hazard |
|---|---|---|---|
ourios.miner.template.count | gauge | tenant | [§3.1] |
ourios.miner.merges | counter | tenant, template_change | [§3.1], H1 |
ourios.miner.alias.assertions | counter | tenant | [§3.1], §6.7, H5 |
ourios.miner.alias.retractions | counter | tenant | [§3.1], §6.7, H5 |
ourios.miner.confidence | histogram | tenant, service | [§3.1], §6.3 |
ourios.miner.confidence.p50 | gauge | tenant, service | [§3.1] |
ourios.miner.confidence.p01 | gauge | tenant, service | [§3.1] |
ourios.miner.body_retention.utilization | gauge | tenant | [§3.1], [§3.3] |
ourios.miner.parse_failures | counter | tenant, service | [§3.1] |
ourios.miner.params.overflow | counter | tenant, service | [§3.2], H2 |
ourios.miner.params.overflow.utilization | gauge | tenant, service | [§3.2], H2 |
ourios.miner.template.version_changes | counter | tenant | [§3.5], H5 |
ourios.miner.duration | histogram | tenant | hot-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].
Amendment (2026-10-17, #1051, maintainer-approved) — buckets near 1.0. The default buckets are now
[0.1, 0.3, 0.5, 0.7, 0.9, 0.95, 0.99, 0.999, 1.0, 1.05, 1.2, 1.5, 2.0, +Inf]. The two new boundaries give finer resolution just under the §6.3 decision boundary at1.0, where(0.95, 1.0]used to be a single bucket. OTel buckets are upper-inclusive, so(0.999, 1.0]still holds every value in that interval,1.0included. A value of1.0is the decision boundary, not a flag for an exact template match, so the bucket is not read as an exact-match share.
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 leadingu8version 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’sreplaydelivers every surviving frame with its offset; the recovery driver suppresses per consumer — the Parquet path consumes only frames above the RFC 0008 checkpointX(below it they are already published), the miner only frames aboveS(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 stateS ≥ Xthe 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’sStoWal::housekeepingas a retain floor, so the WAL never unlinks a frame no snapshot has captured (RFC 0008 §6.7 — closes theS < Xtemplate-drift hole, hazard #5). Stale-snapshot fallback: if recovery nevertheless finds the WAL truncated pastS(external mutation — segment files manually unlinked fromwal_root; the floor prevents the gap arising internally), it restores the snapshot, replays the surviving frames aboveS, and emits a structured warning naming the gap. The data side is complete provided the truncation did not exceedX— legitimate housekeeping never unlinks a frame above the checkpoint, so everything missing is in Parquet; manual deletion beyondXwould 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.
Amended by RFC 0059 (2026-10-05, #898). The
template_idallocator draws only from blocks reserved in a durable template-id high-water in object storage. Every start reads that high-water before replay and allocates above it, and the first start bootstraps it from the data and audit footers.
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.
Amended by RFC 0052 §3.1 (snapshot format 2, #877). The recorded high-water mark is per tenant: the WAL offset of that tenant’s own last frame folded into its state, not the one mark of the cut that took the snapshot. An idle tenant keeps its older horizon across later snapshots. A version-1 artefact, whose mark had the old global meaning, takes the unknown-version path of step (3) below. The §3.5.4 stale-gap check reads an absent
Ssegment as explained when the WAL’sRECLAIMrecord shows the tenant reclaimed throughSor beyond (RFC 0052 §3.2).
Amendment 2026-09-30 (leaf route, snapshot format 3, #892). Each leaf record carries a required
wildcard_routed: the zero-based, strictly increasing prefix-path positions, each below the walk depthmin(prefix_depth, token count), at which the leaf’s node was reached through RFC 0023 §3.1’s wildcard child<*>rather than a keyed branch. Position 0 is the leaf’s first token, unlike §3.5’s 1-indexed token positions. Under that child, lines differ at the path position, so a leaf there can widen or type-expand at it, and restore cannot derive the route from the template. Without the field, restore rejected such a leaf when it carried a wildcard at a routed position that is not a singleton mask-emitted type, reading it as a malformed path tag; a snapshot containing one was discarded on the next start (#892). Filling a node alone triggers no rejection; the #892 rejection fires only for a leaf routed through<*>that then widened or type-expanded at that position. A routed leaf that kept a fixed token there is not rejected, but it can restore non-equivalently, as the next paragraph explains; that is why the format moves to version 3. Restore descends through<*>at the recorded positions and rejects a list that is not strictly increasing or holds a position at or past the walk depth; every other path position keeps the strict rule that a path-position wildcard is a singleton mask-emitted type.The field cannot default when absent, so it bumps the format to version 3 rather than following the additive precedent of RFC 0037’s
event_nameand RFC 0050’s provenance and association fields. A version-2 leaf that kept a fixed token where it was reached through<*>matches no strict-rule check, so an empty default would restore it as keyed. Restore re-inserts leaves intemplate_idorder, and an adopted-first, mined-second convergence can give such a leaf an older id than a keyed sibling, so the defaulted tree could fill the node differently and route another leaf through<*>, silently changing later ids (§3.5.3). The old route cannot be inferred. A payload without the field is therefore a decode error, and every version-2 artefact takes the unknown-version path of step (3): it is discarded once, the tenant full-replays the WAL that remains, and templates first seen in frames already reclaimed re-mint (hazard #5). Only the version-1 mark is still read for RFC 0052 §3.2’s legacy check. By maintainer decision there is no lenient migration: a root still on the RFC 0052 §3.2 legacy branch that wrote a version-2 artefact and restarted on 0.11.0 before its first version-2 checkpoint fails closed withLegacyStaleGap, because the discarded tenant has neither a restored horizon nor a version-1 mark.Restore rebuilds the tree from the snapshot, not from the history that built it, so §3.5.3’s equivalence also requires every choice live ingest makes to be a function of the tree. Three were not. §6.2 step 4 candidate selection broke similarity ties by leaf-list order. The RFC0050.6 convergence lookup took whichever leaf of a shared convergence key
(canonical, severity_number, scope_name)it reached first in map order. Its guard index was a set, so one of two leaves sharing a key widening away hid the other. Ties in both lookups now go to the lowesttemplate_id, and the index counts leaves per key. The format is unchanged by these. RFC 0050 carries the matching note at RFC0050.6.
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, the per-slot slot_types
of §6.1, and, from format version 3, the zero-based, strictly
increasing wildcard-routed path positions below the walk depth of the
2026-09-30 amendment above), 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:
- Load the latest snapshot artefact for the tenant, if one exists.
- 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 toS, apply the stale-snapshot fallback of the v2 amendment: restore, replay what survives, warn. - 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). -
proptestfor §6.6 reconstruction: for every generated line shape (length, separator distribution, masking outcome),reconstruct(mine(line)) == lineormine(line).lossy_flag == true. Property failure blocks merge. Covers: H7.1, H7.4, §3.3.1. -
Corpus tests on
testdata/corpus/(fixed, anonymised; seedocs/benchmarks.md§1): assert bounds onourios.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. Implementsdocs/benchmarks.mdE2. Covers: §3.7.1, §3.7.2. -
Per-
ResourceLogstenant derivation (miner-side stub): assert that when records carrying distinct derivedtenant_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 derivestenant_idperResourceLogs.resourcerather than perExportLogsServiceRequest— 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
OtlpLogRecordfixtures exercising the §6.1 Template-key composition tuple. Assert that varying onlyseverity_numberproduces distincttemplate_ids, varying onlyscope_nameproduces distincttemplate_ids, theseverity_number = 0(UNSPECIFIED) andscope_name = Noneedge buckets are each their own key value, andbody.kind != AnyValue::Stringshort-circuits per §6.2 step 0 with the §6.1 sentinelconfidence = 1.0,lossy_flag = false. Thetime_unix_nanoround-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 ≤Sreached 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 theourios.miner.confidence.p50/ourios.miner.confidence.p01gauges track the same-attributedourios.miner.confidencehistogram quantiles. Covers: §3.1.2, RFC0001.8.Amendment (2026-10-12, #963, maintainer-approved). The meter is resolved through
ourios_core::telemetry::meter("ourios.miner")(ormeter_with_scopewithourios_core::telemetry::scope(..)for a provider-bound reader), notglobal::meter, whichclippy.tomlnow disallows. The test also asserts the collected scope carriesotel.scope.versionequal to the crate version.Amendment (2026-10-17, #1051, maintainer-approved). Two further tests in
ourios-miner:- A collection after one clean line for a fresh tenant asserts the
per-tenant zero points of §6.8 (the four counters, their exact
attribute sets, no
ourios.service, enum members only). A second line and a second tenant check “once per tenant”, and a real merge (whose attribute set equals its zero point’s) checks that it counts on that zero series. - The unit test pinning
ourios.miner.confidence’s boundaries pins the amended list, and a recorded1.0lands in the(0.999, 1.0]bucket.
- A collection after one clean line for a fresh tenant asserts the
per-tenant zero points of §6.8 (the four counters, their exact
attribute sets, no
-
Data-model contract tests: small unit tests against the
template_idquery 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_assertedaudit event under the §3.4 barrier and that folding the event log produces the expected per-tenant alias map; thatresolves_toexpands by the set (representative or member) and to{X}for an un-aliased id; that a retraction emitsalias_retractedand drops membership on rebuild; and that an alias asserted in one tenant is invisible to another. Theresolves_toDSL 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 = truerow the reader returns thebodybytes verbatim (no in-band prefix/marker) carryingReconstruction::RetainedVerbatim, and does not callreconstruct(); for a faithful row it callsreconstruct()and carriesReconstruction::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
OVERFLOWmarker, 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, perdocs/benchmarks.mdD1). 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 store — RESOLVED: 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 cadence — RESOLVED: per WAL-segment
rotation. A snapshot is taken at segment-rotation boundaries
and records the WAL high-water mark (the
WalOffsetit was taken at). See §6.9. - Snapshot scope — RESOLVED: 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 replay — RESOLVED (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 fromd = 3ord = 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-ingesterRFC. - 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_retractedevents, 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’stemplate_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 byourios-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_auditbelongs to a futureourios-parquetRFC. The §6.7 drift query assumes the schema exposesevent_type,template_id,old_version,new_version, andtimestampas 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_fingerprintside 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.mdH1, H2, H5, H7.docs/benchmarks.mdC1, 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 gatedacceptedon 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 indocs/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, andresolves_toalias-set expansion via the RFC 0001 §6.7 operator alias map. §6 gives the design, §7 the grammar, §5 the criteria. Per thedocs/rfcs/README.mdladder,validatedand finallyaccepted(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 theparam(n)andbucket(width)aggregation group terms (grammar v1.1, a §6.6 minor version) and §5 gains RFC0002.12–.16 (aggregation execution). Thegreenabove refers to RFC0002.1–.11; the new scenarios are red (#[ignore]’d stubs, thedocs/verification.md§3 two-loop, landed incrates/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 viaresolves_to) are first-class (§6.3). - The compilation target is a DataFusion
LogicalPlan, no SQL leakage (§6.5).
3.4 Consequences
| Dimension | Branch A (borrow) | Branch B (distance) |
|---|---|---|
| Onboarding for Collector-literate SREs | Near-zero | Mild (new syntax, familiar semantics) |
| Onboarding for SDK / dashboard users | Same (OTel data model) | Same |
| Maintenance cost | Track pinned OTTL, amend on bumps | Own the grammar |
| Same-syntax/different-meaning confusion | Real | Avoided |
| Spec size | Smaller | Larger (owned) |
| Ecosystem signalling | Aligned with OTel | Independent (in the data model: still aligned) |
| Design freedom | Constrained by OTTL | Free 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:
- 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.
- 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
- Familiarity beats cleverness. A first-time reader understands a query within 30 seconds without a reference. No heavy sigils.
- No DataFusion/SQL leakage (
CLAUDE.md§4 hazard 6). If explaining a surface form requires naming a DataFusion type, the form is wrong. - Predicate, then pipeline. A query is a predicate (the
where) followed by ordered stages (range, aggregate, sort, limit, project). Each reads independently. - Template + OTel-canonical fields are first-class vocabulary, not
pseudo-columns:
template_id,confidence,lossy(drift-alias membership viaresolves_to);service,trace_id,span_id,scope(the primary correlation/query dimensions per OTel maintainer guidance, §6.2). - Every query has a time range — explicit
range(...)or a tenant-configurable default window. Never an unbounded scan. - 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.
- 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.
- 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 thedocs/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_idskips row groups (B1),time_unix_nanoprunes partitions and row groups,tenant_idprunes partition directories (not row groups); for the subset the currentourios_querierstructured request can express (template + time) the DSL result is identical to it. Severity compiles via the §6.2/RFC0002.5severity_numbermapping (the column is RFC 0005’sseverity_number), not theseverity_textequality the current request supports, and predicates over non-indexed fields (service,attr.*) compile to a correctFilterwith no row-group-pruning claim (indexedservice.namepushdown would be a future RFC 0005 §3.6 amendment).
- Given a Branch-B predicate (e.g.
-
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.
- Given a query with no
-
RFC0002.5 — Bare-identifier severity maps to its SeverityNumber
[§6.1]- Given
severity >= error(andwarn,info,debug,trace,fatal) - When compiled
- Then each maps, case-insensitively, to the §6.1
SeverityNumberfor 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.
- Given
-
RFC0002.6 — First-class OTel-canonical fields resolve correctly
[§6.2]- Given
service,trace_id,span_id,scopeused as bare fields - When compiled
- Then each resolves to the RFC 0001 §6.1 column / resource-attribute
it names (
service→resource["service.name"]), with no string-flattening required of the user.
- Given
-
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_toexpands to the alias-set membership of RFC 0001 §6.7), without leaking the underlying representation.
- Given
-
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 (todayourios-queriercompile::validaterejectscount/agg_fnstages as “not yet supported”) for thecountfamily. Per thedocs/verification.md§3 two-loop they are red (#[ignore]’d stubs incrates/ourios-querier/tests/it/rfc0002_dsl.rs) and turn green in the implementing slices; the status note’sgreenrefers to RFC0002.1–.11. Execution of thesum/min/max/avgstages 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 barecount(noby) - When the querier executes it
- Then the result is the
group_key → countmap (barecount: the single total) and equals a naive oracle computed outside the query path by filtering and counting the same rows — and thecountstage is no longer rejected bycompile::validate.
- Given a populated tenant store and a query
-
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 stagecount by param(0), bucket(5m)over rows of that template - When the querier executes it
- Then the result is the
(bucket, group_key) → countmap — 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 slot0— equal to a naive oracle, and shape-identical to the map RFC 0031 §3.5 / RFC0031.1 compares for L4 equivalence.
- Given a predicate that pins exactly one
-
RFC0002.14 —
param(n)misuse is a specific compile-time error[§6.3 amendment]- Given (i)
service == "api" | count by param(0)(notemplate_idpin), (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 aby-list (as a predicate path or aprojectfield) - 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_termis grammatically confined toby-lists (§7 v1.1) — citing the grammar. No query reaches execution.
- Given (i)
-
RFC0002.15 — short/NULL params rows are excluded and tallied
[§6.3 amendment]- Given rows of the pinned template whose
paramslist is shorter thann + 1(or whose slotnvalue is NULL) alongside rows carrying slotn - 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
QueryStatsfield, surfaced on the RFC 0016 query-metrics path) so the exclusion is observable, not silent.
- Given rows of the pinned template whose
-
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, andparamsiff aparam(n)term is present — and neverbody/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.
- Given an L4-shaped query (
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 liftcompile::validate’s rejection of theagg_fnstages for scalar aggregation over a promoted attribute — a log attribute (attr.<k>) or a resource attribute (resource.<k>). The value is read bytry_casttoFloat64(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(notcast, 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-templateparam(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 (noby) - When the querier executes it
- Then the result is the
group_key → valuemap (bare: the single scalar over all matching rows), eachvaluethe<fn>of the row values parsed asFloat64, equal to a naive oracle computed outside the query path over the same rows; each group also carries itsCOUNT(*), and theagg_fnstage is no longer rejected bycompile::validate.
- Given a populated tenant store,
-
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_casttoFloat64yields NULL and the aggregate skips NULLs); the returnedvalueequals the oracle over the numeric rows alone, and a group all of whose values are non-numeric carriesvalue = null. The query never fails on dirty data, and those rows still count toward the group’sCOUNT(*)and the query total (matching a plain count). A non-finite scalar result —NaN/±inffrom a crafted"NaN"/"inf"input or fromsumoverflow — is likewise degraded tovalue = null: JSON has no representation for it, so surfacing it would fail serialization and 500 the whole query.
- Given rows of the grouped set whose promoted
-
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_attributessublist 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.
- Given (i)
-
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/queryand the MCPquery_logssurfaces - When the response is serialized
- Then each group carries
{key, count, value}withvaluethe scalar (a JSON number), and a barecountquery omitsvalue; the two surfaces answer identically (the MCP adapter adds only the protocol), andcount/aggregate queries continue to reject a trailinglimit(group-limiting is still unimplemented).
- Given a
§6.1 amendment (2026-07-25) — unspecified severity in ordering comparisons.
SeverityNumber = 0is the OTel logs data model’s unspecified severity, and real sources emit it (Claude Code’s GenAI events, ETWLOG_ALWAYS, Google CloudDEFAULT). Ourios previously compiledseverityordering to a bare numeric comparison, so a minimum-severity floor such asseverity >= traceexcluded those records — the inverse of the OTel Logs SDK, whoseminimum_severitydrops 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 toSeverityNumber=0when 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_numberis0(unspecified) alongside rows with specified severities - When a floor (
>=/>) is applied with a threshold above0 - Then the unspecified rows match — aligning with the OTel Logs
SDK’s
minimum_severity, which unspecified records bypass - And a ceiling (
</<=) with a threshold above0excludes them, so a predicate and its negation still partition the rows (without this,0 < 17would make an unspecified row match both>= errorand< error) - And an explicit threshold of
0keeps ordinary numeric semantics, soseverity > 0still 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
0is 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
- Given rows whose
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 OTelAnyValue: 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 structuredbodyvsattr.*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 formresource["..."]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"); bracketedattr["..."]for the same non-bare-identifier cases. - Severity:
severitycompares against a bare severity name (severity >= error), case-insensitive, or a numeric form (severity >= 17). All severity comparisons — including ordering (</<=/>/>=) — are defined on the OTelSeverityNumber, never on the free-formseverity_text(per the OTel comparing severity guidance). Bare names map to the floor of the matching OTelSeverityNumberrange:trace→1,debug→5,info→9,warn→13,error→17,fatal→21. The spec standardises the ranges and says to compare onSeverityNumber; 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:
| Surface | Resolves to (RFC 0001 §6.1) |
|---|---|
service | resource["service.name"] |
trace_id, span_id | the dedicated columns (log↔trace correlation) |
scope | scope_name |
severity | severity_number (via the §6.1 mapping) |
ts | time_unix_nano (the verbatim event timestamp) |
observed_ts | observed_time_unix_nano |
Amendment 2026-06-11 —
range(...)filters the effective timestamp. This table previously noted thatts/time_unix_nanois “whatrange(...)filters”. Per RFC 0005 §3.2 (amendment of the same date), the time window shall compile against the derivedeffective_time_unix_nanocolumn —time_unix_nanowhen non-zero, elseobserved_time_unix_nano.unwrap_or(0)(RFC 0005 §3.2 is the normative derivation; a record with neither timestamp stays at0). The implementing slice follows this amendment; until it lands, the querier filterstime_unix_nanodirectly. 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 “UseTimestampif it is present, otherwise useObservedTimestamp” recommendation) addressable by time. The baretsfield is unchanged — it still resolves totime_unix_nano, the verbatim wire value (RFC 0001 scenario RFC0001.10). For files written before the column existed the window applieseffective := 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-open —range(from, to)selectsfrom <= effective < to. The half-open shape is what the querier already implements today (overtime_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 thetemplate_idcolumn.resolves_to(42)—Xplus its drift aliases (the RFC 0001 §6.7 drift question); compiles to alias-set membership overtemplate_id.confidence— miner confidence (e.g.< 0.7); theconfidencecolumn.lossy— the lossy-reconstruction flag; resolves to the RFC 0001 / RFC 0005lossy_flagcolumn (lossy == true).render(pipe stage, §6.5) reconstructs the original line, honouringlossy.
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)andbucket(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: “columnarGROUP BYontemplate_id+ a typed param column”. The DSL had no way to say that; this amendment adds it, discharging the §9params[N]open item on its positional half. Two group terms join theby-list of thecountandagg_fnstages (grammar delta in the §7 amendment). They are not general fields:project, predicates, andsortdo not admit them — the §7 v1.1group_termproduction confines them grammatically, so use outside aby-list is a parse error (RFC0002.14).
param(n)— the template’s parameter slotn, zero-based, addressing the positional RFC 0005 §3.2paramslist (List<Struct{type_tag, value}>; RFC 0001 §6.1). Valid only when the query’s predicate pins exactly onetemplate_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 onetemplate_id == <N>comparison, and all such comparisons must name the sameN. Atemplate_id == Nunderor/notpins 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
valuebytes of slotnas UTF-8 (ourios_core::record::Param.value). The storedtype_tagis 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
paramslist is shorter thann + 1, or whose slot-nvalue 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) → countmaps 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 pinnedtemplate_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 sameby-list (e.g.count by param(0), bucket(5m)), and freely withoutparam(n)(e.g.count by service, bucket(1h)— no pinning requirement of its own).widthis the existing §7durationlexical form (30s,5m,1h,1d,1w); it must be positive (bucket(0s)is a compile-time error). It buckets the effective timestamp — the same derivedeffective_time_unix_nanocolumnrange(...)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, sobucket(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). Aby-list admits at most onebucket(...)term and at most oneparam(n)pern; duplicates are a compile-time error. (The RFC 0031 harness pins the LogQLstep/startto 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 theby-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 10A 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, thenowkeyword, or an RFC 3339 timestamp — the §7timeform; defaults per §4 P5),count [by <field, …>](comma-separated, per the §7field_list) and other aggregations (sum,min,max,avgover a path),sort <field-or-aggregate> [asc|desc](the §7sort_key— a field or an aggregate output likecount),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>, … ] }(stagesoptional, default[]). Afieldis 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"). Anopis a §7cmp_opstring ("==",">=","=~", …); avalueis 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": [ … ] }whoseargsfollow the §7 typed signatures —matches/contains/starts_with/ends_withtake[ <field>, <string> ](<field>as above),resolves_totakes[ <number> ], a constant node{ "const": true | false }(the §7bool_litmatch-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 existingourios_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 construct | DataFusion logical node |
|---|---|
implicit from logs | TableScan on the tenant’s log table |
predicate / range | Filter (range → time-column predicate) |
count / aggregations | Aggregate |
sort | Sort |
limit | Limit |
project | Projection |
render | custom 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 theparamscolumn andbucket(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 theAggregatenode. No new logical node — the “onlyrenderandresolves_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
$idmajor (…/structured-query/v1.json) is unchanged — the additions are backward-compatible (byarrays accept two new object forms). Extendingstructured_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_listwith agroup_listand 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_listremains as-is forproject;durationandintegerare the existing productions. Semantics — the single-template pinning rule forparam(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_querierstructured 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-
acceptedvalidation. Resolved (2026-08-25). The artifacts aredocs/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” onrender, an ambiguous “anchored” onmatches, and a preference for!=overnot … ==— 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 forspecified.) -
OTel ecosystem alignmentResolved: 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;severityordering onSeverityNumber). -
--sqladvanced-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).
-
Resolved in part (amendment 2026-07-15): positional access is specified as theparams[N]positional access vs named parameters via the template schema.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, whichparam(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) andCLAUDE.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 indocs/benchmarks.md§7; elsewherevalidatedis 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 / Thenscenarios — 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.mdsections 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_idis derived perResourceLogs(RFC 0001 §6.1 Tenant derivation) and the batch fans out into per-tenant streams.body.kind = Structuredrecords have theirAnyValuebody carried verbatim intoBody::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-tripstored_bytes ↔ AnyValueper RFC 0001 §6.1 Body representation makes thelossy_flag = falsepromise 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, methodExport. Wire encoding: protobuf over HTTP/2. - OTLP/HTTP — POST against the
/v1/logspath. Wire encoding chosen by the client per theContent-Typeheader:application/x-protobuf(recommended by the spec; the same protobuf message as gRPC)application/json(the proto3 JSON mapping with OTLP overrides — hextrace_id/span_id, base64bytes)
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
Receiverwired to a realWal(opened with defaults) and a single OTLPExportLogsServiceRequestcarrying ≥ 1LogRecord- When the receiver runs its accept path
- Then the transport-level success response (gRPC
OK/ HTTP 2xx) is emitted only after theWal::synccall covering the batch’s frame returnsOk(_)— measured by anAtomicBoolset aftersyncreturnsOk(_)(mirroring RFC0008.1; the probe insidesyncwould already be true mid-call). The response-writer asserts the flag istruebefore sending- And the pre-sync points (
decode,tenant_derive,body_materialise,append) all observe the flag asfalse- And the WAL contains a single
FrameKind::OtlpBatchframe (per RFC 0008 §3.2 + §6.2.3) whose payload decodes (viaprost) to the inputExportLogsServiceRequest— verified post-response by shutting down the receiver (which drops itsWalhandle, per RFC 0008 §3.1’s single-writer architecture — enforced bycrates/ourios-wal/src/lib.rs:162) and then opening a secondWalto replay viaWal::replay, asserting one new frame whose payload round-trips viaprostto the input request. ThisAndis a content/existence check; the before-the-ack ordering is established by theAtomicBoolprobe in the precedingThen+Andclauses, 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::ingestand accepted before the ack fires (an instrumentedMinerClusterstub records eachingestcall; 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 aSIGKILLinjected afterWal::syncreturnsOk(_)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::replayruns, and the client retries the timed-out export- Then the post-restart WAL contains the
OtlpBatchframe the killed process had fsync’d before the kill — its payload decodes (viaprost) to anExportLogsServiceRequestsemantically 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 secondAndbelow for why)- And the client’s retry is accepted and produces a second
OtlpBatchframe whose payload decodes to anExportLogsServiceRequestsemantically equivalent to the first (sameresource_logsafterprostdecode — 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
ResourceLogsgroups R_A (service.name = "svc-a") and R_B (service.name = "svc-b"), and an operator-configured tenant-derivation rule keyed onservice.name- When the receiver processes the batch
- Then the
(tenant_id, OtlpLogRecord)pairs accepted by an instrumentedMinerClusterstub fortenant_id_aare 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’sresource_attributesreflects 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.resourcelacks 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 failingResourceLogsindex and the missing attribute key- And no
OtlpBatchframe from the batch is appended to the WAL (asserted by shutting down the receiver — dropping its singleWalhandle per RFC 0008 §3.1’s single-writer architecture (enforced bycrates/ourios-wal/src/lib.rs:162) — and then opening a secondWaland observing viaWal::replaythat 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
ExportLogsServiceRequestprotobuf payload- When the payload is decoded via the
tonicgRPC handler and via theaxumHTTP handler withContent-Type: application/x-protobufindependently- Then the two resulting in-memory
ExportLogsServiceRequestvalues are structurally equal (PartialEq), and every derivedOtlpLogRecordfrom each path is field-for-field equal — includingbody,attributes,resource_attributes,trace_id,span_id, anddropped_attributes_count
Scenario RFC0003.6 — HTTP/JSON ↔ gRPC/protobuf equivalence with OTLP-JSON encoding rules
- Given an
ExportLogsServiceRequestcarrying non-trivialtrace_idandspan_idbytes, a record with abytes-typedAnyValueattribute, and at least one record whoseseverity_numberexercises a non-default enum value- When the payload is serialised as gRPC + protobuf and as HTTP +
application/jsonper the OTLP-JSON mapping (hex-encodedtraceId/spanId, base64-encodedbytes, integer-encoded enums, lowerCamelCase field names) and each is independently decoded by the receiver- Then the two derived
OtlpLogRecordsequences are equal at theAnyValuetree 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::Structuredcarries the decodedAnyValueverbatim
- Given a
LogRecordwhosebodyis anAnyValueof a non-string_valuevariant (kvlist_value,array_value,int_value,double_value,bool_value, orbytes_value)- When the receiver materialises the record via
ourios_core::otlp::Body::from_any_valuefrom either transport- Then the resulting
OtlpLogRecord.bodyisSome(Body::Structured(av))whereavis structurally equal to the wire’sAnyValue(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
AnyValuelevel
Scenario RFC0003.8 —
Body::Stringreaches the miner as the unwrappedL_raw
- Given a
LogRecordwhosebodyisAnyValue { value: Some(string_value(s)) }- When the receiver materialises the record
- Then the resulting
OtlpLogRecord.bodyisSome(Body::String(s))wheresis the original UTF-8 string (no wrapping, no quoting, no escaping)- And the value handed to
MinerCluster::ingestequalssbyte-for-byte (asserted by an instrumentedMinerClusterstub that records eachingestcall’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
LogRecordwithseverity_number = 0(UNSPECIFIED), noscope_nameon its enclosingInstrumentationScope, andobserved_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
OtlpLogRecordcarriesseverity_number = 0(kept as0becauseUNSPECIFIEDis an explicit OTLP value per RFC 0001 §6.1, not absence),scope_name = None, andobserved_time_unix_nano = None— the receiver applies the wire-0→Nonerule forobserved_time_unix_nanospecifically (theOption<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::ingestwithout rejection, coalescing, substitution, or any downcast to a “default” value
Scenario RFC0003.10 —
dropped_attributes_countpreserved verbatim
- Given a
LogRecordwhosedropped_attributes_countis42on the wire- When the receiver materialises the record
- Then the resulting
OtlpLogRecord.dropped_attributes_countis exactly42- 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
POSTto 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/CANCELLEDas 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
OtlpBatchframe 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
ExportLogsServiceRequestreturns success without WAL write
- Given an
ExportLogsServiceRequestthat carries zeroLogRecords — covered shapes are (i)resource_logsempty, (ii)resource_logs[i].scope_logsempty for everyi, (iii) everyresource_logs[i].scope_logs[j].log_recordsempty. 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
ExportLogsServiceResponsewithpartial_successunset (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 theWalhandle that countsappendandsynccalls), and no record reachesMinerCluster::ingest
Scenario RFC0003.13 — Compression over HTTP: identity and gzip MUST be supported
- Given an HTTP request whose body is the byte-equal
ExportLogsServiceRequestpayload of RFC0003.5, transported withContent-Encoding: identity(or absent) and withContent-Encoding: gzipindependently- When the receiver processes each request
- Then the two derived
OtlpLogRecordsequences 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;zstdsupport is deferred per §9
Scenario RFC0003.14 — Default
/v1/logspath with configurable override
- Given the HTTP listener bound with the default path configuration
- When a
POSTarrives at/v1/logs- Then the receiver handles it via the OTLP/HTTP code path defined in §6.2
- And a
POSTto 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/logsas the accepted path without changing any other receiver behaviour (the configurability matches the Collector’s OTLP-receiverpathknob, so deployments that need a non-standard prefix don’t have to front Ourios with a reverse proxy)
Scenario RFC0003.15 — Concurrent
Exportcalls each obey WAL-before-ack independently[§3.4]
- Given N ≥ 2 concurrent gRPC
Exportunary 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::syncreturnsOk(_)and its own batch’s records have all been accepted byMinerCluster::ingest— the §3.4AtomicBoolof 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
OtlpBatchframe per concurrent call (no call’s batch is lost to concurrency, asserted by shutting down the receiver — dropping its singleWalhandle per RFC 0008 §3.1’s single-writer architecture (enforced bycrates/ourios-wal/src/lib.rs:162) — and then opening a secondWalwhoseWal::replayyields N frames whose payloads round-trip to the N inputExportLogsServiceRequests)- 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
Exportcalls for throughput
Amendment (served-binary slice, post-
green). Scenarios RFC0003.1–.15 are implemented and weregreen(exercised in-process:axum/tonichandlers 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 atspecifieduntil RFC0003.16 lands. (Discharged: RFC0003.16 landed — the served-binary suite lives atcrates/ourios-server/tests/it/rfc0003_16_served_binary.rsand all sixteen scenarios run green — so the ladder returned togreen, which is what the 2026-08-25acceptedflip stands on.)
Scenario RFC0003.16 — Served binary: both transports bind, a client export round-trips, graceful shutdown
[§3.4]
- Given
ourios-serverstarted 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 oneIngestPipelineover a singleWal- When a real OTLP client exports a non-empty batch (resolvable tenant) over each bound socket — gRPC
Exportand HTTPPOST /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
Walhandle — 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::replayrecovers each batch’sOtlpBatchframe — 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:
- Iterates
ResourceLogs[], derivingtenant_idper Resource via the operator-configured rule (RFC 0001 §6.1 Tenant derivation). - For each
ResourceLogs, iteratesScopeLogs[]andLogRecord[], materialising oneOtlpLogRecordper record. TheOtlpLogRecordis the in-memory shape RFC 0001 §6.1’s amended record table mirrors; it carries the inheritedResourceattributes and theInstrumentationScopename and version as fields, so downstream code never needs to walk back up the OTLP hierarchy. - For each
LogRecord, materialisesbodyinto theBody::String(String) | Body::Structured(AnyValue)fork perourios-core::otlp::Body::from_any_value. No canonicalisation runs at the receiver — the structured branch carries the decodedAnyValueverbatim per the amended §6.4. - Hands each per-tenant stream to
ourios-miner(oneMinerClusterper process; the cluster routes internally pertenant_id). - After the batch has been written to the WAL as a single
OtlpBatchframe with fsync AND every record accepted by the miner, returns a transport-level success.
6.2 Wire stack defaults
- gRPC:
tonic+ theopentelemetry-protocrate’s generatedLogsServiceServertrait. - HTTP:
axumonhyper. A single/v1/logsPOST handler dispatches onContent-Type:application/x-protobuf→prost::Message::decodeinto the sameExportLogsServiceRequesttype the gRPC path produces.application/json→ proto3-JSON decode intoExportLogsServiceRequest. The decode handles whitespace and field-ordering variation natively (proto3-JSON spec); no separate canonicalisation pass — theBody::Structured(AnyValue)carried downstream is transport-agnostic at theAnyValuelevel.
- 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-serverbinary, 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_ARGUMENTnaming 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 bytenant_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 structuredAnyValuebodies into OTLP-canonical JSON, with the in-memory record carrying pre-cachedBytes. The amended position carries theAnyValueitself 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_structuredencodes theAnyValuethe 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 — andingest_structuredreceives the structured tree: the receiver handsBody::Structured(AnyValue)through untouched, so the exact place such a mode would hook in still sees theAnyValue. 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’sotlp::canonical, operating on the decodedAnyValue); 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(_)vsBody::String(_)) before branching; noAnyValuewalking 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 ofMinedRecord, the miner’s snapshot serialisation (RFC 0001 §6.9), and the render path (RFC 0001 §6.6), all of which carry the encodedbodytoday. 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:
- Receiver accepts the request and decodes to
ExportLogsServiceRequest. - Receiver fans out to per-tenant
OtlpLogRecordstreams (§6.3); body canonicalisation does not happen here, per the amended §6.4. - Receiver appends the request as a single
FrameKind::OtlpBatchframe (per RFC 0008 §3.2 + §6.2.3) whose payload is a protobuf-encodedExportLogsServiceRequestdecodable viaprost— 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. - Receiver fsyncs the WAL segment(s) touched.
- Receiver hands records to the miner for templating.
- 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 decodedAnyValuerather than its OTLP-canonical JSON encoding (see amended §6.4).body_kindis derived frombodyrather 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_nanoParquet column (time_unix_nanowhen non-zero, elseobserved_time_unix_nano.unwrap_or(0)— RFC 0005 §3.2 is the normative derivation). The receiver’s contract is unchanged:time_unix_nanois carried verbatim from the wire including0(RFC 0001 scenario RFC0001.10), and the wire-0→Nonerule forobserved_time_unix_nanostands. No effective-timestamp field is materialised onOtlpLogRecord; 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 deliberateUnimplementedresponse) 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_urlandScopeLogs.schema_urlare separate OTLP fields and do not appear on theOtlpLogRecordshape 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 addresource_schema_url/scope_schema_urlfields (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-walRFC; 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
rotelare themselves built ontonic+opentelemetry-proto— the same primitives we would use directly. Embeddingroteladds 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’sotlp::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 anAtomicBoolordering probe mirroring RFC0008.1 — set afterWal::syncreturns, assertedtrueby the response-writer andfalseby every pre-sync stage. RFC0003.15 spawns N ≥ 2 concurrentExportcalls 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 realWal, the parent SIGKILLs betweenWal::syncreturn and ack-emit, the parent restarts the child and re-issues the export, and the assertion is that the post-restart WAL contains twoOtlpBatchframes whose payloads each decode (viaprost) to anExportLogsServiceRequestsemantically 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
MinerClusterstub that records every accepted(tenant_id, OtlpLogRecord)pair. Apropteststrategy over tenant-derivation rules asserts the cross-contamination-free invariant for any rule that returnsSomefor 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
propteststrategy generatesExportLogsServiceRequestpayloads across the proto’s value space; each is serialised to gRPC + protobuf, HTTP + protobuf, and HTTP + JSON, decoded by the receiver, and the three resultingOtlpLogRecordsequences are asserted equal at theAnyValuelevel. 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
AnyValuevariants, each asserting thatBody::from_any_valueroutesstring_valuetoBody::String(s)(unwrapped) and every other variant toBody::Structured(av), whereavis structurally equal to the inputAnyValueand the inneroneofis moved, not cloned. -
Edge OTLP cases (RFC0003.9, RFC0003.10): hand-curated
LogRecords exercisingseverity_number = 0,scope_name = None,observed_time_unix_nano = 0, and non-zerodropped_attributes_count. Assertions pin the pass-through semantics on the derivedOtlpLogRecord. -
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 noOtlpBatchframe 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
flate2to construct theContent-Encoding: gzipbody; 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):
propteststrategies 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, inourios-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 perCLAUDE.md§6.2. -
Served binary (RFC0003.16): an integration test boots the
ourios-serverreceiver 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 — atonicgRPC 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 singleWalhandle (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 andWal::replayit, confirming each batch’sOtlpBatchframe 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_urlpreservation. §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),OtlpLogRecordand 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’singest_structuredencodes the decodedAnyValuewith 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 decodedAnyValueverbatim and never canonicalises. The cost is once per structured record either way; placing it at ingest keepsMinedRecord, the snapshot format, and the render path on a single stored form. -
dropped_attributes_countsemantics 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 theourios-serverbinary, 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 only400 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_recordsis supported but optional. We keep whole-batch rejection and deferpartial_successto a future RFC if a concrete operator need surfaces (e.g. one failing tenant in a large multi-tenant batch). On full successpartial_successstays 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_idderivation 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
Stringbodies. 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 supportidentityandgzip; both are required acceptance criteria.zstdandbrare out of scope for this RFC — a request carrying an unsupported encoding is rejected with HTTP 415. A future RFC may addzstdif 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/tonicopentelemetry-protoRust crate: https://crates.io/crates/opentelemetry-protoaxum: https://github.com/tokio-rs/axumrotel: 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 indocs/benchmarks.md§7; elsewherevalidatedis 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 (perCLAUDE.md§6.2 precedent), not a runtime toggle. - Contributors reading the
MinerConfigrustdoc 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 againstCLAUDE.md§3; a waiver requires ameta: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:
| Tunable | Default | Validated range | Inside invariant |
|---|---|---|---|
similarity_threshold | 0.7 (RFC 0001 §3.1.1) | (0, 1] | §3.1 — strict-by-default, RFC required to change the default below 0.7 |
similarity_floor | 0.4 (RFC 0001 §6.3) | (0, similarity_threshold] | §3.1 — bounds the §6.3 lossy zone; body retention in that zone is invariant |
prefix_depth | 2 (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_limit | 256 (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
TemplateWidenedaudit event (§6.4). There is noallow_wideningtoggle; 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_numberandscope_nameare part of the §6.1 template-key composition. There is norespect_severitytoggle; 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::Aggressivetoggle;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 noaccept_lossy_reconstructiontoggle;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_deduptoggle;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:
- A
meta:RFC againstCLAUDE.md§3 explaining why the invariant should no longer be load-bearing. Majority maintainer approval (the precedent isCLAUDE.md§6.2’s 2026-05-13 amendment). - Only after the
meta:RFC accepts does the implementation RFC propose theMinerConfigfield 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
MinerConfigconstructed viatry_new_fullwith 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
MinerConfiginstance is produced
Scenario RFC0004.2 — Per-tenant override is honoured
- Given a
MinerClusterwith a defaultMinerConfigand a per-tenant override for tenantTthat differs from the default in at least one tunable- When tenant
Tingests 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
MinerConfigtype as defined by this RFC- When
cargo docis rendered or the type is grep’d in CI- Then there is no
allow_widening,respect_severity,lossy_mode,enable_cross_tenant_dedup, oraccept_lossy_reconstructionfield — 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_fullper failure variant (one test perMinerConfigErrorarm). Already partially in place; the follow-up implementation PR adds thePrefixDepthTooLargevariant + test. - RFC0004.2 — integration test in
crates/ourios-miner/tests/ingesting the same line through two tenants with differentsimilarity_thresholds and asserting different template-allocation outcomes. - RFC0004.3 — a “tunable-set pin” test that uses a
matchagainstMinerConfig’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
TenantStateis allocated lazily and config is captured at allocation. - Does the documentation route stop at
MinerConfig’s rustdoc, or does it also need a page underdocs/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-13meta:amendment).- RFC 0001 §3.1.1 (
similarity_thresholddefault), §3.2.1 (param_byte_limitdefault), §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.mdH1 (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 indocs/benchmarks.md§7; elsewherevalidatedis vacuous for this surface (RFC 0008 precedent).Status note.
green(2026-06-15) — every RFC0005 §5 acceptance criterion has a live, passing test. The priordraftedlabel was stale: the storage layer (schema, writer, reader, audit stream) landed early (PR #41 + the PR-D..Gourios-parquetseries), 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 unknownParamType→Unknown(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 uncompressedtotal_byte_size∈ [128 MiB, 1 GiB] per §3.5 / H4. Per §6 it is not run by CI (the project has noschedule:trigger — §7 open question); verify it manually withcargo 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
AnyValueencoding 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_fingerprintandtemplate_fingerprintreserved extensions named in RFC 0001 §6.1 — those gate on “we have a concrete consumer.” - A typed Parquet representation of
AnyValue’sarray/kvlistbranches — 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”):
| Column | Parquet logical type | Physical type | Repetition | Notes |
|---|---|---|---|---|
tenant_id | STRING | BYTE_ARRAY | REQUIRED | Authoritative 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_id | INTEGER(64, signed=false) | INT64 | REQUIRED | Monotonic; bloom-filter coverage (§3.6) |
template_version | INTEGER(32, signed=false) | INT32 | REQUIRED | Starts at 1; bumped on RFC 0001 §6.4 events |
OTLP-derived columns (RFC 0001 §6.1 “OTLP-derived columns”):
| Column | Parquet logical type | Physical type | Repetition | Notes |
|---|---|---|---|---|
time_unix_nano | TIMESTAMP(NANOS, isAdjustedToUTC=true) | INT64 | REQUIRED | 0 = 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_nano | TIMESTAMP(NANOS, isAdjustedToUTC=true) | INT64 | OPTIONAL | Same overflow contract as time_unix_nano |
effective_time_unix_nano | TIMESTAMP(NANOS, isAdjustedToUTC=true) | INT64 | OPTIONAL | Writer-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_number | INTEGER(8, signed=false) | INT32 | REQUIRED | OTLP SeverityNumber 0..24; part of template key |
severity_text | STRING | BYTE_ARRAY | OPTIONAL | |
scope_name | STRING | BYTE_ARRAY | OPTIONAL | Part of template key |
scope_version | STRING | BYTE_ARRAY | OPTIONAL | |
attributes | STRING (canonical JSON) | BYTE_ARRAY | REQUIRED | UTF-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_count | INTEGER(32, signed=false) | INT32 | REQUIRED | Mostly zero |
resource_attributes | STRING (canonical JSON) | BYTE_ARRAY | REQUIRED | Same contract as attributes: REQUIRED, UTF-8 canonical JSON, empty Vec ↔ [], NULL not valid |
trace_id | (no logical type) | FIXED_LEN_BYTE_ARRAY(16) | OPTIONAL | OTLP / 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) | OPTIONAL | Same opaque-byte contract as trace_id; no Parquet logical type exists for 8-byte opaque ids |
flags | INTEGER(32, signed=false) | INT32 | REQUIRED | Lower 8 bits = W3C trace flags |
event_name | STRING | BYTE_ARRAY | OPTIONAL |
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 carrytimeUnixNanoabsent/0— and 100 % of those carryobservedTimeUnixNano(verified by sampling). Under the pre-amendment contract those records are unaddressable by time: the DSL window filterstime_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. ItsTimestampfield 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
ObservedTimestampfield 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
Timestampif it is present, otherwise useObservedTimestamp.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):
- 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.2u64→i64overflow contract, so the derived value is always in-range.- Derived, never overwriting. The wire
time_unix_nanois stored verbatim, including0— RFC 0001 scenario RFC0001.10 (verbatim preservation) is explicitly intact.- 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;
0means genuinely timeless, mirroring thetime_unix_nanosentinel);NULLappears only in pre-amendment files. The redundancy costs ≈ 8 B/row before encoding and almost always equalstime_unix_nano, soDELTA_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 END—time_unix_nanois REQUIRED with0as the unknown sentinel, so a plaincoalescewould never fall back) would defeat row-group min/max pruning, which is the B1 mechanism.- 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.
- Query semantics. The DSL time window (
range(...)) filterseffective_time_unix_nano(RFC 0002 §6.2, amended the same date). The baretsfield still resolves totime_unix_nano, the verbatim wire value.- 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.- 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”):
| Column | Parquet logical type | Physical type | Repetition | Notes |
|---|---|---|---|---|
body_kind | INTEGER(8, signed=false) | INT32 | REQUIRED | 0 = String, 1 = Structured |
body | (no logical type) | BYTE_ARRAY | OPTIONAL | Original 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 |
params | LIST<STRUCT<type_tag: INT32, value: BYTE_ARRAY>> | as schema | REQUIRED | Always written (mirrors RFC 0001’s Vec<Param>); the list is empty (zero elements) when body_kind = Structured. NULL is not a valid encoding |
separators | LIST<BYTE_ARRAY> | as schema | REQUIRED | Always 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 |
confidence | FLOAT | FLOAT | REQUIRED | 1.0 sentinel when body_kind = Structured |
lossy_flag | BOOLEAN | BOOLEAN | REQUIRED | Always 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).
u64 → i64 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
statuschange.
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:
- Faithfulness. The encoding is bidirectional —
stored_bytes ↔ AnyValueround-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). - Schema simplicity. A single
BYTE_ARRAYcolumn versus a recursiveSTRUCT<string_value, int_value, ..., array_value: LIST<...>, kvlist_value: LIST<STRUCT<...>>>pseudo-schema with unrolled recursion depth. - Query consumer absence. Phase 3’s thesis-gate B1/B2
queries are predicate-pushdown on
template_id,tenant_id, andtime_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 promotedOPTIONALcolumns over theMAPsketch (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’sgreenslices land (atredonly 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-encodedTenantIdper RFC 3986 §2.1, with two project-specific overrides:- The input is the
TenantId’s UTF-8 byte sequence (theTenantIdnewtype wraps a RustString, 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 (%XXwith 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.
%XYwith 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.
- The input is the
year/month/day/hourare derived from the effective timestamp (the next bullet; equal totime_unix_nanowhenever that is non-zero) rendered as UTC. Audit-event partitioning stops atday=DDbecause 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 checkingtime_unix_nano; if it is0, the writer falls back toobserved_time_unix_nano. This derivation is the effective timestamp of the 2026-06-11 §3.2 amendment; the writer stores the same value in theeffective_time_unix_nanocolumn, so the partition tuple and the stored column never disagree. Ifobserved_time_unix_nanois also absent or0, the record is placed under the epoch partitionyear=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 attime_unix_nano = 0placed 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.
ZSTDlevel 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 inLISTcolumns — 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:
| Column | Dictionary | Page index | Bloom filter | Rationale |
|---|---|---|---|---|
tenant_id | yes | no | no | Exactly 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_id | yes | yes | yes | B2 (where template_id = X) is bloom-friendly; high cardinality but small relative to row count |
template_version | yes | yes | no | Always small per template |
time_unix_nano | no | yes | no | DELTA_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_nano | no | yes | no | Same encoding/compression as time_unix_nano; the observation timeline is also broadly monotonic, so delta encoding pays |
effective_time_unix_nano | no | yes | no | Same 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_number | yes | yes | no | 0..24 — dict alone is enough |
severity_text | yes | yes | no | Bounded set in practice |
scope_name | yes | yes | no | Bounded per deployment |
scope_version | yes | yes | no | Bounded per deployment |
attributes | no | no | no | JSON BYTE_ARRAY, high entropy, dict would balloon |
resource_attributes | yes | no | no | Repetitive across rows of one tenant; dict pays |
trace_id | no | yes | yes | Near-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_id | no | yes | yes | Same |
flags | yes | yes | no | Bounded |
event_name | yes | yes | no | Bounded |
body_kind | yes | yes | no | Two values |
body | no | no | no | CLAUDE.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) | no | no | no | Per-row entropy too high |
separators (list values) | yes | no | no | Almost always a single space — dict crushes it |
confidence | no | yes | no | Float, narrow range, page-index sufficient |
lossy_flag | n/a | yes | no | Boolean, RLE handles it |
dropped_attributes_count | yes | yes | no | Almost always zero |
Amendment (2026-07-12): bloom filters on
trace_idandspan_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 — thetrace_idcolumn 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 ordinal | event_type string | Rust variant | Source |
|---|---|---|---|
0 | template_widened | TemplateWidened | RFC 0001 §6.4 |
1 | template_type_expanded | TemplateTypeExpanded | RFC 0001 §6.4 |
2 | template_widening_rejected_degenerate | TemplateWideningRejectedDegenerate | RFC 0001 §6.4 |
3 | compaction | Compaction | RFC 0009 §3.6 (amendment 2026-06-03) |
4 | alias_asserted | AliasAsserted | RFC 0001 §6.7 (amendment 2026-06-12) |
5 | alias_retracted | AliasRetracted | RFC 0001 §6.7 (amendment 2026-06-12) |
6 | template_created | Template { change: Created } | RFC 0017 §3.1 |
7 | record_quarantined | RecordQuarantined | RFC 0025 §3.3 |
8 | ingest_denied | IngestDenied | RFC 0026 §3.4 |
9 | conversation_erased | ConversationErased | RFC 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 leavesreasonNULL— the facts live in thecompaction_*columns). Two changes accommodate it, both backward-compatible:
- 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 areNULLforcompaction. Existing audit files keep their (non-null) values, so no data migration is needed.- New OPTIONAL
compaction_*columns (below) carry the file set / generation / row count (§3.8 rule 1). They areNULLfor the template kinds.The RFC 0009 §7 fork (structured
reasonvs additive columns) is resolved here in favour of explicit columns: they are first-class queryable columns where a JSON blob inreasonwould 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_fileand thecompaction_input_filesLISTare 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 areasonblob.
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) andalias_retracted(5), join the mapping table (§3.8 rule 1 territory — the ordinals match the constantsourios-core::auditalready 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 OPTIONALalias_*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 intoreason:
alias_member_idsis aLIST<INTEGER(64, signed=false)>, not canonical JSON. The §3.3-style canonical-JSONUtf8alternative was considered and rejected on the same grounds the 2026-06-03 amendment rejected a structuredreason: 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 alreadyLIST(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 asNone.representative_idgets its own column (alias_representative_id) rather than reusingtemplate_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 /NULLotherwise; 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.reasonis 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 toNULLon 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’smember_idsverbatim (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 fromNULL(member_ids: vec![]on a single-id retraction ↔ empty list;NULLmeans “not an alias row”), mirroring thepositions_widenedempty-list convention. Alias rows leave every template-specific andcompaction_*columnNULL; conversely thealias_*columns areNULLfor all other kinds and required-by-convention non-null for kinds 4–5 (alias_member_idspossibly empty,reasonper the operator’s optional input) — the §3.8 rule 6 convention, writer-enforced and test-pinned (RFC0005.14).Unknown-
event_kindtolerance. Today’sAuditReaderhard-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 anevent_kindordinal above its known range MUST NOT fail the file — it surfaces the row as an opaque unknown-kind event (envelope only), theParamType::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 filtersevent_typestrings) 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 interimAliasEventNotYetPersistablerejection.
The row-level audit columns are:
| Column | Parquet logical type | Physical type | Repetition | Notes |
|---|---|---|---|---|
tenant_id | STRING | BYTE_ARRAY | REQUIRED | Same contract as data-file tenant_id: row authoritative, replicated in partition path, mismatch → reader error |
timestamp | TIMESTAMP(NANOS, isAdjustedToUTC=true) | INT64 | REQUIRED | Cluster clock at emit time (matches RFC 0001 §6.4 timestamp) |
event_kind | INTEGER(8, signed=false) | INT32 | REQUIRED | Ordinal per the mapping table above |
event_type | STRING | BYTE_ARRAY | REQUIRED | Canonical 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_id | INTEGER(64, signed=false) | INT64 | OPTIONAL† | The leaf the event applies to |
old_version | INTEGER(32, signed=false) | INT32 | OPTIONAL† | Pre-event template version |
new_version | INTEGER(32, signed=false) | INT32 | OPTIONAL† | Post-event template version (equal to old_version for the rejection variant) |
old_template | STRING (canonical JSON) | BYTE_ARRAY | OPTIONAL† | 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_template | STRING (canonical JSON) | BYTE_ARRAY | OPTIONAL† | 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_widened | LIST<INT32> | as schema | OPTIONAL† | 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_expanded | LIST<STRUCT<slot_index: INT32, types_added: LIST<INT32>>> | as schema | OPTIONAL† | 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_sample | STRING | BYTE_ARRAY | OPTIONAL | First 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 |
reason | STRING | BYTE_ARRAY | OPTIONAL | The 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_partition | STRING | BYTE_ARRAY | OPTIONAL | Compaction 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_files | LIST<STRING> | as schema | OPTIONAL | Compaction only. The input file names that were merged away (RFC 0009 §3.6 ourios.compaction.files). NULL for all other kinds |
compaction_output_file | STRING | BYTE_ARRAY | OPTIONAL | Compaction only. The consolidated output file name (the sole live file after the commit). NULL for all other kinds |
compaction_generation | INTEGER(64, signed=false) | INT64 | OPTIONAL | Compaction only. The manifest generation the consolidation committed at (RFC 0009 §3.4). NULL for all other kinds |
compaction_rows | INTEGER(64, signed=false) | INT64 | OPTIONAL | Compaction only. Rows in the consolidated file — equal to the total input rows, the conserved count (RFC0009.2). NULL for all other kinds |
alias_representative_id | INTEGER(64, signed=false) | INT64 | OPTIONAL | Alias 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_ids | LIST<INTEGER(64, signed=false)> | as schema | OPTIONAL | Alias 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_actor | STRING | BYTE_ARRAY | OPTIONAL | Alias 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):
| Column | Dictionary | Page index | Bloom filter | Rationale |
|---|---|---|---|---|
tenant_id | yes | no | no | Bounded per cluster |
timestamp | no | yes | no | DELTA_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_kind | yes | yes | no | A small bounded set (six ordinals today), plus future ordinals |
event_type | yes | yes | no | Same bounded set as event_kind; predicate-pushdown surface for the RFC 0001 §6.7 drift query |
template_id | yes | yes | no | Bounded by tenant template count; bloom filter is unnecessary at audit volume |
old_version, new_version | yes | no | no | Small per template |
old_template, new_template | no | no | no | Per-tenant repetitive but variable-length JSON; defer the dict decision until bench data exists |
positions_widened (list values) | yes | no | no | Small INT32s |
slots_expanded (list / struct values) | yes | no | no | Same |
triggering_line_hash | no | no | no | Near-random 16 bytes, dict loses |
triggering_line_sample | no | no | no | High-entropy text, dict loses |
reason | yes | no | no | Guard 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_partition | yes | yes | no | Bounded per tenant; page index supports range pruning on the compacted partition |
compaction_input_files (list values) | no | no | no | UUID file names, near-random — dict loses |
compaction_output_file | no | no | no | UUID file name, near-random — dict loses |
compaction_generation | yes | no | no | Small monotonic integers per partition |
compaction_rows | no | no | no | High-cardinality counts; neither dict nor index earns its keep |
alias_representative_id | yes | yes | no | Bounded by tenant template count — same shape as template_id |
alias_member_ids (list values) | yes | no | no | Same bounded id space; list volume is tiny (rare operator actions) |
alias_actor | yes | no | no | A 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:
- Scan the tenant’s
audit/partition subtree for rows withevent_kind ∈ {4, 5}— pruned by thetenant_idpartition key plus theevent_kind/event_typedictionary 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. - Fold the matching events in
timestamp(event-time) order through the RFC 0001 §6.7 projection semantics — eachalias_assertedunions its asserted set into one equivalence class (merging classes that share a member), eachalias_retractedremoves its asserted set’s ids, canonical representative derived asmin(members). Those semantics are owned by RFC 0001 §6.7 and implemented byourios-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. - Hand the folded map to the RFC 0002
resolves_tocompilation (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:
- 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 surfaceNone(or the documented default), new files include it. - 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.
- Changing a column’s type. Forbidden in-place. Add a new
column (
<name>_v2or a semantically meaningful new name), migrate, drop. The amendment RFC pins the migration plan. - 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. - 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. - Relaxing a column
REQUIRED→OPTIONAL. 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 asSomeeverywhere; only new rows of a new kind may writeNULL. The forward-compat caveat — a reader predating the amendment reads a relaxed column asREQUIREDand would mishandle aNULL— is bounded because (a) Ourios versions reader and writer together and (b) the rows that exercise theNULLbelong to a kind introduced by the same amendment, so no previously-deployed reader is expected to read them. The reverse (OPTIONAL→REQUIRED, a tightening) is forbidden in-place — older files may already storeNULL, which aREQUIREDcolumn 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:
- 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.
- 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 relaxedREQUIRED→OPTIONALis read the same way —Nonewhen a row storesNULL(e.g. the template-specific columns on acompactionrow),Somefor 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’stime_unix_nano, notNone— 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 tofalseon 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.
-
- Row-vs-path partition validation. For every row read
under a partition-aware path (i.e. via
Reader::open_partitionor the DataFusionListingTableintegration that feeds a partition tuple in), the reader compares the row-leveltenant_idagainst the partition path’stenant_idsegment 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: prefertime_unix_nanoif non-zero, else fall back toobserved_time_unix_nanoif 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 diagnosticReader::open_filehelper 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 asMinedRecords 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
MinedRecordpopulated with every row-level column in §3.2 (every OPTIONAL field set toSome, every variant ofbody_kindexercised across a batch — including the row-leveltenant_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
MinedRecordis 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
paramsandseparatorslists), the recovered value equals the original under the column’s Rust-level equality — UTF-8 equality forString, numeric equality for integers/floats/timestamps, element-wise equality forVec<T>- And for the canonical-encoded structural columns (
attributes: Vec<KeyValue>andresource_attributes: Vec<KeyValue>— encoded with the Ourios canonical body encoding as aBYTE_ARRAYon disk per §3.3), the recoveredVec<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 theMinedRecordboundary; 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
Nonefor 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-encodedTenantIdper §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 onRowGroupin 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_widenedevent (the Rust variant isTemplateWidened)- 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.
reasonis NULL fortemplate_widened;slots_expandedis an empty list)
Scenario RFC0005.8 —
bodycolumn 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
bodycolumn chunk’scompressioncodec isZSTD(ParquetCompressionCodecfield)- And the
bodycolumn chunk’sencodingslist does NOT includePLAIN_DICTIONARYorRLE_DICTIONARY(ParquetEncodingenum)- And the
bodycolumn chunk’sdictionary_page_offsetis unset (None) in the column-chunk metadata — there is no dictionary page on disk for this column
Scenario RFC0005.9 — Unknown
ParamTypeordinal surfaces asUnknown
- Given a Parquet file with a
params.type_tagvalue that the current reader’sParamTypeenum doesn’t recognise (e.g. ordinal99)- When the reader reads it
- Then the resulting
Param.type_tagisParamType::Unknown- And the record’s
reconstructcall surfaces it as lossy (consistent with RFC 0001 §6.6’s fallback path)
Scenario RFC0005.10 — Schema declaration is greppable and immutable
- Given the
Schemasingleton defined inourios-parquet- When the test suite extracts the column list from
Schemaand 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 (prefertime_unix_nanoif non-zero, elseobserved_time_unix_nanoif 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 = 0and a non-zeroobserved_time_unix_nanoplaced 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
compactionaudit 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 thecompaction_*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 asNone/ null- And a
template_widenedevent written to the same stream still populates all of those template columns and reads back itscompaction_*columns asNone— 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 = 0andobserved_time_unix_nano = T(non-zero)- When the writer flushes it and a time-window query whose window contains
Truns over the store- Then the stored
effective_time_unix_nanoequalsT- 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_nanois still0— the wire value is never overwritten (RFC 0001 scenario RFC0001.10)- And given a pre-amendment file lacking the
effective_time_unix_nanocolumn, the same time-window semantics apply witheffective := 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_assertedevent (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 (
reasonround-trips"" ↔ NULL; an emptymember_idsreads back as an empty list, notNULL)- And every template-specific and
compaction_*column reads back asNone/ null, and atemplate_widenedevent in the same stream reads itsalias_*columns back asNone(§3.8 rule 6, per kind)- And given a stream carrying
alias_asserted(A, {B})followed by the matchingalias_retractedfor tenantT, when the querier derivesT’s alias map at compile time (§3.7.1), thenresolves_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.rsusingproptestto generateMinedRecords 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 theparquetcrate 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.rsthat 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 viacargo test --ignored. Scheduling it on a CI cadence is an open question (§7) — the project’s CI workflow has noscheduletrigger today, so the RFC does not commit to one. - RFC0005.7 — integration test in
crates/ourios-parquet/tests/audit.rsthat 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 theparquetcrate’s column-chunk metadata, asserts thebodycolumn’scompressionisZSTDand itsencodingslist does not includePLAIN_DICTIONARYorRLE_DICTIONARY(the two distinct Parquet-metadata fields per RFC0005.8). - RFC0005.9 — unit test in
crates/ourios-parquet/src/reader.rswith an in-memory Parquet file built directly fromarrowarrays carrying a forged99in thetype_taglist. - RFC0005.10 — unit test in
crates/ourios-parquet/tests/schema_pin.rsthat holds a const expected-column-list and compares againstSchema::columns(). This is the “schema-as-spec” pin: adding a column toSchemawithout 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.rsthat builds Parquet files at deliberately mismatched partition paths (row saystenant_id = a, path segment saystenant_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 acompactionaudit event and atemplate_widenedevent throughAuditWriter, read them back viaAuditReader, 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 forcompaction). - RFC0005.13 — integration test spanning
crates/ourios-parquet(writer derivation + the §3.9 absent-column default) andcrates/ourios-querier(the time-window filter): write atime_unix_nano = 0record withobserved_time_unix_nanoset, assert the stored column, the partition path, the window hit, and the verbatim zero; then build a pre-amendment-shaped file (noeffective_time_unix_nanocolumn) with theparquetcrate directly, per the RFC0005.2 pattern, and assert the window filter behaves aseffective := time_unix_nano. - RFC0005.14 — lands with the issue-#148 implementation
slice. Round-trip test in
crates/ourios-parquet/tests/audit.rsper the RFC0005.12 pattern: writealias_asserted/alias_retractedand atemplate_widenedevent throughAuditWriter, read back viaAuditReader, assert each kind’s columns populated / null per §3.7 (including the"" ↔ NULLreasonrule and the empty-vs-NULLalias_member_idsdistinction). Derivation test incrates/ourios-querier: fold a written assert/retract stream into the tenant’sAliasMapper §3.7.1 and assertresolves_toover 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_levelas a tunable per RFC 0004. Defer until A1 numbers exist. - Bloom filter sizing. §3.6 names
template_idas 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’sListingTableis 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 oncargo test --ignored/ manual invocation. Adding a GitHub Actionsschedule: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 3ourios-benchbenchmark 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 inparams), §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-parquetis the named crate).- RFC 0001 §6.1 (
MinedRecorddata 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 (OVERFLOWmarker + forced body retention — the source of unbounded values in thebodycolumn), §6.6 (reconstruction — the consumer of the schema’sparams/separators/lossy_flagcolumns), §6.7 (template versioning; the 2026-06-07 alias write path whosealias_asserted/alias_retractedevents 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.mdH1 (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.mdA1 (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 ontemplate_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,
LISTencoding) — 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 = Structuredbuilds 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 indocs/benchmarks.md§7; elsewherevalidatedis 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-benchcrate’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
*.txtfiles undertestdata/corpus/, one line per row, UTF-8, the same on-disk shapetestdata/corpus/README.mddocuments and the existing H7.1 property test incrates/ourios-miner/tests/hazards.rsreads. The bench reuses the on-disk format, not the test’sOtlpLogRecordfixture 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/*.jsonfiles in the OTel File Exporter format (oneLogsDataper 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 todocs/benchmarks.md§9 under a date-stamped sub-heading. - The invocation surface:
cargo run -p ourios-bench --orjust thesis-bench, with CLI flags pinning corpus selection, result-file output, and the optional “annotate-only mode” that runs measurements but does not writebenchmarks.md. The recipe is not namedjust bench— that name is already taken by the criterion-micro-benchmark recipe injustfile; see §3.7.
This RFC does not pin:
B1/B2measurement — both needourios-querier(RFC 0007, where they since landed; authoritative results indocs/benchmarks.md§9.4).The OTLP-LogsData corpus migration (Landed in PR-K2 (2026-05-28). The loader now readsdocs/roadmap.md§4’s “OTLPLogsData(canonical JSON or protobuf)” goal).*.jsonl/*.jsonfiles in the OTel File Exporter format alongside the plain-text*.txtpath. As predicted, the measurement formulas were unchanged; only the loader’s parse step grew. The follow-up that swaps in the protobuf (*.binpb)LogsDatadecode (instead of JSON) remains out of scope for this RFC.criterionmicro-benchmarks for the miner / writer hot paths.criterionis 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 acrates/ourios-bench/ benches/directory; this RFC does not specify it.- A
Validated-stage flip for any RFC — this RFC landsgreen(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
(StringValue → Body::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 ofstd::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).*.binpbprotobuf-encodedLogsDatais reserved for a future follow-up and not counted today. No transformation: this is the byte count an operator measures withfind 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*.txtand*.jsonlproduces one aggregate number that conflates both encodings. The §3.6 results JSON’scorpus.directoryfield 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 ofstd::fs::metadata(p).len()for every*.parquetfile 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.tmpfiles are skipped (the writer’s atomic-publish contract per RFC 0005 §7 means an open*.parquet.tmpindicates an in-flight write, not a durable artefact).bytes(zstd_corpus): sum ofstd::fs::metadata(p).len()for every*.zstfile produced by runningzstd -19 --no-progressagainst each consumed input file individually — same extension set asbytes(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 (zerobytes(zstd_corpus)on an OTLP-only corpus would producezstd_ratio = 0and 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-progressflag suppresses the progress bar so the bench is deterministic on reinvocation.A1_deltais 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 byourios_miner::reconstruct::reconstruct, signaturefn reconstruct(record: &MinedRecord, template: &[OwnedToken]) -> Vec<u8>— same function RFC 0001 §6.6 specifies and the H7.1 property test incrates/ourios-miner/tests/hazards.rsalready 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 walkscluster.templates_for(tenant)and records the current template tokens into aHashMap<(template_id, template_version), Vec<OwnedToken>>viaor_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. bytesis the original line bytes the loader handedMinerCluster::ingest, captured by the harness alongside eachMinedRecord. The bench MUST capture the input line beforeMinerCluster::ingestborrows or transforms it; the comparison happens against the exact bytes the miner saw.- Equality is byte-for-byte
==betweenreconstruct(record, template)(aVec<u8>) andline.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’s100.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
Nlines, whereN = 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 indicesN-1, 2N-1, 3N-1, …(i.e. after every N-th line, zero-indexed). The final sample is always taken attotal_lines - 1(the last line), regardless of whether it falls on a cadence boundary. The sample count is thereforeceil(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_000lines. - Convergence ratio:
count_at_1m / SS, defined only whenSS > 0. By monotonicity (count_at_1m ≤ SS) it is≤ 1.0; it is0.0when no template has been minted as of the sample nearest the 1 M-line mark (count_at_1m == 0,SS > 0) —count_at_1mis that nearest sample, not the exact millionth line — so the defined range is[0.0, 1.0]. It is undefined (null, paired with anullcount_at_1m) whenSS == 0— a ≥ 1 M corpus that mints no templates at all, a0/0ratio. - 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 iscount_at_1m / SSover that service’s lines, withcount_at_1mtaken 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_000lines has ratio≥ 0.5— with one exception: a service that mints zero templates over its ≥ 1 M lines (SS == 0, an undefined0/0ratio) passes trivially, since a flat-zero count is the strongest possible convergence (C2’s falsifier is linear growth; an all-NO_TEMPLATEservice 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 below0.5; it abstains (c2.pass = null) when no service reaches 1 M lines. A single-service corpus — including the plain-text<unknown>bucket (noservice.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-corpusconvergence_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-corpusconvergence_ratiois retained as a diagnostic (theby_servicebreakdown is the gate basis). Note: token-level polishing of high-cardinality infra logs is an OTel Collector concern (atransform/redactionprocessor upstream), not the miner’s — consistent with “format parsing is the Collector’s job”. Cardinality cap: the decomposition holds at mostMAX_SERVICES = 1024distinctservice.namebuckets (an O(services) memory guard mirroring §3.2); beyond that, further services fold into one<other>bucket andc2.services_truncatedis 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_truncatedflags 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 = 64samples 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-diris where the JSON results file from §3.6 lands. Default:benchmarks/results/.--bucket-diris thebucket_rootpassed to theourios-parquetwriter — the directory the writer’sdata/andaudit/partition trees grow under, and whose total byte size isbytes(ourios_output)in the §3.4.1 A1 formula. Default: a fresh temp dir understd::env::temp_dir()per invocation, cleaned up on exit unless--keep-parquetis 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>(defaulttestdata/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 — oneLogsDataper line, the OTel File Exporter format). Both formats may coexist in the same directory; any other extension is silently skipped.--results-dir <path>(defaultbenchmarks/results/): where the §3.6 JSON file lands.--bucket-dir <path>(default: fresh temp dir): the Parquet writer’sbucket_root. Cleaned up on exit unless--keep-parquetis 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 thezstd_safeRust crate is linked (per the §7 resolution of the ZSTD-integration question)- When the bench runs the A1 measurement
- Then
bytes(raw_corpus)equalssum(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 theaudit/...partition- And
bytes(zstd_corpus)equals the sum ofstd::fs::metadata(f).len()over the*.zstfiles produced byzstd -19 --no-progresson each consumed input (same extension set asbytes(raw_corpus))- And the reported
deltaequalsourios_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’stemplate_id,template_version, expected bytes, and actual reconstruction to stderr and exits with non-zero, and the results JSON recordsc1.pass = false- And the bench writes the results JSON irrespective of
--update-benchmarks-md— the JSON file always lands; only thedocs/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_000lines whose template alphabet is bounded (constructed by the bench’s integration test; not committed totestdata/corpus/)- When the bench runs the C2 measurement
- Then
c2.corpus_at_least_1m = true- And
template_count_at_1m_linesis the integer template count at the sample whose line index is closest to999_999(zero-indexed; per §3.4.3)- And
template_count_at_endis 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: indicesN-1, 2N-1, 3N-1, …plus a guaranteed final sample attotal_lines - 1)- And on a corpus of
< 1_000_000lines,c2.corpus_at_least_1m = false,c2.pass = null, andc2.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::ResultsFilewithrfc_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-mdis 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-kindflag 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-hardwareis passed, the resulting JSON carrieshardware_kind = "unknown"and stderr emits a warning naming the §1 baseline tag for reference
Scenario RFC0006.6 —
--gatesflag 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
c1populated anda1,c2set tonull- And the §9 update path (when
--update-benchmarks-mdis 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 fromtimestamp). 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. Callsourios_bench::runagainst a fixture corpus committed undercrates/ourios-bench/tests/fixtures/, captures the resulting JSON, and asserts each formula leg (raw_bytesfromfs::metadata,total_parquet_bytesfrom inspecting the output bucket,zstd_bytesfrom thezstd_safecrate per the §7 ZSTD-integration resolution). - RFC0006.2 — integration test in
crates/ourios-bench/tests/c1.rs. Drives the bench against the seed corpus; assertsc1.rate == 1.0. A second sub-test injects a synthetic record whosereconstruct()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 committedtestdata/) of 1.5 M lines with a known small template alphabet; assertsconvergence_ratio ≥ 0.5and the convergence curve has exactlytotal_lines / sample_cadenceentries (rounded). A second sub-test feeds a non-plateauing corpus (every line introduces a new template structure) and assertsc2.pass = false. - RFC0006.4 — colocated unit test in
crates/ourios-bench/src/report.rs. Serialises a hand-builtResultsFile, 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-kindflag without--allow-unknown-hardwareproduces a usage error beforeHarness::runis invoked. - RFC0006.6 — same test file as RFC0006.5; covers the
--gatesfiltering. - RFC0006.7 —
crates/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
-
zstdintegration. Resolved 2026-05-25: the bench links thezstd_safeRust crate. Already in the dep tree viaparquet’szstdfeature, so the marginal build cost is zero. The decision turns on cross-platform reproducibility: shell-out requireszstdon 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 byCargo.lockand 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_safewraps 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 recordszstd.level = 19so 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/*.jsonwill be gitignored by default (§3.6); specific runs are committed when the maintainer cites them in §9. Open: should there be abenchmarks/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 (sha256of 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.mdreading 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 atvalidated.Status note.
validated(2026-06-12, per the maintainer’s authorization of the same date). Thedocs/verification.md§3 ladder requires forvalidatedthat “every thesis-gate inbenchmarks.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) authoritativebaseline-8vcpu-32gibrerun — ✓ 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 wentgreenviacrates/ourios-querier/tests/{execution,boundary,forward_compat}.rsand thecrates/ourios-querier/src/lib.rsno-leakage unit test (tests/acceptance.rsis a pointer to them); the first indicativeci-runnerB1/B2 readings are §9.3.acceptedfollows on maintainer sign-off per thedocs/rfcs/README.mdladder.
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,
LogicalPlantypes, andarrow/datafusionerrors 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_idand 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_readinQueryResultstats 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_idlives 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)(bothQueryResultstats fields) exceeds a floor (e.g. ≥ 80% on the bench corpus) - And
bytes_readis sub-linear in corpus size for fixed result size.
- Given a corpus partitioned across many row groups where a
target
-
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.mdB2) — measured bycriterionacross thecorpus/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→
LogicalPlanlowering (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-parquetwriter; assertrow_groups_pruned/bytes_read(RFC0007.1) and tenant isolation (RFC0007.5). - Bench (
criterion) — RFC0007.2 latency-vs-corpus-size acrosscorpus/otel-demo-v*; wired intoourios-benchas 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. -
ListingTablevs a customTableProvider— 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
paramsno 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.mdB1/B2 (the thesis-gate definitions).docs/roadmap.mdPhase 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 perdocs/rfcs/README.md). Reachedgreen2026-06-13 (criteria below);validatedis vacuous for the WAL (its pillar touches no thesis gate — see the closing paragraph), so the maintainer advances it directly fromgreentoaccepted. Thedocs/verification.md§3 /docs/rfcs/README.mdladder definesgreenas all §5 acceptance criteria pass. Every RFC0008 §5 arm (.1–.10) now has a live, passing test — no#[ignore]/unimplemented!()acceptance stubs remain undercrates/ourios-wal/tests/(theencode_audit_eventfree fn inlib.rsis stillunimplemented!(), but that is the §9AuditEventserde-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 boundedwal_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.
validatedis vacuous here;acceptedis the maintainer’s call. The ladder’svalidatedstage gates on thesis-gates inbenchmarks.md§7 (compression / query-latency / reconstruction). The WAL is a durability buffer — its pillar touches no thesis gate — so thevalidatedcondition (“every thesis-gate the RFC’s pillars touch passes”) is vacuously satisfied now that §5 is green. The WAL never self-promoted through the vacuousvalidatedstage; the terminalacceptedflip was the maintainer’s call perdocs/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 / Thenscenarios — the contract. §6 is the precise specification theourios-walcrate 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:
- 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.
- 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 singleourios_core::audit::AuditEvent(theTemplateWidened/TemplateTypeExpanded/TemplateWideningRejectedDegenerateshape 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-MVPourios-walcrate; 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 exactAuditEventbyte encoding is§9open (“AuditEventhas 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
§9open 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,rocksdbvia 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 (sledis still pre-1.0 with known durability bugs;rocksdbadds 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 OTLPExportLogsServiceRequestbatch ready to acknowledge- When the receiver runs its accept path
- Then the 2xx (gRPC
OK) response is emitted only after theWal::synccall returnsOk(_)— measured by anAtomicBoolset aftersyncreturnsOk(_)(not insidesync; the probe insidesyncwould already be true mid-call, which would let an ack racing the sync trivially pass). The ack-emit path asserts the flag istruebefore sending the response, and the receiver’s pre-sync points (decode,tenant_derive,append) all assert the flag isfalse- And
wal_unflushed_bytesis non-zero betweenappendandsync, and zero aftersyncreturns- And a fault injected at
append(returnsAppendError) suppresses both thesyncand the ack (no record of the batch ever reaching disk or the client)- And a fault injected at
sync(returnsSyncError) suppresses the ack and surfaces the error to the receiver per§9’s “what does the receiver do whensyncreturns 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 testharness that forks a child running a receiver wired to a realWal, with the parent controlling the request rate and a deterministic record generator- When the parent sends
SIGKILLbetweenWal::appendandWal::sync(first arm), and betweenWal::syncand the simulated ack (second arm)- Then on restart,
Wal::replayproduces every frame the child had fsync’d before the kill (no fsync’d frame is lost — the H3 invariant)- And any frame whose
appendcompleted butsyncdid 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 thanSIGKILLsemantics admit, since process death does not discard dirty page-cache data- And
FrameKind::AuditEventframes 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 defaultwal_segment_size_bytes(128 MiB) of representativeOtlpBatchpayloads, captured by acriterionbenchmark- When
Wal::replayruns 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_totaldoes 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::replaywalks 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,fdatasyncthe segment,fsyncthe parent directory — the truncated bytes are physically gone from disk- And the post-recovery
Wal::appendlands 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
kindbyte (>0x03since the RFC 0046 amendment; >0x02before it), a non-zero_pad, an oversizelen(>MAX_FRAME_BYTES), or a torn header/payload on a closed (non-newest) segment- When
Wal::replaywalks the segment containing the corruption- Then the scan emits a structured
RecoveryErrorcarrying 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:replayreturns the structuredRecoveryErrorand 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
Walat defaultwal_segment_size_bytes(128 MiB) andwal_segment_age_secs(600 s)- When an
appendwould push the current segment past the size cap (size-cap arm), ORwal_segment_age_secselapses 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
fdatasyncon the segment +fsyncon 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
appendlands 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: subsequentappendcalls 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
Walthat has accumulated K segments and a simulated Parquet writer that has durably committed records up toWalOffset X- When
Wal::checkpoint(X)runs followed by a housekeeping pass- Then the
<wal_root>/CHECKPOINTsidecar is atomically written (CHECKPOINT.tmp→fsync→rename→ parent- dirfsync), 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_bytesdrops by the sum of the deleted segments’ sizesCrash-between-checkpoint-and-housekeeping arm:
- Given the same setup
- When SIGKILL fires between
Wal::checkpoint(X)returning and the housekeeping pass starting- Then the
CHECKPOINTsidecar survives the crash (the atomic-write + fsync above is what guarantees this)- And on restart,
Wal::last_checkpoint()returnsSome(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).replayitself 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::openis followed by a newWal::checkpoint(Y)whereY > X- Then the operation proceeds against the surviving UUID-named files without reconstructing a global offset counter — the
(segment_uuid: UUIDv7, byte)WalOffsetrepresentation makes this trivially well-definedRetain-floor arm (2026-06-12 amendment):
- Given a checkpoint at
Xand a retain floorS < X(a miner snapshot lagging the Parquet writer)- When
Wal::housekeeping(Some(S))runs- Then only segments whose highest frame offset is ≤ both
XandSare 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' ≥ Xreclaims them — the floor delays truncation, never cancels it
Scenario RFC0008.8 — Batched-fsync knob
- Given the group-commit
CommitCoordinatorover aWal, withwal_batch_window_msset 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
≈ 0independent 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_totaladvances at a rate≈ (steady-state arrival-rate / per-window batch size)rather than per-record (soappends_per_syncis well above 1 in every setting)- And the §3.4 invariant holds across all three: no ack precedes the
Wal::syncwhose returned offset covers the corresponding frame
Scenario RFC0008.9 —
wal_unflushed_bytesis bounded[H3 detection]
- Given a
Walunder 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_bytesat everyappendandsyncboundary across the run- Then the metric never exceeds
2 × wal_segment_size_bytesat 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_bytesbelow the lower bound is rejected atWal::opentime (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
CHECKPOINTatXand a durable miner snapshot whose recorded high-water mark isS ≥ 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 ≤Xare 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
Sis below the checkpointX, 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::consumenow receives the frame’sWalOffset, andreplaydelivers every well-formed surviving frame — suppression moves out ofreplayand 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-replayskip would make a lagging snapshot’s retained frames undeliverable); the checkpoint offset is exposed to the driver aslast_checkpoint; the housekeeping pass becomes an explicit API taking an optional retain floor so truncation never outruns the miner snapshot (§6.7); andreplaytakes&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:
appendwrites 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 callssyncat the §3.4 batch boundary.syncfsyncs the current segment and returns the highest offset that is now durable. The receiver gates its acks on this returning successfully.checkpointrecords “records ≤durable_toare 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.housekeepingreclaims disk: unlinks every segment whose highest frame offset is ≤ the checkpoint and, whenretain_floorisSome(floor), ≤floor(§6.7). The caller (the ingester) runs it periodically — everywal_housekeeping_secs— passing the latest durable miner snapshot’s high-water mark as the floor;Nonemeans no snapshot consumer exists and the checkpoint alone governs.last_checkpointexposes theCHECKPOINTsidecar’s offset (Nonepre-first-checkpoint). The recovery driver reads it once at startup as the Parquet-side suppression horizon (§6.6).replayis 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 — tosinkalong with itsWalOffset. 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-replayskip 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 OTLPExportLogsServiceRequestprotobuf bytes the receiver decoded — verbatim, no re-encoding — with no tenant; replay used to re-derive one fromResource.attributes(RFC 0003 §6.3). Since RFC 0046 nothing derives a tenant, so the current recovery driver refuses0x01frames as unsupported for replay (aSinkRejectednaming 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 isu16 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 aSinkRejectedinvalid payload, not RFC0008.5 corruption. This is the only kind the receiver writes.FrameKind::AuditEvent: payload is one serialisedourios_core::audit::AuditEvent. The exact serde format is a§9open question —AuditEventhas no serde derives today (Debug, Clone, PartialEq, Eqonly), and the encoder lands alongside them. Strong candidates arebincode(compact, fast, Rust-native) andserde_json(human-debuggable, already in the dep tree). The choice doesn’t affect this RFC’s frame layout; it lives entirely inside thekind = 0x02payload 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(default100): the receiver-side upper bound on time from firstappendto correspondingsync. The receiver implements this; the WAL itself is policy-free at this layer.wal_segment_size_bytes(default128 MiB): segment rotation cap (§6.5). The receiver’ssyncMUST also fire whenever this cap is reached on the current segment, even ifwal_batch_window_mshasn’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ₖ:
- Append all audit-event frames for R first:
wal.append(AuditEvent, encode(E₁)) … wal.append(AuditEvent, encode(Eₖ)). - 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 norequired_audit_offsetand does not contribute to the row group’smax_required_audit_offsetin step 3 (maxover the empty set isMIN, so the gate in step 4 is trivially satisfied for an all-zero-audit row group — common steady-state). - The row group accumulating R’s
MinedRecordcarries a runningmax_required_audit_offsetacross its contained records (the maximum of every record’srequired_audit_offset). - The Parquet writer’s atomic-publish gate compares the
row group’s
max_required_audit_offsetagainst the WAL’slast_synced_offset(the largest offset for whichWal::synchas returned). The publish proceeds only whenmax_required_audit_offset ≤ last_synced_offset; otherwise the row group is held until the nextWal::syncadvanceslast_synced_offsetpast 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(default600= 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:
- Read
<wal_root>/CHECKPOINT(per §6.7) intocp: Option<WalOffset>. A present sidecar yieldsSome(parsed); an absent sidecar yieldsNone(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 asNonewould 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 = 0would be a synthetic offset undefined for the(segment_uuid, byte)pair, so absence is modelled as an option rather than a zero value.cpis exposed to the recovery driver asWal::last_checkpoint()(§6.1) — it is the driver’s Parquet-side suppression horizon, not a delivery filter insidereplay(2026-06-12 amendment; see step 3). - List every
*.walfile underwal_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). - For each segment in order:
- Open read-only, verify the header (magic + version).
- Walk frames left-to-right. For each frame:
- Read the 12 B frame header (
len+kind+_padcrc32). 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,kindis unknown (>0x02), or_padis non-zero: structured corruption error → same all-segments-stop path. - Read
lenpayload 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)tosink.consume(offset, kind, payload)— every well-formed surviving frame is delivered, including frames at or belowcpthat a straddling segment retains (2026-06-12 amendment; the pre-amendment design skipped them insidereplay, 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 abovecp(frames ≤cpare 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 markS(frames ≤Sare already folded into the snapshot — re-feeding them would double-apply, RFC 0001 §6.9 v2). The two horizons are independent and either ordering ofcpandSis handled by the same rule: withS ≥ cpthe miner consumes a suffix of what Parquet consumes; withS < cp(lagging snapshot) the miner additionally consumes the retained(S, cp]frames that Parquet suppresses. The sink is the recovery driver inourios-ingester; forOtlpBatchit decodes the bytes asExportLogsServiceRequestand runs the same tenant-fan-out + miner-ingest pipeline the live receiver does (gated per consumer as above); forAuditEventit deserialises and reinjects into the audit-event Parquet writer’s queue, gated on the Parquet horizon.
- Read the 12 B frame header (
- 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, thenfdatasyncthe segment file andfsyncthe 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. - 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: u64—fdatasynccalls.wal_unflushed_bytes: u64— bytes appended but not yet fsync’d (theH3detection metric, bounded by RFC0008.9).wal_disk_bytes: u64— sum of file sizes underwal_root(operator dashboard).wal_segment_count: u32— current segment count.wal_sync_seconds: histogram— per-synclatency.wal_checkpoint_segment: String(UUIDv7 of the segment in the lastcheckpointarg) +wal_checkpoint_byte: u64(byte-in-segment of the same arg). Two fields rather than one becauseWalOffsetis now a(segment, byte)pair (§6.1) and a singleu64would lose the segment identity needed to interpret the value once housekeeping deletes older segments.Nonecheckpoint (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:
| Knob | Class | Default | Validated range | Inside invariant |
|---|---|---|---|---|
wal_batch_window_ms | Tunable | 100 (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_bytes | Tunable | 128 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_secs | Tunable | 600 (10 min, §6.5) | 1..=86_400 | §3.4 — bounds recovery window |
wal_housekeeping_secs | Tunable | 60 (§6.7) | 1..=3_600 | §3.6 — bounds local-disk pressure on a slow-publishing Parquet writer |
wal_macos_full_fsync | Tunable | false (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_BYTES | Invariant | 16 MiB | n/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) | Invariant | per §6.2 | n/a | format compat — the on-disk shape is the format-version contract |
| Segment-header layout (§6.2.1) | Invariant | per §6.2 | n/a | format compat — same |
| CRC32-C polynomial | Invariant | 0x1EDC6F41 (Castagnoli) | n/a | format compat |
CHECKPOINT sidecar format | Invariant | per §6.7 | n/a | format 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.
§9carries 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
OtlpLogRecordback 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 realWal; asserts thesync-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
SIGKILLbetweenappendandsyncand (in a second arm) betweensyncand the simulated ack. The assertion has two halves:- No fsync’d frame is lost. Every frame the child fsync’d before the kill is recovered on restart.
- 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
SIGKILLsemantics admit.
Runs on every PR; failure blocks merge.
-
RFC0008.3 —
criterionbenchmark incrates/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, assertsreplaysucceeds 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:- Payload bit-flip (CRC mismatch) on a closed
segment — flip a random bit in a random frame, assert
replayemits the structured corruption error and stops scanning all segments. - Unknown
kind— generate a segment containing a frame whosekindbyte is anything other than0x010x02or0x03(i.e. inside §6.2.2’s reserved0x04..=0xFFrange — the RFC 0046 amendment added0x03 = TenantOtlpBatch, exercising exactly the “future kinds without a version bump” clause); assert structured corruption. - Non-zero
_pad— generate a frame whose 3 B reserved_padcarries a non-zero byte; assert structured corruption (the §6.2.2 contract says_padMUST be zero and is validated on read). - Oversize
len— generate a segment whose frame header declareslen > MAX_FRAME_BYTES; assert structured corruption (the read must reject the header before attempting to readlenbytes that could exceed the segment size). - 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
WalRecoveryCorruptionaudit event — is deferred per the 2026-06-13 §5 amendment (system-scoped audit, §9);wal_corrupt_frames_totalcarries the operator-visible signal in the interim. - Payload bit-flip (CRC mismatch) on a closed
segment — flip a random bit in a random frame, assert
-
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:- Normal-flow truncation —
checkpoint(X)followed by a housekeeping pass unlinks segments wholly belowXand keeps segments straddling it. - Crash between
checkpoint(X)and housekeeping — fork a child that callscheckpoint(X), SIGKILL the child before housekeeping runs; restart, assert theCHECKPOINTsidecar survives the crash:last_checkpoint()returnsSome(X), so the driver’s Parquet-side suppression of records ≤ X holds (they aren’t re-fed and duplicated on the data side), whilereplaystill delivers them with their offsets. Without this arm a passing test could still hide a non-durable checkpoint (the §6.7 sidecar requirement). - Surviving-segments offset reconstruction —
checkpoint(X)advances past several older segments, housekeeping deletes them, then a freshWal::openplus a newcheckpoint(Y > X)proceeds against the surviving UUID-named files without needing a global offset counter to be reconstructed — verifying the §6.1WalOffset = (segment, byte)representation is enough. - Retain floor — with
checkpoint(X)and a floorS < X,housekeeping(Some(S))keeps every segment holding frames in(S, X]; advancing the floor toS' ≥ Xon a later pass reclaims them.
- Normal-flow truncation —
-
RFC0008.8 — integration test that drives the
wal_batch_window_msknob 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_bytesis monitored over a randomized arrival pattern and asserted bounded by2 × 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 inourios-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. - snapshot fixture with
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 — aWalRecoveryCorruption { 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 siblingaudit/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 structuredRecoveryErrorhalt +wal_corrupt_frames_totalare 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. -
AuditEventserde format.AuditEventhas no serde derives today (Debug, Clone, PartialEq, Eqonly), so theFrameKind::AuditEventpayload encoding is intentionally deferred to the implementation PR — this RFC atspecifiedpins 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 arebincode(compact, fast, Rust-native — picks up cross-version compatibility concerns later if the struct evolves) andserde_json(human-debuggable, already in the dep tree, larger on disk). Either lives entirely inside thekind = 0x02payload 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_FULLFSYNCpolicy. macOSfsyncdoesn’t flush the on-disk cache withoutF_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 explicitwal_macos_full_fsync = trueknob with a documented trade-off. - Recovery-time snapshot of miner state.
IfRESOLVED 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).replayon 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. - 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
syncreturns 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.mdH3 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
drafteduntil this RFC reachesspecified(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 —
OtlpLogRecordshape, the in-memory form the recovery sink fans out to. - RFC 0001 §6.4 / §6.7 —
AuditEventschema and the audit-stream-through-WAL contract this RFC implements viaFrameKind::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-walcrate; 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
Amendment (2026-10-16, #1027, maintainer-approved) — planning reads a tenant once, not every partition. Candidate planning (§3.3) lists each tenant’s data prefix once, recursively, and decides every partition from that listing. It reads a partition’s manifest only when the listing shows two or more committed
*.parquetobjects there. A settled partition (one live file) then costs no request of its own, so a sweep’s planning cost follows the objects a tenant holds (about 1,000 per listing page), not the hours it has retained. The candidate predicate is unchanged; only the reads it is computed from change. No state is persisted or cached. Adds the in-place note in §3.3, scenarios RFC0009.8–.12 (§5), their test map (§6), the alternatives and open questions (§7) and the history (§9). Erasure and the graph backfill keep walking every hour partition, so they are unaffected (RFC0009.12). The accepted text below is left as written and the frontmatter staysacceptedfor the seven original criteria. The new scenarios were approved with the amendment and move throughredandgreenwith their tests. Refs #1019 (item 2). Records a pre-existing gap found while drafting it: a late write into an already-compacted partition is not re-flagged (#1026; §3.3 note, §7).
Amendment (2026-10-16, #1033, maintainer-approved) — late writes stay live. A record whose timestamp falls in an hour partition that was already compacted is written as a new
<uuidv7>.parquetthat the manifest does not name, so today it is invisible to every reader andgc_orphansdeletes it the next time the partition is consolidated (#1026). The same happens to every flush into the current hour after an erasure, because an erasure rewrites every partition of the tenant and leaves a manifest in each. This amendment changes what the manifest records: it names the compacted files that are live and the flush files a committed compaction consumed, and every other flush file is live. A flush file can then be deleted only after a won commit has copied its rows, whatever crashes or races happen, and the writer’s flush path is unchanged. Adds the in-place notes in §3.3 and §3.4, the alternatives in §4, scenarios RFC0009.13–.24 (§5), their test map (§6), the decisions and open questions (§7) and the history (§9), plus an in-place note in RFC 0047 §3.6 on erasure. Breaking for the persisted layout: a manifest written before this amendment is rejected, with no dual-read (§3.4 note). The accepted text below is left as written, and the frontmatter staysacceptedfor the seven original criteria. The maintainer approved the amendment with decisions D1–D4 (§7). Its scenarios move throughredandgreenwith their tests. Refs #1026.
Amendment (2026-10-16, #1039, maintainer-approved) — orphan GC is observable. The sweep’s orphan GC (
gc_orphans, RFC0009.4) emits no telemetry today: what it removed is summed into the sweep report and never exported, and a delete it could not make is dropped.ourios.compaction.orphan.filesdoes not cover it, since that counter counts the superseded inputs the committing rewrite could not delete. This amendment adds one counter,ourios.compaction.orphan.reclaimed, per tenant, witherror.typeon a file whose delete failed (§3.6), and runs the GC as one pass after the consolidation pass, so the pass can carry one RFC 0038 phase span (reclaim orphans, RFC 0038 §3.9). The pass visits the candidates and, so that a sweep stopped before it loses nothing, every sealed partition whose listing shows an orphan, picked from the planner’s one listing with no persisted state. The GC rule, RFC0009.17 and the files GC may delete are unchanged. Adds the in-place note in §3.6, scenarios RFC0009.25–.26 (§5), their test map (§6) and the history (§9). The new names go to the ourios-semconv registry.ourios.compaction.orphan.fileskeeps its meaning and its attributes.
Status:
accepted(2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip; thesis-gates passed atvalidated.Status note.
validated(2026-06-15) — the RFC0009.7 D2/D3/B2-post benches were measured authoritatively onbaseline-8vcpu-32gib(benchmarks.md§9.7, git4d52288): 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 priorgreenstatus (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_rowproptest), .3 atomic publish / no torn read (atomic_publish_…+ theourios-querierrfc0009_3_*manifest tests), .4 crash safety (rfc0009_4_*—gc_orphansreclaims 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 inourios-ingester(run_sweep/Compactor) with the §3.6 OTel metrics + audit event.RFC0009.7 — measured. The D2/D3
criterionbenches (compaction throughput, small-file size band) + the post-compaction B2 re-run live inourios-bench’scompactionbench — CI-indicative viacompaction-bench.yml, authoritative onbaseline-8vcpu-32gibin §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 — confirmplan_candidatescovers it); the S3 atomic-swap primitive + single-writer lease for object storage (local FS usesrename); 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).
Amendment (2026-10-16, #1027, maintainer-approved) — how the candidates are read. The predicate above is unchanged. What changes is the reads it is computed from, because planning had become the sweep’s dominant cost (#1019: about one listing and one manifest read per retained hour partition, at about 55 ms each, every sweep, growing with history and not with the work there is).
Before. For each tenant, a delimiter walk enumerates the
year=/month=/day=/hour=prefixes. Then, for every sealed partition, oneGET manifest.json(a 404 when there is none) and one listing of the partition.After. For each tenant, the planner makes one recursive listing of
data/tenant_id=<enc>/(the backend pages it, 1,000 keys per page on S3). It groups the listed keys by hour partition, parsing each Hive segment in the canonical zero-padded form as today (a non-canonical segment is skipped), and keeps only keys that sit directly in a partition. Partitions are visited oldest first. For each sealed partition, with C the committed*.parquetobjects listed there and their sizes:
- |C| < 2 — not a candidate, and no further request. The live set is a subset of C whatever a manifest says, so fewer than two listed files means fewer than two live files. This covers the settled partition (one consolidated file and its manifest), a lone small file, and a partition holding only a manifest or
*.tmpobjects.- |C| ≥ 2, no
manifest.jsonlisted — the live set is C (the glob fallback). Decided from the listing; no manifest read.- |C| ≥ 2,
manifest.jsonlisted — oneGET manifest.json; the live set is C restricted to the names it lists, as today.The candidate test is then the one above: at least two live files, and either more than
compaction_min_filesor one below the small-file size. The result is the same as the per-partition reads would give on the same objects (RFC0009.9). The listing replaces those reads; it does not summarise them, so there is nothing to persist, invalidate or keep consistent between compactor processes.Visibility timing changes slightly. The per-partition reads looked at each partition when the walk reached it, so a write that landed before then was seen in the same sweep. Now the tenant is read once, at the start of its planning: a write that lands after that listing waits for the next sweep, even if the partition comes later in the walk. The delay is at most one sweep interval. A late write is never lost and never skipped for good: every sweep lists the tenant afresh (RFC0009.10).
Erasure and backfill are unaffected. Only consolidation planning changes. The RFC 0047 §3.6 erasure pass and the RFC 0048 graph backfill still enumerate every hour partition of the tenant through
hour_partitions(compactor/erasure.rs,compactor/backfill.rs) and visit each one. They never use the candidate planner. A settled single-file partition, which the planner skips without a read, is still rewritten by an erasure whose rows it holds (RFC0009.12).Cost. Per tenant per sweep: one logical listing, served as ⌈objects ÷ 1,000⌉ page requests on S3, plus one manifest read per sealed partition that holds two or more committed files and a manifest. A settled partition costs nothing beyond its two keys in the listing. At the H4 steady state of one file and one manifest per hour, a year of history is about 17,500 keys, or 18 pages. The listing is held in memory while the tenant is planned: about 100 bytes per key, so a few MB per year of history, released before the tenant’s partitions are compacted.
Late writes into a compacted partition (#1026). The sentence above (“late data that arrives after a partition is compacted … re-flags the partition”) does not hold today, and this amendment does not change that. Once a partition has a manifest, the manifest is the live set (§3.4). A late writer’s file is not in it, so readers, the planner and
visit_partition_rowsignore it, andgc_orphanswould delete it as an orphan. Under this amendment such a partition falls under case 3 (the late file makes |C| ≥ 2), so it is read every sweep, and still not selected. Fixing it changes the manifest contract and is left to its own amendment (§7).
Amendment (2026-10-16, #1033, maintainer-approved) — late writes re-flag the partition again. With the §3.4 late-write note, the sentence above holds as written. A late flush file in a compacted partition is live, so the partition has two live files (the compacted one and the late one), and a late file below the small-file size makes it a candidate. The listing planner above needs one change: a committed
*.parquetis now either a flush file or a compacted file (§3.4 note), and in case 2 (no manifest) only the listed flush files are live. Case 1 still holds, because the live set is still a subset of the listed files, and case 3 applies the §3.4 note’s live-set rule instead of “C restricted to the manifest’s names”. RFC0009.10’s second arm changes from “read, not selected” to “selected” (RFC0009.13).
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:
- reads the live set, writes the consolidated
*.parquet.tmp; renames it to its committed*.parquetname (still not referenced by any manifest, so invisible to queries);- writes
manifest.json.tmpnaming only the new file atgeneration + 1, and atomically swaps it into place (single- object rename / conditional put) — this is the commit point; - 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.
Amendment (2026-10-16, #1033, maintainer-approved) — late writes stay live (#1026).
The defect. The receiver derives a record’s partition from its own timestamp (
PartitionKey::derive), with no admission window, so a record can land in any old hour at any time. TheWriterputs a new<uuidv7>.parquetand never touchesmanifest.json. Once a partition has a manifest, the manifest above is the whole live set, so the late file is invisible to the querier (resolve_live_files,resolve_live_keys), the planner,visit_partition_rowsand the erasure rewrite, andgc_orphansdeletes it the next time the partition is consolidated. The same rule hides a flush that lands between a compaction’s listing and its commit, and, because the RFC 0047 §3.6 erasure rewrites every hour partition including the current one, every flush into the current hour after an erasure. The data was acknowledged (WAL-before-ack,CLAUDE.md§3.4) and object storage is its truth (§3.6), so this is a durability defect, not a freshness one.The fix: the manifest records what compaction consumed, not the whole live set. It rests on telling the two writers’ files apart by name.
- A flush file is what the receiver’s
Writerputs:<uuidv7>.parquet, as today. The flush path does not change.- A compacted file is what a compaction (or an erasure rewrite) writes:
<uuidv7>.g<N>.parquet, where N is the manifest generation the compaction will commit at. Only the compactor writes this form.The manifest becomes:
{"format": 2, "generation": 7, "files": ["0199f2c4-….g7.parquet"], "consumed": ["0199f1a0-….parquet", "0199f1b3-….parquet"]}
fileslists only compacted files;consumedlists only flush files.consumedis append-only: a commit adds names to it and never removes one. The live set of a partition is
- every compacted file named in
files, plus- every listed flush file that
consumeddoes not name.With no manifest,
filesandconsumedare both empty, so the live set is every listed flush file (the glob fallback, unchanged), and a compacted file is never live without a manifest that names it. One function computes this rule, shared by the querier’s two resolvers,live_file_keys,is_candidate,visit_partition_rowsandgc_orphans.Commit protocol (replaces steps 1–4 above).
- Read
manifest.jsonand itsETag(absent ⇒ generation 0, empty lists). Then list the partition. The inputs are the live set by the rule above.- Write the output as
<uuidv7>.g<N+1>.parquet. Nothing names it, so no reader sees it. The bootstrap manifest of today’s step is no longer needed: an unnamed compacted file is invisible with or without a manifest.- Commit
{format: 2, generation: N+1, files: outputs, consumed: consumed ∪ flush inputs}with the existing conditional PUT (If-Matchon theETag, or create-if-absent at generation 0) on S3, or the local atomic overwrite (RFC0019.7). This is the commit point.outputsis every file the compaction wrote, each named….g<N+1>.parquet. Todaycompact_sortedwrites exactly one (files: vec![consolidated]), sooutputshas one entry. A compaction that splits its output into the “one or few” files §3.1 allows names all of them in this one commit. Compacted inputs drop out offiles.- On a won commit, delete the inputs, best effort (unchanged). An input is never deleted before the commit that names every output has won. On a lost commit, delete every output, best effort; any that survive are orphans by the generation rule below.
Why a late file is never lost. A flush file becomes invisible only by entering
consumed, and it entersconsumedonly in a won commit whose output was written from it. A file that lands after step 1’s listing is not an input, so the commit does not consume it and it stays live. A file that lands after the commit is live by the same rule, and it makes the partition a candidate again (§3.3 note). Nothing compares clocks: the rule depends only on names and on the manifest’s contents. Becauseconsumedis never trimmed, a flush file whose put is retried after its first copy was consumed and deleted reappears under a consumed name and stays hidden; its rows are already in the output.Orphans (replaces
gc_orphans’s rule). With manifest generation G:
- a flush file is an orphan only if
consumednames it. A flush file that the manifest does not mention is live, never an orphan;- a compacted file
….g<N>.parquetis an orphan iffilesdoes not name it and N ≤ G. Its commit at generation N either won (and a later commit has since replaced it) or lost to another commit at N. If N > G, a compaction may still be about to commit it, so it is kept;*.parquet.tmpis an interrupted local put, as today.This is also what makes a lost CAS safe.
compact_sorteddeletes its unnamed output on a lost commit, but only best effort. A leftover output is a compacted file whose generation the winner has already reached, so it is never live and the next GC reclaims it. It cannot pass for a late file, because late files are flush files.Crash interleavings. Each case leaves every acknowledged row in exactly one live file:
Crash or race What is left Read as Reclaimed by Writer dies before its put completes nothing on S3; a *.parquet.tmplocallythe WAL replays the batch (RFC 0008) GC ( .tmp)Writer’s put lands during a compaction, after step 1’s listing an unconsumed flush file live, before and after the commit never; it is compacted later Compactor dies before step 3 an unnamed output at N+1 > G the inputs, unchanged GC once a later commit reaches N+1 Compactor dies after step 3, before or during step 4 consumed inputs not yet deleted the output only GC ( consumed)Compactor loses the CAS, and its output delete fails an unnamed output at N+1 ≤ G the winner’s generation GC Two compactors race at generation N (S3) one wins, the other is a lost CAS the winner’s generation as above The local backend has no conditional update, so its commit stays last-writer-wins (RFC0019.7), and it requires at most one active compactor per store at any instant. A second local compactor is unsafe today and remains unsafe. Two last-writer-wins commits can replace a manifest after the other commit’s inputs were deleted, and those rows are lost.
replicas: 1alone does not provide this. The chart’s compactor Deployment sets nostrategy, so Kubernetes defaults toRollingUpdateand can run the old and the replacement pod at the same time on the same data volume. The implementation therefore setsstrategy: { type: Recreate }on the compactor Deployment (deploy/helm/ourios/templates/compactor-deployment.yaml), so the old pod has stopped before the new one starts (RFC0009.24). On S3 an overlap is safe for the data, because of the CAS, but it still widens the #1034 tuple race, soRecreateapplies on every backend. A local deployment outside the chart must give the same exclusivity itself (§7).Reader order. A reader lists the partition first and reads the manifest second, which is the order both querier resolvers and the listing planner already use. That order cannot tear: a flush file the listing shows and the later manifest consumed is excluded, and its rows are in a compacted file that manifest names. The reverse order could miss rows, because an old manifest plus a listing taken after the inputs were deleted shows neither. The compactor reads the manifest first, but its CAS rejects any commit based on a stale read.
A resolution can still go stale before the files are opened. The store-backed resolver (
live_keys_from_listing) fetches only the manifests its listing shows, to avoid a 404GETfor every partition that has none. So if the listing comes before a partition’s first commit, the resolver applies the glob fallback to flush names that the commit then consumes and deletes. The same happens to a compacted file named by a manifest that a later commit replaces and deletes. In both cases the stale key is one whose rows a committed file already holds, because a data file is deleted only after a won commit replaces it. Opening that key returns not-found and never silently returns fewer rows. The querier therefore treats a not-found on a resolved data key as a stale resolution:
- It discards the plan, resolves the file set again (a fresh listing, then the manifests it shows), and re-runs the query.
- It does this at most twice more (three attempts in all), and only while no row has been sent to the caller.
- After that, or once rows have been sent, the not-found surfaces as a storage error that names the key. Rows are never returned short.
The local resolver reads each partition’s manifest at resolution time, so it sees a first manifest. It meets the same not-found only when a commit deletes files between resolution and execution, and it takes the same retry. A fresh manifest
GETfor every unmanifested partition would also close the first-commit case. It is not chosen, because it brings back the per-partition 404 that the listing avoids, for a race that needs a commit and its deletes to land inside one query’s resolution (RFC0009.18).Manifest size.
consumedgrows by the flush files compacted into the partition: a few dozen names per hour at today’s flush cadence, about 50 bytes each. Trimming it is deliberately not offered; a trim would reopen both the reader-order race and the retried-put case above (§7).Layout impact (breaking). The manifest gains
formatandconsumed,filesholds only….g<N>.parquetnames, and compaction output is renamed. A manifest without"format": 2is rejected with a typed error that names the partition: no dual-read and no migration tool, per the pre-production policy. In an old manifest the unnamed*.parquetfiles are inputs left behind, late files and lost-CAS outputs, all with the same name form, so no rule can tell them apart. Pre-production stores start from an emptydata/prefix. The implementation PR carries a!and aBREAKING CHANGEfooter. RFC 0005 needs no amendment: the manifest stays an optional per-partition artefact owned here, and Parquet files and their schema are unchanged.
Amendment (2026-10-16, #1033, maintainer-approved) — erasure covers late files (RFC 0047 §3.6). Today an erasure rewrites only the files a manifest names. Once late files are live, an erased conversation’s rows could reappear from one, so the erasure’s rows phase must account for every flush file that can hold rows acknowledged before the request. Three cases:
A late file that landed before the erasure. It is live, so the rewrite’s inputs include it and the commit consumes it. Nothing new is needed beyond the live-set rule. (Today this file is hidden, skipped by the rewrite, and would surface with the erased rows if it were made visible without this rule, so the rule and the erasure change land together.)
A file that lands, or is committed, while the rows phase runs. The rows phase records, for each partition it visits, a checked generation:
- after a won erasure commit, the generation that commit won; the partition’s live set is then exactly that commit’s compacted files;
- when the rewrite finds no live input and commits nothing, the generation it observed. That is 0 when there is no manifest, as in a partition holding only
*.parquet.tmpobjects or nothing live at all.An empty partition that stays empty is therefore settled, not “changed” forever. Before the marker advances to
tuples, the rows phase runs a confirming pass: it re-lists the tenant and reads the manifest of every partition. A partition must be rewritten again, with the same drop filter, if it holds a live flush file, or if its generation is no longer the one recorded for it. A generation that moved on means another commit landed since: an ordinary consolidation by a second compactor (S3 allows more than one) may have folded a late file holding the conversation’s rows into a new compacted file, which then shows no live flush file. Each rewrite or empty check records its checked generation. A partition the phase has not visited yet (it was created during the phase) has no checked generation, so it is visited. The pass repeats until a pass finds nothing to rewrite, for at most 8 rounds, the bound the tuple phase already uses. If partitions are still changing after that, the marker stays inrowsand the next sweep runs the whole rows phase again. The pass may drop rows of the conversation that were acknowledged after the request. That is over-erasure, which is the safe direction for an erasure, and it is limited to the rows phase. A confirming pass costs one listing plus one manifestGETper partition of the tenant, small next to the rewrite of every partition that the erasure already does.A row acknowledged before the request but published after the confirming pass has passed its partition. The receiver publishes from its WAL on its own cadence, in another process, and nothing tells the compactor that the receiver has published every row acknowledged before the request. A publish can therefore land after the last confirming pass has checked its partition but before the marker advances to
tuples. It can also land later still: after a long flush backlog, or after a restart that replays the WAL (RFC 0008). Later sweeps retry only the tuple deletion, so such a row stays. Neither the 8 rounds nor any delay closes this, because the race is with an unbounded publish. The rows phase does not start until the marker is older thanerasure_settle, measured as the store’sLastModifiedof the marker against the compactor’s clock. The default is twice the receiver’s maximum flush interval pluscompaction_grace. That makes the window rare in normal operation: a pre-request row would have to be still unpublishederasure_settleafter the request. It does not make the window empty. This is the recorded residual (§7, decision D3). RFC 0047 §3.6 scopes erasure to “the rows durable at the rewrite”, and this amendment states the scope as: rows published before the last confirming pass checks their partition. Closing it needs a fence that the compactor can read across processes. The candidates, none specified here, are:
- a per-receiver, per-tenant publication frontier exported to the store, which the rows phase waits on until every live receiver has published past the marker’s creation. RFC 0055’s
PUBLISHEDfrontier is that watermark, but it is node-local and RFC 0055 is stilldrafted;- receivers that read pending markers and drop the conversation’s rows at publish. This needs a receiver membership list, so the compactor knows that every receiver has seen the marker.
The rewrite is safe on unsealed partitions now. An erasure keeps rewriting every hour partition, including the current one (RFC0009.12). Under the live-set rule, a flush into a partition after its erasure rewrite is live. Before this amendment it was hidden by the manifest the rewrite left behind (RFC0009.21).
Graph tuples (RFC 0047 §3.3, RFC 0048 §3.4). The emitter has three feeds: the receiver’s flush cadence (every published batch, late files included, so a late row’s tuples are written today even while the row is hidden), the compaction pass’s
observehook, and the backfill throughvisit_partition_rows. Under this amendment the last two also see late rows, because those rows are live. All three write withon_duplicate = ignore, so this amendment adds no tuple that would not otherwise exist. That does not make the feeds free of races with erasure. A separate known residual exists today, independent of late data: with more than one compactor, a sweep’s bufferedobservetuples can be written after another sweep erased the conversation (#1034, §7 next to D3). The OpenFGA model and the tuple shapes do not change. The OpenFGA documentation assistant (2026-10-16) confirmed the one point that matters for erasure:on_duplicate = ignoreskips only a tuple that currently exists, so a tuple that an erasure deleted is created again by any later write of it. Two things follow:
- The erasure rewrite still passes no
observehook, so it never re-emits the tuples it is about to delete. The confirming pass in case 2 uses the same rewrite, so it does not re-emit them either.- In case 3, a row published after the last confirming pass re-creates its tuples at flush cadence (or at the next consolidation through
observe), and both row and grant are visible again. This is the same residual as case 3, on the graph side.
3.5 Correctness, idempotency, crash safety
- Row conservation. Compaction preserves every row value
exactly — including the raw
bodybytes — 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_idcounts 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_kindis 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 newcompactionmember in theevent_kindmapping 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 gatered(tracked in §7). The two routes:
- structured
reason— carry the file set / generation as a structuredreasonpayload. Avoids new columns, but still needs theevent_kindmember 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).
| Metric | Instrument | Unit | Attributes | Source |
|---|---|---|---|---|
ourios.compaction.sweeps | Counter | {sweep} | ourios.compaction.result | RFC 0009 §3.2 |
ourios.compaction.partitions | Counter | {partition} | — | partitions consolidated |
ourios.compaction.files | Counter | {file} | — | input files merged away (H4) |
ourios.compaction.rows | Counter | {row} | — | rows rewritten (RFC0009.2) |
ourios.compaction.io | Counter | By | ourios.io.direction | bytes read / written |
ourios.compaction.duration | Histogram | s | ourios.compaction.result | sweep wall-clock |
ourios.compaction.orphan.files | Counter | {file} | — | inputs left un-GC’d (gc_failures) |
ourios.compaction.backlog | UpDownCounter | {partition} | ourios.tenant | sealed-but-uncompacted (lag) |
ourios.storage.parquet.file.size | Histogram | By | ourios.tenant | H4 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) — mirrorsdisk.io.direction; oneiocounter with a direction attribute rather than two_in/_outmetrics.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.
Amendment (2026-10-16, #1039, maintainer-approved) — the orphan GC pass gets a counter. The table gains this row:
Metric Instrument Unit Attributes Source ourios.compaction.orphan.reclaimedCounter {file}ourios.tenant(required);error.type(iff the delete failed)the GC pass’s orphan delete attempts (RFC0009.4) What it counts. Each delete
gc_orphansissues for an orphan file, once, under the tenant whose partition holds it. A successful delete response adds one with noerror.type. A failed delete adds one witherror.type = io, and the file is left for a later pass, which counts it again when it tries again. Only an explicit not-found result adds nothing. The count is of successful delete responses, not of files known to have existed: S3 answers aDELETEof an absent key with success (Store::delete_blocking), so a file another actor removed between the listing and the delete is still counted there. On the local backend an absent file is a not-found and is not counted. A partition whose GC scan fails (an unreadable manifest, a failed listing) deletes nothing and adds nothing here; the scan failure is a sweep error and marks the phase span (RFC 0038 §3.9). This follows OpenTelemetry’s guidance to report successes and failures on one metric and tell them apart byerror.type(recording errors). The failed fraction isreclaimed{error.type="io"} / reclaimed.What stays.
ourios.compaction.orphan.fileskeeps its meaning (the superseded inputs the committing rewrite could not delete,gc_failures) and stays without attributes. The two are distinct events: an input left by a rewrite becomes an orphan, and a later GC pass reclaims it, so one file can appear on both counters.When the GC runs. The sweep reclaims orphans in one pass, after the consolidation pass and before the RFC 0047 erasure pass. Each candidate’s GC still runs after its own rewrite, which is what RFC0009.17 needs: an output a crashed compaction wrote for the generation this sweep commits is dead only once that commit is in. A partition whose rewrite failed is still GC’d, as before. Moving the GC out of the per-partition loop changes when, not what, it deletes: no file becomes deletable earlier. Erasure rewrites still run no GC.
Which partitions: eligibility from the listing. Running the GC only on candidates is not restart-safe. If a sweep commits partition A and the process stops before A’s GC (while rewriting B, or between A’s commit and its cleanup, a window that already existed), A is left with one live file beside a dead one. A is then no longer a candidate, and nothing would visit it again. So the planner (§3.3) also returns, from the same one listing per tenant, the sealed partitions that are not candidates but whose listing shows an object
gc_orphanswould delete, judged by the GC’s own rule (the §3.4 late-write live set,LiveSet::is_orphan):
- a
*.parquet.tmp(an interrupted publish), with no manifest read; or- a listed committed file that is an orphan under that rule: a consumed flush file, or a compacted file the manifest does not name whose generation the manifest has reached. The rule is applied with the manifest the planner already reads for the candidate test (it reads one exactly when two or more committed files are listed beside it), or with no manifest when none is listed, which is what
gc_orphansitself sees, so this costs no extra request.Where a manifest is listed but not read (one committed file beside it), a lone file cannot be an orphan beside a live one, so only a
*.parquet.tmpmakes the partition eligible. The selection matches the GC’s verdict, including the RFC0009.17 generation guard: an unnamed compacted file for a generation past the manifest’s (an in-flight output) and an unconsumed flush file (a live late write) are kept by the GC and do not get a partition visited. A settled partition, one with no orphan, still costs nothing beyond its share of the listing.gc_orphansstill decides each object when it runs. The selection is derived from the listing alone, so it needs no persisted state and survives a restart: the next sweep that lists the partition finds the orphan. Only sealed partitions are visited, as before, so a writer’s in-progress publish in an open hour is never touched. The RFC 0038 §3.8 planning counts are unchanged, since no request is added.
Validation gate. This set is the OpenTelemetry semantic- conventions registry at
semconv/registry/, validated byweaver registry checkin CI (thesemconvjob, 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 generaterenders the registry into a dependency- free leaf crateourios-semconv(const &strper metric / attribute, mirroring upstreamopentelemetry-semantic-conventions), which every instrumented crate depends on. The generator template lives attemplates/registry/rust/; regenerate with the same command CI runs (--futurematchesweaver registry check --future):weaver registry generate rust crates/ourios-semconv/src \ -t templates -r semconv/registry --future cargo fmt -p ourios-semconvThe same
semconvCI 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 theCLAUDE.md§7 layout; the commitment is blessed here, the same wayourios-telemetrywas 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
compactorrole/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.)
Amendment (2026-10-16, #1033, maintainer-approved) — late writes (#1026). Three fix directions were filed with the issue. The recommended design (§3.4 late-write note) is a fourth. Each was weighed against the same requirements: a late file is visible to queries from the moment its put completes, it counts toward the candidate predicate like any other live file (so it is compacted once §3.3 selects its partition; two large live files below
compaction_min_filesstay unselected, as RFC0009.10 states), it is never deleted as an orphan under any crash or lost CAS, the flush path stays as it is, and the design works with a writer and a compactor in separate processes (the chart’s separate compactor Deployment) on S3 and on the local backend.
- (1) The writer appends its file to the manifest by CAS. It keeps
the manifest as the positive live set, and it is the runner-up.
Rejected for four reasons:
- It puts a manifest
GETand a conditionalPUTon every flush into a manifested partition (two S3 round trips, about 55 ms each), and those writes race the compactor’s swap, so both sides need retry loops and the compactor’s commit becomes a merge. - The local backend has no conditional update (
LocalFileSystemrejectsPutMode::Update). A receiver and a compactor in separate processes on one host would overwrite each other’s manifest and lose the late file again. - Between the writer’s put and its append, the file is unnamed and looks like an orphan. A GC in that window deletes it, and the append then names a missing file. Closing that needs an age gate on GC, which is a clock, and a WAL checkpoint held until the append, which is a change to the RFC 0052 publish barrier.
- Every partition with no manifest needs a create-if-absent race between the writer and the compactor’s bootstrap.
- It puts a manifest
- (2) A UUIDv7-time rule. The manifest records its commit instant,
and an unnamed
*.parquetwhose UUIDv7 time is later is live. Rejected. The UUIDv7 time comes from the receiver’s clock and the commit instant from the compactor’s, so a late file from a receiver whose clock runs behind counts as an orphan and is deleted. A compaction output, which also has a UUIDv7 name, that a compactor opened after a concurrent commit counts as live and double-counts its rows. That happens whenevercompact_sorted’s best-effort delete of a lost-CAS output fails. Both failures are silent. - (3) The compactor folds unnamed files into the manifest after a
settle delay. Rejected. The data is invisible for the delay, and
folding cannot tell a late file from an input a crashed compaction
left behind or a lost-CAS output, since all three are unnamed
<uuidv7>.parquet. Folding the wrong one double-counts rows. Telling them apart needs the flush/compacted name split, and with that split no delay is needed. - Recommended: (4) the manifest records what compaction consumed. Flush and compacted files have distinct names, the manifest names live compacted files and consumed flush files, and every other flush file is live. The flush path does not change, there is no window of invisibility, and no clocks are compared. A compacted file’s name carries the generation it was written for, so GC never touches an in-flight output, and a lost-CAS leftover can never be mistaken for a late file. The cost is a breaking manifest format and a manifest that grows with the flush files compacted into each partition (§3.4 note).
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_idcounts 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_idand 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.
Amendment (2026-10-16, #1027, maintainer-approved) — planning cost. RFC0009.8–.12 cover the §3.3 note on how candidates are read.
-
RFC0009.8 — a settled partition costs no request of its own
[H4 / #1019]- Given a tenant with many sealed partitions, each holding one
committed
*.parquet(some with amanifest.json, some without) - When its candidates are planned
- Then none is selected, and planning makes exactly one logical
recursive listing of the tenant’s prefix: no request per partition
and no
GET. The backend may serve that listing as several page requests (1,000 keys per page on S3); the page count follows the tenant’s object count, not its partition count.
- Given a tenant with many sealed partitions, each holding one
committed
-
RFC0009.9 — the selection is unchanged
[H4 / RFC0009.1]- Given any set of sealed and unsealed partitions, each with any
number of committed files of any size, with or without a manifest
naming any subset of them, and with orphans and
*.parquet.tmpobjects - When its candidates are planned
- Then the selection, in order, is exactly what the per-partition reads (a manifest read and a partition listing for every sealed partition) select on the same objects.
- And a partition whose listing holds two or more committed files and a manifest is selected or skipped by the manifest’s live set, never by the listing alone.
- Given any set of sealed and unsealed partitions, each with any
number of committed files of any size, with or without a manifest
naming any subset of them, and with orphans and
Amendment (2026-10-16, #1033, maintainer-approved, late writes) — RFC0009.9’s oracle. The comparison stays: the listing planner must select exactly what per-partition reads select on the same objects. Both sides change their live-set rule to the format-2 rule of the §3.4 late-write note. The oracle’s per-partition read becomes “the compacted files the manifest names, plus the listed flush files it does not consume”, replacing “the listed files restricted to the manifest’s names”. The generated fixtures are in the new layout:
- flush files and
….g<N>.parquetcompacted files;- format-2 manifests whose
filesname any subset of the compacted files and whoseconsumednames any subset of the flush files;- orphan compacted files with N ≤ G and in-flight ones with N > G;
*.parquet.tmpobjects.Under the old oracle, one compacted file plus an unconsumed small flush file was skipped; under the new one it is selected (RFC0009.13). The two-file non-candidate unit case stays, rebuilt as a manifest naming one compacted file plus an orphan compacted file (unnamed, N ≤ G): two listed files, one
GET, one live file, not selected.
-
RFC0009.10 — a late write into a settled partition is re-planned
[H4 / §3.3]- Given a sealed partition with no manifest and one committed file at or above the small-file size, which a planning pass did not select
- When a writer later lands a second committed file in it, below the small-file size, and candidates are planned again
- Then the partition is selected. (A late file at or above the
small-file size leaves two large files, which §3.3 does not select
until there are more than
compaction_min_files; seeplan_skips_when_files_are_large_and_few.) - And given a compacted partition (a manifest naming its one file) that receives a late file, the next pass reads its manifest rather than skipping it. Whether it is then selected is #1026’s to decide; until then it is not, as today.
-
RFC0009.11 — planning keeps no state
[§3.6 / multiple compactors]- Given a store with sealed candidates
- When candidates are planned, by one planner or by two independent ones over the same store
- Then no object is written, and both select the same partitions.
-
RFC0009.12 — erasure still reaches a settled partition
[RFC 0047 §3.6 / CLAUDE.md §3.7]- Given a sealed partition with one live file at or above the small-file size, which candidate planning skips, holding rows an erasure request matches
- When a sweep runs the erasure
- Then the partition is rewritten without the matching rows, and the other rows are kept.
Amendment (2026-10-16, #1033, maintainer-approved) — late writes stay live. RFC0009.13–.24 cover the §3.4 late-write note and the erasure note after it. RFC0009.13 replaces RFC0009.10’s second arm.
-
RFC0009.13 — a late write into a compacted partition is read and compacted
[CLAUDE.md §3.4 / §3.6 / #1026]- Given a partition with two flush files (templates 1 and 2), compacted so that the manifest names only the output
- When a third flush file (template 3, below the small-file size) lands in the same hour
- Then the querier and
visit_partition_rowsreturn templates 1, 2 and 3, andplan_candidatesselects the partition. - And when the partition is compacted again, the output holds all three rows, the late file is consumed, and no row is counted twice.
- And
gc_orphansrun at any point before that compaction leaves the late file in place.
-
RFC0009.14 — no unconsumed flush file is ever deleted
[CLAUDE.md §3.4 / RFC0009.4]- Given any interleaving of writer puts, compaction steps 1–4,
lost CASes, failed deletes, crashes at each step, and
gc_orphanspasses over one partition - When the partition is read after every step
- Then every row of every completed flush put is read exactly
once, and the only flush files ever deleted are ones a won commit’s
consumednames.
- Given any interleaving of writer puts, compaction steps 1–4,
lost CASes, failed deletes, crashes at each step, and
-
RFC0009.15 — a flush that lands during a compaction stays live
[RFC0009.3]- Given a compaction that has listed its inputs and not yet committed
- When a flush file lands in the partition and the compaction then commits
- Then the committed manifest does not consume the new file, and reads before and after the commit both include its rows.
-
RFC0009.16 — a lost-CAS leftover never reads as live
[RFC0009.3 / RFC0009.4]- Given two compactions of one partition at generation N on a CAS backend, where the loser’s delete of its output fails
- When the partition is read, and then
gc_orphansruns - Then the read returns the winner’s generation only, without the leftover output, and GC deletes the leftover.
-
RFC0009.17 — GC keeps an in-flight output
[RFC0009.4]- Given a manifest at generation G and an unnamed compacted file written for generation G + 1
- When
gc_orphansruns - Then the file is kept. Once a commit reaches G + 1 without naming it, the next GC deletes it.
-
RFC0009.18 — reads do not tear between the listing and the manifest
[RFC0009.3]- Given a reader that has listed a partition and not yet read its manifest
- When a compaction commits and deletes its inputs in between
- Then the reader returns every row exactly once.
- And given a partition with no manifest when the store-backed resolver lists it, when that partition’s first commit creates the manifest and deletes the inputs before the query opens them, then the query re-resolves on the not-found and returns every row exactly once. It never returns fewer rows.
- And when the resolution goes stale on all three attempts, or rows have already been sent, the query fails with a storage error naming the key, and does not return a short result.
-
RFC0009.19 — a manifest in the old format is rejected
[CLAUDE.md §3.5]- Given a partition whose
manifest.jsonhas no"format": 2 - When the querier, the planner or the compactor reads it
- Then each fails with a typed error naming the partition, and none of them falls back to globbing or deletes anything.
- Given a partition whose
-
RFC0009.20 — erasure covers a late file that landed before it
[RFC 0047 §3.6]- Given a compacted partition plus a late flush file holding rows of conversation c
- When an erasure of c completes
- Then no row of c is read from the partition, and the late file is consumed.
-
RFC0009.21 — after an erasure, the current hour still takes writes
[CLAUDE.md §3.4]- Given an erasure that rewrote the tenant’s current, unsealed hour partition
- When a flush file lands in that hour afterwards
- Then its rows are read.
-
RFC0009.22 — the confirming pass catches what changed during the rows phase
[RFC 0047 §3.6]- Given an erasure of c whose rewrite of a partition has committed at generation E, and a flush file with a row of c that lands there before the confirming pass
- When the rows phase finishes
- Then the marker advances only after a pass finds every partition at its recorded generation with no live flush file, and no row of c is read.
- And given the same late file, when a second compactor’s ordinary consolidation commits it into a new compacted file at E + 1 before the confirming pass, the pass sees the generation change, rewrites the partition, and no row of c is read.
- And given a partition holding only
*.parquet.tmpobjects (no live input, no manifest), the erasure records checked generation 0. The confirming pass, finding it still at 0 with no live flush file, treats it as settled, and the marker advances totuples. - And if partitions keep changing for 8 rounds, the marker stays
in
rowsand the sweep reports it. - And (residual, accepted under D3) a pre-request row whose flush publishes after the last confirming pass has checked its partition is not erased. The marker advances, the row is read, and its tuples are written again. The test pins this behaviour, so the residual cannot quietly change without a scenario change.
-
RFC0009.23 — the rows phase waits for
erasure_settle[RFC 0047 §3.6]- Given an erasure marker younger than
erasure_settle - When a sweep runs
- Then no partition is rewritten for it and the marker stays in
rows. Once the marker is older, the next sweep runs the rows phase.
- Given an erasure marker younger than
-
RFC0009.24 — at most one active compactor through a rollout
[§3.4 local backend / #1034]- Given the Helm chart with the compactor enabled
- When its manifests are rendered, for any values
- Then the compactor Deployment has
replicas: 1andstrategy: { type: Recreate }, so an upgrade stops the old pod before it starts the new one, and two compactor pods never share the data volume.
The following scenario is added by the 2026-10-16 orphan-GC amendment (§3.6, #1039, maintainer-approved).
-
RFC0009.25 — the orphan GC pass is counted per tenant
[CLAUDE.md §6.3]- Given two tenants’ sealed candidates, one holding two orphan files a crashed compaction left, the other none
- When a sweep runs
- Then
ourios.compaction.orphan.reclaimedgains 2 under the first tenant with noerror.type, and no point under the second, andourios.compaction.orphan.filesis unchanged. - And when the orphan’s delete fails, the counter gains 1 under the
tenant with
error.type = io, the file is still listed, and the next sweep that deletes it adds 1 with noerror.type. - And every partition’s GC runs after the sweep’s last consolidation commit, and RFC0009.17’s sweep test still reclaims the crashed output once.
-
RFC0009.26 — an orphan a stopped sweep left is reclaimed by the next
[RFC0009.4]- Given a partition whose compaction committed and whose process
stopped before the GC pass, leaving one live compacted file beside a
dead output (or a stray
*.parquet.tmpin a sealed hour) - When a later sweep runs
- Then the partition is not a candidate and is not rewritten, its GC runs and deletes the dead object, and every row is read once.
- And planning it costs no request beyond the listing and the one manifest read the candidate test already makes, and once reclaimed the partition is settled and costs no manifest read.
- And a
*.parquet.tmpin an unsealed hour is never planned for GC.
- Given a partition whose compaction committed and whose process
stopped before the GC pass, leaving one live compacted file beside a
dead output (or a stray
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-parquetwriter, compact, assert file-size/count and that aQuerierreturns 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:
SIGKILLthe 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.
Amendment (2026-10-16, #1027, maintainer-approved) — test map for RFC0009.8–.12. RFC0009.8–.11 are in
ourios-parquet’s compaction suite, against a local store seen through a request-countingObjectStorewrapper. On the local backend one logical listing is onelistcall on the wrapper, so the wrapper counts logical listings, not the S3 pages behind them.
Scenario Test Kind RFC0009.8 rfc0009_8_settled_partitions_cost_no_request_of_their_own— 48 settled partitions (half with a manifest): noGET, exactly one logical listing, the same as a 1-partition tenantintegration RFC0009.9 rfc0009_9_listing_plan_selects_what_per_partition_reads_select— proptest over partitions × files × sizes × manifest subsets × orphans/tmp; the oracle is the pre-amendment per-partition predicate, kept in the test module (the late-write amendment changes the oracle’s live-set rule and fixtures to format 2; see the note under RFC0009.9)property RFC0009.9 rfc0009_9_a_manifest_decides_when_two_files_are_listed— two listed files, a manifest naming one: not selected, oneGET(under the late-write amendment, the two files are a named compacted file and an orphan compacted file)unit RFC0009.10 rfc0009_10_a_late_write_into_a_settled_partition_is_replanned— one file above the small-file size, planned (skipped, noGET); a late file below the small-file size lands; planned again (selected)integration RFC0009.10 rfc0009_10_a_late_write_into_a_compacted_partition_is_read— compacted partition plus a late file: oneGETof its manifest, not selected (pins today’s #1026 behaviour; that fix updates this assertion)integration RFC0009.11 rfc0009_11_planning_writes_nothing_and_two_planners_agree— store listing unchanged after planning; twoStorehandles plan the same listintegration RFC0009.12 rfc0009_12_erasure_rewrites_a_settled_partition_the_planner_skips(inourios-ingester’s compactor suite, next to the erasure tests) — one file above the small-file size, confirmed skipped byplan_candidates; an erasure sweep rewrites it, drops the matching row, keeps the otherintegration The existing
plan_*tests stay as they are; they pin the predicate the amendment keeps.
Amendment (2026-10-16, #1033, maintainer-approved) — test map for RFC0009.13–.24.
ourios-parquet’s compaction suite holds the storage scenarios, theourios-querierfile-set tests hold the reader ones, andourios-ingester’s compactor suite holds the erasure ones. Crash points are driven through a fault-injectingStorewrapper that fails or stops before a named step, the same way the RFC0009.4 tests stop before and after the swap.
Scenario Test Kind RFC0009.13 rfc0009_13_a_late_write_into_a_compacted_partition_is_read_and_compacted: the #1026 repro as written in the issue (write_file×2,compact_partition, latewrite_file), thenvisit_partition_rowsreturns templates [1, 2, 3],plan_candidatesselects the partition,gc_orphanskeeps the late file, and a secondcompact_partitionconsumes itintegration RFC0009.13 rfc0009_10_a_late_write_into_a_compacted_partition_is_read(RFC0009.10’s second arm) changes its expectation from “not selected” to “selected”; it is a contract change that this amendment approvesintegration RFC0009.13 rfc0009_13_querier_reads_a_late_file_beside_a_compacted_one: through bothresolve_live_files(local) andresolve_live_keys(store)integration RFC0009.14 rfc0009_14_no_unconsumed_flush_file_is_deleted: proptest over interleaved operations (flush put, compaction steps 1–4 with a crash at any step, lost CAS, failed delete, GC). The oracle is the multiset of rows from completed flush puts, compared after every stepproperty RFC0009.15 rfc0009_15_a_flush_between_listing_and_commit_stays_live: a hook between step 1 and step 3 writes a flush fileintegration RFC0009.16 rfc0009_16_a_lost_cas_leftover_is_never_live: a CAS-simulating store, with the loser’s delete failingintegration RFC0009.17 rfc0009_17_gc_keeps_an_output_for_a_future_generationunit RFC0009.9 rfc0009_9_*(above): the oracle and generators move to the format-2 live-set rule, and the two-file non-candidate case uses an orphan compacted file. This is a contract change to RFC0009.9’s test, approved with this amendmentproperty, unit RFC0009.18 rfc0009_18_list_then_manifest_does_not_tear: commit and input deletes injected between the resolver’s listing and its manifest readintegration RFC0009.18 rfc0009_18_first_manifest_after_listing_re_resolves: a partition with no manifest is listed; its first commit and the input deletes are injected before execution; the query re-resolves and returns every row once, throughresolve_live_keysintegration RFC0009.18 rfc0009_18_stale_three_times_is_a_storage_error: a not-found injected on every attempt gives a storage error naming the key, and no partial resultintegration RFC0009.19 rfc0009_19_an_old_format_manifest_is_rejected: querier, planner andcompact_partition, with the store unchanged afterwardsunit RFC0009.20 rfc0009_20_erasure_rewrites_a_late_file_that_predates_itintegration RFC0009.21 rfc0009_21_a_flush_after_an_erasure_rewrite_is_read: the current-hour caseintegration RFC0009.22 rfc0009_22_confirming_pass_rewrites_a_flush_during_the_rows_phaseandrfc0009_22_rows_phase_stays_open_while_flushes_keep_arrivingintegration RFC0009.22 rfc0009_22_confirming_pass_catches_a_consolidation_since_the_erasure: a secondSweephandle consolidates the late file between the erasure rewrite and the confirming passintegration RFC0009.22 rfc0009_22_a_tmp_only_partition_does_not_block_the_marker: a partition with only a*.parquet.tmpobject; the erasure finishes its rows phase and advancesintegration RFC0009.22 rfc0009_22_residual_a_publish_after_the_last_pass_is_not_erased: a flush is injected after the final confirming listing and before the marker advances; the row stays readable (pins D3’s residual)integration RFC0009.23 rfc0009_23_rows_phase_waits_for_erasure_settle: an injected clock against the marker’sLastModifiedunit RFC0009.24 a deploy/helm/render-tests.shassertion (run bydeploy-test.yml): the rendered compactor Deployment carriesstrategy.type: Recreateandreplicas: 1, with defaults and with the per-role overrides set. The chart change is part of the implementation:strategy: { type: Recreate }indeploy/helm/ourios/templates/compactor-deployment.yamlchart render The existing
rfc0009_4_*GC tests keep their assertions, with one exception below. Their fixtures are rebuilt in the new layout (outputs named….g<N>.parquet, superseded inputs listed inconsumed), which the layout break requires.Test-contract change, pending separate maintainer approval.
rfc0009_4_pre_commit_orphan_consolidated_is_reclaimable(crates/ourios-parquet/src/compaction/tests/mod.rs) cannot keep its immediate-reclaim assertion. It models a compactor that crashed before its commit: the manifest is at generation 1 and the consolidated output is present but unnamed. Under RFC0009.17 that output is written for generation 2, so it is an in-flight output, and GC at generation 1 must keep it. Renaming the fixtures cannot preserve both the crash scenario and the assertion. The proposed test, renamedrfc0009_4_pre_commit_orphan_consolidated_is_reclaimed_once_generation_passes:
- Kept unchanged: two flush inputs, then a manifest at generation
- The output is written and not named, and it is now named
….g2.parquet. The reader sees only the inputs (read_partition_rows == originals).- Changed:
gc_orphansat generation 1 returnsOrphanGc { reclaimed: 0, failures: 0 }, and the….g2.parquetoutput is still listed. Today the assertion isreclaimed: 1.- Kept unchanged: the inputs stay live (
live_keys(..).len() == inputs.len()) and are intact (read_partition_rows == originals).- Added: another commit reaches generation 2 without naming that output, for example a
compact_partitionof the same inputs, which writes and names its own….g2.parquet. A subsequentgc_orphansthen returnsreclaimed: 1for the crashed output. The rows read after it equaloriginals, each exactly once.The reclaim is not dropped, only delayed until the generation passes, which is exactly what RFC0009.17 requires. No other assertion is removed. Under
CLAUDE.md§6.2 this is a contract change: approving the amendment does not approve it. The implementation PR changes this test only after the maintainer has approved this wording.
Amendment (2026-10-16, #1039, maintainer-approved) — test map for RFC0009.25–.26. In
ourios-ingester, beside the existing compaction metric tests, over an in-memory metric reader.
Scenario Test Kind RFC0009.25 rfc0009_25_reclaimed_orphans_are_counted_per_tenant: two tenants, one with two orphans; the counter’s points per tenant, andorphan.filesunchangedintegration RFC0009.25 rfc0009_25_a_failed_orphan_delete_carries_error_type: an orphan in a partition directory made read-only;error.type = io, the file still listed; after the directory is restored, the next sweep counts it withouterror.typeintegration RFC0009.25 rfc0009_17_sweeps_reclaim_an_output_a_crashed_compaction_left(existing, unchanged): the crashed output is reclaimed once under the moved passintegration RFC0009.26 rfc0009_26_a_later_sweep_reclaims_what_an_interrupted_one_left(ourios-ingester):compact_partitioncommits over a crashed output and stops; a sweep compacts nothing and reclaims the output; rows read onceintegration RFC0009.26 rfc0009_26_a_settled_partition_with_a_dead_output_is_reclaimable(ourios-parquetplanner): planned for GC with one manifest read; after the GC, settled with noneunit RFC0009.26 rfc0009_26_a_stray_tmp_makes_only_a_sealed_partition_reclaimable(ourios-parquetplanner): no manifest read; unsealed never plannedunit
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.jsonwith 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:
renameis atomic. S3: needs conditional-put / versioned-put or a single-writer lease. Which object-store abstraction (and doesobject_storegive 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_gracedefault. - 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
reasonpayload vs. OPTIONAL audit columns (RFC 0005 §3.8 additive amendment). Per §3.6 the template columns are non-nullable andevent_kindhas no compaction member, so the structured-reasonroute 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.
Amendment (2026-10-16, #1027, maintainer-approved) — planning cost (#1019 item 2).
Alternatives considered for the planning cost. The recommended
design is the one-listing planner in the §3.3 note. Each alternative
below was weighed against the same constraint: never skip a partition
that the §3.3 candidate predicate selects, meaning at least two live
files and either more than compaction_min_files of them or one below
the small-file size. (Wording narrowed by the late-write amendment: a
partition with two large live files is not selected, as RFC0009.10
states.)
- A per-tenant “settled-through” watermark in object storage. The
compactor records “every partition up to hour H was settled when I
looked”, and the planner skips them. Rejected: a late write lands in
any old hour at any time (the partition comes from the record’s own
timestamp, and the receiver has no admission window), and the writer
runs in the receiver, a different process from the compactor (§3.2,
the chart’s separate
compactorDeployment). Lowering the watermark safely needs the writer to record a dirty mark before its put, and the compactor to consume marks only once their puts must have landed. That puts a new object-store write on the flush path, adds a second artefact to RFC 0005 with its own migration story, and still rests on an assumption about how long a put can take. The one-listing planner needs none of it. - An in-memory cache of settled partitions, invalidated by writes. Rejected: the compactor does not see the receiver’s writes (separate processes), so nothing would invalidate it. A cache with a periodic full re-check bounds the delay but breaks the constraint above in between.
- A manifest cache keyed by
ETag. Keep each partition’s parsed manifest in memory and re-read it only when the listing shows a newETag. Not needed: after the §3.3 note, only partitions with two or more listed files read a manifest, and those are the candidates and the #1026 case. It also needs the listing to carryETags, which the store seam does not expose today. Revisit if those reads show up. - One recursive listing per day prefix instead of per tenant. Bounds memory to one day of keys, at about 24× the requests of the tenant listing (still about 50× fewer than today). Kept as the fallback if a tenant’s listing ever gets too large to hold (see below).
- Keep the per-partition reads but drop the manifest
GETwhen the partition listing shows one file. Halves the cost and stays O(history). Subsumed by the recommended design.
Open (amendment):
- Late writes into a compacted partition (#1026). The manifest
hides them from readers and the planner, and
gc_orphanswould delete them. The fix changes the §3.4 manifest contract (the writer appends to the manifest, or a file newer than the manifest’s commit counts as live). Its own amendment; RFC0009.10’s second arm is updated with it. - Listing size. The tenant listing is materialised while the tenant is planned (§3.3 note). Stream it, or fall back to per-day listings, if a tenant’s key count ever makes that matter. At the H4 steady state it is a few MB per year of history.
- A sealed non-candidate with two or more listed files (two
large files, or one live file plus an orphan that
gc_orphansnever reaches because the partition is never a candidate) reads its manifest every sweep. Bounded by the number of such partitions, not by history. Runninggc_orphanson them would clear the orphan case.
Amendment (2026-10-16, #1033, maintainer-approved) — late writes. The first open item above (#1026) and the original “late-arriving data” item are answered by the §3.4 late-write note.
Decided (late-write amendment, maintainer approval 2026-10-16): the maintainer took the recommended option for each decision.
- D1. Design: the consumed-set manifest (§3.4 note, §4 option 4). Flush and compacted files are told apart by name, the manifest names live compacted files and consumed flush files, and every other flush file is live. The runner-up, the writer CAS-append (§4 option 1), is rejected: it puts a CAS on the flush path, has no local-backend primitive, and needs a GC age gate.
- D2. Layout break: old manifests are rejected, and stores start
from an empty
data/prefix (§3.4 note), per the pre-production policy. There is no dual-read and no one-shot conversion. The implementation PR carries a!and aBREAKING CHANGEfooter. - D3. Erasure scope: the narrower guarantee, with its residual
tested.
erasure_settleplus the generation-keyed confirming pass cover every row published before the last confirming pass checks its partition, including rows that a second compactor folds in meanwhile. The accepted residual is that a pre-request row whose flush publishes after that pass is not erased. That covers a flush still pending when the last pass checks its partition, a long flush backlog, and a WAL replay after an outage longer thanerasure_settle(§3.4 erasure note, case 3). RFC0009.22’s residual arm pins it. The fences that would close it are recorded for a later RFC, each a design of its own:- a per-receiver publication frontier exported to the store, which the
rows phase waits on. It builds on RFC 0055’s
PUBLISHED, which is node-local anddrafted; - receivers that drop a marked conversation’s rows at publish, plus a receiver membership list, so the compactor knows that every receiver has seen the marker;
- a completed marker kept as a tombstone for a retention window, with
the drop filter applied, ahead of
observe, in every compaction of the tenant during it. This catches late publishes at the next compaction, but the rows are readable until then and new rows of the same id are erased too.
- a per-receiver publication frontier exported to the store, which the
rows phase waits on. It builds on RFC 0055’s
- D4. Compacted-file name:
<uuidv7>.g<N>.parquet, where N is the generation the compaction commits at. A flush name can never take this form.
Known residual, separate from D3 and pre-existing (#1034): sweep A
buffers the tuples its observe hook derives during the blocking pass
and writes them in its graph phase afterwards (compactor/daemon.rs
sweep_deriving, compactor/graph_phase.rs). With more than one
compactor, sweep B can erase a conversation in between, and A then
creates the deleted tuples again. Only the grant comes back, not the
rows. Neither the generation-keyed confirming pass nor the erasure
rewrite’s missing observe hook prevents it. This amendment neither
causes nor fixes it. #1034 tracks it as an RFC 0047 question, and the
Recreate rollout strategy (RFC0009.24) narrows it to deployments that
deliberately run more than one compactor.
Pending separate approval: the
rfc0009_4_pre_commit_orphan_consolidated_is_reclaimable test-contract
change (§6 late-write test map).
Open (late-write amendment):
- Trimming
consumed. It is append-only (§3.4 note). A trim is safe only for a name that has been deleted and can no longer reappear through a retried put, and only for readers that list first. Revisit if manifest size shows up, which needs a high flush rate and many late files per partition. - A second local-backend compactor. It is unsafe today and stays
unsafe, because
LocalFileSystemhas no conditional update. The chart pins one replica and, with this amendment, uses theRecreatestrategy (RFC0009.24), so a rollout never overlaps two pods. A deployment outside the chart must provide the same exclusivity itself. A lock file would be the fix if a local multi-compactor deployment is ever wanted. -
erasure_settledefault. Twice the receiver’s maximum flush interval pluscompaction_grace. Where it lives depends on the RFC 0004 configuration surface. - WAL replay duplicates. Unchanged by this amendment: a replayed batch is a new flush file under a new name. Whether replay can duplicate an already published batch is RFC 0008/0052’s question.
8. References
docs/hazards.mdH4 (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.mdD2 (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).
9. Amendment history
- 2026-10-16 — planning reads a tenant once (#1027, maintainer-approved). Candidate planning lists each tenant’s data prefix once and reads a partition’s manifest only when two or more committed files are listed there, so a settled partition costs no request of its own (§3.3 note). The candidate predicate is unchanged and no state is kept. Erasure (RFC 0047 §3.6) and the graph backfill (RFC 0048) keep walking every hour partition (RFC0009.12). New scenarios RFC0009.8–.12 (§5) and their test map (§6); alternatives and open questions in §7. Records the late-write gap #1026 (§3.3 note, §7). Refs #1019.
- 2026-10-16 — late writes stay live (#1033, maintainer-approved
with D1–D4 as recommended). The manifest names live compacted files and consumed
flush files, and every other flush file is live. A late write into a
compacted partition, a flush during a compaction, and a flush into an
hour an erasure already rewrote are all read and later compacted, and
never deleted as orphans. Compaction output is named
<uuidv7>.g<N>.parquet, so GC tells a lost-CAS leftover or an in-flight output apart from a late file without comparing clocks. The RFC 0047 §3.6 erasure covers late files through the live set, a generation-keyed confirming pass and anerasure_settledelay. A pre-request row published after the last confirming pass is a recorded residual (D3). A query whose resolution goes stale (a first manifest, or deletes, after its listing) re-resolves on the not-found. RFC0009.9’s oracle moves to the format-2 rule. Old manifests are rejected (breaking, no dual-read). Local commits need one active compactor, so the chart’s compactor Deployment usesstrategy: Recreate(RFC0009.24). §3.3 and §3.4 notes, §4 options, RFC0009.13–.24, test map, decisions D1–D4 in §7. Therfc0009_4_pre_commit_orphan_consolidated_is_reclaimabletest-contract change (§6) awaits separate approval. Refs #1026. - 2026-10-16 — orphan GC is observable (#1039, maintainer-approved).
Adds
ourios.compaction.orphan.reclaimed, a per-tenant counter of the files the orphan GC deleted or failed to delete (error.typeon a failure; the count is of successful delete responses), and moves the GC into one pass after the consolidation pass so it can carry RFC 0038’sreclaim orphansphase span. The pass visits the candidates and every sealed partition whose listing shows an orphan, so a sweep stopped before the pass loses nothing. The GC rule and RFC0009.17 are unchanged, andourios.compaction.orphan.fileskeeps its meaning. §3.6 note, RFC0009.25–.26, test map. The new names go to the ourios-semconv registry.
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 gatedacceptedon 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 indocs/benchmarks.md§7.Status note.
green(2026-06-15) — all eight §5 acceptance scenarios (RFC0010.1–.8) have live, passing tests incrates/ourios-querier/tests/it/drift.rs: drift returns drifted templates with counts (.1), half-open[from, to)window (.2),event_typescoping excludes non-widenings (.3), tenant isolation (.4), empty-is-empty-not-error (.5),widening_countdesc /template_idasc ordering (.6), aggregate version/time bounds (.7), no DataFusion/SQL leakage (.8). The dedicateddriftsurface (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) tocrates/ourios-querier/tests/drift.rs::h5_3_drift_query_returns_templates_that_gained_a_version, where thedriftsurface 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 forgreen, 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_auditrelation, 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, …), andourios-parquetshipsParquetAuditSink/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 0005audit/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/sumover audit events — i.e. RFC 0002’s deferredcount/ aggregation-stage pipeline applied to a generic audit source — stays deferred.driftis 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_degenerateevent.driftcounts widenings and type-expansions only, exactly as RFC 0001 §6.7’sevent_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 towardwidening_count(RFC0010.3). Thecompactionevent (RFC 0005 ordinal 3) is likewise not a template change and is excluded by the sameevent_typefilter. - Alias /
resolves_tomembership. “Is template X aliased to Y” is the cross-alias axis already served by RFC 0002resolves_toover the RFC 0001 §6.7 alias map.driftis 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.mdH6): the DataFusion SQL surface is never exposed.driftis DSL-only (§8 alternative C). - The compaction-event query surface. RFC 0005 routes
compactionevents 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 thedata/logs, keyed bytenant_idthen a day-granularity time bucket derived fromtimestamp.tenant_idis 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(REQUIREDSTRING, the predicate-pushdown surface RFC 0005 names “for the RFC 0001 §6.7 drift query”),event_kind(REQUIRED, ArrowUInt8/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 onlytenant_id,event_type,template_id,old_version,new_version, andtimestamp. - Event-kind mapping (RFC 0005 §3.7).
0 → template_widened,1 → template_type_expanded,2 → template_widening_rejected_degenerate,3 → compaction. Drift’sevent_typefilter selects ordinals 0 and 1. - Reader (
ourios-parquet).AuditReader::open_partitionis 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_widenedand/ortemplate_type_expandedevents for template A and for template B, all withtimestampinside[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_countequals 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.
- Given a tenant’s audit stream containing
-
RFC0010.2 — Window boundary excludes out-of-window events
[§6.5]- Given a qualifying event whose
timestampis strictly beforet1, 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 boundfromis included, the upper boundtois excluded — so a template with only a boundary event is present iff that boundary is the included (lower) one.
- Given a qualifying event whose
-
RFC0010.3 —
event_typescoping excludes non-widenings[RFC 0001 §6.7], §3 (out of scope)- Given a template C with only
template_widening_rejected_degenerateand/orcompactionevents in[t1, t2)(and notemplate_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_degenerateevents,widening_countcounts only the qualifying ones.
- Given a template C with only
-
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_idHive 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.
- Given a tenant with no qualifying events in
-
RFC0010.6 — Result ordering is
widening_countdescending[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_countdescending, matching RFC 0001 §6.7’sORDER 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.
- Given templates whose qualifying-event counts in
-
RFC0010.7 — Aggregate version/time bounds per template
[RFC 0001 §6.7]- Given template A with qualifying events spanning versions
v_lo … v_hiand timestampsts_lo … ts_hiinside[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’sMIN(old_version), MAX(new_version), MIN(timestamp), MAX(timestamp).
- Given template A with qualifying events spanning versions
-
RFC0010.8 — No DataFusion/SQL leakage
[H6], RFC0007.3- Given the public
driftsurface (string DSL and structured form) - When a malformed or SQL-shaped drift query is submitted
- Then neither the accepted grammar nor any error
Displayexposes DataFusion or SQL types/identifiers; the drift head is DSL-only, asresolves_toandrenderare (RFC 0002 §6.5).
- Given the public
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:
- Different source. It scans
audit/, a different Parquet series with a different schema, notdata/. - A fixed aggregation. RFC 0001 §6.7 fully specifies the projection, grouping, and ordering. There is exactly one drift query shape.
- No log predicate vocabulary.
service,severity,body,template_idpredicates etc. (RFC 0002 §7nonsev_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 production — now, 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:
- Filter to
event_type IN ('template_widened', 'template_type_expanded')(RFC 0005 ordinals 0 and 1) andtimestampin the window (§6.5). - Group by
template_id. - 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)
- Order by
widening_countdescending, thentemplate_idascending (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 atenant_idpartition-key filter over theaudit/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).
driftdefines its window as half-open[from, to)— lower bound included, upper bound excluded. (RFC 0002’srange(from, to)stage does not pin its boundary semantics today; RFC 0010 defines half-open for the drift window independently.)from/toreuse the RFC 0002 §7timegrammar (relative durations resolve against query-evaluationnow; RFC 3339 timestamps are absolute). - Window → partition prune. The window’s resolved
[t1, t2)bounds driveyear/month/daypartition pruning over the audit layout (day-granularity per RFC 0005 §3.4), then an exacttimestamppredicate 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_versionstub is replaced by a real test driving thedriftquery over a seeded audit stream and asserting templates A, B and their counts. Because the query path lives inourios-querier, the integration test seeds an audit partition via theourios-parquetParquetAuditSinkand runs the compiled drift query; the miner-sidehazards.rstest 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_queryproduction and the structured{ "drift": … }object, including rejection of trailing|stages and of apredicate/stagessibling 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_degenerateandcompactionevents seeded alongside qualifying ones; assert exclusion and correctwidening_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
DriftRowordering 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-
Displaystring 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 bystages plus a genericauditsource, 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 dedicateddrifthead can later be re-expressed on top of the general engine (sameDriftRowoutput, samedriftsurface) 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_toonly. RFC 0002 already shipsresolves_to(X), which answers “whattemplate_ids are aliased to X” (cross-alias membership; RFC 0001 §6.7). One might argue drift is already covered. It is not:resolves_tois 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_tocannot 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.mdH6, “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_driftboolean 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 (overaudit | 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
rangevs a head-local window. Resolved (maintainer, 2026-08-25): the dedicated clause. The shipped grammar carriesfrom/toon the drift head itself (DriftQuery { from: Time, to: Time }indsl/ir.rs— “no|stage to compose”), andrangestays a log-pipeline stage token. Green under the full RFC0010 §5 suite. - Default window. Resolved (maintainer, 2026-08-25):
mandatory, as specced.
DriftQuery.from/.toare 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 aswidening_count DESC, template_id ASC(drift.rs§6.6) and pinned by the §5 ordering test — a deterministic, pagination-stable order;last_seen DESCwas rejected because recency is already a visible column and would make equal-count ordering non-deterministic across reruns. -
old_version/new_versionon type-expansion rows. Resolved (maintainer, 2026-08-25): both kinds aggregate.TemplateChange::TypeExpandedcarriesold_version/new_versionexactly 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 §7timeproduction 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 staysgreenand 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.mdH5 (schema evolution / drift) + H6 (no DataFusion/SQL leakage).ourios-parquetParquetAuditSink/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 — thedocs/benchmarks.md§7 gate table marks A1 diagnostic, RFC 0001’svalidatedis judged on C1/C2, and §9.5/§9.6 record the A1 readings. Accepting ratifies the re-scope that RFC 0001’svalidated/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 amendsdocs/benchmarks.md(the A1 gate’s role) and the thesis-gate set RFC 0001’svalidatedstage 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× → FAIL — worse 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
- A1 is reclassified
diagnostic, notgating. The measurement (ourios ratio, zstd-19 ratio, delta) is still computed and recorded in thebenchmarks.md§9 series — it characterises the columnar queryability premium and guards against regression in the codec path — but adelta < 3.0×no longer blocks any RFC’svalidatedstage.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. - The template-miner pillar’s gating thesis criteria are C1 + C2.
RFC 0001 (
green) reachesvalidatedwhen 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 alreadyvalidated. - 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. 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 butCLAUDE.mdis load-bearing and changes require ameta: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
validatedpermanently 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’svalidatedblocking 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
validatedstage 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.
| gate | result | verdict |
|---|---|---|
| A1 | ourios 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) |
| C1 | 1.000000 reconstruction — 11,175,578 / 11,175,578 non-lossy rows bit-identical; lossy ratio 4.6e-06 (51 rows) | PASS |
| C2 | end 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 ameta:RFC perCLAUDE.md’s footer; recommended follow-up.- Authoritative representative rerun. C1/C2 here are on local
hardware. The
validatedflip for RFC 0001 should cite abaseline-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.) TheCLAUDE.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 updatedbump landed in the same diff. This meta-RFC required majority maintainer approval perCLAUDE.md’s footer; that gate is satisfied.
This is a
meta:RFC. It proposes a change toCLAUDE.md, which its own footer declares load-bearing: “This document is load-bearing; further changes require ameta:RFC and majority maintainer approval.” PerCLAUDE.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 informalmeta:RFC waiver (commitb50067d, 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):
- 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:
- 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, stabletemplate_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):
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.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.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 isaccepted, 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
acceptedand explicitly deferred theCLAUDE.mdedit to ameta:RFC (its §3 item 4 / §7), becauseCLAUDE.mdchanges 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 phrases —
before any byte-level codecandover 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 generateno-diff CI) or RFC acceptance scenarios (greppable test ids),CLAUDE.mdcarries 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.mdreconciliation 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 andREADME.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 thisCLAUDE.mdedit to ameta: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 (themeta:RFC + majority- approval rule; precedentb50067d).- 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 indocs/benchmarks.md§7; elsewherevalidatedis vacuous for this surface (RFC 0008 precedent).Status note.
green(2026-06-17;red2026-06-15). The first shipping-milestone spine: the writer/reader/compactor/audit-sink addressed a single local filesystembucket_root: &Path, butCLAUDE.md§3.6 declares object storage the source of truth. This RFC abstracts the storage seam behind the Apacheobject_storecrate (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-integrationCI job againstLocalStack(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 defaultcargo test; and RFC0013.6 (WAL stays local) is greened end to end through the served binary inourios-server(tests/rfc0013_6_wal_stays_local.rs), which wires theStoreinto the RFC 0014 data write path and asserts only Parquet/manifest objects reach the store while the WAL*.walsegments 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_storeawsfeature) + conditional-PUT atomic publish (Manifest::publish_cas, RFC0013.3/.4); the writer/reader/compaction/audit consumers migrated frombucket_root: &PathontoStore; and the §7 questions (conditional-PUT portability, credentials via theobject_storechain, 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-s3client. Direct control, but reimplements whatobject_storealready 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 diceyrename/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
&Pathcode, but inherits non-atomicrenameand 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
MinedRecordbatch covering every RFC 0005 §3.2 column- When it is written and then read back through the
AmazonS3backend (a MinIO / localstack container via thetestcontainerscrate)- Then the recovered rows and Parquet bytes equal those from the
LocalFileSystembackend, byte for byte.
RFC0013.2 — Local backend regresses nothing
- Given the existing RFC 0005 and RFC 0009 acceptance suites
- When they run against the
LocalFileSystembackend 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_partitionruns 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 norenamedependency 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
LocalFileSystemvia 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 arename. - 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: astoremodule inourios-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-Matchcover the compare-and-swap on every targeted store (S3, R2, MinIO, GCS viaobject_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-Matchprecondition 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 indocs/benchmarks.md§7; elsewherevalidatedis vacuous for this surface (RFC 0008 precedent).Status note.
green(2026-06-17;red/specifiedsame day). The conspicuous gap in the ingest stack is closed: the miner (RFC 0001) emits each minedMinedRecordinto aRecordSink, and production formerly wiredNoOpRecordSink— 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-putWriter), but nothing carries a mined record to a Parquet object on the store. This RFC specifies the missing piece: a bufferingRecordSinkand the flush policy that governs when buffered records become a Parquet object — aCLAUDE.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 toManifest::publish_cason S3 (RFC0013.3/.4) are follow-on work, tracked as open questions, not part of this RFC’s acceptance.
specifiedfinalizes 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 hard —emitblocks 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 thered/greenPRs.
redlanded the six#[ignore]d acceptance stubs (RFC0014.1–.6) incrates/ourios-ingester/tests/rfc0014_ingest_write_path.rs.greenbuilt the bufferingParquetRecordSink(the hybrid size/age/rotation flush policy + the hard ceiling), then wired it into the miner in place ofNoOpRecordSinkvia aSharedParquetSinkthe server constructs and the pipeline drives (flush_allon rotation,flush_agedon 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 aLocalFileSystem-backedStore; 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
Storeselection via RFC 0004; compaction’spublish_casadoption) 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 byPartitionKey, which carriestenant_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 aPartitionBufferaccumulatesMinedRecords plus a running estimate of its encoded size and the wall-clock time of its oldest record. ThePartitionKey(RFC 0005 §3.4) carriestenant_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:
- 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.
- 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. - 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),
emitblocks 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>.parquetobjects. - 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
*.parquetglob; 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
emitflushes 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
emitblocks 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
MinedRecordstreams and aLocalFileSystem-backedStore. - 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_age300 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_allfrom the rotation hook,flush_agedfrom a server-owned age sweep — keeping the sink ignorant of the WAL. - Size estimation: a cheap running estimate (
estimate_bytesover 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
Storetoday; S3 selection (RFC 0004) is still open. Compaction’s manifest publish adoptingManifest::publish_cason 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;
RecordSinkand theemitcontract. - 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
Storeseam;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 indocs/benchmarks.md§7; elsewherevalidatedis 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/
-Zsupport absent from stable.rust-toolchain.tomlstaysstable(the source of truth for every shipping crate perCLAUDE.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 (rootCargo.toml[workspace.lints.rust]; every shipping crate inherits it via[lints] workspace = true), because thelibfuzzer_sys::fuzz_target!macro (thelibfuzzer-syscrate) expands tounsafeglue.CLAUDE.md§6.1 permits a per-crate waiver where an RFC justifies one (it cites a possibleourios-parquetzero-copy need as the example; no crate carries such a waiver today — every crate root,ourios-parquetincluded, is#![deny(unsafe_code)]). This RFC is that justification, scoped tofuzz/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.
Amendment (2026-10-11, #951, maintainer-approved): shared harness code. Code the targets share (the telemetry bootstrap, the miner oracles, the OTLP round-trip check) lives in
fuzz/common/*.rsand is pulled into each target with a#[path]modinclude. The package still has nolib.rs, so the no-library-API rule above holds.
3.2 The targets
Four targets, ranked by risk. Each is a fuzz_target!(|data: &[u8]|)
reaching a stable entry point with minimal glue.
| Target | Entry point | Crate | Oracle |
|---|---|---|---|
miner_roundtrip ⭐ | ingest (with an observable RecordSink) → drain the MinedRecord → templates_for → reconstruct::render, on a string-body record | ourios-miner | invariant: the rendered bytes equal the original string body whether render reports Reconstruction::Faithful (rebuilt) or Reconstruction::RetainedVerbatim (retained) — §3.3 |
otlp_json | decode_json(&[u8]) | ourios-ingester | no panic; Ok/Err both fine |
otlp_protobuf | decode_protobuf(&[u8]) | ourios-ingester | no panic; Ok/Err both fine |
wal_frame | frame::read_frame(&mut Cursor::new(data)) — today pub(crate), exposed to the target via the fuzzing feature (§3.2, §7) | ourios-wal | no 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.
Amendment (2026-10-11, #951, maintainer-approved): five targets, each an invariant oracle. The table above lists four targets with mostly panic-only oracles. As amended there are five, and each asserts an invariant of what its entry point accepts:
Target Entry point Oracle miner_roundtrip⭐the input as length-prefixed bodies (little-endian u16length, capped at 4 KiB, at most 64 bodies, lossy UTF-8; a body may contain\n, which the tokenizer treats as a separator); every body a string-body record into oneMinerClusterfor one tenant, with observable record and audit sinks§3.3 per row (RFC0015.1 as amended); §3.1 audit per ingest (RFC0015.1 as amended); §3.1 no silent merge (RFC0015.10) otlp_jsondecode_json(&[u8])no panic (RFC0015.2); an accepted request survives the protobuf round trip (RFC0015.9) otlp_protobufdecode_protobuf(&[u8])no panic (RFC0015.3); decode → encode is a byte fixpoint (RFC0015.9) wal_frameread_frameon the raw input, andread_frame(write_frame(kind, payload))with the first byte picking theFrameKindtyped FrameError, never UB (RFC0015.4); the writer’s frame reads back as(kind, payload), fully consumed (RFC0015.9)dsl_parseparse_statementandparse_structured_statementon the same textno panic (RFC0015.8); an accepted statement round-trips its canonical form (RFC0002.7)
miner_roundtripdrives a sequence. One input now reaches leaf creation, clean reuse, widening and type expansion. Right after each ingest, the leaf the row was stamped with is snapshotted by(template_id, template_version), the read path’s registry key (RFC 0017 §3.5). A later widening bumps the leaf past the row’s version, and a row stamped N must render against the N-version tokens. Every row is then checked against both halves of §3.3: it renders back to its own line whetherrenderreportsFaithfulorRetainedVerbatim, and a clean row (not lossy, no §6.5 overflow) must beFaithful. A diverted row must be lossy-flagged with the line retained verbatim.renderonly retreats to the body on a template-shape mismatch, which a stored body would otherwise paper over.§3.1 has two checks in this target. First, after each ingest the tenant’s leaves before and after are compared with what the audit sink received. “Every merge emits an audit event with old + new templates” (RFC 0001 H1.3) becomes: a new leaf has exactly one
Createdevent, and a version that rosev → whas exactly theWidened/TypeExpandedchainv → … → w, its templates matching the tree before and after. Second, ported from the RFC 0024 property suite’sassert_no_silent_merge: a row agrees with its template’s literals at every non-wildcard position, and successive versions of one template only ever turn a literal into a wildcard.The parser targets assert round trips. The OTLP decoders’ output is what the WAL stores re-encoded, so the re-encoding must be a byte fixpoint. The comparison is on bytes, not messages: a
NaNdouble is a legal wire value unequal to itself. JSON re-encoding is not asserted, because proto3-JSON is not canonical (1vs"1"for 64-bit integers, enum names vs numbers). A DSL statement must re-parse from its canonical serialisation to the same IR, the RFC0002.7 contract thedsl/structured.rsproptest pins over generated trees.frame::write_framejoinsread_framebehind thefuzzingfeature, which makes the modulepuband#[doc(hidden)]. The status staysaccepted. Scenarios RFC0015.8–.10 and the RFC0015.1 amendment are the criteria.
Amendment (2026-10-11, #955, maintainer-approved): a sixth target for the persisted artifacts. The process reads back from object storage bytes it may not have written, or may have written in an older version; a decoder that panics or half-applies them turns every restart into a crash loop or a silently wrong state (#892: an unreadable self-written snapshot made every restart rebuild from the WAL). One target covers the small JSON artifacts, the first input byte picking the decoder:
Target Entry point Oracle artifact_decodedata[0] % 5: 0snapshot::recoverthenMinerCluster::restore_tenant; 1Manifest::from_json; 2 the RFC 0059 high-waterparse; 3TemplateMap::from_artifact_byteson the raw frame; 4 the same on the input zstd-compressed, so mutations reach the JSON bodyan allowed outcome, never a panic (RFC0015.11); a rejected snapshot leaves no state (RFC0015.12); decode → encode → decode is a fixpoint, and a restored snapshot captures back what it restored (RFC0015.13) One binary, not four: each decoder is a few functions of JSON over the same
serde_json, coverage is still attributed per function, and the ClusterFuzzLite budget (3000 s shared, parallel) splits six ways instead of nine. The template-map frame is the only expanding decoder; its 64 MiB decompression bound classifies a bombTorn, well inside-rss_limit_mb=2048. The high-water codec is private toourios-ingester; afuzzingfeature exposes it astemplate_ids::fuzzing(#[doc(hidden)]), asourios-waldoes for its frames, so non-fuzz builds see no API change. The template-map reader is already public. Seeds are one valid artifact per decoder, each produced by its real encoder (fuzz/README.mdhas the recipe), with a dictionary of the artifacts’ JSON keys (fuzz/dict/artifact_decode.dict).The audit-event Parquet reader (
AuditReader::open_bytes) is not in this target: a Parquet decode is a far larger and slower coverage space than these, and it gets its own. The status staysaccepted; scenarios RFC0015.11–.13 are the criteria.
Amendment (2026-10-11, #958, maintainer-approved): a seventh target for the audit-event Parquet files. The template registry, the alias map and the drift query all fold audit events read back from object storage (RFC 0005 §3.7), and the upstream
parquet/arrowdecoders assert, index and unwrap on malformed pages and footers. Since #956 every Parquet decode inourios-parquetruns behind a panic boundary (panic_boundary.rs) that turns such a panic into a typedDecodePanicked:
Target Entry point Oracle audit_readerthe input is the whole object: AuditReader::open_bytes, thenread_allOkor anAuditReaderErrorother thanIo/PartitionMismatch, a caught decoder panic being the expectedDecodePanicked; a panic past the boundary crashes; decoded events re-encode throughaudit_events_to_batchand read back equal (RFC0015.14)libfuzzer-sys installs a panic hook that aborts before any unwinding, so on its own the boundary’s
catch_unwindnever runs under the fuzzer. The target swaps that hook for a recording one around the single decode call and restores it straight after; its owncatch_unwindre-raises, under the restored hook, any panic the boundary let through. Per input and on the fuzzing thread only, so no other panic is ever masked. The seed is the audit object of theartifact_decodeseed store; no dictionary (a binary format whose column names the seed already carries). While parquet 59’s footer decoder can be driven into a multi-gigabyte allocation (#959, §6 below), which the first run reached after ~145 s, the daily smoke run gives the target 30 s instead of 300 s and ClusterFuzzLite (batch, prune, coverage) does not run it:.clusterfuzzlite/build.shskips staging it. Both the smoke override and the exclusion are deleted when #959 closes with the parquet 60 / DataFusion 56 upgrade (#846). The status staysaccepted; scenario RFC0015.14 is the criterion.
Amendment (2026-10-11, #964, maintainer-approved):
dsl_parsereaches the plan. An accepted query used to stop at the parser. The querier then validates it (plan::validate), compiles it and lowers it to aDataFusionlogical plan, and the engine’s analyzer and optimizer run over that plan. Every one of those steps sees client text first. A query the DSL accepts but the engine rejects while planning comes back asStorage(“failed to read storage”): the engine, not the DSL, has decided what is a valid query (hazardCLAUDE.md§4.6). The target now goes on from the parser:
Target Entry point Oracle dsl_parseas before, then for an accepted log query the querier’s fuzzing::validateandfuzzing::lower:plan::compile,plan::applyand the aggregation node over an empty in-memory scan, then the analyzer and optimizer, against the current writer’s schema (one promoted key per RFC 0042 class) and a scan with every OPTIONAL column absent (RFC0007.4)validation accepts or returns InvalidQuery; lowering returns exactly validation’s rejection and lowers an accepted query to a plan or anInvalidQuery; the canonical re-parse validates and lowers to the same verdict and an equal plan (RFC0015.15)One binary, not two. The plan oracle runs only on the queries a parser accepts, a share of the executions (a 300 s run with ASan still executes about 1 800 inputs a second, peak RSS about 800 MiB). It needs the same seeds and the same dictionary, so a second target would duplicate both and split the ClusterFuzzLite budget eight ways instead of seven. The binary keeps its name, so ClusterFuzzLite’s stored corpus carries over. Nothing executes and no store is opened. The alias map and template registry are empty, the clock is fixed, and the session is built once per process. The seam lives behind an
ourios-querierfuzzingfeature (#[doc(hidden)] pub mod fuzzing, asourios-walandourios-ingesterdo). The lowered plan crosses it as an opaqueLoweredPlanthat is onlyPartialEqandDebug, so noDataFusiontype enters the crate’s API. The aggregation lowering moved out ofQuerier::execute_aggregateinto a function the execution path and the seam share, so the plan fuzzed is the plan run. The token denylist on error text (RFC0007.3) is not part of the oracle: anInvalidQuerydetail echoes the query’s own keys and literals, which can contain any token. The first runs found two planner errors that surfaced asStorage, each fixed in its own PR:starts_with/matches/=~/!~on theBinarybodycolumn, now rejected at validation because matching only the stored body would silently miss mined lines (RFC 0044 §7, #960, #968), and a malformed regex pattern (#961). The status staysaccepted; scenario RFC0015.15 is the criterion.
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_roundtripseeds from a few real-shaped log lines;otlp_jsonseeds from a minimalExportLogsServiceRequest(an empty{"resourceLogs":[]});otlp_protobufandwal_framestart 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.
Amendment (2026-10-11, #951, maintainer-approved): binary seeds and dictionaries. The binary seeds the list above defers now exist:
otlp_protobuf: the prost encoding of a one-record request that covers everyAnyValueshape. The same request is also anotlp_jsonseed.wal_frame: onewrite_frameoutput perFrameKind.miner_roundtrip: multi-line seeds (same shape, differing parameters) start the fuzzer on widening and type expansion.
fuzz/README.mdrecords how the binary seeds are regenerated. The two text-grammar targets take a libFuzzer dictionary (fuzz/dict/<target>.dict):
otlp_json: the proto3-JSON keys and value shapes ofExportLogsServiceRequest;dsl_parse: the §7 grammar’s keywords and operators, plus the structured surface’s tags.The smoke job passes it with
-dict=, and ClusterFuzzLite throughdict =in<target>.options(§3.4 as amended).
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.
Amendment (2026-10-11, #950, maintainer-approved): one
Fuzzworkflow, four jobs. The three workflows above are one.github/workflows/fuzz.ymlwith four jobs, each on its ownscheduleentry (gated bygithub.event.schedule) or onworkflow_dispatchwith ajobinput naming one job (smoke | batch | coverage | prune); still nopull_requesttrigger.
smoke(daily 04:17) is no longer a matrix: onecargo fuzz build --target "$host"of every target, then a loop over the targets, each run for its budget (300 s on the schedule, 60 s on dispatch) with-timeout=30 -rss_limit_mb=2048. A crash is recorded and the loop continues, so a crash in one target does not stop the others; the job fails at the end and uploadsfuzz/artifacts/.batch(daily 02:00) is ClusterFuzzLitemode: batch, 3000 s split across the targets,parallel-fuzzing: true.prune(daily 01:00, new) ismode: prune. The ClusterFuzzLite GitHub Actions guide requires it of any project that batch-fuzzes (“If batch fuzzing is running, you must also run corpus pruning”).batchandpruneshare a no-cancel concurrency group and never run in the same workflow run: the corpus artifact is their handoff, and ClusterFuzzLite cannot hand it over within one run.coverage(Mondays 03:00) ismode: coverageand appends per-target line coverage to the step summary.Every job has a
timeout-minutes(smoke 60, batch 90, prune 60, coverage 30). The ClusterFuzzLite jobs take the sametimeout/rss_limit_mbfromfuzz/<target>.options, staged next to each binary bybuild.sh. The corpus persists as thecifuzz-corpus-<target>workflow artifacts (90-day expiry, rolled by each run) — not the Actions cache as written above and in §7; a storage repo remains an open maintainer decision. The status staysaccepted; RFC0015.5 and RFC0015.7 as amended are the criteria.
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.
Amendment (2026-10-11, #951, maintainer-approved): naming. A committed reproducer is named
fuzz/seeds/<target>/regression_<issue>_<n>. Every oracle’s panic message names its invariant (RFC0015.N / CLAUDE.md §3.x), so a crash artefact says which contract broke.
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_roundtriptarget and aMinerClusterbuilt fromMinerConfig::default()with an observableRecordSinkattached- When the target builds an
OtlpLogRecordwith aStringbody from the arbitrary input, ingests it, drains the emittedMinedRecordfrom the sink, looks up the leaf tokens viatemplates_forfor the record’s(template_id, template_version), and callsrender- Then the rendered bytes equal the original string body in both outcomes — whether
renderreportsReconstruction::Faithful(rebuilt from the template) orReconstruction::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
Reconstructionmarker 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.mdis greppable
Amendment (2026-10-11, #951, maintainer-approved): RFC0015.1 as amended. The When applies to each length-prefixed body of the input (a body may contain
\n). Every body is ingested into oneMinerClusterthat has an observable audit sink as well as the record sink. Each row’s tokens are looked up by its own(template_id, template_version)right after its ingest. Added clauses:
- And a clean row (
lossy_flagunset, noOverflowparam) renders withReconstruction::Faithful;- And a diverted row (
NO_TEMPLATE) haslossy_flagset andbodyequal to the line;- And (
CLAUDE.md§3.1, RFC 0001 H1.3) after each ingest, every leaf the ingest created has exactly oneCreatedaudit event carrying its tokens. Every leaf whose version rosev → whas exactly theWidened/TypeExpandedchainv → … → w, its old template equal to the leaf before the ingest and its new template equal to the leaf after. No other leaf has a template event;- And each violation panics with a message citing
RFC0015.1 / CLAUDE.md §3.1or§3.3.
Scenario RFC0015.2 — OTLP/JSON decode never panics
- Given the
otlp_jsontarget- When it is run on arbitrary bytes
- Then
decode_jsonreturnsOk(_)orErr(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_protobuftarget- When it is run on arbitrary bytes
- Then
decode_protobufreturnsOk(_)orErr(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_frametarget wrapping the input in aCursor- When
read_frameis run on arbitrary bytes- Then it returns
Ok((kind, payload))or aFrameError(bad CRC, length overMAX_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.ymlon the nightly toolchain- When a PR touches
ourios-miner,ourios-ingester, orourios-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)
Amendment (2026-10-11, #950): RFC0015.5 as amended. The scenario reads:
- Given
.github/workflows/fuzz.ymlon the nightly toolchain- When the daily schedule fires or the workflow is dispatched (there is no
pull_requesttrigger — §3.4)- Then every target is built once (
cargo fuzz build) and each is run for its configured bounded budget with libFuzzer-timeoutand-rss_limit_mbset- And each target’s crash fails the job and uploads its reproducer; a crash in one target does not stop the others
- And the workflow uses top-level
contents: readonly, and every job carries atimeout-minutes
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 itsregressions/subdir) and is re-exercised on every subsequent run- And the seed is never removed to make a run pass (
CLAUDE.md§6.2)
Amendment (2026-10-11, #951, maintainer-approved): RFC0015.6 path.
fuzz/corpus/is the gitignored working corpus (§3.3), so the reproducer goes under the trackedfuzz/seeds/<target>/asregression_<issue>_<n>, as §3.5 already says.
Scenario RFC0015.7 — ClusterFuzzLite is detected by Scorecard (Phase 2)
- Given the Phase 2 follow-up has landed
.clusterfuzzlite/and thecflite_*workflows- When the OpenSSF Scorecard workflow runs
- Then the
Fuzzingcheck detects ClusterFuzzLite and scores greater than 0- Note: this scenario is out of scope for this RFC’s
greenand gates the Phase 2 PR; it is recorded here so the phasing is explicit.
Amendment (2026-10-11, #950): Phase 2 done.
.clusterfuzzlite/landed with thecflite_*workflows, since folded intofuzz.ymlas thebatch,coverageandprunejobs (§3.4 as amended), so the Given reads “.clusterfuzzlite/and the ClusterFuzzLite jobs offuzz.yml”. Scorecard’sFuzzingcheck scores 10/10 (“project is fuzzed”; Scorecard API, 2026-08-21 run).
Scenario RFC0015.8 — DSL parse never panics and round-trips its canonical form (amendment 2026-10-11, #951)
- Given the
dsl_parsetarget- When
parse_statementandparse_structured_statementare run on arbitrary UTF-8 text- Then each returns
Ok(Statement)orErr(DslError)and the target never panics, aborts, or triggers a sanitizer error on any input in a bounded run- And for every
Ok(s),parse_statement(serialize_statement(&s))isOkand equalss(RFC0002.7); a canonical form that fails to parse or re-parses differently makes the target panic
Scenario RFC0015.9 — what a parser accepts survives its round trip (amendment 2026-10-11, #951)
- Given the
otlp_protobuf,otlp_jsonandwal_frametargets- When
decode_protobufordecode_jsonreturnsOk(request)- Then
request.encode_to_vec()decodes, and re-encodes to the same bytes (compared as bytes so aNaNdouble cannot raise a false positive)- When the
wal_frametarget writeswrite_frame(kind, payload)for theFrameKindits first input byte selects and the remaining bytes as the payload- Then
read_frameon those bytes returnsOk((kind, payload))and consumes exactly the frame- And any violation makes the target panic with a message naming this scenario
Scenario RFC0015.10 — the miner sequence target enforces §3.1 no silent merge (amendment 2026-10-11, #951)
- Given the
miner_roundtriptarget with every line of the input ingested into oneMinerClusterfor one tenant, and each row’s template snapshotted by(template_id, template_version)right after its ingest- When the rows are checked after the last ingest
- Then every string-body ingest emitted exactly one row
- And every row with a template has the template’s token count and matches its literal tokens at every non-wildcard position
- And for every
template_id, each later version keeps the token count and changes tokens only from a literal to a wildcard — never a literal to a different literal, never a wildcard back to a literal- And the same holds for every
Widened/TypeExpandedaudit event of an ingest, including one for an intermediate version no row was stamped with: a widening changes only its declaredpositions_widened, each to a wildcard, and a type expansion changes no token- And the slot types follow the events (RFC 0001 §6.1/§6.4): replayed from the leaf before the ingest, a widening opens one slot per declared position, seeded
{Str, type of the line's token there}, and a type expansion’sslots_expandedis non-empty, names each existing slot at most once, and adds only types new to it; the replayed slot types equal the leaf’s after the ingest- And any violation makes the target panic with a message citing
CLAUDE.md§3.1
Scenario RFC0015.11 — persisted-artifact decoders return an allowed outcome (amendment 2026-10-11, #955)
- Given the
artifact_decodetarget- When a miner snapshot, a partition manifest, a template-id high-water or a template-map frame (or its JSON body) is decoded from arbitrary bytes
- Then no decoder panics, aborts, or triggers a sanitizer error
- And
recover(Some(_))is(Some, Restored)only for a byte 0 ofSNAPSHOT_VERSIONthatload_snapshotreads, and otherwise(None, UnknownOrCorruptDiscarded);legacy_v1_markreads as legacy exactly the byte-0 = 1 artefacts- And
Manifest::from_jsonnever reports an I/O error and accepts only manifestsvalidateaccepts; the high-water parses to an id within the template-id domain or isMalformed- And a template-map read is
Valid,Torn, anUnknownVersionother than the reader’s own, or the loud tenant-mismatch error
Scenario RFC0015.12 — a rejected snapshot leaves no partial state (amendment 2026-10-11, #955)
- Given a snapshot that decodes but
restore_tenantrejects- Then the cluster holds no state for the tenant and its template-id allocator has not moved
Scenario RFC0015.13 — what a persisted-artifact decoder accepts survives its writer (amendment 2026-10-11, #955)
- When a snapshot decodes, its payload re-encodes and decodes equal; when it restores, the captured state (stamped with the folded horizon) equals the decoded state in canonical form: every field of every leaf, structured-template entry and adopted template, and the horizon, with only the format’s documented rewrites (set-valued lists sorted and deduplicated, an empty provenance read as its pre-RFC 0050 default, an owned adoption at version 1, a tree-backed adoption carrying no provenance or associations of its own); and that capture restores again to an equal capture
- When a manifest decodes,
to_jsondecodes back equal and is a byte fixpoint; when the high-water parses,encodeparses back to the same id (and so does every id in the domain)- When a template map reads
Valid,to_artifact_bytesreads backValidwith the same frontier, registry and alias classes, and re-encodes to the same bytes- And any violation makes the target panic with a message naming the scenario
Scenario RFC0015.14 — an audit-event Parquet file decodes to a typed outcome behind the panic boundary (amendment 2026-10-11, #958)
- Given the
audit_readertarget and arbitrary bytes as one object from the store- When
AuditReader::open_bytesandread_alldecode them- Then the result is
Okor anAuditReaderError, neverIoorPartitionMismatch(bytes have no file system and no partition)- And a panic inside the
parquet/arrowdecoders surfaces asDecodePanicked, an accepted outcome, while any panic outside that boundary (in Ourios’s own decode) still crashes the target- And the scenario is about panics, not allocation failures: an abort from parquet 59’s footer decoder reserving an untrusted list count is a known exception no boundary can catch, tracked by #959 until parquet 60 removes it
- And the decoded events re-encode through
audit_events_to_batch, write to Parquet and read back equal- And any violation makes the target panic with a message naming the scenario
Scenario RFC0015.15 — an accepted query validates and lowers to the DSL’s own outcome (amendment 2026-10-11, #964)
- Given the
dsl_parsetarget and a log query either parser accepts- When it is validated and lowered to its optimized logical plan against the current writer’s schema and against a scan with every OPTIONAL column absent, with nothing executed
- Then nothing panics, and validation accepts it or rejects it with
InvalidQuery- And lowering rejects exactly what validation rejects, with the same error, and lowers what validation accepts to a plan, a provably empty result, or an
InvalidQuery, never aStorageerror carrying the engine’s planning failure (CLAUDE.md§4.6)- And the canonical re-parse validates to the same verdict and lowers to an equal plan or the same error
- And any violation makes the target panic with a message naming the scenario
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.rsand the round-trip unit tests incrates/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 buildproves they compile even on runs where they are not executed. - RFC0015.5 — the
fuzz.ymlworkflow 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).
Amendment (2026-10-11, #951, maintainer-approved): testing the new oracles.
- RFC0015.8 extends the panic-oracle bullet above. It mirrors the
dsl/structured.rsproptest and thedisplay.rscorpus test (parse(serialize(q)) == q), so the two layers stay in sync as the grammar grows.- RFC0015.9 / .10 and the RFC0015.1 amendment run inside the same bounded runs. Each oracle was shown to fire before landing: the code under test was broken locally (a render mismatch, a mislabelled marker, a dropped row, an absorbed literal, a rewritten literal, a suppressed
Widenedaudit event, a mis-encoded frame kind, a non-round-tripping serialiser), a crafted input crashed with the expected message, and the break was reverted. The seeds in §3.3 keep every oracle on its happy path each run.- The RFC0015.5 bullet’s “per-PR smoke job” predates §3.4’s schedule-only trigger. The smoke job runs on its schedule and on dispatch, never per PR.
Amendment (2026-10-11, #955, maintainer-approved): testing RFC0015.11–.13. Each oracle was shown to fire the same way before landing: a restore that moves the id allocator before rejecting, a snapshot writer that drops the wildcard route, a restore that drops the adopted templates, a manifest reader that skips
validate, a high-water encoder off by one, a template-map writer that drops the alias classes, andrecoverreporting a corrupt snapshot as absent each crashed on a seed or a crafted input with the expected message, and were reverted.
Amendment (2026-10-11, #958, maintainer-approved): testing RFC0015.14. The boundary was shown both ways before landing: the 147
audit_readercrash inputs found before #956 (eight upstream panic messages) and thetestdata/corrupt-parquet/fixtures all run clean, and a panic injected intoAuditReader::read_allcrashed the seed with the scenario’s “escaped the decode boundary” message, and was reverted. Four minimised inputs (an out-of-bounds offset, an empty slice, a negative column range, an unset dictionary decoder) stay asregression_decoder_*seeds, beside the #954 arrow-schema reproducer;artifact_decodegains the #957 unknown-slot-type reproducer. The first 300 s run found one out-of-memory the boundary cannot catch: parquet 59’s footer decoder (read_thrift_vec) reserves a list’s declared length before reading it, so a corrupt footer asks for gigabytes (#959). parquet 60 bounds that count by the remaining input. Until the DataFusion 56 upgrade (#846) brings it, the smoke run’s 30 s budget for the target rarely reaches it (it can still report it as a crash), and ClusterFuzzLite does not stage the target (§3.2 as amended); the reproducer becomes a regression seed when #959 closes.
Amendment (2026-10-11, #964, maintainer-approved): testing RFC0015.15. Each oracle was shown to fire before landing. A validation that returns
Storagefor an unsupported stage, a panic in the group-term lowering, a compile that lowers what validation rejected, and a default window read off the wall clock instead of the query clock each crashed a seed with the expected message, and were reverted. TheStoragearm fired on real defects first: the seedstarts_with(body, "GET /")(#960) and a run that mutated a seed into an invalid regex class (#961). Their minimized inputs are theregression_960_*andregression_961_*seeds.
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 pin → pin a dated
nightly-YYYY-MM-DDin the fuzz job (not a floatingnightly), 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. (Amended 2026-10-11, #951: the smoke job never runs per PR — ~300 s per target on its daily schedule, ~60 s on a manual dispatch, §3.4 as amended.)
-
read_frameexposure → afuzzingcargo feature onourios-walgating thepubexport, rather than a#[doc(hidden)]shim — slightly cleaner and reusable for future non-fuzz tests. -
OtlpLogRecordconstruction → expect a hand-writtenArbitraryimpl (or a thin newtype) for the string-body path, rather than relying onderiveacross the body variants, if a derive proves messy. (Amended 2026-10-11, #951: neither —miner_roundtripreads length-prefixed bodies off the raw bytes and builds each record with..Default::default();arbitraryis no longer a direct dependency of the harness.)
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. (Amended 2026-10-11, #951: resolved inside
miner_roundtriprather than as a second binary — the input is a sequence of length-prefixed bodies into one tree, and the §3.1 checks run beside the §3.3 one; §3.2 as amended, RFC0015.1 as amended, RFC0015.10.) - A structured-body round-trip target exercising the §6.1
canonical encoding (
AnyValue ↔ stored bytesdeterminism), separate fromminer_roundtrip’s string-body scope? Possible Phase 1.5. - Phase 2 corpus-persistence backend → GitHub Actions cache
(ephemeral, in-repo, no external storage or secrets), chosen over a
storage branch/bucket. Implemented in
cflite_batch.yml. (Amended 2026-10-11, #950: with no storage repo ClusterFuzzLite in fact keeps the corpus ascifuzz-corpus-<target>workflow artifacts — 90-day expiry, rolled by each run — now thebatch/prunejobs offuzz.yml, §3.4 as amended.) -
unsafewaiver forfuzz/: confirm that having the fuzz member opt out of the workspaceunsafe_code = "deny"lint (the first such waiver in the repo) is acceptable, given thefuzz_target!macro requires it.
8. References
- cargo-fuzz book: https://rust-fuzz.github.io/book/
- libFuzzer: https://llvm.org/docs/LibFuzzer.html
- ClusterFuzzLite: https://google.github.io/clusterfuzzlite/
- OSS-Fuzz: https://google.github.io/oss-fuzz/
arbitrarycrate: https://docs.rs/arbitrary/- OpenSSF Scorecard
Fuzzingcheck: https://github.com/ossf/scorecard/blob/main/docs/checks.md#fuzzing - Related RFCs: RFC 0001 (template miner), RFC 0003 (OTLP receiver), RFC 0008 (write-ahead log).
CLAUDE.md§2 (pillar #2, miner risk), §3.1 (no silent merges), §3.3 (bit-identical reconstruction), §3.4 (WAL), §4 (hazards), §6.1 (stable-toolchain pin — contained deviation), §6.2 (testing discipline), §7 (new-crate commitment);docs/hazards.md.
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 indocs/benchmarks.md§7; elsewherevalidatedis vacuous for this surface (RFC 0008 precedent).Status note.
green(2026-06-22;red2026-06-19). All seven §5 scenarios pass. The querier role is wired intoourios-serveras an env-gated HTTP endpoint (POST /v1/query) over the RFC 0007 engine, mirroring the receiver role’sserve/Handletopology:.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 aourios.query.durationhistogram — 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 ashutdown()future, on the samewatch::channel(())graceful-shutdown topology asreceiver::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(default0.0.0.0:4319), reusingOURIOS_BUCKET_ROOTfor 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 byparse_statement;application/json→ either a{ "query": "<dsl text>" }wrapper (the text grammar, unwrapped thenparse_statement) or RFC 0002’s structured-IR JSON (the top-level IR object, parsed byparse_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 engine — Querier::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) →400with a structured{ "error": { "kind": ..., "message": ... } }. - Missing/empty
X-Ourios-Tenantheader →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 (itsDisplayalready 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-serverstarted withOURIOS_QUERIER_ENABLED=1andOURIOS_BUCKET_ROOTset- When a client
POSTs a logs DSL statement to/v1/querywith anX-Ourios-Tenantheader- Then the server parses it via the RFC 0002 front-end, executes it through
Querier::run_query, and returns200with 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
400by 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
Driftarm torun_driftand returns the RFC 0010DriftResultshape
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
400with 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_ENABLEDunset- 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_prunedis 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_ENABLEDandOURIOS_QUERIER_ENABLEDset, 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(orourios-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/LogicalPlansubstrings (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 viaOURIOS_QUERIER_DEFAULT_WINDOW_SECS(a non-zero integer of seconds). The server passes it to the compiler asdefault_window_nanos; it is never unbounded (RFC 0002 §4 P5). - Endpoint surface — single
POST /v1/querythat 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
QueryResultas 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 — theserve/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 indocs/benchmarks.md§7; elsewherevalidatedis 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
0–5 (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 retainedbodyverbatim; - structured (
body_kind = Structured) → the structuredAnyValue, decoded from thebodycolumn’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 OTLPBodyis 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) andobserved_time_unix_nano(optional);severity_number+severity_text;- trace context —
trace_id(16 B),span_id(8 B),flags; event_name;attributesandresource_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
Reconstructionmarker.
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_createdaudit event is emitted carrying(template_id, new_version = 1, new_template = the initial tokens)on the WAL-before-ack path- And the new
event_kindordinal /event_typestring 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, andtemplate_type_expandedevents- When
derive_template_registryfolds 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
Reconstructionmarker isFaithful
Scenario RFC0017.4 — lossy / parse-failure rows return the retained body
- Given a row flagged lossy or with no template (parse failure), whose
bodywas retained- When the querier renders it
- Then the returned line is the retained
bodyverbatim and the marker isRetainedVerbatim— 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 = 1row- 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.recordsholds up tolimitLogRows (rendered/structured body + marker + the OTLP fields per §3.4), andQueryResult.rows(the count) andstatsare unchanged so B1/B2 and existing tests still pass- And
QueryResultis 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/QueryResultsurface- 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_attributesare 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 OTLPBodywas a map/array, canonical JSON inbody, 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 ingestedAnyValue
6. Testing strategy
- RFC0017.1 — a miner unit/integration test asserting a
template_createdevent on first leaf allocation (with tokens), plus an audit-schema test that the newevent_kind/event_typeis appended (existing ordinals unchanged). - RFC0017.2 / .5 —
derive_template_registryunit 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
recordslength ≤limit, the rendered content, and thatrows/statsare unchanged (a B1/B2-style count assertion still holds). - RFC0017.7 — a grep-style guard that the public crate surface has no
arrow/datafusiontypes (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
LogRowfield equals the ingested value (a field-completeness assertion over the RFC 0005 §3.2 column set), withattributes/resource_attributesdecoded 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
(
AnyValuemaps/arrays),LogRow.body == LogBody::Structured(v)wherevequals the ingestedAnyValue(decoded canonical JSON), never a flattened line. Cross-references theourios-corecanonical 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_createdpayload — does it also carryslot_types(likeTypeExpanded), or just tokens? (Leaning tokens-only for v1; slot types are derivable / not needed forrender.) - Structured-body rendering — resolved (§3.3 / §3.4): the OTLP
Bodyis anAnyValue, and the storage path already preserves the structured case (body_kind = Structured, canonical JSON inbody, 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 gap —
LogRowreturns 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-scopeschema_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 rendersRetainedVerbatimfrombody, but a clean-pathbody_kind = Stringrow has nobody(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_createdclean 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
QueryResultas typed rows + stats — the payload this RFC implements), §8 (result-materialisation open question); RFC 0002 (renderstage); RFC 0010 (drift, the other audit-derived query); RFC 0016 (the query-serving endpoint that consumesrecords). 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 indocs/benchmarks.md§7; elsewherevalidatedis 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 MUST — AnyValue 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:
- Preserve what arrived, byte-for-byte, up to the point where a storage invariant physically forbids it.
- 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.
- 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’sKeyValuelist, encoded as canonical JSON exactly likeattributes/resource_attributes(RFC 0005 §3.3), empty →[];resource_schema_url— theResourceLogs.schema_urlstring;scope_schema_url— theScopeLogs.schema_urlstring.
Add three OPTIONAL columns to the RFC 0005 §3.2 schema
(crates/ourios-parquet/src/lib.rs):
| column | type | required | note |
|---|---|---|---|
scope_attributes | STRING (canonical JSON) | OPTIONAL | per RFC 0005 §3.3 encoding; [] when empty, NULL only in pre-amendment files |
resource_schema_url | STRING | OPTIONAL | OTLP ResourceLogs.schema_url |
scope_schema_url | STRING | OPTIONAL | OTLP 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(optionallyRESOURCE_EXHAUSTEDwith aRetryInfodetail for saturation, per otlp/#otlpgrpc-throttling); HTTP503(optionally429for saturation) with an optionalRetry-Afterheader. - Permanent failures stay non-retryable, but are not a single HTTP code:
malformed payload and tenant-resolution failure → HTTP
400(gRPCINVALID_ARGUMENT), while an oversize payload (AppendError::TooLarge, a batch over the 16 MiB WAL frame ceiling) → HTTP413(gRPCINVALID_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 aWalAppenderror.
Amendment 2026-09-27 — post-rotation quiesce (RFC 0052 §3.3). The terminal post-rotation quiesce, once the retry budget is exhausted, is no longer in the transient class. Incident #791 showed the latch is permanent until a restart, and RFC 0052 §3.3 puts rotation failure under a bounded retry and adds a third class to the two above: server-terminal, client-retryable — the node needs an operator, and the client keeps its data and retries with exponential backoff. A rotation failure still within its retry budget stays transient; only the terminal state, once the budget is exhausted, enters the new class. Both map to gRPC
UNAVAILABLE/ HTTP503with noRetry-AfterorRetryInfo, since the server cannot predict when the terminal state clears. The binding rule below is unchanged: neither carries a non-retryable code.
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) and25..=255(out of the named ranges but storable and monotone-meaningful) → stored as the wire value;- a record with
severity_numberoutside0..=24is recorded on the existingourios.ingest.recordscounter with the standarderror.typeattribute set toseverity_out_of_range— the OTel “recording errors on metrics” convention (one counter for success + anomaly, reason on a low-cardinalityerror.type; success records carry noerror.type), not a bespoke counter.severity_textis retained, so the violation is observable, not masked; - the values a
u8physically cannot hold (negative,> 255) become0— here the storage invariant wins (§3.0 point 1’s limit). Because they narrow to0, 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); the25..=255case — 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
InstrumentationScopecarriesattributes, whoseScopeLogscarries aschema_url, and whoseResourceLogscarries aschema_url- When the receiver materialises the records and they are written to Parquet
- Then
scope_attributes(canonical JSON),scope_schema_url, andresource_schema_urlare 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_urlcolumns)- 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, orRESOURCE_EXHAUSTED+RetryInfo) and the HTTP status is503(or429) — neverINTERNAL/500- And a permanent failure (malformed payload, tenant resolution) still maps to
INVALID_ARGUMENT/400
Scenario RFC0018.4 —
event_nameis filterable in the DSL
- Given stored rows with differing
event_namevalues- When a DSL query filters on
event_name- Then the predicate compiles to the
event_namecolumn 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) containingNaN,Infinity, and-Infinity- When it is canonical-encoded and decoded
- Then the decoded value equals the original (no
nullcollapse)
Scenario RFC0018.6 — out-of-range SeverityNumber is preserved, not clamped (§3.0)
- Given OTLP records with
severity_number = 25and= 200(out of the named ranges butu8-storable)- When the receiver materialises them
- Then the stored
severity_numberis25/200verbatim (never silently clamped to0), theourios.ingest.recordscounter records them witherror.type = severity_out_of_range, and aseverity >= ERRORquery still matches them (monotonicity preserved)- And a value a
u8cannot hold (negative,> 255) maps to0(the storage invariant, not a correction); narrowed to0, 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_attributesdecoded to structured kv); a reader test over a fixture file lacking the columns (back-compat).scope_attributesreuses theourios-corecanonical 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_namefilters 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
AnyValueincluding non-finite doubles, replacing the current “encodes to null” assertion with a round-trip one. - RFC0018.6 — a receiver test feeding
severity_number25 and 200 and asserting they are preserved (not clamped), that theourios.ingest.recordscounter records them witherror.type = severity_out_of_range(in-memoryMeterProvider, mirroring the compaction-metric test), and that aseverity >= ERRORquery still matches them (monotonicity); plus a negative />255case asserting0(the storage-invariant limit). Replaces the prior clamp-to-0 assertion inseverity_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_attributesas a template-key input? — resolved: stay out of the key. The key today is(severity_number, scope_name)(cluster.rs:1680);scope_versionis already retained-but-not-keyed, andscope_attributesfollow 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 explodetemplate_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_nanoamendment.) -
SeverityNumberreject vs clamp — resolved: 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
u8vsi32— resolved:u8.0..=255covers the defined1..=24with 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 (
LogRowgains the complete scope) and RFC 0010 (drift over scopeschema_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(theFieldenum).
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 var | Backend | Meaning | Default |
|---|---|---|---|
OURIOS_STORAGE_BACKEND | both | local or s3 | local |
OURIOS_BUCKET_ROOT | local | data + audit store root (existing) | — (required for local) |
OURIOS_S3_BUCKET | s3 | bucket name | — (required for s3) |
OURIOS_S3_ENDPOINT | s3 | S3-compatible endpoint (MinIO, R2) | unset (AWS) |
OURIOS_S3_REGION | s3 | region | unset |
OURIOS_S3_PREFIX | s3 | key prefix within the bucket | unset (bucket root) |
OURIOS_S3_ACCESS_KEY_ID | s3 | static access key (secret, §3.4) | unset (→ credential chain) |
OURIOS_S3_SECRET_ACCESS_KEY | s3 | static secret key (secret, §3.4) | unset (→ credential chain) |
OURIOS_S3_SESSION_TOKEN | s3 | session 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’sobject_storeon theSessionContextand address tables by object-store URL rather than a localListingTableUrlpath. The audit-stream helpers that read withstd::fs(audit_scan,alias_store::derive_alias_map,template_registry::derive_template_registry) move toStorelisting +get_blocking.Querier::newtakes aStore(orStoreConfig). - Compactor. The filesystem walks (
tenants,plan_candidates,compact_partition,gc_orphans) move toStorelisting + theourios-parquetStore-based read/write/delete. The manifest swap adoptsManifest::publish_cas(conditional PUT, RFC0013.3/.4) so concurrent or retried sweeps cannot clobber a generation.Compactor::newtakes aStore.
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:
- Explicit Ourios config.
OURIOS_S3_ACCESS_KEY_ID/OURIOS_S3_SECRET_ACCESS_KEY(and optionallyOURIOS_S3_SESSION_TOKEN), when set, are read by Ourios and applied to theAmazonS3Builder(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). - The standard credential chain (fallback). When the explicit keys are all
unset,
AmazonS3Builder::from_env()resolves the usual way: staticAWS_*keys, a shared profile, IRSA, or instance metadata. Retained because AWS IRSA injects its ownAWS_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_ROOTwith ans3://bucket/prefixURL. 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 toobject_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::fscode 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_BACKENDunset andOURIOS_BUCKET_ROOTset- When the server resolves its config
- Then it selects the local backend from
OURIOS_BUCKET_ROOT; and withOURIOS_STORAGE_BACKEND=s3+OURIOS_S3_BUCKETit selects S3; ands3withoutOURIOS_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_ROOTand 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
StoreI/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_BACKENDset andOURIOS_BUCKET_ROOTset (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_KEYset and noAWS_*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,Debugoutput, 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(themain.rspattern), including the missing-key / secret-scrub assertion for hygiene and the local-default regression. They live incrates/ourios-server/src/main.rs(rfc0019_1_*/rfc0019_6_*/rfc0019_7_*) and run in the defaultcargo testjob. - RFC0019.2 / .3 / .4 / .5 — server-level testcontainers + localstack
integration tests in
crates/ourios-server/tests/rfc0019_storage_backend.rs, reusing therfc0013_object_store.rsharness (Store::s3against a localstack endpoint) and spawning theourios-serverbinary configured for the S3 backend, driven over HTTP..2asserts the WAL stays local while the data backend is S3 (therfc0013_6_wal_stays_localpattern);.3ingests then queries end to end on S3;.4runs the background compactor against S3 and asserts the conditional-PUT manifest swap;.5proves cross-tenant isolation. They are#[ignore]d for the defaultcargo testrun and gated to the CIs3 integration (localstack)job (Docker-API runtime + theAWS_*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 theOURIOS_S3_*secret keys, awith_s3_credentialstest (main.rs) that the explicit keys land inS3Config(blank reads as unset,localcarries none), andourios-parquetstoretests thatStore::s3accepts a full pair, fails fast on a partial set without echoing the value, and thatS3Config’sDebugredacts credentials. The authentication half is the localstackrfc0019_8_explicit_s3_credentials_authenticateintegration test (the server configured with the S3 credential keys only,AWS_*removed), gated to thes3 integration (localstack)CI job.
7. Open questions
- Single-writer lease vs. conditional-PUT contention (carried from
RFC 0013 §7) — is
publish_casretry sufficient for the compactor under multi-writer races, or is a dedicated lease object warranted? This RFC adoptspublish_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_storedefaults 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 staysgreen(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. Novalidatedstage applies — backend selection is server wiring, not a thesis-gate benchmark — so acceptance followsgreendirectly (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 indocs/benchmarks.md§7; elsewherevalidatedis 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. --configpresent → 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 bareOURIOS_*env var does not override a value set in the file. (The standardOTEL_*SDK environment is a separate concern entirely — it configures Ourios’s own telemetry SDK, never the data-plane config; see §3.8.)--configabsent → the currentconfig_from_env()path runs unchanged (readsOURIOS_*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):
-
${env:NAME}and the prefix-less${NAME}are equivalent and both resolveNAMEfrom the process environment. -
${env:NAME:-default}/${NAME:-default}substitutedefaultwhenNAMEis unset or empty. -
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. -
Scalar-only: substitution applies to scalar values only. A reference appearing in a mapping key position is left verbatim.
-
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.
-
$$is an escape for a literal$:$${NAME}yields the literal text${NAME}with no substitution. -
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
ServerConfigfield, sodefault_window_secs: ${env:W}withW=3600yields the integer3600; 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.Amendment (2026-10-12, #978, maintainer-approved) — verbatim scalars. This supersedes the YAML type inference above: a scalar reaches its
ServerConfigfield as its string content after substitution, never as a YAML-typed value. A plain scalar’s content is its spelling; a quoted scalar’s content is what remains after YAML’s quoting and escape processing. Numeric and boolean fields parse that text with their own validation, sodefault_window_secs: ${env:W}withW=3600still yields3600and a baretruestill yieldstrue. What changes is the spelling YAML typing used to rewrite: a string field keeps007,0700,1e3ortRUEas written, and an integer field rejects1_000instead of reading it as1000. An unquoted empty or~scalar is still null (rule 3). One exception: the bare-key entries ofpromoted_attributes, which share a list with{ key, type }mappings, are still type-resolved; quote such a key to keep its spelling. This follows the OpenTelemetry Collector, which passes the verbatim string to string fields (confmap v0.106, open-telemetry/opentelemetry-collector#10605). Covered bybare_scalars_reach_string_fields_verbatimandbare_null_is_still_absentincrates/ourios-server/src/config/file.rs, and byverbatim_bare_booleans_and_integers_still_resolveincrates/ourios-server/src/config/resolve.rs. -
A
${…}reference that does not conform toREF(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
ServerConfigGiven a YAML file settingstorage.backend: s3with a bucket, an enabled receiver with awal_root, an enabled querier, and a compaction interval, When the server resolves configuration with--config <that file>, Then the resultingServerConfigequals the one the equivalentOURIOS_*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 setsOURIOS_QUERIER_DEFAULT_WINDOW_SECS=3600, Then the resolved value is1800(the file), and the bare env var has no effect.
Scenario RFC0020.4 — no
--configpreserves the env-only path Given no--configflag, When the server resolves configuration fromOURIOS_*variables, Then the resolvedServerConfigis identical to today’s behaviour (the existingconfig_from_envscenarios 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: s3with 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
ServerConfigor 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 existingbuild_store_config/build_*_configvalidation tests as the shared oracle. - Regression (RFC0020.4): the existing
config_from_envunit 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
criterionbenchmark — config resolution is a one-shot startup cost, not a hot path.
7. Open questions
-
--configvsOURIOS_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 forOURIOS_*(it appears to; verify againstbuild_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
- OpenTelemetry Configuration data model — Environment variable substitution: https://opentelemetry.io/docs/specs/otel/configuration/data-model/#environment-variable-substitution
- OpenTelemetry Collector configuration (env var expansion,
${env:…}): https://opentelemetry.io/docs/collector/configuration/ - OpenTelemetry Environment Variable Specification (the
OTEL_*SDK variables held out of the config per §3.8): https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/ - Collector RFC, Stabilizing environment variable resolution: https://github.com/open-telemetry/opentelemetry-collector/blob/main/docs/rfcs/env-vars.md
- RFC 0004 — Configuration policy: tunables vs invariants (the what; this RFC is the how).
- RFC 0019 — Storage-backend selection (§3.4 credentials and secret hygiene; this RFC extends that invariant to the file path).
CLAUDE.md§3.6 (object storage source of truth), §3.7 (tenancy), §5.1 (RFC process), §6.2 (testing).
RFC 0021 — DataFusion / Arrow upgrade
rfc: 0021 title: Coordinated DataFusion / Arrow upgrade — phased behind upstream status: green 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:
green(2026-08-29) — phase 2a complete; phase 2b still upstream-gated. DataFusion 55.0.0 shipped and fired this RFC’s own reopening trigger, but only half of it: DF 55 carries arrow/parquet 59.2 yet still pinsobject_store ^0.13.2. Phase 2 is therefore split (§3.2a/§3.2b):
- Phase 2a — landed. arrow 58→59, parquet 58→59, DataFusion 54→55, MSRV 1.88→1.94.
thriftleaves the lockfile, clearing GHSA-2f9f-gq7v-9h6m (#295) and the two Dependabot alerts previously dismissed as risk-tolerated;pasteleaves too, retiring the RUSTSEC-2024-0436 ignore. RFC0021.8 green; RFC0021.1 re-baselined (below); the deny half of RFC0021.9 green.- Phase 2b — deferred, trigger unchanged. object_store ≥ 0.14 (RFC0021.7, the CAS re-proof) and the rest of RFC0021.9 (quick-xml ignores, renovate hold #313, #310) open when a released DataFusion accepts object_store 0.14. Tracked by epic #314.
RFC0021.1 was re-baselined, deliberately. It asserted literal versions (
arrow 58,datafusion 54.x,MSRV 1.88) and so failed on the phase-2a bump — not because anything regressed, but because a version-literal assertion is a change-detector that fails on every intentional upgrade. Its invariant half (exactly one arrow major, exactly one datafusion) passed throughout, and that is the property the RFC actually bought (it is what forced RFC 0017’s dual decoder when it was violated, #276). The criterion now asserts the invariant plus floors (arrow ≥ 59, DataFusion ≥ 55) — a routine upgrade passes, a downgrade that would dragthriftback fails. Literal versions live where they are executable:Cargo.tomland the lockfile.(
accepted, 2026-08-25, maintainer sign-off: covered the shipped phase 1 scope, RFC0021.1–.6.)
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 — soourios-parquetmoves 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 theschema_force_view_types = falseoverride 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
thriftdependency 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-xmldeny.tomlignores (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:
| Lock | Fact |
|---|---|
| DataFusion 54.0.0 → arrow/parquet | pins ^58.3.0 |
| DataFusion 54.0.0 → object_store | pins ^0.13.2 |
| parquet 58.3 → thrift | pins ^0.17 (vulnerable); dropped in parquet 59 |
| object_store 0.14.0 → quick-xml | pins ^0.40.1 (< patched 0.41) |
| ourios-querier ↔ ourios-parquet | must share one object_store (the querier registers Store::object_store() with DataFusion’s SessionContext, RFC 0013 §2.2) |
| DataFusion 54.0.0 → rustc | MSRV 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 theschema_force_view_types = falseoverride (#276).ourios-bench: compile-level churn only.rust-toolchain.tomlnote + workspacerust-version1.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.2a Phase 2a — arrow 59 + parquet 59 (landed 2026-08-28)
DataFusion 55.0.0 pins arrow/parquet ^59.2, so the parquet half of
the original phase 2 became available on its own:
-
parquet59 dropsthrift→ the lockfile has none,cargo denypasses, and the GHSA ignore matches nothing (#295 closed). -
pastealso leaves the tree, retiring the RUSTSEC-2024-0436 ignore. -
MSRV 1.88 → 1.94 (DataFusion 55’s floor). That enables clippy’s
duration_suboptimal_units, which suggestsDuration::from_days/from_mins— both unstable (rust-lang/rust#120301) — so it is allowed workspace-wide with that reason rather than chased into nightly-only APIs. -
API churn was two items: an
arrow-castpin, and DataFusion 55’s new requiredExecutionPlan::apply_expressions(three test doubles inourios-df-otel). No production code changed. -
execution.collect_statisticsis turned off on the querier’s session (ourios_querier::exec::session), which is the one behavioural decision in this phase. DataFusion 55 substitutes columns that per-file statistics prove constant; the all-NULL case folds to a NULL literal, collapsing predicates likebody == "…"to a bare constant, which leaves no column reference for a pruning predicate — so RFC 0044’s body-equality queries scan every row group instead of skipping them (pillar #1, and the RFC0044.7/.8 + RFC0007.1 tests caught it). Upstream bug [apache/datafusion#24769]; fix proposed in [apache/datafusion#24770], bisected to DataFusion #22969 which madecollect_statisticsthe session-level default.The override is not purely defensive: statistics collection also costs a footer read per file at plan time, which a many-file log store pays on every query, and Ourios derives its own pruning from the RFC 0009 manifest plus partition-directory windowing rather than from DataFusion’s collected file statistics. Revisit when the upstream fix ships in a release we depend on — the same three pruning tests are the gate in both directions.
3.2b Phase 2b — object_store ≥ 0.14 (still upstream-gated)
DataFusion 55 still pins object_store ^0.13.2, so this half did not
open. Trigger unchanged: a released DataFusion that accepts
object_store 0.14. Scope, known today:
object_store0.13 → 0.14+ API churn concentrated incrates/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.parquet59:thriftleaves the lockfile → close #295.- Supply chain: object_store 0.14 pulls new transitives (aws-lc-rs /
aws-lc-sys family among them) —
deny.tomllicenses/advisories re-cleared,osv-scanner.tomlupdated if needed. - Renovate: lift the
<0.14.0hold (#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 2a = .8 and
the deny half of .9. This section lists only criteria whose
trigger has fired — green means all of them pass, per
docs/rfcs/README.md. Phase 2b’s criteria live in §5b below and are
inactive: they enter this ledger (as red stubs, moving the RFC
back to red) the day a released DataFusion accepts object_store
≥ 0.14, and not before.
Status note (2026-08-29): phase 1 discharged 2026-07-03 (#339, #340; indicative B1/B2 ci-runner run on #340). Phase 2a discharged 2026-08-29 (
.1re-baselined per the header,.8landed,.9’s deny half green). The 2026-07-03 note’s “opening phase 2 moves the status back tored” now applies to phase 2b — 2a opened and closed within one PR, so the red window collapsed to nothing.
Scenario RFC0021.1 — one arrow (re-baselined, see header). Given the current dependency set, When the workspace lockfile is inspected, Then exactly one arrow major and exactly one
datafusionversion are present, with floors arrow ≥ 59 / DataFusion ≥ 55 (a downgrade that would dragthriftback fails), and the workspace builds with the pinned MSRV (1.94).
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_typesis 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) andlive-check (weaver).
Scenario RFC0021.8 (phase 2a — green) — thrift is gone. Given the parquet 59 bump, When the lockfile is inspected, Then no
thriftcrate is present (#295 closed). Asserted byrfc0021_8_thrift_is_absent_from_the_lockfile.
Scenario RFC0021.9 (phase 2a half) — supply chain re-cleared. Given the new transitive set, When
cargo deny checkruns, Then it passes (it does, and the dead RUSTSEC-2024-0436pasteignore was removed). The quick-xml half of this criterion is phase 2b’s — see §5b.
5b. Phase 2b criteria (inactive — trigger not fired)
These activate, as red stubs in §5, when a released DataFusion accepts object_store ≥ 0.14 (epic #314). Until then they are forward declarations, not acceptance criteria, and do not count against the status.
Scenario RFC0021.7 — 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.9 (phase 2b half) — quick-xml ignores retired. Given the object_store ≥ 0.14 transitive set, When
cargo deny checkruns with the renovate hold lifted (#313), Then it passes with 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 anexceptionsentry). - 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 atvalidated.Status note.
validated(2026-07-18) — the RFC0022.5 no-regression contract holds on both rungs of the bench ladder:benchmarks.md§9.9 (indicativeci-runner, git6e3301b— thegreenmerge itself: B1 34.8× vs the ≥ 10× bar, B2 flat few-ms windowed latencies, pruning counters pinned structurally incrates/ourios-querier/tests/it/rfc0022_attr_columns.rs), andbenchmarks.md§9.11 (authoritativebaseline-8vcpu-32gib, git19e0886, a descendant of6e3301b— 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 outstandinggreen → validatedstep; §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 thedocs/rfcs/README.mdlifecycle 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
OPTIONALUtf8 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(theRequired,Stableidentity attribute of the OTelserviceresource entity per the semconv registry, the DSL’s bareservicefield); operators extend it via a newstorage.promoted_attributeskey 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_opset (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 0002LIKEbehaviour unchanged. - The JSON columns (
attributes,resource_attributes) remain the source of truth. Promoted columns are query-only projections: the read path (RFC 0017LogRow) 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:
- 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).
- 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 betweenseverity_text =~ "..."andattr.http.route =~ "...". - 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:
| Property | Value |
|---|---|
| Name | The DSL path, literally: resource.<key> or attr.<key> (so resource.service.name, attr.http.request.method) |
| Arrow / Parquet type | Arrow Utf8 / Parquet STRING logical type over BYTE_ARRAY (matching the RFC 0005 §3.2 string columns), OPTIONAL |
| Value | The attribute’s string value, exactly as stored in the JSON column — no truncation, no normalisation |
NULL when | The key is absent on the record, or its value is not a string AnyValue |
| Encodings | Dictionary yes, page index yes, bloom filter yes (extends the §3.6 table) |
Rules:
service.nameis 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 orNULL. The cardinality/size exposure this creates is handled by telemetry, not by lying (§3.5). - Projection, not truth.
attributes/resource_attributeskeep 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_attributesrequires a binary at or above this RFC’sgreen. 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.Jcannot 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
Pnull-count is 0, soP IS NULLprunes 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:
Pis 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
OPTIONALand 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.namecannot 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 startsNULL-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.methodvshttp_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.nameis always projected. Given records whose resource attributes carryservice.nameas a string (plus records where it is absent or non-string), When the writer flushes them, Then the file carries anOPTIONALUtf8resource.service.namecolumn whose cells equal the JSON values byte-for-byte where the value is a string and areNULLotherwise, and theresource_attributesJSON column is byte-identical to a pre-amendment writer’s output.
Scenario RFC0022.2 — configured keys project the same way. Given
storage.promoted_attributesnaming a resource key and a log key, When records carrying those keys (string and non-string) are flushed, Thenresource.<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-LIKEcompile’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 withInvalidQuery,==/!=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 keysa,b(each on top of the implicit, non-removableservice.name), When one scan spans all three and predicates onaandbrun, 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
- 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 numericAnyValue(http.status_codeas an int, the common OTLP emission) projectsNULL(§3.1), so ordering/regex predicates on it silently never match and even==only answers through the JSON arm. A futureInt64-typed promotion class (per-key type declaration in config) is what makes numeric attributes first-class; deferred until a consumer demands it. - 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.
- Automatic demotion / cardinality guards. Telemetry-first (§3.5); revisit if promoted-column bloat shows up in practice.
- 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
LIKEstopgap PR), RFC 0002 (#143 epic) — the DSL field surface. - RFC 0005 §3.2 (data schema), §3.3 (
AnyValueencoding 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 indocs/benchmarks.md§7; elsewherevalidatedis 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.
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 stayssimSeq-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.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 areasonattribute (§3.4). Existing leaves keep widening normally — the ceiling stops growth, not matching.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_idsemantics, 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_depthare untouched; the new bounds compose with them.
3.4 Telemetry (weaver registry, per the standing discipline)
Amended by RFC 0059 (2026-10-05).
ourios.miner.parse_failure.reasongainsid_reservation_failed: a fresh mint with no durably reserved template id. It is checked aftertemplate_ceiling.
ourios.miner.parse_failures(existing counter) gains aourios.miner.parse_failure.reasonattribute — valuesbelow_floor|line_too_long|template_ceiling|id_reservation_failed(added by RFC 0059, 2026-10-05) — 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_templatesplus a non-zerotemplate_ceilingfailure rate is the operator’s saturation signal.- Exact registry entries are settled in
semconv/registry/at implementation time viaweaver 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
MinerConfigwith a smallmax_templatesand 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_childrendistinct 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_failurescarriesreason = template_ceilingincrements andourios.miner.template.countreads the ceiling value.
Scenario RFC0023.7 — the scale run completes (the falsifier). Given LogHub HDFS_v2 (16 GiB) on
baseline-8vcpu-32gibunder 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
- 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.
- Per-tenant overrides. Global defaults now, consistent with every RFC 0020 knob; revisit with multi-tenant operations.
- 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.
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 indocs/benchmarks.md§7; elsewherevalidatedis 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:
- Calibration manifests — small, committed distribution summaries
extracted from a real capture (attribute-count and body-length
histograms, severity mix,
AnyValueshape frequencies), so generators are shaped by measured reality rather than guesses. - OTLP-envelope generators —
propteststrategies overOtlpLogRecord, with a calibrated mode (the realistic centre) and an adversarial mode (the envelope’s legal extremes). - 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
AnyValuebodies, 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_kindmix, - severity number/text distribution,
AnyValueshape 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:
AnyValuenesting to the canonical-JSON depth bound, attribute maps to a few thousand entries, empty/absent everything, zero andu64::MAXtimestamps, non-ASCII and confusable keys, text-heavy bodies pastmax_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_TEMPLATEwith 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
--calibrateruns, 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
- Deep-run cadence. A scheduled high-case-count run (nightly? weekly?) vs CI-only defaults — decide once the suite’s wall-clock is known.
- 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 indocs/benchmarks.md§7; elsewherevalidatedis 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:
- A third
body_kindordinal —2 = Absent— with aNULLbodycell, making the wire-legal state representable on disk. - A read-path contract — absent-body rows render with no body
(the RFC 0017
LogRowcarries none), never as an empty string. - 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.typeattribute. Defense in depth: with (1) in place,UnsupportedAbsentBodydisappears, but the wedge mechanism would fire identically for any future permanentBatchError(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_nanoexceedsi64::MAXis 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)
Readeraccepts ordinal 2 and materializesbody_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):
- 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).
- 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. - Flush the remainder normally.
- Count via the existing flush-error counter with
error.type= theBatchErrorvariant 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::Absentrecord, When it is written and read back, Then every RFC 0005 §3.2 column round-trips,bodyisNULL, 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 withbody_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_quarantinedevent, and subsequent flushes of the partition succeed.
Scenario RFC0025.5 — quarantine telemetry. Given a quarantine, Then the existing flush-error counter increments with
error.typeset to theBatchErrorvariant, 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
- Miner sentinel for absent bodies. Absent rows currently take
the
NO_TEMPLATEid withlossy_flag = true; with §3.1 they keepNO_TEMPLATEbut drop the lossy flag. Should they instead share the structured-sentinel mechanism (per(severity, scope))? Deferred —NO_TEMPLATEis adequate and queryable. - Quarantine replay tooling. The audit event carries the WAL
position; an operator
replay-quarantinedsubcommand is deferred until demand exists.
8. References
- #362 (the finding), RFC 0024 §2 (the suite that found it),
RFC 0005 §3.2 (
body_kindordinals), RFC 0001 §6.1 (BodyKind::Absentemission), 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:
- 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. - Query authn — the same token mechanism on the RFC 0016 HTTP API (and thereby everything layered on it, e.g. RFC 0027).
- 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-tenantheader 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. tenantsis 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
authsection ⇒ open mode, preserving today’s behavior for local/dev, with a structured startup warning naming the exposure. An emptyauth.tokenslist 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 ⇒ gRPCUNAUTHENTICATED/ HTTP 401 before any decode work. - Per-batch authz: every
ResourceLogsgroup’s derived tenant (RFC 0003 §6.3, unchanged) is checked against the token’s set. Any out-of-set tenant rejects the whole batch withPERMISSION_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.tokenslist using${env:VAR}values, When the server starts, Then tokens resolve through the RFC 0020 substitution engine; an emptyauth.tokenslist is a startup configuration error; a missingauthsection 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 anyResourceLogsgroup 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
authsection, 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 toourios_core::authfor 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 newrejectedkind member), and theingest_deniedaudit event (kind 8,denied_token_namecolumn — §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_deniedevent 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
- Token rotation ergonomics. Config reload vs restart; whether two tokens per name (old + new) is worth first-class support.
- 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. - Tenant patterns. Prefix grants (
team-*) if explicit lists prove operationally painful; needs careful semantics before any wildcard beyond"*". - 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_templatesgives 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 inourios-serveronly. - Transport: streamable HTTP on the existing querier listener at
/mcp, enabled by aquerier.mcp.enabledconfig 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)
| Tool | Backs onto | Notes |
|---|---|---|
query_logs | RFC 0002 DSL via the RFC 0016 path | args: tenant, query (DSL string), optional limit; returns count + up to limit rendered rows + pruning stats |
list_templates | RFC 0017 registry | args: tenant; returns (template_id, rendered_template, version) rows — the corpus’s shape at a glance |
template_drift | RFC 0010 drift surface | args: 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_logsdefaults to a conservativelimit(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-mcpsidecar 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.enabledunset or false, When the querier role serves, Then/mcpreturns 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/mcpspeaks MCP streamable HTTP on the same listener, And no new crate exists (the adapter is anourios-servermodule).
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
authsection) serves MCP exactly as it serves the JSON API.
Scenario RFC0027.3 —
query_logs. Given a seeded tenant, Whenquery_logsruns a DSL statement, Then the result carries the total count, at mostlimitrendered 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, Whenlist_templatesruns, 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, Whentemplate_driftruns 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):
rmcpserver-side at/mcpbehindquerier.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/.5are payload-equality proofs against the JSON API over the same seeded store; thelimitargument is a hard cap; all tools record on the sharedourios.query.durationhistogram (logs/drift/the newtemplateskind member). - RFC0027.6 — #415 (resource):
ourios://dsl-grammarserves 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/listadvertises exactly the §3.2 three.resources/read ourios://dsl-grammarserves the §7 section (heading-checked; byte-identity is the §5.1 CI test’s oracle).query_logsover really-ingested rows returns a payload equal to/v1/query’s for the same statement and tenant.template_driftanswers 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
- Result pagination. Whether
query_logsgrows a cursor for result sets past the row limit, or agents are expected to refine predicates instead (the DSL makes refinement cheap). - stdio bridge. A
ourios mcp-stdio --endpoint <url>local proxy for clients that only speak stdio — convenience, not architecture; demand-driven. - 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.
- Aggregation tools.
count by templatestyle 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-tenantscoping), RFC 0017 (template registry behindlist_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 indocs/benchmarks.md§7; elsewherevalidatedis 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:
- 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 testwall time, measured at 57 s fortouch core → querier test binariesbefore 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). ourios-coredecomposition — split the fat hub along its fault line: pure data types (tenant, records, OTLP, audit, alias, confidence) stay inourios-core;MinerConfigand its validation move to a newourios-configcrate (name bikesheddable). Acoreedit currently rechecks 9 crates (38 s); config churn — a frequent edit class — stops invalidating type-only consumers.- Deferred-with-tripwire:
ourios-parquetsplit (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. - cargo-nextest for test execution (local + CI): per-test
parallelism over the consolidated binaries, faster reruns,
crisper failure output. Additive;
cargo testkeeps 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/debughit 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-ingestereach 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_1still 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/commoncrate. 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> -- --listinventory, 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 (--listprints test names as module paths; a file moving undertests/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 bycargo test -p ourios-querier --no-run— drops below 30 s, And full-workspace suite wall time — under the test runner CI adopts (plaincargo test, orcargo nextest runonce 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-configextraction, When the full workspace suite runs, Then results are identical pre/post split, And aMinerConfigedit 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.mdandcrates/ourios-miner/tests/README.md(+ the server harness header); all exemptions are process-globalOTelmeter-provider installers. - RFC0028.3 — both gates pass (measurement tables on epic #382,
2026-07-06):
touch core → querier --no-run57 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
MinerConfigwhitespace edit leavesourios-coreandourios-parquetFresh; 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.
rednote — this RFC’s scenarios are review/measurement mechanisms (§6), not stub-able tests; there was noredrung, 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
- Crash-recovery isolation inventory. Which tests genuinely
need their own process/binary (SIGKILL, env-mutating,
#[ignore]d hardware gates)? Slice 1 produces the list. - Per-branch target dirs. Worktrees already give this
implicitly; whether to document
CARGO_TARGET_DIRconventions for branch-heavy local work, or leave it to worktree practice. ourios-confignaming and scope — config only, or does the RFC 0020 file-config layer’s schema (currently inourios-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 indocs/benchmarks.md§7; elsewherevalidatedis 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:
- 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. - 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).
- 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
oauth2clientextension 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)
issueris the OIDC discovery root: Ourios fetches/.well-known/openid-configurationonce 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).audienceis required — an Ourios deployment must never accept tokens minted for another service.tenant_claimnames a claim whose value is a list of tenant ids (or the wildcard"*"), mapped verbatim onto RFC 0026’sTenantSet;name_claim(defaultsub) 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/oidcmust be configured in anauthsection; both together are valid. The RFC 0026 empty-list rule is unchanged and unconditional: an explicittokens: []always fails startup — to run OIDC-only, omittokensentirely. Noauthsection 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: noneand HMAC are rejected outright),issequality,audcontainment,exp/nbfwith a small configured clock skew. A verified token resolves to the RFC 0026(name, tenants)binding — the values of the configuredname_claim/tenant_claimkeys — 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.typevalues unchanged) andingest_deniedaudit 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
unauthenticatedreasons only at the existing low-cardinalityerror.typelevel.
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-serverlinks 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.oidcsection whose values use${env:VAR}, When the server starts, Then they resolve through the RFC 0020 substitution engine; a missingaudienceis a startup configuration error; anauthsection with neithertokensnoroidcis a startup configuration error; an explicittokens: []is a startup configuration error regardless of whetheroidcis present; anoidc-only section starts and serves; a missingauthsection 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
nbfbeyond the configured skew, (d) a wrong-audtoken, (e) a wrong-isstoken, (f) a token with a corrupted signature, (g) analg: nonetoken, (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_claimvalue 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 thename_claimvalue appears as the name label where the token name appears today.
Scenario RFC0029.4 — wildcard claim. Given a verified token whose
tenant_claimvalue 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
tokensandoidc, 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 anoidc-only config each serve; and with noauthsection 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 unchangederror.typevalues and an ingest authz denial emits theingest_deniedaudit event carrying thename_claimvalue — 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 Dexmasterpinned by image digest (reproducible; recorded inci.ymland 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
- JWKS outage grace. How long verified-key caches may serve after the issuer becomes unreachable (bounded staleness vs. fail-closed on rotation-with-outage).
- Human/agent flows for the query and MCP surfaces. Device
flow via Dex for CLI/agent login, and whether
/mcpshould advertise OAuth metadata per the MCP authorization spec once this layer exists. - Claim schema convention. Whether
ourios_tenantsbecomes a documented convention Dex configs ship, or stays fully deployment-chosen. - 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 Collectoroauth2clientextension (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 indocs/benchmarks.md§7; elsewherevalidatedis 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:
- Server TLS on all three listeners — OTLP gRPC (:4317), OTLP
HTTP (:4318), and the querier HTTP surface (:4319, including the
RFC 0027
/mcproute) — via rustls (already the workspace TLS stack; no OpenSSL linkage is required — the only openssl-named crate in the tree staysopenssl-probe, rustls-native-certs’s pure-Rust trust-store path prober). - 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).
- Certificate reload without restart: cert/key pairs are re-read on a configurable interval so cert-manager-style rotation works with no dropped listener.
- Config mirrors the OTel Collector’s
configtlsserver 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*_secsconventions; 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
bearertokenauthextension “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 setinsecure: 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_filefor 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_fileandkey_filecome as a pair; one without the other is a config error at startup (named field in the message).client_ca_filewithoutcert_file/key_fileis a config error — mTLS presupposes server TLS.min_versionaccepts"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_secsis 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
*_tlsblock 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::ServerConfigbuilder in one place: certificate chain + key from the configured files, client CA into aRootCertStore+WebPkiClientVerifierwhen present, ALPNh2/http/1.1as appropriate per listener (gRPC requiresh2). - Both HTTP-family listeners (OTLP HTTP, querier axum router) accept
through
tokio-rustls’sTlsAcceptorin front of the existing hyper serve loop; the gRPC listener uses the same acceptor in front of tonic’sServer::serve_with_incoming(tonic’s owntlsfeature is not enabled — one rustls wiring for all three listeners instead of two). - Reload (
reload_interval_secs): the acceptor holds the activeArc<rustls::ServerConfig>behind a read-mostlystd::sync::RwLock(each handshake clones theArcout); 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-rustlsonly (already in the transitive tree; declared directly at the seam’s home), plusrcgenas a dev-dependency to mint test CAs and leaf/client certs. The reload swap usesstd::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
*_tlsblock 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_tlsblocks) in a follow-up chart release; the chart is not part of the acceptance gate here.
4. Alternatives considered
- tonic’s built-in
tlsfeature for gRPC + separate axum-side wiring. Two TLS stacks to configure and keep consistent; tonic’s feature also pins its own rustls wiring. OneTlsAcceptorin 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_intervalis 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
grpclistener withcert_file/key_filefrom 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
httplistener withcert_file/key_filefrom a test CA, When an OTLP/HTTP client posts a batch tohttps://…:4318trusting that CA, Then the export succeeds and the batch is ingested; And When a plaintexthttp://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-Tenantfor a tenant the token binds) and an MCPinitialize(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_fileset 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_filewithoutkey_file, orclient_ca_filewithout a server pair, ormin_version: "1.1", When the server starts, Then startup fails with an error naming the exact offending field; Given an unreadable or non-PEMcert_file, Then startup fails naming the path.
Scenario RFC0030.6 — certificate reload. Given a TLS listener with
reload_interval_secsset 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.tokensconfigured and a listener without a*_tlsblock, When the server starts, Then exactly one warning naming that listener is emitted; Given the same listener with its*_tlsblock configured, Then no such warning.
Scenario RFC0030.8 — served end-to-end (Collector-shaped client). Given the served
ourios-serverbinary running both roles in one process (the deployment-level end-to-end; the test lives inourios-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_fileplus 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-ingesterfor the .1/.2/.4/.5/.6/.9 receiver + seam arms,ourios-serverfor .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. rcgenmints 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
- 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. - 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. cipher_suites/curve_preferencesexposure. The Collector exposes both; rustls’s defaults are deliberately safe and narrow. Left out until someone presents a compliance requirement.- 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
configtlsserver settings — the config model mirrored here (cert_file/key_file/client_ca_file/min_version; the Collector’sreload_intervalis spelledreload_interval_secsin 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). Reachedvalidated2026-07-21: the §7 gate set is frozen and CI-enforcing (greenvia the asserting ci-runner series, §9.13–§9.18), and the first fully authoritative comparative run onbaseline-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).acceptedis 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) context —
trace_id/span_idon 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 context —
service.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_TEMPLATEon 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_idis 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 promotestrace_idto 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 BYfor 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:
- Ingests one fixed corpus into both systems over their native OTLP path (§3.3).
- 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.
- Records the
L-gate metrics (§3.6) intodocs/benchmarks.md§9 in the same diff-reviewable shape RFC 0006 established. - 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:
| Class | Query | OTel axis / Ourios pruning mechanism | Disposition |
|---|---|---|---|
| L1 | Template-exact lookup: all lines of one rare template over the full corpus | body pattern → template id (RFC 0001) | must-win (thesis) |
| L2 | Attribute predicate: severity ≥ ERROR AND service.name = X over a bounded window | resource context → promoted columns + Parquet stats (RFC 0022) | must-win (thesis) |
| L3 | Trace correlation: every log line for one trace_id | execution context → high-cardinality bloom column | must-win (thesis, OTLP-native) |
| L4 | Frequency aggregation: count of a template over time, grouped by an extracted param | typed template params → columnar GROUP BY | must-win (thesis, OTLP-native) |
| L5 | Substring 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 |
| L6 | Broad scan: all lines in a wide time range, low predicate selectivity | little prunes | floor — bounded, not must-win |
| L7 | Ingest throughput: sustained OTLP lines/s to steady state | — | parity — 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) → countmap 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 0016scanned/prunedrow-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_readfigure is the total bytes fetched from object storage to answer the query: the count/pruning scan plus the row-materialization scan that fetches the ≤limitreturned 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’sQueryStats::bytes_readkeeps its count-scan-only meaning (the B1/B2 gates and the RFC 0016 metrics depend on it); the two new components are additiveQueryResultfields 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.jsonartifact GET on a cache hit.QueryResult::registry_bytes_readkeeps 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 + headChunkBytesfrom 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 readsloki.bytes_read(or namesSummary.totalBytesProcesseddirectly — 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 (revisitCLAUDE.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) → countmaps are identical within theL4_COMPLETENESS_MARGINdocumented in §7 (2026-07-17), checked pergroup_key— a phantom cell (one Loki reports that Ourios’s own answer doesn’t contain at all) or anygroup_keywhose total across all its buckets exceeds Ourios’s is never tolerated regardless of margin; only Loki under-counting agroup_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_L1whereM_L1is the committed must-win margin (§7)- And the class disposition in the results is
must-win, so a result above the ratio flipsl1.pass = falseand is surfaced as a pillar-level finding perbenchmarks.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 = Xover 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_idpresent in it, withtrace_idnot 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-winwith 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 BYontemplate_id+ a typed param column; Loki:count_over_timewith a LogQL pattern/label_formatextraction 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-winwith 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_L4freeze (below, §7): the dispatch run gates the L4 pair on the processed channel atM_L4 = 10(primary) plus the 1.1× storage-side floor (m_l4_storage_floor_tenths = 11) vial4_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_readand latency are recorded with class dispositionacknowledged- 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_p50whereF_L6is 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_L7whereF_L7is 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_footprintvalues and their ratio are written tobenchmarks.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 inbenchmarks.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’sa1/c2gate-math unit tests). The marginsM_L1,M_L2,M_L3,M_L4,F_L6,F_L7are 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 excludespruned), 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_dispatchjob (indicative onci-runnerfirst, authoritative onbaseline-8vcpu-32gibon 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-validatedstep, consistent withbenchmarks.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 = 10andM_L3 = 10are 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_L2is 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 atM = 10(measured 32.5–39.3×), with the storage-side figure recorded as informational.M_L4was 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 thebenchmarks.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; likeF_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 asm_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_L4andF_L7deferrals are untouched. - Processed channel (primary):
-
Floor / parity factors — F_L6 FROZEN, F_L7 DEFERRED (2026-07-13).
F_L6 = 3is 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, orientedloki_p50 / ourios_p50so > 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 (informationalbar,benchmarks.mdtaxonomy): 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 = 2stays 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 includingL4_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 aourios_aggregate_answerquery 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 frozenotel-demo-v8corpus this lands ontemplate_id=60(“Periodic task <type> generated”),param(0),bucket(12h). The LogQL equivalent issum by (value) (count_over_time({service_name=~".+"} |= "<needle>" | regexp `<pattern>` [<width>])), withstart/endepoch-aligned tobucket_width— Loki’squery_rangestep-grid evaluates the range vector at each instanttin[start, end), covering(t - width, t]; the instant at exactlystarttherefore decodes to an empty phantom bucket (nothing in the corpus precedesstartby construction) that gets silently dropped, while every real bucket is still fully covered by the remaining evaluated instants — Ourios’s ownbucket(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_timeaggregation 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.idvalues under the samecontainer.id) is cleanly sequential with no interleaving. push_corpus_to_loki/push_otlpwere read end to end; no drop path exists, andpartial_success.rejected_log_recordsis asserted clean on every push in every run.- Loki’s own container stderr carries zero
level=warn/level=errorlines (bar one harmless startup"empty ring"transient) — nothing Loki considers log-worthy. - Loki’s own
loki_discarded_samples_total/loki_discarded_bytes_totalPrometheus counters — its dedicated accounting for silent/expected discards, incremented even when nothing is logged — never appear in/metricsat 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, pergroup_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_marginstill 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 anygroup_keywhose 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 hidingAbeing fabricated to compensate forBbeing nearly lost. Refined to aggregateourios/lokibygroup_keyfirst (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 changeelectPreferred’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-sideloki/ourios = 3.73×, processed-channel87.1×— both reported only, per the deferral below. - PR #536 code review, round 2 (same day, after run #18): a
per-
group_keyPERCENTAGE margin breaks down at low cardinality. Run #19 (dispatched to confirm the round-1 review fixes) found it directly: a realgroup_keywith exactly 1 total Ourios row, where Loki captured 0 — 0%, below any normal margin, but forn = 1there 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 justn = 1(o = 2at a 90% margin:ceil(2 * 0.1) = 1tolerance permits losing 1 of 2, i.e. 50% completeness, nowhere near 90%). Switched the non-n=1case tofloor, 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 to3, not the exact4, because1.0 - 0.9is not exactly representable inf64(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_L4FROZEN (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 asM_L2, from the same kind of evidence (a strong processed win with storage nearer parity):M_L4 = 10on 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 asourios × 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_L7remains the only deferred §7 value (until L7 is first measured). - A plain unaggregated line-filter count came back exactly as short
as the
-
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) vsboltdb-shipper. Pick the one a competent 2026 operator would deploy; likelytsdb. Confirm against Loki’s current guidance at implementation time. -
OTLP → Loki path. Loki’s native OTLP endpoint vs an OTel Collector with the
lokiexporter. 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-benchextension. Does the comparative driver + equivalence harness live inourios-benchor a newbench/comparative/(non-crate) harness plus a small querier-side metric addition? A new crate is aCLAUDE.md§7 commitment; a harness underbench/is not. Leaningbench/+ 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/prunedcounts, extended here to bytes-read). - RFC 0022 — promoted attribute columns (the resource-context pruning L2 exercises).
- RFC 0023 — bounded template memory (the
NO_TEMPLATEfraction 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
Summarystatistics (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 indocs/benchmarks.md§7; elsewherevalidatedis vacuous for this surface (RFC 0008 precedent).
1. Summary
Add a second MCP resource — ourios://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’sresource.<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 >= erroronly works if the client knows severity is the numeric OTelSeverityNumberand 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 andcontains(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_resourcehook, the config plumbing (storage.promoted_attributesis already resolved into aPromotedAttributesat 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
Resourcein the RFC 0027 module (crates/ourios-server/src/mcp.rs), URIourios://query-schema, MIMEapplication/json.list_resourcesreturns both resources;read_resourcedispatches on URI. - The querier role today does not receive the resolved
PromotedAttributes(only the receiver’s write path does). The server threadsconfig.promotedintomcp_router→OuriosMcp, 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 §7fieldproduction (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 shortdescriptionstring; the shape above is the minimum.severity— the six names with theirfloor/ceilbands MUST equal the DSL’sSeverityName::floor/ceilmapping (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 writeseverity >= ERROR”.promoted_attributes— the effective running set from the threadedPromotedAttributes(resource_keys()/log_keys()):service.namealways 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 thecost_modeldeployment-specific:promoted_attribute_equalityis index-backed for exactly these keys, in this instance; the same predicate on any other key isnon_promoted_attribute_predicate(the RFC 0022 §3.3 JSON-LIKEfallback — 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:bloommechanism entries correspond one-to-one to the columnswriter.rsactually 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).severitycarries no bloom filter — its predicates prune through min/max page statistics (ordinal data, where statistics are the right index); the resource saysstatistics, notbloom, because claiming index-backing that the writer does not provide is exactly the drift RFC0032.4 gates against.time_windowis therange(t1, t2)stage pruning on the time columns’ statistics;unscoped_browse(norangestage 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_schematool. 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-semconvnames. 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 andourios://query-schema(application/json); When the client readsourios://query-schema, Then the body parses as JSON withformat_version: 1and carries the §3.2 top-level keys (fields,severity,promoted_attributes,cost_model); Andtools/liststill advertises exactly the RFC 0027 §3.2 three — no new tool.
Scenario RFC0032.2 — content matches the running config. Given
storage.promoted_attributesconfigured with resource and log keys, When the resource is read, Thenpromoted_attributesequals the effectivePromotedAttributesset —service.namefirst, configured keys deduplicated in order; And with the section omitted,promoted_attributes.resourceis["service.name"]and.logis 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.namesentries equal the DSL’sSeverityNamemapping — for each of the six names,floorequalsSeverityName::floorandceilequalsSeverityName::ceil— the test asserts against theourios-querierfunctions, 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.classificationentry withmechanism: "bloom"names only columns the writer actually bloom-filters — the test derives the expected set from the writer’s properties for the configuredPromotedAttributes(template_id,trace_id,span_id, and everyPromotedAttributes::column_namescolumn) and asserts the resource’s index-backed equality kinds cover exactly the DSL fields backed by that set; Andseverity’s entry carriesmechanism: "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 ofquery_logs,list_templates, andtemplate_driftcarries exactly one advisory sentence namingourios://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 readingourios://query-schemaperforms 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/listagainst servers built with distinctstorage.promoted_attributesconfigs;.6additionally 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 againstSeverityName::floor/ceiland against the writer-properties bloom set derived from the samePromotedAttributesvalue, 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_templatesalready 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_attributesreloadable, the resource must follow and MCPlistChanged/subscription semantics become relevant — out of scope here, but the once-at-startup build is the assumption to revisit. -
severity_textexposure. The stored schema carriesseverity_text, but the DSL deliberately compares on the numeric scale (RFC 0002 §6.1). If the DSL ever exposes it, the resource’sfieldsfollows the grammar automatically — noting so the two don’t drift silently. - Tier vocabulary stability.
index_backed/pruned/scanare 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 aformat_versionbump).
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_versionevolution-hook precedent for small versioned JSON artifacts. - OTel Logs Data Model —
SeverityNumber1–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 indocs/benchmarks.md§7; elsewherevalidatedis 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:
- 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.
- 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. - 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_eventsbyte 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 sametenant_id=partition key the audit walk is already scoped to. - Invisible to every existing reader by construction: the local
audit walk collects only
*.parquetentries and the S3 listing filtersends_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*.parquetfile 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) → tokensmap, template encoded in the canonical space-joinedformat_templateform the audit stream itself stores (the exact inputparse_templatealready 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 asread_all_eventsdoes 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_version2. 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, statusgreen → 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 onlytemplate_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 onlyextension == "parquet"entries, S3 listing filtersends_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.jsonand 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 pinstornas 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’sUnknownVersiondisposition — designed for exactly this evolution — is unreachable there, because the parse fails before the version probe runs. A.jsonkey 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_VERSIONbecomes 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) classifiesUnknownVersion→ 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_idmismatch → 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. (Refined 2026-10-12, #962: the comparison is against the acquisition’s total bytes, meaning the artifact GET plus every audit object fetched. That equals the folded audit bytes on a miss. See the §3.3 amendment.)
- 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.jsonelsewhere in this RFC (§3.3–§3.5, §5, the §3.4 diagram) read as the versioned key post-amendment; the local tmp istemplate_map.v2.json.zst.tmp(extensiontmp, ignored by the walk as before). (Superseded 2026-10-12, #962: each local publish uses its own uniquely named temp,template_map.v2.json.zst.<UUIDv7>.tmp; see the §3.3 amendment.)- Dependency. Zero new dependencies:
zstd0.13 is already compiled into every querier build (parquet58’szstdfeature viaourios-parquet, andarrow-ipc), andourios-benchalready binds the crate directly as the A1 reference codec (RFC 0006 §3.4.1). Addingzstd = "0.13"toourios-querierintroduces 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, orerror— so run #20’s ambiguity (abstention and publish IO failure both leaving the same “no artifact” label) cannot recur.
Amendment pointer (2026-10-12, #962, maintainer-approved). The v2 body gains an optional
fold_stateobject (high_water_ns,binding_conflicts,incremental_depth) that the incremental fold needs.format_versionstays 2, the key is unchanged, and an artifact without the field is ineligible for the incremental path. The specification is the §3.3 amendment of the same date.
3.3 Freshness — the frontier check
The audit stream is append-only, so cache validity is exactly:
the artifact’s
folded_filesset equals the tenant’s live audit-file listing at read time.
The read path becomes:
- List the tenant’s audit
*.parquetset (the existingaudit_fileswalk / prefix LIST — no GETs). - GET
template_map.json. If absent, torn (JSON parse failure), unknownformat_version, ortenant_id-mismatched — see dispositions below. - If
folded_files== the live set (set equality; both sides are sorted-unique already): cache hit — deserialize, use. - 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_eventspath, then write-through (§3.5).
Dispositions, pinned:
| Condition | Disposition |
|---|---|
| Artifact absent | Fresh fold (today’s behaviour), write-through |
| Frontier ≠ live set | Fresh fold, write-through at the new frontier |
| Torn / unparseable JSON | Treat as absent; fresh fold, write-through overwrites; emit telemetry (§3.7) |
Unknown format_version | Treat as absent (forward compat) |
tenant_id mismatch | Fail 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_versionprobe 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.
Amendment (2026-10-12, #962, maintainer-approved): guarded incremental fold on staleness. Production evidence (the production node, v0.12.2, 24 h): every template-map derive had
ourios.template_map.derive.reason = stale, derive p95 was 58–111 s, acquire-wait reached 46 s, and one cold derive read ~7,375 audit files (~1 GB) for one tenant. A stale artifact is almost always “the same history plus a few appended files”, yet row 4 of the table above re-reads the whole history. This amendment adds a guarded fast path to that row and closes the §7 open question. Every other row, the LIST-before-GET rule and “re-derive, never serve stale” are unchanged: the incremental path produces the map a full fold over the same listing would, or it is not taken.The artifact carries its fold state (§3.2 pointer amendment). An optional
fold_stateobject joins the v2 body:"fold_state": { "high_water_ns": 1760227200123456789, "binding_conflicts": [ { "template_id": 7, "version": 2 } ], "incremental_depth": 3 }
- A fold-relevant event is one either fold consumes: a template event that binds a
(template_id, version)key (created,widened,type_expanded,adopted; a degenerate-widening rejection binds none) or analias_asserted/alias_retractedevent. Compaction, quarantine, ingest-denied, erasure and unknown events change neither fold, so they never constrain the guard.high_water_ns— the greatest RFC 0005 §3.7timestamp(TIMESTAMP(NANOS, UTC), as integer nanoseconds since the epoch) of any fold-relevant event infolded_files;nullwhen there is none. It is a JSON integer, read and written asu64, never through a float.binding_conflicts— the RFC0033.8 conflict set of the fold (every key seen bound to two semantically different templates), sorted ascending by(template_id, version). The registry keeps only each key’s winner, so without this field an incremental fold cannot know a key was already in conflict (see Equivalence).incremental_depth— the number of incremental folds since the last full fold; a full fold writes0.Validation, in addition to §3.2’s (a failure classifies Torn):
binding_conflictsstrictly sorted and each key present inregistry;high_water_ns = nullonly whenregistryandalias_mapare both empty;incremental_depth≤ the depth cap.Evolution: an optional field, not a version bump.
format_versionstays 2 and the key staystemplate_map.v2.json.zst. The field is additive: the pre-amendment reader’s serde struct does not deny unknown fields, so an old binary serves afold_stateartifact on a hit exactly as before, and on a stale lookup it full-folds and republishes without the field. A new binary treats an artifact withoutfold_stateas ineligible for the incremental path (full fold, then republish with the field). A mixed fleet therefore stays correct; it only loses the fast path until the old binaries retire. A bump (v3 at a new key) was rejected: it buys nothing for correctness (the absent field already means “full fold”), and the §3.2 amendment reserves new keys for encoding changes. The persisted-layout-break license pre-production is not needed here. No reader ever needs a dual read.The guard. Given the one listing (live frontier
L) and aValidartifactAwhosefolded_filesF≠L, the querier takes the first matching row:
Condition Disposition derive.fallback.reasonAhas nofold_stateFull fold no_fold_stateF ⊄ L(a folded file disappeared)Full fold files_removedincremental_depth≥ the depth capFull fold (writes depth 0) depth_caphigh_water_nsis non-null and deltaD = L \ Fholds a fold-relevant event withtimestamp ≤ high_water_nsFull fold out_of_orderOtherwise Incremental fold of DontoA—
- A
nullhigh-water admits any delta.nullmeansFholds no fold-relevant event, so no delta event can be ordered before one, and the timestamp guard passes whateverDholds. The equivalence below still holds: validation pinsregistryandalias_mapempty when the high-water isnull, and everybinding_conflictskey must be inregistry, so that set is empty too. The seeded fold therefore starts from the empty state, which is exactly the full fold’s state afterF’s events, and the fold-relevant events in the total order overLare exactlyD’s, inD’s own order. No key is seeded, so the seed timestamp is never used.- “New” is set difference, nothing else.
Dis the listed*.parquetkeys not inF, in the lexicographic order the full fold scans. Audit keys are immutable once visible (uniquely named<UUIDv7>.parquet; a single PUT on S3, tmp+rename locally; §3.2), so a key in bothFandLnames the same bytes.- What is deliberately not trusted. The guard reads the fold-order key itself, the event
timestamp, from the delta’s rows. It never infers order from anything else. The UUIDv7 in the key is the writer’s clock at file open, which skews between ingester, compactor and querier processes. Theday=partition is derived from the event timestamp, so a backdated event lands in an old day directory. LIST order is lexicographic, not temporal. An object’sLastModifiedis store PUT time and has no local-backend equivalent. A rule built on any of these would admit a delta that a full fold orders differently.- The guard is strict (
>, not≥). A delta event at exactlyhigh_water_nswould tie an already-folded event, and the full fold breaks that tie by file path, which an append cannot see. Ties fall back.- Reading the delta is the guard’s price. The delta must be fetched to learn its minimum timestamp. On a rejection the full fold runs over
L; an implementation may reuse the delta bytes it already holds, and the telemetry counts the objects actually fetched.The incremental fold. The registry fold is seeded with
A’s registry, every held key at timestamphigh_water_ns; the alias projection withA’s classes; the conflict set withbinding_conflicts.D’s events are read in scan order, with the same row-level tenant backstop, and each fold then orders them by the full key(timestamp, file path, row index)the way it already does for a full fold.RegistryFolddoes this while streaming: it replaces on>=timestamp in scan order.AliasFoldbuffers the alias events and stably sorts them by timestamp before applying them. The incremental path differs only in what the projection starts from:A’s classes instead of the empty map. The guard ordersFbeforeD, and it is these folds that order events withinD. Applying delta alias events in raw scan order would be wrong: interleaved timestamps across delta files would reorder union and retraction. The result carriesfolded_files = L,high_water_ns = max(high_water_ns, the delta's greatest fold-relevant timestamp), the union conflict set, andincremental_depth + 1. A delta with no fold-relevant event (only compaction events, say) passes trivially. It leaves the registry, alias map, conflict set andhigh_water_nsunchanged, but it is still an incremental step:folded_filesgrows andincremental_depthincrements, counting toward the cap.Equivalence. The full fold’s order is RFC 0005 §3.7.1’s
(timestamp, file path, row index). When the guard passes, every fold-relevant delta event is strictly later than every fold-relevant event inF, so in that order all ofF’s relevant events precede all ofD’s. Non-relevant events change neither fold, so they may sit anywhere. WithinD, scan order is the restriction of the full scan order. Therefore:
- Registry. A key’s full-fold winner is its last binding in the total order:
D’s last binding of the key ifDbinds it, otherwiseF’s winner, which is whatAholds (RFC0033.1). The seeded fold computes exactly that: every delta binding has a timestamp greater than the seed’s, so it replaces the seed, and among delta events the existing>=-in-scan-order rule picks the total-order last.- Alias map.
AliasMap::applyis a state machine whose whole state is the class set.A’s classes are the state afterF’s events (RFC0033.1). The alias fold’s stable timestamp sort overD’s scan-ordered events produces exactlyD’s restriction of the total order, and applies it after them.- Binding conflicts. A key is in conflict iff its bindings span at least two distinct token sequences, regardless of order. If
Falready held two, the key is inbinding_conflicts. IfFheld one (soA’s winner has it), a distinct delta binding differs from the held text or from an earlier delta binding, and the fold’s existing check flags it. Without the persisted set the first case is lost, which is why the field exists.- Frontier and high-water are the union and the max by construction.
Each incremental step is thus a full fold over its listing, and by induction so is any chain of steps. RFC0033.10 property-tests both.
Late, out-of-order and concurrent writers. Every case reduces to the guard:
- An ingester flushes a buffered or retried batch late, or a second ingester or the compactor writes concurrently: the new file is in
D. If its fold-relevant events are later than the high-water it folds incrementally; if not,out_of_order.- Clock skew between writers: a writer whose clock runs ahead raises
high_water_ns, and an honest writer’s next relevant events then fail the guard until real time passes the skewed mark. Correct, but it degrades to full folds, and theout_of_orderfallback rate is the signal.- A file was invisible to the listing that built
Aand appears later with backdated events: it is in the nextDand fails the guard. Listing consistency is otherwise unchanged from §3.3. The incremental and full paths consume the same one listing, so a missing file affects both identically.- Concurrent queriers: as §3.4, per backend.
- S3: the incremental publish is a CAS against the
ETagof the artifact it extended. A lost race is harmless, because every artifact is a correct fold of its own frontier.- Local: publication is a rename with no precondition, so the last writer wins and no
lost_raceoccurs. This is safe, and is required to stay safe, for one reason: every artifact either writer can install is a correct fold of the listing it was built from, with afold_statedescribing exactly that listing. A rename of an older fold (frontierF1) over a newer one (F2) therefore makes the artifact staler, never wrong. The next read seesF1 ≠ Land runs the guard againstF1’s ownhigh_water_nsandbinding_conflicts, which are correct forF1. The overwritten artifact’s state is not needed, because the delta fromF1re-covers it.incremental_depthafter an overwrite: the count belongs to the surviving artifact’s own lineage, i.e. the number of incremental steps since the full fold it descends from. It stays an exact count and the cap still bounds the surviving lineage. The overwritten lineage simply ends.- The one local requirement: each publish writes its own uniquely named temp file in the tenant’s audit directory (e.g.
template_map.v2.json.zst.<UUIDv7>.tmp, extensiontmp, ignored by the walk) and renames it into place. Today’s fixedtemplate_map.v2.json.zst.tmplets two concurrent publishers write into the same temp file. One rename could then install interleaved bytes. That would almost surely classify Torn and self-heal, but it is not a correct fold of any listing. A unique temp name restores the property the argument above relies on: every renamed file is one writer’s complete artifact.- Reclaiming leaked temps: unique names mean a temp leaked by a crash is no longer overwritten by the next publish, so leaks must be swept explicitly.
- What is swept. Each local publish, before writing its own temp, removes the tenant-directory siblings named
template_map.v2.json.zst.*.tmp, plus the legacy fixedtemplate_map.v2.json.zst.tmp, whose modification time is more than 1 hour old. A failed removal is ignored, as are a missing file and a racing remover.- Why 1 hour. A publish writes at most the 64 MiB decode-bound artifact to local disk, which takes seconds. An hour is two to three orders of magnitude above any plausible publish, so a live publisher’s temp never matches. It is still short enough that leaks stay bounded by the crashes in one hour.
- Safety does not rest on the number. If a stalled publisher’s temp were removed anyway, its
renamefails, the publish recordserror, and the query answers from the fold it holds (§3.5). Nothing wrong is installed.- A failed publish cleans up after itself. Once its temp exists, a publisher that fails at any later step (write, rename, or any error before the rename) removes its own temp, best-effort. The age sweep is only for leftovers of a crash, where no cleanup could run.
- The sweep is local-only. S3 publishes are single conditional PUTs and leave no temp object.
- Deletion, retention, audit compaction or rewrite: no code deletes or rewrites audit files today. Any future one removes a key from
F, so the next acquisition isfiles_removed, a full fold. A rewrite under the same key would break §3.2’s immutability premise for the plain hit path too. It is out of contract for both.Bound: a depth cap, no timer. The equivalence holds for chains of any length, so the cap is not needed for correctness. It bounds the blast radius of an undetected implementation defect. A persisted artifact is otherwise only rebuilt from the source of truth when something else forces a full fold, and on a busy tenant that may be never. The cap is a code constant, 256, not configuration: it costs at most one full fold per 256 published incremental steps. A wall-clock refresh was rejected because it adds a clock to a rule that is otherwise a pure function of the store.
The guarantee is per served map, so no publish is needed to keep it.
- The invariant: every map the querier serves or publishes is at most 256 incremental steps from a full fold. The guard extends only a base whose
incremental_depthis below the cap, and the result’s depth is the base’s plus one, so the result is within the cap whether or not it is published.- Without a publish: an incremental result that is not published persists nothing. That covers size abstention, the hard ceiling, an IO error and an S3 lost race. The next acquisition extends the same stored base again, and its result is again one step from a base within the cap.
- Why a defect cannot compound: a defect in that step affects only the answer it is in. It cannot be persisted, so it never becomes the base of a further step. The cap exists to bound exactly that compounding.
- Why no extra state: a skipped publish therefore needs no separate counter and no forced full fold.
- What an abstaining incremental path costs. No eventual publication is promised. On a stable listing every acquisition re-derives the same delta from the same base. Compressed JSON is not guaranteed to drop below base plus delta, so the write-through may abstain every time. The bound is per acquisition instead: at most the base artifact plus the delta’s bytes. The gate abstains only when the result would cost at least that much to GET, so re-reading is the cheaper option by the gate’s own definition. Correctness is unaffected, since every answer is one guarded step from a stored base within the cap. No forced-publication rule is added.
Write-through and abstention (§3.5 pointer amendment). An incremental fold publishes like a full one. Abstention keeps its principle: publish iff the compressed artifact costs less than what the next acquisition would pay without this publish. The listing is unchanged in that case, so that cost is this acquisition’s own acquisition bytes: the fetched artifact’s bytes (zero on an absent artifact) plus every audit object fetched. One definition covers every path:
- a miss: the folded audit bytes, exactly the pre-amendment rule;
- a full fold behind a stale, torn or unknown-version artifact: the artifact GET, plus any rejected delta read, plus the full fold;
- an incremental fold: the base artifact plus the delta.
The pre-amendment rule compared only against the folded audit bytes. That under-counts whenever an artifact was fetched, and could abstain where publishing is cheaper. This amendment replaces it with the acquisition-bytes comparison.
QueryResult::registry_bytes_readstays “template-map acquisition bytes”, and the abstention compares against exactly that figure.Telemetry (§3.7 pointer amendment). The lookup outcome stays
staleandourios.template_map.derive.reasonstaysstale. RFC0033.9 pins that the reason is known before the fold, and the path is only known after the guard has read the delta. Two attributes are added toourios.template_map.derive.duration,derive.audit.filesandderive.audit.size:
ourios.template_map.derive.mode:full/incremental, conditionally required: present once the path is decided. A miss, torn or unknown-version derive is alwaysfull. Astalederive carries it once the guard has decided, and a failure after that point keeps the decided mode. Astalederive that fails before the guard decides (e.g. a delta object fails to read or decode while its timestamps are being checked) records its duration sample withoutmode, carryingerror.typeas RFC0033.9 requires. No third value is added for “undecided”; the attribute’s absence means exactly that;ourios.template_map.derive.fallback.reason: the guard row that declined the fast path (no_fold_state/files_removed/depth_cap/out_of_order), only onstalesamples withmode = full.Both names are new. They are maintainer-approved and registered in the shared
ourios-semconvregistry via ourios-semconv#17; the registry pin bump lands with the implementation. The OTel naming guidance ({object}.{property}, closed enums for short value sets) was checked, and no upstream attribute fits. RFC0033.9’s duration boundary is unchanged: one sample per fold, and none for a failure before the disposition is known (the listing, or an artifact naming another tenant). The guard and the delta read run after the stale disposition is known, so they fall inside that boundary. A rejected delta read is part of the same fold’s duration. The files/size samples count the objects actually fetched. An incremental acquisition reports conflicts like any fold-backed one (RFC0033.8), i.e. the whole union set, as the full fold would. (2026-10-13, #987: “reports” here means its conflict set and lookup anomaly; the event announces only the keys the replaced artifact lacks, per the §3.7 amendment of that date.)What this does not change. The freshness LIST still walks the whole audit prefix (~7,375 keys, about 8 LIST pages per acquisition on the node above). Bounding it is #853 option (a), a separate amendment. Whether a hit re-reports
binding_conflictsstays #928. This amendment persists the set, which #928’s option 1 needs anyway, but leaves the hit-path report cadence to that issue.Expected effect (measured on Dash0, scenario RFC0033.14). A stale acquisition’s audit IO goes from O(tenant history) to O(files since the last publish), plus one artifact GET. Against the baseline above:
- stale derive p95 drops from 58–111 s to seconds;
- acquire-wait tracks it;
- audit GETs per stale derive drop from ~7,375 to the delta count;
- the 5–100 s tail (~10% of queries) shrinks to the residual full-fold fallbacks.
The ~187k object-store calls per 6 h should fall by roughly the audit GETs that full re-folds paid. LIST pages remain until #853. The status stays
accepted. Scenarios RFC0033.10–RFC0033.14 are the criteria.
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,renameinto place (Manifest::write_atomicshape). A crash mid-write leaves the prior artifact (or its absence) authoritative and a harmless.tmpfor the GC sweep; the rename is the only visibility point. - S3 backend: single-object conditional put
(
Manifest::publish_casshape). 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 tmptemplate_map.v2.json.zst.tmp, same rename — superseded 2026-10-12, #962: a uniquely namedtemplate_map.v2.json.zst.<UUIDv7>.tmpper publish, see the §3.3 amendment; 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 v1template_map.jsonkey. 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 stale, fold_state present, folded_files ⊆ S, depth below cap (2026-10-12 amendment)
Q->>FS: GET only the delta files D = S minus folded_files
alt high_water_ns null, or every fold-relevant event in D later than it
Q->>Q: fold D onto the artifact's registry + alias map (§3.7.1 order within D)
else guard rejects (out_of_order)
Q->>FS: GET every audit file in S (delta bytes may be reused)
Q->>Q: full fold registry + alias map (§3.7.1 order)
end
Q-->>FS: publish template_map.v2.json.zst @ frontier S (unique tmp+rename / CAS, best-effort)
else miss / torn / unknown version / other stale fallbacks
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 (unique tmp+rename / CAS, best-effort)
end
Q->>Q: answer the query (identical on every path)
Diagram updated 2026-10-12 (#962) for the §3.3 incremental-fold
amendment. “Other stale fallbacks” are no_fold_state,
files_removed and depth_cap. Abstention (§3.5) can skip any
publish arrow.
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).
Amendment pointer (2026-10-12, #962, maintainer-approved). The cost described in the paragraph above, one fresh fold per mutation-driven staleness, becomes one fold of the new audit files on top of the loaded artifact whenever the §3.3 guard admits it. An incremental fold writes through like a full one. Every write-through’s abstention now compares against that acquisition’s own acquisition bytes (artifact GET plus every audit object fetched), i.e. what the next acquisition would pay without the publish. For an incremental fold that is the base artifact plus the delta. See the §3.3 amendment of the same date.
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.jsonunconditionally; 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.
Amendment (2026-10-10, #927, maintainer-approved): binding conflicts. Within one
(template_id, version)the template never legitimately changes, because widening and type expansion bump the version. The registry fold therefore checks every key it already holds against each incoming binding. A semantically different template (different parsed tokens) is a binding conflict, i.e. a template-id collision (CLAUDE.md §3.1). Each fold-backed acquisition reports every conflicting key once (event cadence narrowed by the amendment of 2026-10-13, #987, below):
- the
ourios.template_map.binding.conflictedlog event (WARN), carryingourios.tenant,ourios.template.idandourios.template.version. The template texts are never attached, because they can contain user data;ourios.template_map.lookup.anomaly = binding_conflicton that acquisition’s lookup data point.The served map is unchanged and stays last-wins. An identical re-emit is not a conflict. Neither is a widening (a new version) or an adoption riding an existing leaf, which restates the leaf’s text exactly. A cache hit runs no fold, so it doesn’t re-report a conflict; persisting conflicts in the artifact is #928. The status stays
accepted. Scenario RFC0033.8 is the criterion.
Amendment (2026-10-13, #987, maintainer-approved): announce a conflict once per artifact that records it. With incremental folds, derives are frequent, and each one re-announced the whole union conflict set. On the node that was about 630 identical WARNs per derive for
nocturnal, and about 2,385 per fold ofeq-perses. The event now names only the keys that are new to this acquisition: keys in the derived conflict set that are absent from thebinding_conflictsof the artifact this acquisition replaces. That set persists (§3.3 amendment 2026-10-12), so a querier restart doesn’t re-announce it either.
“Once” is per published artifact, not durable suppression. The known set is whatever artifact the store holds when the next acquisition reads it. After a size abstention or an IO error, that is the same artifact, so the same new keys are announced again. After a lost race it is the concurrent winner’s artifact, so only keys absent from the winner’s set are announced again. No separate suppression state is kept: the repetition lasts only while publication does not land, is at most one event per new key per acquisition, and is never worse than the behaviour before this amendment.
No known set. An acquisition with no stale artifact, a torn or unknown-version artifact, or one without
fold_stateannounces its whole conflict set, as before. This includes the one-timeno_fold_statebootstrap.The anomaly is unchanged.
ourios.template_map.lookup.anomaly = binding_conflictstill marks every fold-backed acquisition whose conflict set is non-empty, so the lookups counter still shows a tenant living with conflicts. The persisted set still names every key.A full-fold fallback from a stale artifact that has
fold_state(files_removed,depth_cap,out_of_order) subtracts that artifact’s set like an incremental fold. The served map is unchanged, and a hit still announces nothing. The status staysaccepted. Scenario RFC0033.8 gains the criterion below.
Amendment (2026-10-11, #924, maintainer-approved): what an acquisition costs. The lookup counter says a derive happened, not what it cost. Each fold behind a non-hit lookup also records:
ourios.template_map.derive.duration(s), keyed byourios.template_map.derive.reason(miss/stale/torn/unknown_version). The reason is known before the fold, so a failed fold records its duration too, witherror.type;ourios.template_map.derive.audit.files({file}) andourios.template_map.derive.audit.size(By), the audit set a completed fold read, by the same reason.A hit records none of them. A failure before the disposition is known (the audit listing, or a fetched artifact naming another tenant) runs no fold and records no derive sample. A query that joins another query’s in-flight acquisition records
ourios.template_map.acquire.wait.duration(s, no attributes): the time it waited, whether the shared acquisition succeeded or failed. Itsjoinedlookup outcome stays success-only. The acquirer records no wait. The status staysaccepted. Scenario RFC0033.9 is the criterion.
Amendment pointer (2026-10-12, #962, maintainer-approved). The derive instruments gain
ourios.template_map.derive.mode(full/incremental) andourios.template_map.derive.fallback.reason(no_fold_state/files_removed/depth_cap/out_of_order). The lookup outcome andderive.reasonare unchanged. Both names are new, approved and registered via ourios-semconv#17 (the pin bump comes with the implementation). The specification is the §3.3 amendment of the same date; scenario RFC0033.13 is the criterion.
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.
(2026-10-12, #962, maintainer-approved: that guarded fast path is specified
in the §3.3 amendment, with a strict > guard so that ties fall
back. The unguarded append-only fold stays
rejected, for the reason given here.)
Guarding on key order, partition or object timestamps instead of
event timestamps (2026-10-12, #897). Skipping the delta read by
trusting the UUIDv7 in the key, the day= directory, or
LastModified would save GETs on a rejection. Rejected: none of
them is the fold’s ordering key. Writer clocks skew, backdated events
land in old day directories, and a store’s PUT time says nothing
about event time. A guard on any of them could admit a delta the
full fold orders differently.
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_registryresult and the cache-hit alias map equals the freshderive_alias_mapresult, 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).
- Amendment (2026-10-12, #962, maintainer-approved): the republish and the next-query hit are required only after a successful publish (
published). Publication is best-effort (§3.5), so an abstention, anerroror an S3lost_raceleaves the prior artifact, or a concurrent winner’s, in place. The answer still equals the no-cache fold over the live set for every publish outcome.
Scenario RFC0033.3 — Crash/tear safety around the publish
- Given a publish interrupted mid-write (simulated: a stray
template_map.json.tmp, a truncated/corrupttemplate_map.json, or an S3 CAS loss to a concurrent writer)- When the next query runs
- Then a stray
.tmpis 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
tornoutcome- 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
*.parquetscan 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_iddiffers 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_readequals 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/2on 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.mdalongside 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).
Scenario RFC0033.8 — Binding conflicts are reported, never served differently (amendment 2026-10-10, #927)
- Given a tenant’s audit stream that binds two semantically different templates to one
(template_id, version)- When a query acquires the template map by folding that stream
- Then exactly one conflict is reported, as one
ourios.template_map.binding.conflictedevent carrying the tenant, template id and version and no template text, plusourios.template_map.lookup.anomaly = binding_conflicton that lookup- And the served map is unchanged (last-wins)
- And an identical re-emit, a widening (same id, new version) and an adoption onto an existing leaf report no conflict.
Amendment (2026-10-13, #987):
- And when a later audit file makes the artifact stale, the refreshing acquisition announces no event for the already-known key (the conflicting acquisition’s artifact was published), but its lookup still carries the anomaly
- And a further audit file that binds a second key to two templates is announced once, as one event naming only that key
- And when an audit file the published artifact had folded is removed, the
files_removedfull-fold fallback announces no event for keys in that artifact’sbinding_conflicts, and its lookup still carries the anomaly.
Scenario RFC0033.9 — Derive cost is observable (amendment 2026-10-11, #924)
- Given the RFC0033.7 fixture and acts
- When they drive a miss, a hit, a staleness, a torn artifact, an unknown-version artifact, a shared acquisition, a failing fold, a failing shared acquisition, and an artifact naming another tenant
- Then
ourios.template_map.derive.durationhas exactly one sample per fold, keyed by itsourios.template_map.derive.reason, and none for a hit or for the other-tenant artifact; a failing fold’s sample also carrieserror.type- And
derive.audit.files/derive.audit.sizehave one sample per completed fold, the cold miss’s and the unknown version’s equal to their fixture’s audit file count and bytes- And
ourios.template_map.acquire.wait.durationhas exactly one sample per joiner, the failing shared acquisition’s included, and none for an acquirer.
Scenario RFC0033.10 — Incremental fold ≡ full fold (property) (amendment 2026-10-12, #962, maintainer-approved)
- Given any generated audit history (the RFC0033.1 generator: template creations/widenings/type-expansions/adoptions/rejections, alias assertions/retractions, conflicting re-bindings, non-fold events at arbitrary timestamps), split into a folded prefix file set and one or more delta file sets whose fold-relevant events are each strictly later than every earlier set’s. Timestamps may interleave arbitrarily across the files of one delta, including alias assertions and retractions.
- When the prefix is full-folded and published, and each delta is then acquired through the incremental path in turn. Each base is published with the direct
TemplateMap::publish, which has no size abstention, so a chain never depends on the write-through’s publish decision.- Then after every step the registry, alias map, binding conflicts,
folded_filesandhigh_water_nsequal those of a full fold over the same listing- And decoding the acquired map’s
to_artifact_bytes(the zstd frame) and normalizingincremental_depthon both sides gives JSON bodies equal byte for byte to the full fold’s, whether or not the write-through then publishes or abstains- And the query answer through either path is identical.
Scenario RFC0033.11 — The guard falls back, never guesses (amendment 2026-10-12, #962, maintainer-approved)
- Given a published v2 artifact, with
fold_stateexcept in the pre-amendment case below- When the next listing adds a delta holding a fold-relevant event with
timestamp<high_water_ns, or=it; or a folded file is missing from the listing; or the artifact’sincremental_depthis at the cap; or the artifact has nofold_state(a pre-amendment v2 artifact)- Then the acquisition is a full fold over the listing, its answer equals the no-cache fold whatever the publish outcome. When the write-through passes the size abstention and the publish succeeds (
published), the stored artifact carriesfold_statewithincremental_depth = 0. If the write-through abstains (no publish outcome recorded), or the publish recordserroror an S3lost_race(best-effort, §3.5), the prior artifact, or a concurrent winner’s, stays in place, and the answer is still the full fold’s.- And an incremental result that is not published (forced abstention or a failing publish), extended from a base at
incremental_depth = 255, leaves the stored artifact unchanged. Repeated acquisitions each fold incrementally from that same base, each acquired map’s canonical bytes carryincremental_depth = 256(never more), and each answer equals the full fold. Once a result at depth 256 is published, the next stale acquisition is adepth_capfull fold.- And a delta whose only events are not fold-relevant (e.g. compaction events, whatever their timestamps) folds incrementally:
folded_filesgrows andincremental_depthincrements by one, while the registry, alias map,binding_conflictsandhigh_water_nsare unchanged- And a
fold_statefailing the §3.3-amendment validation (unsorted or registry-absent conflict keys,nullhigh-water with a non-empty fold, depth above the cap) classifies Torn- And on both backends a delta file holding a row for another tenant fails the acquisition exactly as the full scan does: the same loud
QueryError::Storagefrom the shared validated visit (visit_validatedinaudit_scan.rs). The incremental path reads the delta through that same visit, never a separate reader. No fold is served and nothing is published- And an artifact carrying
fold_stateread by the pre-amendment v2 reader is aValidhit when its frontier matches- And the abstention uses acquisition bytes, pinned at the boundary. For an incremental derive whose compressed result is larger than the delta’s bytes but smaller than base plus delta, the write-through publishes; the delta-only rule would have abstained. A result of at least base plus delta abstains. For a stale full-fold fallback whose compressed result is at least the folded audit bytes but smaller than the artifact GET plus the audit objects fetched (any rejected delta read included), it publishes; the pre-amendment folded-bytes rule would have abstained. A result of at least that total abstains. Each case asserts the publish outcome (
published, or no outcome on abstention) and the answer equals the full fold.
Scenario RFC0033.12 — Late files and concurrent writers (amendment 2026-10-12, #962, maintainer-approved)
- Given both backends (local root; S3 via the localstack harness) and an artifact published by an incremental fold
- When an audit file with a backdated fold-relevant event becomes visible after that publish, and separately two queriers extend the same stale artifact concurrently
- Then the backdated file forces a full fold whose answer includes it in §3.7.1 order
- And on S3 the concurrent incremental publishes resolve by CAS: the loser’s query succeeds with the correct answer and its write is discarded (
lost_race)- And on the local backend both publishes report
published, last writer wins, and each writes and renames its own uniquely named temp file. The installed artifact decodesValidand equals exactly one writer’s complete artifact (never Torn, never interleaved). With an older fold renamed over a newer one, the next acquisition sees the older frontier as stale and still returns the full-fold answer- And on the local backend a publish removes sibling
template_map.v2.json.zst.*.tmpfiles, and the legacy fixed.tmp, that are more than 1 hour old, and leaves younger ones in place. A temp removed or missing under a publisher makes that publish recorderrorwithout failing its query. A failed removal fails nothing. A publish that fails after creating its temp (e.g. an injected rename failure) leaves no temp of its own behind- And on both backends both queries answer correctly, and the next acquisition is a hit or a guarded fold (incremental or full) whose answer equals the full fold over its listing.
Scenario RFC0033.13 — The path taken is observable (amendment 2026-10-12, #962, maintainer-approved)
- Given the RFC0033.9 fixture
- When acts drive an incremental fold, each of the four fallbacks, and a stale acquisition whose delta object fails to read before the guard has decided
- Then each completed derive sample carries
ourios.template_map.derive.reason = staleandourios.template_map.derive.mode(incrementalorfull), and each fallback sample carries the matchingourios.template_map.derive.fallback.reason- And a miss, torn or unknown-version derive carries
mode = fulland no fallback reason- And the stale acquisition that failed before the guard decided records exactly one
derive.durationsample withderive.reason = staleanderror.type, and with neitherderive.modenorderive.fallback.reason; it records noderive.audit.files/derive.audit.sizesample- And the incremental sample’s
derive.audit.files/derive.audit.sizeequal the delta’s file count and bytes- And the
out_of_orderfallback’sderive.audit.files/derive.audit.sizeequal the audit objects actually fetched, including the rejected delta read. That is the delta plus the full set’s remaining objects when the delta bytes are reused, or the delta plus the whole full set when they are re-fetched. The test asserts against a fetch-counting store, so it holds whichever the implementation does- And an incremental acquisition over an artifact whose
binding_conflictsis non-empty reports every persisted and new conflict exactly as RFC0033.8 does for a full fold (2026-10-13, #987: in its conflict set and lookup anomaly; the event names only the new ones)- And the attribute names exist in the semconv registry (weaver-generated constants).
Scenario RFC0033.14 — The stale tax collapses in production (amendment 2026-10-12, #962, maintainer-approved)
- Given the baseline on Dash0 (the production node, v0.12.2, 24 h): every derive
stale, derive p95 58–111 s, acquire-wait up to 46 s, ~7,375 audit files per derive, ~10% of queries 5–100 s, ~187k object-store calls per 6 h for 639 requests- When a release with the incremental fold has run 24 h on the same node
- Then at least 90% of
stalederives aremode = incremental- And the
stalederive-duration p95 is at most 1/10 of the baseline’s lower bound (≤ 5.8 s)- And the median
derive.audit.filesofincrementalderives is at most 1% of the full fold’s- And the before/after numbers, including the fallback-reason mix and object-store calls per request, are recorded on #897.
6. Testing strategy
Mapped to CLAUDE.md §6.2:
- RFC0033.1 —
proptest: 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.
- RFC0033.8 — unit tests on the registry fold in
crates/ourios-querier/src/template_registry.rs: one conflict with the map unchanged, re-emit, widening, a real-miner adoption, and a property test that the conflicts are exactly the multiply-bound keys. Act 6 oftests/rfc0033_7_observability.rsasserts the event and the lookup anomaly through the in-memory log and metric exporters; itsannounced_once_act(2026-10-13, #987) covers a stale refresh that announces nothing for a known key and a new key announced alone. - RFC0033.9 —
tests/rfc0033_7_observability.rs: the RFC0033.7 acts plus a failing shared acquisition and a final act on its own store (unknown version, then an other-tenant artifact), asserted through the in-memory metric exporter. The acquire-wait samples need the shared acquisitions, which run under thetestingfeature (CI runs--all-features); without it the test asserts no wait was recorded.
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).
Amendment (2026-10-12, #962, maintainer-approved).
- RFC0033.10 —
proptestextending RFC0033.1’s generator with a split point and chains of 1..k deltas (generated so the guard admits them), round-tripped through real audit Parquet files on the local backend. Equivalence is asserted on every fold output and on the canonical JSON minusincremental_depth. A companion property generates unconstrained splits and asserts that the acquisition result always equals the full fold, whichever path the guard picks.- RFC0033.11, RFC0033.12 — integration tests in
crates/ourios-querier/tests/on both backends. The validation cases are unit tests besidevalidateintemplate_map.rs. The pre-amendment-reader case deserializes afold_statebody through a copy of the pre-amendment struct.- RFC0033.13 — new acts in
tests/rfc0033_7_observability.rs, asserted through the in-memory metric and log exporters, plus the semconv no-diff CI gate.- RFC0033.14 — a production measurement, not a CI test: Dash0 queries on the node that produced the baseline, recorded on #897.
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 every fold-relevant event in them
is strictly later than the artifact’s recorded maximum folded
timestamp, falling back to a fresh fold otherwise, ties included.
Worth it, or is the fresh fold on miss cheap enough forever?
Answered 2026-10-12 (#962, maintainer-approved): worth it. Every
production derive was stale, at 58–111 s p95. The guard is strict
(
>): a new event at exactly the recorded maximum ties an already-folded one, so the acquisition falls back to a full fold. Only fold-relevant events count, and anullmaximum (nothing fold-relevant folded yet) admits any new file. Specified in the §3.3 amendment. - Depth cap value (2026-10-12 amendment): 256 is a judgment call, trading one full fold per 256 stale acquisitions against how long a latent defect could persist. Revisit with the RFC0033.14 fallback mix.
- Skew-driven fallback (2026-10-12 amendment): a writer whose
clock runs ahead pins
high_water_nsand makes honest writers’ deltas fall back asout_of_order. If RFC0033.14 shows that dominating, the remedy belongs to writer clock discipline, not to a looser guard. - 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_fileslists 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 existingzstd(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 tovalidate). All three §5 criteria were in force from 2026-07-21. RFC0034.1 is enacted by this change: thedocs/benchmarks.md§ D1 block is recast per-node (≥ 100 000 lines/s onbaseline-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 reachedgreenand the one-hour--tenants 8asserting 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 thebenchmarks.md§ D1 block and §7 gate table; no code, no on-disk byte. Perdocs/rfcs/README.md,specifiedmeans 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 reachinggreen.
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
- D1’s must-win: sustained ≥ 100 000 lines/s per node on
baseline-8vcpu-32gib, multi-tenant load (soak --tenants Nwith 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 isgreen— the prototype number is the calibration input, not the verdict. - 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). - D2 unchanged — already node-level, passed at every measured load including saturation.
- 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
greenbefore 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 isgreen.
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
validatedblocking set.
Scenario RFC0034.2 — the bar asserts on baseline hardware once RFC 0035 is green.
- Given RFC 0035’s production implementation at
greenand asoak --tenants 8run onbaseline-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 itsgreen. - 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 indocs/benchmarks.md§7; elsewherevalidatedis 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 bydocs/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 stampwal_high_waterwhile the age sweep’s off-lockwrite_orderedwas 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 inflush_then_snapshotbefore flushing + stamping — the publish half of the §3.1 barrier, pinned by a mutation-checked stamp-waits race arm and aSIGKILLmid-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_idvalues, 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 untilred→green. Perdocs/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 atgreen.
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 singleMutex<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
3.1 Design A (recommended) — ordered id-assignment, concurrent encode
Split MinerCluster::ingest into an ordered, cheap phase and a
concurrent, expensive phase:
- 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
MinedRecordcarrying the assignedtemplate_id, template version, and slot values. This phase does no Parquet work. It stays under theingest_gate+ a (now much shorter) critical section. - Concurrent phase (off the gate): hand each
MinedRecordto 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 wal_high_water asserts frames ≤ mark
are durably captured (at the time of this RFC, one global mark stamped on
every tenant; since RFC 0052 §3.1’s snapshot format 2, each tenant’s own
folded horizon — its last frame folded into its state, never above the
cut’s mark — written by the barrier’s install and by
recovery::write_folded_snapshots), 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
RecordSinkbuffering 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-tenantu64— 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 existingu64(tenant_ordinal << K | per_tenant_seq): still au64— 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–203stamps one global mark on every tenant today). - Per-tenant miner lock only, keep the global gate. Insufficient —
the global
ingest_gatestill 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_minerpasses 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 ≤ markare 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_*andrfc0008_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_*andrfc0008_8_batched_fsyncpass 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 == Nqueries run- Then the Parquet schema,
template_idvalues, 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 extendsrfc0008_8_concurrent_ingest_preserves_wal_order_at_the_miner(today single-tenant): drive N tenants’ records concurrently, assert every tenant’ssnapshot_stateequals a serial control that replayed the same WAL frames in strict global order. Aproptestgenerator 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 ≤ markat rotation, then assertswal_high_wateris 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 fullrfc0001_3_5_*(snapshot-restore) andrfc0008_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_durableandrfc0008_8_batched_fsyncgate 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+ thesoak --tenants Nharness onbaseline-8vcpu-32gib. The ingest write-path bench and a saturating multi-tenant soak measure the achieved multiple and core utilisation; recorded in thedocs/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_idvalues identical and decoded rows equal as a multiset (never a file hash — row order may differ); runtemplate_id == Nqueries 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
u64vs 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-
seqcompletion 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
RecordSinkbuffering 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 nometa:RFC — confirm at sign-off. (Design B would touchCLAUDE.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); §1baseline-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 sharednext_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 aCLAUDE.mdsection.
RFC 0036 — Write-side layout
rfc: 0036 title: Write-side layout — compacted-partition clustering and row-group sizing status: red author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-21 supersedes: — superseded-by: —
RFC 0036 — Write-side layout
Status:
red— reopened 2026-10-02. A regression aftervalidatedinvalidated RFC0036.3’s memory criterion, so perdocs/rfcs/README.md(Lifecycle: “a regression detected afterValidated… reopens the RFC (if a criterion is invalidated)”) the RFC returns tored(issue #895). The §3.2 skip-spill gate measured encoded input bytes, so a ~15 MB / 1,555-file partition stayed under it yet decoded past 1.5 GiB and ran a node with a 1.5 GiB memory limit out of memory on every boot sweep. The reopened contract is the corrected §3.2 algorithm (one decoded-row budget governing the skip-spill decision and run formation) and the rewritten RFC0036.3 memory clause. The test that reopened it ismany_small_inputs_within_the_encoded_budget_do_not_decode_at_once(crates/ourios-parquet/src/compaction/tests/bounded_sort.rs, PR #900). RFC0036.3 now has two memory clauses: the decoded-row budget (#900) and the byte-bounded phase-2 merge (#916). The RFC returns togreenonce both have landed and every §5 criterion passes onmain; tovalidatedonce the compaction benches are re-recorded and thedocs/benchmarks.md§7 thesis-gates pass on representative corpora; and toacceptedonly on the maintainer’s sign-off (docs/rfcs/README.mdlifecycle). The status note immediately below is the pre-reopen history (the earliervalidated/acceptedrecord); the rest of this document, including the revised §3.2 algorithm and RFC0036.3, is the current contract.
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 byclamp(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
validatedon 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 incrates/ourios-parquet/tests/it/rfc0036_write_side_layout.rs(andcompaction.rs’s forced-spill unit test); RFC0036.2 (window-query scanned-row-group bound) passes incrates/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.4sorting_columnsdeclaration are incompaction.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 theourios-benchharness); the remaining §7 questions stay open as noted there.
validatedevidence (2026-07-22). The deferred comparative arm resolved in two parts, both recorded indocs/benchmarks.md:
- RFC0036.5 no-regression (§9.26, authoritative
baseline-8vcpu-32gib, HEAD5e5aa66). The frozen RFC 0031 dispatch reran on post-RFC-0036main: 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, socompact_partitionno-ops and RFC 0036’s sort never runs there — everyourios_bytes_readis 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 avalidatedblocker (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_boundenforces).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 (promotedservice.name,time_unix_nano), rotates compacted row groups at a smaller threshold, and declares Parquetsorting_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 whose peak residency is a decoded-row
budget plus one input file (§3.2, reopened 2026-10-02); (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 atwriter.rs:627andwriter.rs:644). There is nomax_row_group_sizeproperty and nosorting_columnsdeclaration anywhere; rows are in append order. PartitionKeyis (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:
- 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.
- 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:
- Promoted
service.namevalue, 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 includeservice.namefall back to key 2 alone — a time-only sort still buys row-group time-pruning granularity (§7). time_unix_nano, ascending.- 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
Reopened 2026-10-02 (issue #895). This section originally formed one sorted run per input file and let the whole partition skip spilling while its encoded input total stayed ≤ 256 MiB, arguing that one seal-target’s worth of input decodes no larger than one worst-case input file. Production invalidated that: small, well-compressed inputs decode to many times their encoded size, so a ~15 MB / 1,555-file partition stayed under the gate yet decoded past 1.5 GiB, and the boot compaction sweep ran a node with a 1.5 GiB memory limit out of memory on its first candidate, on every start. The algorithm below is the corrected contract: one decoded-row budget governs both the skip-spill decision and run formation.
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
with a decoded-row budget B (in_memory_max_bytes, default
64 MiB). B is measured as an estimate of the heap each decoded
row holds — the record, its strings, params, separators and attribute
trees — and never as encoded input bytes.
- Run formation. Read the inputs in sorted-basename order (the
§3.1 input ordinal), each streamed batch by batch, appending their
decoded rows to one buffer in (input ordinal, row ordinal) order.
Each row is validated against the partition as it decodes
(RFC0009.5). At the end of each input, if the buffer’s decoded
footprint exceeds
B, stably sort the buffer by the §3.1 key and spill it to local scratch as one sorted run, then empty the buffer (local disk is cache, not truth —CLAUDE.md§3.6; the run format is §7). Runs are cut only between inputs, so a run holds one or more whole inputs and never part of one, and runs are numbered in spill order. - Skip-spill. If no run was spilled by the last input, the whole
partition fits
B: sort the buffer in place and write it straight to the output. Otherwise, if the buffer still holds rows (inputs read after the last spill that kept it at or belowB), stably sort it by the §3.1 key and spill it as the final run; an empty buffer spills nothing, so no run is empty. Then go to step 3 with the runs spilled so far. Every run is therefore sorted before it is merged. - Merge. Stream a k-way merge over the sorted runs, holding
one decoded batch per run (the
Readerwraps the streamingParquetRecordBatchReader), breaking §3.1 key ties by run ordinal. The fan-in cap F (64) bounds the number of runs merged at once: while more than F runs remain, merge consecutive groups of F runs into intermediate runs, preserving run order, and repeat. Each run’s reader batch is sized in bytes, not rows: every run records the largest per-row decoded footprint it holds (its widest row, measured as it is formed, or the maximum over the runs a hierarchical merge combines). A merge of k runs gives each reader⌊B / (k + 1)⌋bytes, divides that share by the run’s widest row, and clamps the result to [1, 1024] rows. The 1,024-row cap is the batch narrow rows always used. The one-row floor exists because a merge cannot advance a run without holding its head row (#853). Merged rows collect in one output chunk of 1,024 rows (the writer’s sub-batch), which is emitted into the existingWriter(rotating row groups at the §3.3 threshold) each time it fills.
Order and determinism. The buffer holds rows in (input ordinal,
row ordinal) order, every sort is stable, runs are contiguous in that
order, and the merge breaks ties by run ordinal, so the §3.1 total
order holds however the inputs are grouped into runs. Both the
skip-spill and the merge path drive the Writer with the same
sub-batch sequence, so their output is byte-identical (§3.5); spilling
changes only local scratch I/O.
Memory bound (the load-bearing claim). Phase 1 holds at most B
plus one decoded input: the buffer is under B when an input starts,
and that input is the most it can overshoot by. Phase 2 holds, whatever
the run count, the open runs’ reader batches plus the output chunk. The
reader batches together hold at most max(B, k × widest row) decoded
bytes for k open runs. That is B, plus at most one row per run whose
widest row is wider than its ⌊B / (k + 1)⌋ share: such a run holds
its one head row, the documented overshoot. The output chunk holds at most 1,024 rows, a
fixed term the writer’s sub-batch contract sets. In rows, phase 2 still
holds at most (F + 1) × 1,024. The phases do not overlap, because the
run-formation buffer is released before the merge starts. Neither bound depends
on the partition’s file count or compression ratio. The writer’s
in-memory output accumulation (ArrowWriter<Vec<u8>>) is unchanged.
Everything around the sort — manifest bootstrap, CAS commit, GC, the
RFC0009.5 per-row partition validation — is untouched.
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’sin_progress_sizecrossing 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 fixedCOMPACTED_ROW_GROUP_FLUSH_BYTESvalue). 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_bytesis the sum of the partition’s live input file sizes, read from the store during compaction (the inputs are already listed incompact_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.nametoPartitionKeywould 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_BYTESand 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.namevalues 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_columnsdeclared as §3.1 keys 1–2 on every row group, and per-row-groupservice.namemin/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
redfrom a first measurement, and “keeps up” — throughput ≫ per-partition seal rate — must still hold)- And (reopened 2026-10-02) for any N-file partition — including many small inputs whose encoded total is far below the budget
Bwhile their decoded rows exceed it — peak decoded-row residency stays within the phase-specific bounds: at mostBplus one decoded input in phase 1, and (F + 1) × one decoded batch (F open runs plus the merge’s output chunk) in phase 2. Phase 2 is also bounded in decoded bytes, whatever the row width: the open runs’ reader batches hold at mostmax(B, k × widest row)for k open runs (B, overshot by at most one row per run whose widest row exceeds its⌊B / (k + 1)⌋share), plus the 1,024-row output chunk (#853). Holding the whole partition is allowed only within those bounds (the skip-spill case, where it fitsB); the gate is measured on decoded rows, not encoded bytes.Bis a byte budget over the per-row decoded-footprint estimate, so a row-counting test states the phase-1 bound in rows asBdivided by the smallest per-row footprint in the fixture, plus one input’s rows.
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 viaParquetMetaDatathe row-group sizes,sorting_columns, and per-groupservice.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 —
criterioncompaction bench + a memory test. The existingcompactionbench 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 a decoded-row residency gauge and assert residency stays within the phase-specific bounds, in rows: phase 1 holds at most⌊B / f⌋rows plus one input’s rows, wherefis the smallest per-row decoded-footprint estimate in the fixture (the samedecoded_footprintthe sort charges againstB), and phase 2 at most (F + 1) × one batch’s rows. The phases do not overlap, so a single peak is checked against the larger of the two bounds. The skip-spill fixture may hold the whole partition, since it fitsB. Reopened 2026-10-02: the gauge test includes a many-small-inputs partition whose encoded total is well below its budget (Bset to twice the encoded total) while its decoded rows exceed that budget (many_small_inputs_within_the_encoded_budget_do_not_decode_at_once), which fails on the encoded-bytes gate. Bytes (#853): the same gauge also counts decoded-footprint bytes.wide_rows_merge_within_the_byte_budgetcompacts a hierarchically merged partition of wide rows (40 attributes × 250 B) under a smallBand asserts that peak decoded bytes stay within the larger of the phase-1 bound (Bplus one input) and the phase-2 bound (max(B, F × widest row)plus 1,024 widest rows for the output chunk). It fails when reader batches are sized in rows.cursor_batches_are_sized_from_the_byte_budgetpins the sizing, including the one-row floor and the 1,024-row cap, andbyte_sized_merge_batches_write_the_in_memory_bytespins §3.5 byte identity under one-row merge batches. All three are incrates/ourios-parquet/src/compaction/tests/bounded_sort.rs(#916). - 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 recomputesT = adaptive_flush_bytes(input_total)— exactly what the writer derived — and the boundceil(B_sw / T) + 2from 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 paidbaseline-8vcpu-32gibmeasurement 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 (nomax_row_group_sizeis 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 asinput_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 tobaseline-8vcpu-32gib. Still tunable via theOURIOS_COMPACTED_RG_BYTESenv knob (overrides the adaptive value) and the explicitcompact_partition_with_flush_thresholdseam (the sweep’s test arm; setting a process env var is unsound undercargo 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 compactedservice.namecolumn 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 thesorting_columnsdeclaration 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 bytime_unix_nanois 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 atredif 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 counts runs, not inputs: it single-passes any partition of up to 64 budgets of decoded rows while capping worst-case phase-2 residency at (F + 1) × one decoded batch, with each reader’s batch sized from its share ofBin bytes (§3.2, #853). Small partitions skip spilling entirely while their decoded rows fit the 64 MiB decoded-row budget; larger ones spill runs of whole inputs as the budget fills (reopened 2026-10-02, §3.2 — originally gated on ≤ 256 MiB of encoded input, which let a partition of many small files decode past a node’s memory limit; issue #895). - The H4 wording amendment (§3.3): landed with this slice —
docs/hazards.mdH4 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; nosorting_columns, nomax_row_group_sizeanywhere),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 atcompaction.rs:228–234, sorted basenames atcompaction.rs:267–268),reader.rs:57(the streamingParquetRecordBatchReader§3.2’s merge builds on). docs/hazards.mdH4 — 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_bytesobservability (#600), §3.3 group-by-promoted-attribute (#601) — carrying RFC0037.1 (minerrfc0037_1_*example + property tests), RFC0037.3 (minerrfc0037_3_*unit +tests/rfc0037_structured_body.rsintegration), and RFC0037.4 (ourios-querierrfc0037_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) andrfc0025_absent_body.rsrespectively — see §6.validatedwaits 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_bytesmetric plus a per-service alert, not a size cap. The opt-instructured_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-v9capture (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.messagesarray can be tens of kilobytes. Theparam_byte_limitoverflow guard (crates/ourios-miner/src/overflow.rs) protects the string path only; structured bodies are stored whole (cluster.rsingest_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, andcount … byrejects 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
AnyValuebody becomesBody::Structured(crates/ourios-core/src/otlp.rs) and is stored whole as canonical JSON in the nullablebodycolumn withbody_kind = Structured(ordinal 1). The Drain tree is never walked for structured bodies. event_nameis captured end-to-end and stored as a nullableUtf8column (crates/ourios-parquet/src/lib.rs,columns::EVENT_NAME).- RFC 0025 represents the event-shaped absent-body record (
event_name+ attributes, no body) asbody_kind = Absent(ordinal 2),body = NULL. - A
gen_ai.*resource/log attribute can be promoted to a pruned, full-operator, filterable column viastorage.promoted_attributes.{resource,log}(RFC 0022), advertised in theourios://query-schemaresource (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_iddistinguishes…inference.operation.detailsfrom 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_flagstaysfalsefor the structured path (RFC 0001 §6.1). structured_body_bytesmetric. 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.
3.5 Recommended promotion set — and why not a GenAI vertical slice
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 workloadsgen_ai.agent.name,gen_ai.tool.name,gen_ai.tool.type. These are the naturalcount … bykeys 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_idis 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_tokensare 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 OTelgen_ai.client.token.usagemetric, 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 adevelopment-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_limitwith 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
LIKEgroup 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)whoseevent_names differ- When they are mined
- Then they receive distinct
template_ids- And
… | count by template_idseparates them into distinct groups.
Scenario RFC0037.2 — structured-body reconstruction fidelity (§3.3 invariant).
- Given a structured GenAI body (an
AnyValuearray/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_bytesmetric 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.modelpromoted to a column- When
… | count by attr.gen_ai.request.model, bucket(1h)runs- Then the
(model, bucket) → countmap (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_nameand attributes but no body- When it is stored
- Then
body_kind = Absentand thebodycell isNULL- 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-minercluster.rsrfc0037_1_event_name_distinguishes_structured_templates(example) andrfc0037_1_structured_key_is_the_whole_template_identity(proptestover arbitrary(severity, scope, event_name)tuples), plussnapshot.rsstructured_template_record_without_event_name_restores_as_none(the#[serde(default)]migration). - RFC0037.2 (structured-body reconstruction): RFC 0024’s
ourios-*property tests generate structuredAnyValuebodies and assert they round-trip canonical-JSON equal —canonical::decode_any_valueon the rebuilt bytes equals the originalAnyValue(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 byrfc0037_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 inrfc0025_absent_body.rs/rfc0021_arrow_upgrade.rs. - RFC0037.3 (§3.2 fidelity + observability):
ourios-minercluster.rsrfc0037_3_structured_body_retained_byte_for_byte(colocated unit, byte identity + non-lossy) andtests/rfc0037_structured_body.rsrfc0037_3_structured_body_unbounded_fidelity_and_observability(thestructured_body_byteshistogram records the canonical-JSON length under the requiredourios.tenant, plus the recommendedourios.servicepresent for this service-bearing record, via the in-memory meter). - RFC0037.4 (§3.3 grouped count):
ourios-queriertests/it/rfc0002_dsl.rsrfc0037_4_count_by_promoted_attribute— the(model, bucket) → countmap (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,bodycellNULL). - 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_bytesmetric + alert; no cap). Former Option B rejected (§4). -
event_namein the string-path template key? No — scoped to the structured path only. String log records rarely carryevent_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 adevelopment-stability, recently-moved semconv (invariant §3.5 caution). - v9 timing (sequencing, not a fork): this RFC reaches
greenon the synthetic GenAI fixture (§3.4) and onlyvalidatedonce the demo releases andcorpus/otel-demo-v9is 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.detailsevent;OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental. - Issue #546 —
corpus/otel-demo-v9readiness (k6 port #547; demoagentservice). - 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
Amendment 2026-09-28 — query-phase and object-store client spans (
drafted). Adds twoINTERNALchildren ofPOST /v1/query(resolve files,load template_map) and per-requestCLIENTspans for the S3 backend’s object-store calls made under them (§3.7). Amends RFC0038.1’s query arm and adds RFC0038.8–.11 (§5). The accepted text below is left as written; each place this amendment changes carries an in-place note, and §9 records the history. The frontmatter staysacceptedfor the seven original criteria. The amended RFC0038.1 and the new scenarios aredrafteduntil review confirms they are testable, then move throughspecified,redandgreenon their own. Refs #853.
Status update (2026-10-13, #985, maintainer-approved) — RFC0038.1’s query arm is
red. Theresolve filesspan is implemented withourios.tenantandourios.file_set.live_files, andrfc0038_1_query_emits_the_server_span_and_its_resolve_phasereplaces the superseded one-span test, as §6 directs. The arm staysreduntil the other threeourios.file_set.*counts and the S3 variant land (#986). RFC0038.8–.11 staydrafted.
Status update (2026-10-16, maintainer-approved) — RFC0038.1’s query arm is
green.resolve filesnow carries all fourourios.file_set.*counts, asserted byresolution_spans_the_file_set_under_the_queryanda_narrow_window_counts_only_the_partitions_it_reaches. The S3 variant,s3_resolution_requests_nest_under_the_phase, passes in thes3-integrationCI job.infer schemais covered byschema_inference_spans_the_gap_between_resolution_and_the_scananda_failed_inference_marks_its_phase_as_an_error. All are incrates/ourios-querier/tests/it/rfc0038_file_set_span.rs(#986). RFC0038.8–.11 staydrafted.
Amendment (2026-10-16, #1025, maintainer-approved) — the sweep’s planning phase gets a span. Adds one
INTERNALchild ofsweep partitions,plan partitions, around the sweep’s up-front planning of every tenant, carrying theourios.file_set.*listing and manifest-read counts (§3.8). Amends RFC0038.1’s sweep arm and adds RFC0038.17 (§5). No new attribute names. The sweep still emits noCLIENTspans, so the §7 question about them stays open. Refs #1019.
Amendment (2026-10-16, #1027, follows the approved RFC 0009 planner) — the planning counts. RFC 0009’s amended planner (#1027, implemented in #1030) plans each tenant from one recursive listing and reads a manifest only where it can change the decision. §3.8’s count definitions, its reuse paragraph, RFC0038.17’s expected counts and the §6 test map are corrected in place to match. The span, its attributes and its names are unchanged.
Amendment (2026-10-16, #1039, maintainer-approved) — the sweep’s orphan-reclaim phase gets a span. RFC 0009’s orphan-GC amendment moves the GC of every candidate into one pass after the consolidation pass. That pass gets one
INTERNALchild ofsweep partitions,reclaim orphans, carrying the files it deleted and the deletes that failed, and markedErrorwhen any partition’s GC failed (§3.9). Amends RFC0038.1’s sweep arm (exactly two children) and adds RFC0038.18 (§5). Two new attribute names go to the ourios-semconv registry. GC of one partition still gets no span.
Status:
accepted(2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing indocs/benchmarks.md§7; elsewherevalidatedis 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 theOTEL_TRACES_EXPORTER=nonedisable 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.
Amendment 2026-09-28. “One per query” now means one query root. Under it are two
INTERNALphase spans and, on the S3 backend, oneCLIENTspan per object-store request those phases make (§3.7). The ingest hot path still mints no spans.
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 span | Signal | Anchor |
|---|---|---|
A logs query (POST /v1/query) | server span, root | querier.rs handle_query |
Each MCP tool call (query_logs, list_templates, template_drift) | span, child of rmcp’s own serve_inner span | mcp.rs #[tool] fns |
| One OTLP Export batch (gRPC or HTTP) | server span at the shared choke point | receiver/pipeline.rs ingest_bound |
| One compaction sweep | internal span | compactor.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.
Amendment 2026-09-28 — the query span gets phase children (§3.7). The table above gives a logs query exactly one span. Issue #853 showed what that costs: in a 110 s query, the scan was under a second, and about 30 s before it and 75 s after it fell under no span at all. The query row now reads: one
SERVERroot, plus twoINTERNALchildren for the phases that make network calls (file-set resolution, template-map acquisition), plus, on the S3 backend only, oneCLIENTspan per object-store HTTP request made inside those two phases. The RFC 0040 operator tree is unchanged. The MCP, Export-batch and sweep rows are unchanged, and so is the “never gets a span” table: none of the new spans is per record. The discipline is the same one §2 draws. A phase gets a span because it has duration and crosses the process boundary, not because it is a step.
Amendment (2026-10-16, #1025, maintainer-approved) — the sweep span gets one phase child (§3.8). The sweep row now reads: one
INTERNALsweep partitionsspan, plus oneINTERNALplan partitionschild: a single aggregate span over the planning of all tenants. Planning one tenant or one partition gets no span of its own, and neither does consolidating a partition.
Amendment (2026-10-16, #1039, maintainer-approved) — and a second phase child (§3.9). The sweep row now reads: one
INTERNALsweep partitionsspan, plus twoINTERNALchildren,plan partitionsand thenreclaim orphans, each a single aggregate span over all tenants. Reclaiming one partition’s orphans gets no span of its own.
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:
- 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. TelemetryGuardmust 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.
Amendment 2026-09-28 — two more boundaries: the blocking pool and the store bridge (§3.7.4). The query path now crosses two more boundaries that drop context. Resolution and template-map acquisition run on tokio’s blocking pool (
Querier::spawn_blocking_io, which re-enters the caller’s span). Each synchronous store call is then spawned onto the store’s bridge runtime (block_on_off_runtimeincrates/ourios-parquet/src/store.rs). §3.7.4 fixes how the parent context crosses both. It also records a test-harness gap found in #858. AWithSubscriber-scoped subscriber is the default dispatcher only on the thread that polls the scoped future, so it never sees a span opened on a blocking-pool thread or a bridge worker. The production subscriber is global and does see it. A scoped harness therefore passes a test that production would fail, and RFC0038.9 closes that gap.
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 fromOTEL_TRACES_SAMPLER/OTEL_TRACES_SAMPLER_ARG(any standard sampler name; defaultparentbased_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=noneturns the traces pipeline off, restoring today’s logs-plus-metrics posture exactly (no tracer, notrace_idon logs).init()reads it directly — Ourios plays the “autoconfigure” role Go’sautoexport/ 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=truedisables all three signals together. - Endpoint / transport:
OTEL_EXPORTER_OTLP_ENDPOINTand the otherOTEL_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:
| Operation | Span name | Kind |
|---|---|---|
| Logs query | POST /v1/query (HTTP {method} {route}) | SERVER |
| MCP tool call | execute_tool query_logs / execute_tool list_templates / execute_tool template_drift (GenAI execute_tool {tool.name}) | INTERNAL (child of rmcp serve_inner) |
| OTLP Export batch | ingest logs | SERVER |
| WAL group-commit | commit wal | INTERNAL (child of the batch) |
| Compaction sweep | sweep partitions | INTERNAL |
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.
Amendment 2026-09-28 — three more span names (§3.7). The table above gains these rows. Their attributes are in §3.7.2 and §3.7.3.
Operation Span name Kind File-set resolution (listing + manifest reads) resolve filesINTERNAL(child ofPOST /v1/query)Template-map acquisition (RFC 0033) load template_mapINTERNAL(child ofPOST /v1/query)One object-store HTTP request (S3 backend) S3.{Operation}, e.g.S3.ListObjectsV2,S3.GetObjectCLIENT(child of one of the two above, or ofderive template_mapfor the fold’s reads, per the 2026-10-12 amendment below)The first two follow this section’s
{action} {target}rule. TheCLIENTnames follow the upstreamService.Operationrule for AWS-API spans, not{action} {target}: an upstream convention, where one exists, takes precedence over a local pattern (the GenAIexecute_toolnames above are the same kind of case).
Amendment (2026-10-12, #966, maintainer-approved) — two children of
load template_map(§3.7.2). The table gains these rows. Both follow the{action} {target}rule.
Operation Span name Kind The RFC 0033 fold behind a miss,stale,tornorunknown_versionlookup (nothit, notjoined)derive template_mapINTERNAL(child ofload template_map)Waiting on another query’s in-flight acquisition, of the same frontier or another; one per single-flight loop iteration that finds one wait template_mapINTERNAL(child ofload template_map)
Amendment (2026-10-15, #1014, maintainer-approved) — the schema inference gets a span (§3.7.2). The table gains this row. It follows the
{action} {target}rule.
Operation Span name Kind Union-schema inference over the resolved files’ footers infer schemaINTERNAL(child ofPOST /v1/query)
Amendment (2026-10-16, #1025, maintainer-approved) — the sweep’s planning phase (§3.8). The table gains this row. It follows the
{action} {target}rule.
Operation Span name Kind Planning every tenant’s consolidation candidates, once per sweep plan partitionsINTERNAL(child ofsweep partitions)
Amendment (2026-10-16, #1039, maintainer-approved) — the sweep’s orphan-reclaim phase (§3.9). The table gains this row. It follows the
{action} {target}rule.
Operation Span name Kind Reclaiming orphan files from every partition the plan marks for GC, once per sweep after consolidation reclaim orphansINTERNAL(child ofsweep partitions)
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.
3.7 Query-phase and object-store client spans (Amendment 2026-09-28)
This section is new in the 2026-09-28 amendment (
drafted). It amends §3.1, §3.3 and §3.5 as the in-place notes there say, and RFC0038.1 as the note in §5 says.
Why. Issue #853 traced a 110 s query that returned no rows. The trace
had the POST /v1/query root and the RFC 0040 operator tree, and the
operators took under a second in total. About 30 s before the scan and 75 s
after it fell under no span. The first gap was later attributed to
file-set resolution listing the tenant’s whole history (fixed in #858); the
second is still unattributed. #858 added and then removed resolve files
and load template_map spans, because RFC0038.1 fixed a served query at
exactly one span. This section specifies them, plus the object-store
requests beneath them.
The rule for what gets a span is OpenTelemetry’s, as in §2. The Tracing API says child spans “represent sub-operations which require more detailed observability” and should measure the sub-operation’s own timing (Span). The semantic-convention authoring guide says to define spans for operations that have duration and involve network calls. It says not to define them for short in-process operations such as serialization or parsing, or for point-in-time occurrences, which are events (defining spans).
3.7.1 The span tree
flowchart TD
Q["POST /v1/query<br/>SERVER (root)"]
T1["load template_map<br/>INTERNAL<br/>(compile time: body == / resolves_to)"]
R["resolve files<br/>INTERNAL<br/>(exactly one)"]
O1["RFC 0040 operator tree<br/>count / aggregate scan"]
O2["RFC 0040 operator tree<br/>materialize pass"]
T2["load template_map<br/>INTERNAL<br/>(render time, if not acquired above)"]
RL["S3.ListObjectsV2 × n<br/>CLIENT"]
RG["S3.GetObject × m<br/>CLIENT (manifests)"]
TL["S3.ListObjectsV2 × k<br/>CLIENT (audit prefix)"]
TG["S3.GetObject<br/>CLIENT (artifact)"]
W["wait template_map × w<br/>INTERNAL<br/>(behind an in-flight acquisition)"]
D["derive template_map<br/>INTERNAL<br/>(at most one: a folding lookup)"]
DG["S3.GetObject × f<br/>CLIENT (audit files)"]
TP["S3.PutObject<br/>CLIENT (write-through)"]
Q --> T1
Q --> R
Q --> O1
Q --> O2
Q --> T2
R --> RL
R --> RG
T1 -.-> TL
T1 -.-> TG
T2 --> TL
T2 --> TG
T1 -.-> W
T1 -.-> D
T1 -.-> TP
T2 --> W
T2 --> D
T2 --> TP
D --> DG
Amendment (2026-10-12, #966, maintainer-approved). The tree above includes the §3.7.2 children of
load template_map:
wait template_map: one per single-flight loop iteration that finds another query’s acquisition in flight.derive template_map: at most one, for amiss,stale,tornorunknown_versionlookup the query itself acquires. The fold’s audit-file reads are itsCLIENTchildren.The audit listing, the artifact
GETand the write-throughPUTstay directly underload template_map.
Amendment (2026-10-15, #1014, maintainer-approved) —
infer schema. The tree gains a third phase under the query’s root, betweenresolve filesand the first RFC 0040 operator tree:
flowchart TD
Q["POST /v1/query<br/>SERVER (root)"]
R["resolve files<br/>INTERNAL"]
I["infer schema<br/>INTERNAL<br/>(at most one: when a live file exists)"]
O["RFC 0040 operator trees"]
IH["S3.HeadObject × n<br/>CLIENT"]
IG["S3.GetObject × m<br/>CLIENT (footers)"]
Q --> R
Q --> I
Q --> O
I --> IH
I --> IG
Before the scan can be planned, the querier builds one table schema from every resolved file’s footer (
SchemaMode::Union: RFC 0005 §3.9’s union, which RFC 0022’s predicate compilation relies on). DataFusion does that per file with aHEADand a footer read,meta_fetch_concurrencyfiles at a time. The phase was missing from the list above. It is neither in-process work nor an operator span’s I/O, so the “what does not get a span” reasoning never covered it, and the closing claim of this section did not hold for it. On v0.13.2 it was about 3 s of a 16.2 snocturnalquery with no span over it at all (trace36049ab82fd1dc1212e6e3f6cd08c34e, 2026-10-15), where an inference regression and a slow scan look the same.
infer schemaappears at most once per query: exactly once whenresolve filesfound at least one live file, and none when it found none (that query runs no scan, as above). It is opened by the caller, which knows the tenant. The listing-table helper stays tenant-free by design. A query whose filter turns out provably empty after inference still has the span, because the footers were read.Where the text below says “both
INTERNALspans”, it now means all three phase spans.infer schemais emitted on both backends, for the same reason: on the local backend its reads arestd::fsand open noCLIENTspan. The closing paragraph’s pre-scan gap is now attributed toresolve filesandinfer schematogether. The §3.7.2 heading “The twoINTERNALspans” keeps naming the two in its table.infer schemahas its own table there.
A query run through MCP never passes through the HTTP handler. Its root is
the execute_tool query_logs span (§3.5), which is itself the child of
rmcp’s serve_inner, and the same children below hang from that span
instead. Wherever this section says POST /v1/query as a parent, read
“the query’s root span”, which is one of those two.
Children of the query’s root span, in the order they start:
load template_mapappears at most once per query. It opens around the one RFC 0033 acquisition (template_map::load_or_derive) wherever that runs. At compile time, abody ==/!=orresolves_topredicate needs the map. At render time, the materialize pass decoded at least one row and the map was not acquired at compile time. The twoload template_mapboxes in the diagram are these two alternatives. The querier reuses a compile-time acquisition at render time, so no query opens both. A query that needs no map (a zero-row query with neither predicate, like #853’s) opens none.resolve filesappears exactly once per query that invokes file-set resolution, which is every query that compiles. It opens aroundQuerier::resolve_data_urls: the window-scoped listing and the per-partition manifest reads, through to the finished table URLs. When resolution finds no live file, the query returns without a scan and without operator spans. The span is still emitted, withourios.file_set.live_files = 0, since the listing and manifest work it covers still ran.- The RFC 0040 operator spans are unchanged: one tree per executed physical plan.
- On the S3 backend,
resolve filesandload template_mapeach parent oneCLIENTspan per HTTP request the phase makes (§3.7.3). The fold’s audit-file reads are the exception: theirCLIENTspans are children ofderive template_map(2026-10-12 amendment). On the local backend they parent none.
Both INTERNAL spans are emitted on both backends. On the local
backend, resolution is a std::fs walk and not a network call, but the tree
should not change shape with the backend. The phase has real duration on
local disk too, and one span name per phase keeps dashboards
backend-agnostic. That the phase makes network calls is the reason it
qualifies on S3. It is not a condition on emitting the span.
What does not get a span, and why. Each of these is in-process work with no network call, so each stays span-free under the guidance above:
- DSL parsing and compilation take microseconds of CPU and are pure functions of the request body.
- Row materialization is building
LogRows from decoded batches (LogRow::from_records) in memory. The scan that feeds it is a DataFusion plan, already timed by its RFC 0040 operator tree. - Template rendering (template + params → body,
CLAUDE.md§3.3) runs per row in memory. A span here would also be per row, which §3.1 forbids. - The DataFusion scan’s own object-store reads are covered by the
RFC 0040 operator spans (
DataSourceExec). DataFusion issues them from its own spawned tasks, which carry no Ourios context, so §3.7.3 emits noCLIENTspan for them (see the scoping rule there).
If one of these is later measured to matter, for example rendering on a
very large result, it gets an attribute on POST /v1/query or a span
event, not a span. Its name goes through the ourios-semconv registry
(§3.7.5). This amendment defines no such name.
With these spans in place, time under POST /v1/query that no child
covers can only be in-process work, or scan I/O already inside an operator
span. That is how they would have attributed #853’s gaps: the pre-scan gap
directly, as resolve files with its request and object counts. The
post-scan gap either as load template_map or, if neither phase covers it,
narrowed to the in-process steps above.
3.7.2 The two INTERNAL spans
resolve files | load template_map | |
|---|---|---|
| Kind | INTERNAL | INTERNAL |
| Parent | the query’s root: POST /v1/query (HTTP) or execute_tool query_logs (MCP) | same as resolve files |
| Opens / ends | around resolve_data_urls, from before the first listing to the finished URLs | around template_map::load_or_derive |
| Status | Error iff the phase returns an error (the query then fails with it); Unset otherwise | same |
| Required attributes | ourios.tenant; ourios.file_set.list_request_count, ourios.file_set.listed_objects, ourios.file_set.manifest_read_count, ourios.file_set.live_files (§3.7.5) | ourios.tenant; ourios.template_map.lookup.outcome (hit / miss / stale / torn / unknown_version, the existing RFC 0033 §3.7 attribute and values) |
The four ourios.file_set.* counts are set when the phase ends, and are
set on both backends. list_request_count counts store listing calls
(each list_* call on the Store, or each directory read on the local
walk). It does not count HTTP pages: one S3 listing call can take several
ListObjectsV2 requests, and those show as separate CLIENT spans. A
Store call returning an error still counts, and the counts are set on the
error path too. These are counts of work done, not identifiers, so they
stay low-cardinality attributes as §3.5 requires. Status handling follows
Recording errors.
A child CLIENT span that failed (a 404 on an absent manifest, say)
does not make its phase Error if the phase handled that outcome as
normal.
Amendment (2026-10-12, #966, maintainer-approved) — the fold and the wait get their own spans. A
load template_mapspan that covers a 40 s fold, or a query parked behind another query’s fold, still does not say which. TwoINTERNALchildren separate them:
derive template_mapwait template_mapParent load template_mapload template_mapOpens / ends around the fold ( fold_template_map) behind amiss,stale,tornorunknown_versionlookup; ahitopens nonearound one wait on the per-tenant single-flight’s result; a query whose frontier differs from the running flight’s waits, lists again, and may open a second Status Erroriff the fold fails,error.type= the fold’s error classUnset; a failed shared acquisition marks the waiter’sload template_mapinsteadAttributes ourios.template_map.derive.reason(the existing RFC 0033 attribute)none
load template_mapalso carriesourios.template_map.lookup.outcome = joinedfor a query that was served another query’s acquisition, the existing RFC 0033 value. The fold runs inside the query phase, so its object-store requests open theirCLIENTspans underderive template_map. The artifactGET, the audit listing and the write-throughPUTstay underload template_map. No new attribute names.
Amendment (2026-10-15, #1014, maintainer-approved) —
infer schema.
infer schemaKind INTERNALParent the query’s root, as for resolve filesOpens / ends around registering the scan’s listing table: from before the first footer read, through the merge, to the registered table (the build after the merge is in-process) Status Erroriff the inference or the merge fails (the query then fails with it),error.type= the error’s class as onresolve files;UnsetotherwiseRequired attributes ourios.tenantIt carries no count. The number of files it reads is the sibling
resolve filesspan’sourios.file_set.live_files, since it reads each of those files once. On the S3 backend, itsCLIENTchildren show how many requests it made. A second attribute with the same value would add a name and say nothing new. It installs the query-phase marker (§3.7.3), so its object-store requests openCLIENTspans beneath it.
3.7.3 Object-store CLIENT spans (S3 backend)
These follow the upstream object-store conventions
(S3,
AWS SDK),
which are at Development stability.
- Seam. They are emitted inside
Store::s3’s backend, by anobject_storeHttpConnectorpassed toAmazonS3Builder::with_http_connector. It wraps the defaultReqwestConnector’sHttpService. This sits below everyStoremethod and belowStore::object_store(), so one wrapper sees every HTTP request the backend makes: pagination pages andobject_store’s internal retries included. That is what “per request” means here. AnObjectStore-level wrapper (likeStore::wrap_backend) would see one call where the wire saw several, so it is rejected (§4 note). Requests to anything other than the configured bucket endpoint (credential or instance-metadata endpoints, if they share the connector) emit no span. - Kind.
CLIENT, per SpanKind: an outgoing call whose caller waits for the response. - Name.
S3.{Operation}, the AWS API operation the request performs, derived from the HTTP method and query string:GETwithlist-type=2→ListObjectsV2;GET→GetObject(ranged or not);HEAD→HeadObject;PUTwithx-amz-copy-source→CopyObject;PUTwithpartNumber→UploadPart;PUT→PutObject(conditional or not);POSTwithuploads→CreateMultipartUpload;POSTwithuploadId→CompleteMultipartUpload;POSTwithdelete→DeleteObjects;DELETEwithuploadId→AbortMultipartUpload;DELETE→DeleteObject. A request matching none of these is namedS3alone (the service, per the general “most general low-cardinality string” rule) and still gets a span. - Attributes. The AWS SDK span definition as pinned (§3.7.5):
rpc.system = "aws-api"(Requiredthere),rpc.service = "S3", andrpc.method= the bare operation (GetObject,ListObjectsV2), as that definition gives it (“the name of the operation … as returned by the AWS SDK”, examplesGetItem,PutItem);aws.s3.bucket;aws.s3.keyon object operations (the full object key as sent,Storeprefix included; never set onListObjectsV2);aws.request_idfrom thex-amz-request-idresponse header when present;aws.extended_request_idfrom thex-amz-id-2response header when present (Conditionally Required“if available” in the pinned S3 convention; S3-compatible endpoints may omit the header, in which case it is not set);cloud.regionwhen the store is configured with one;server.address/server.portfrom the endpoint.url.fullis not recorded: the query string can carry continuation tokens, and the key already sits inaws.s3.key. At v1.42.0 the registry deprecatesrpc.system(renamed torpc.system.name) andrpc.service(folded into a fully-qualifiedrpc.method), while the AWS SDK span definition still requires the old shape, andrpc.system.namehas noaws-apimember yet. Upstream is mid-migration. Ourios follows the span definition, not the half-migrated registry (§3.7.5, §7). Upstream names only; nothing here is Ourios-defined. - Status. Per Recording errors:
Errorwitherror.typeset to the HTTP status code (e.g."404","412") on any response>= 400, or to the transport error class on a request that got no response. OtherwiseUnset. Expected outcomes such as a404on an absent manifest or a412on a lost CAS mark theCLIENTspanError, as the request did fail. The phase above decides whether that failed the operation (§3.7.2).
Amendment (2026-10-12, #966, maintainer-approved) — as implemented. The spans also carry the upstream HTTP client attributes the
http.client.request.durationmetric already records (http.request.method,server.address,server.port) andhttp.response.status_codeonce a response arrives. A request its caller abandons before the response (a dropped future) endsUnsetwith noerror.type, since the HTTP span conventions say an intentional cancellation SHOULD NOT be an error. The storage metrics keep recording it ascancelled.
- S3-compatible, non-AWS endpoints. Ourios is S3-compatible, not
AWS-specific (RFC 0019 §9). An S3-compatible endpoint implements the same
S3 API operations over the same wire protocol, so the spans keep
rpc.system = "aws-api"and theS3.{Operation}names. The convention marks the valueRequiredwhatever the backend, because it names the wire protocol, not the deployment:aws-apinames the protocol spoken, not who runs the endpoint. The endpoint is identified byserver.address.cloud.provideris never set, because Ourios cannot know it, andcloud.regiononly echoes configuration. This reading is an open question (§7) for when upstream says anything specific about S3-compatible stores. - Local backend.
LocalFileSystemmakes no network call, so it emits noCLIENTspan and no other span. Local I/O time stays inside theINTERNALphase spans.
Scoping: only under a query phase, only when sampled. A CLIENT span
is emitted only when the request’s context carries both a valid,
sampled parent span and a query-phase marker. resolve files and
load template_map install the marker when they open, as a value in the
OTel Context they attach. With no marker there is no span. So:
- the ingest record sink’s
PUTs, the compactor’s reads and writes, and the audit sink emit noCLIENTspans. RFC0038.1’s ingest and sweep arms keep their exact span counts, and §3.1’s rule that the async record-sink flush has no span stands; - no
CLIENTspan is ever a root: an unsampled or context-less request makes nothing, instead of starting a fresh trace per request underparentbased_always_on; - DataFusion’s scan reads carry no marker and no parent, so they emit none (§3.7.1).
Amendment (2026-10-15, #1014, maintainer-approved) — a third phase installs the marker.
infer schemainstalls it too. The inference runs on the calling task rather than the blocking pool, so the markedContextis attached to the inference future itself, not entered in a closure. ItsHEADand footer reads are DataFusion requests, like the scan’s, and they are scoped by the same rule: under a phase span they getCLIENTspans, and the scan’s reads, under no phase, still get none. Their number is bounded by the query’s live files, asresolve files’ manifest reads are bounded by its partitions.
Extending CLIENT spans to the sweep is left open (§7): a sweep over a
large backlog can make thousands of requests, and that trade-off deserves
its own look.
Amendment (2026-10-16, #1025, maintainer-approved) — still open.
plan partitions(§3.8) does not install the query-phase marker, so the sweep’s requests still open noCLIENTspan. The planning phase’s request count is summarised as attributes on that span instead.
Always on, not opt-in or separately sampled. Under that scope the
spans follow the trace’s own sampling decision and have no switch of their
own. Under resolve files the count is bounded by the query’s window.
After #858, a windowed query’s resolution makes one delimited listing per Hive level it
visits, one recursive listing per in-window partition it covers whole, and
at most one manifest GET per in-window partition. Acquisition makes the
RFC 0033 audit listing (one request per page of about 1000 keys, plus
retries) and one artifact GET. That count is not bounded by the query:
it grows with the tenant’s audit history, because the audit listing is
neither windowed nor capped (the open RFC 0033 fork on #853, left
unbounded by RFC 0058 §3.4.3). The spans still record it, since that is
exactly the fan-out an operator needs to see in a #853-style incident.
The fix for its size is to bound the listing, not to hide it from the
trace. §3.4’s lever, OTEL_TRACES_SAMPLER, is the one volume control, as
for every other span. A bespoke toggle would be the second configuration
surface §3.4 rejects. Capping how many requests a query may make is not
a tracing concern. It belongs to RFC 0058 (query resource limits); see
§3.7.6.
Tenant IDs in keys. aws.s3.key carries tenant-partitioned paths
(data/tenant_id=<enc>/year=…/….parquet, audit/tenant_id=<enc>/…), so
the tenant ID appears in the attribute. That is acceptable. §3.5 already
puts the tenant on the query span as ourios.tenant, so the same
identifier at the same trust level is already in self-telemetry. Ourios’s
own traces go to the operator’s collector, not to any tenant. The rest of
a key is partition time and a generated file name, never record content.
The key is an attribute, never part of the span name, so names stay
low-cardinality.
3.7.4 Carrying context across the blocking pool and the bridge
- Blocking pool.
resolve filesandload template_mapare opened on the calling async task, and each is closed when its phase’s future completes. The blocking closure must re-enter the span, whichspawn_blocking_ioalready does, and must attach the span’s OTelContextcarrying the query-phase marker for the duration of the closure. Attaching theContextis new. - Store bridge.
block_on_off_runtimecapturesopentelemetry::Context::current()on the calling thread and runs the spawned future under it (FutureExt::with_context), so theHttpServicewrapper on a bridge worker sees the parent and the marker.ourios-parquetneeds only theopentelemetryAPI for this, consistent with RFC 0001 §6.8. The SDK stays inourios-telemetry. - Harness. A test that asserts on spans opened on either kind of
thread must use a process-global subscriber and tracer provider, in
its own test binary so the global install does not leak into the shared
harness. That is the pattern
crates/ourios-server/tests/rfc0038_1_mcp_span.rsalready uses for the MCP arm. RFC0038.9 makes this a criterion, with a canary proving that the harness sees a span opened insidespawn_blocking.
Amendment (2026-10-12, #966, maintainer-approved) — a scoped harness for the template-map spans. Item 3 still binds the server-level RFC0038.1 query-arm and RFC0038.8 tests. The template-map acquisition is an exception: it hands the caller’s
tracingdispatcher to its blocking-pool thread along with its span, so its spans can be asserted under a scoped subscriber. In production the caller’s dispatcher is the global default, so the forwarding changes nothing there. Under a scoped subscriber it is what makes the fold’s span visible, so a regression at this boundary removesderive template_mapor its parent, which the tests assert. These tests live in the crate’s consolidateditbinary (RFC 0028) and install nothing process-global. The store-bridge boundary is covered separately, at the connector, with a tracer of the test’s own (§6).
3.7.5 Names and the registry
Upstream attributes are used where they exist: rpc.system,
rpc.service, rpc.method, aws.s3.bucket, aws.s3.key, aws.request_id,
aws.extended_request_id, cloud.region, server.address, server.port, error.type. No upstream
attribute counts listed objects or list requests.
db.response.returned_rows counts rows a database operation returns, and
reusing it would collide with that meaning (the RFC 0040 §3.3 argument).
Two existing Ourios attributes are reused: ourios.tenant and
ourios.template_map.lookup.outcome.
Amendment (2026-10-12, #966, maintainer-approved) — the full emitted set. The
CLIENTspans also carryhttp.request.methodandhttp.response.status_code(§3.7.3, “as implemented”), alongsideserver.addressandserver.portabove. Both are upstream names, so no registry change. Thederive template_mapspan reuses the existingourios.template_map.derive.reasonattribute. RFC0038.11’s no-violation list covershttp.*as well.
Amendment (2026-10-15, #1014, maintainer-approved) — no new names.
infer schemais a span name, fixed in §3.5 like the others. It carries onlyourios.tenantand, on failure,error.type. ItsCLIENTchildren use the §3.7.3 upstream set (S3.HeadObjectis already a §3.7.3 name). No registry change and noREGISTRY_REFbump.
New names, added to the ourios-semconv registry (a registry change in
that repo, a semconv/REGISTRY_REF bump here, and regenerated
ourios-semconv constants; no name written inline in code):
| Name | Type | Unit | Brief |
|---|---|---|---|
ourios.file_set.list_request_count | int | {request} | Store listing calls made by one file-set resolution. |
ourios.file_set.listed_objects | int | {object} | Objects returned across those listing calls, before window and manifest filtering. |
ourios.file_set.manifest_read_count | int | {request} | Manifest reads made by one file-set resolution. |
ourios.file_set.live_files | int | {file} | Live data files the resolution hands to the scan. |
They follow the upstream naming rules
(attribute naming):
lowercase dot-separated namespaces with snake_case components, and the
project namespace ourios. first. ourios.file_set is a namespace and
never an attribute itself. Counts use the upstream _count suffix pattern
(http.request.resend_count, messaging.batch.message_count), and
returned quantities mirror db.response.returned_rows. All are
stability: development. Only attributes go to the registry. The span
names resolve files and load template_map are fixed in this RFC, as
§3.5 fixes every other Ourios span name, and ourios-semconv generates
no span constants. The CLIENT spans use upstream attributes only.
Pinning the Development upstream conventions. semconv/REGISTRY_REF
pins a tag of the ourios-semconv registry. That registry’s manifest pins
the upstream semantic-conventions version it depends on (v1.42.0 today,
§3.6), so the one ref fixes which upstream S3 / AWS-SDK / RPC attribute
set live-check validates against. Weaver runs with --future and reports
stability: development as improvement-level advice, which passes. What
fails is a violation: an unknown or deprecated name. So the
Development status is pinned by the ref, not by a live-check exemption.
If an upstream bump renames one of these attributes, as it did
rpc.system → rpc.system.name, the next ref bump makes live-check fail
until the emitter follows. The bump and the code change then land
together, deliberately. One narrow exemption is needed. At the pinned
v1.42.0, the AWS SDK span definition (span.aws.client) itself requires
rpc.system and uses rpc.service, both deprecated in the same release’s
registry. Live-check therefore exempts exactly those two names, on
CLIENT spans named S3.* only, in the same shape as the §3.6 genai
exemption. The exemption is removed by the ref bump that moves the AWS SDK
definition to rpc.system.name and a fully-qualified rpc.method, and
the emitter changes in that same bump. If the pinned upstream version does not define
an attribute used here, that is a registry-bump prerequisite for
implementation, not a reason to invent a local name.
3.7.6 Relation to #853, RFC 0040 and RFC 0058
- #853. These spans cover the two gaps #853 could not attribute. #858 fixed the listing cost itself; this amendment makes the next such cost visible in a trace instead of only through an OOM kill.
- RFC 0040. Unchanged. Operator spans stay children (transitively) of
POST /v1/queryand siblings of the phase spans. They are never children ofresolve filesorload template_map: the scan does not run inside either phase. RFC0040.1’s “transitively parented to the query span” still holds. The scan’s own object-store reads remain RFC 0040’s to represent (§3.7.1). - RFC 0058 (query resource limits, drafted in parallel). No overlap:
this amendment observes and does not limit. It defines no budget, cap,
rejection or configuration, and no
CLIENT-span cap either, because the sampler is the only volume control. Any limit on objects listed, requests made or memory used is RFC 0058’s. Theourios.file_set.*counts are what such a limit would be measured against, and if RFC 0058 rejects a query mid-phase, the phase span endsErrorunder §3.7.2’s rule with no change here. Whatever RFC 0058 adds to its own error type and signal is its own.
3.8 The sweep’s planning phase (Amendment 2026-10-16)
This section is new in the 2026-10-16 amendment (#1025, maintainer-approved). It amends §3.1 and §3.5 as the in-place notes there say, and RFC0038.1’s sweep arm as the note in §5 says.
Why. Issue #1019: on v0.13.1–v0.13.3 every sweep partitions span
took 300–390 s, however little the sweep compacted, and the span had no
children, so the trace could not say where the time went. The answer came
from a metrics query instead: about 3,000 listings and 3,000 manifest
reads per sweep, at about 55 ms each, all of it the planning of every
tenant’s hour partitions (plan_candidates). #1020 made the sweep plan
each tenant once, up front, so planning is now one contiguous phase
before any partition is consolidated. Planning reads every sealed
partition’s manifest, so its cost grows with retained history rather than
with the compaction work there is, and it is the part of the sweep most
likely to regress again. It has duration and makes network calls on the
S3 backend, which is the §3.7 rule for a phase span.
Amendment (2026-10-16, #1027, follows the approved RFC 0009 planner). The per-partition reads described above are what #1019 measured. Under the RFC 0009 planner, a tenant’s planning is one recursive listing plus one manifest read per sealed partition whose listing shows two or more committed files and a manifest. Its cost grows with the tenant’s object count rather than with its retained hours, and the phase span stays the place a regression would show.
The span.
plan partitions | |
|---|---|
| Kind | INTERNAL |
| Parent | sweep partitions |
| Opens / ends | around planning every tenant (plan_tenants): from before the first tenant’s partition listing to the last tenant’s finished candidate list. The tenant enumeration before it stays directly under sweep partitions. |
| Cardinality | exactly one per sweep whose tenant enumeration succeeds, also when the store has no tenant (the counts are then zero); none when the enumeration fails, since the sweep then ends before planning |
| Status | Error iff planning at least one tenant failed, with error.type = the first such failure’s class (the classes sweep partitions already uses: read, write, manifest, io, _OTHER); Unset otherwise. The sweep skips that tenant and goes on, so sweep partitions stays Unset (its existing rule) |
| Required attributes | ourios.file_set.list_request_count, ourios.file_set.listed_objects, ourios.file_set.manifest_read_count (§3.7.5), summed over every tenant planned |
It carries no ourios.tenant: it covers every tenant. It carries no
ourios.file_set.live_files, since planning hands nothing to a scan; the
candidates it selects are the existing ourios.compaction.* metrics’
concern.
The counts. They are set when the phase ends, on both backends, including for a tenant whose planning failed (the work up to the failure). They follow §3.7.2’s rules:
list_request_countcounts each store listing call: the delimited roll-up at each Hive level (year=,month=,day=,hour=), and the one recursive listing of each sealed partition whose manifest read succeeded. A call returning an error still counts. HTTP pages are not counted.listed_objectscounts the objects those calls return, before the seal, manifest and file-type filters. A delimited roll-up returns only common prefixes, which are not objects.manifest_read_countcounts each manifest read issued: one per sealed partition examined, whether the manifest is present, absent or fails to read. The querier’s S3 resolution only reads manifests its listing shows, so it never issues a read for an absent one; planning reads before it lists, so it does. Both count the reads they issue. On a planning pass this count is therefore also the number of sealed partitions examined, so no separate partition count is defined.
Amendment (2026-10-16, #1027, follows the approved RFC 0009 planner) — the counts under the one-listing planner. The three bullets above describe the per-partition planner and no longer hold. Read instead:
list_request_countcounts one logical recursive listing of each tenant’sdata/prefix, failed ones included. HTTP pages are still not counted. There are no delimited roll-ups and no per-partition listings.listed_objectscounts every key that listing returns, before any filter: keys in unsealed hours,manifest.json,*.tmpfiles and anything outside a canonical hour partition included.manifest_read_countcounts the manifest reads actually issued: one per sealed partition whose listing shows two or more committed*.parquetfiles and amanifest.json, whether that read succeeds or fails. Planning now lists before it reads, so it never issues a read for an absent manifest, which is the querier’s S3 resolution’s rule too. The count is not the number of partitions examined: a settled or single-file partition, and a partition without a manifest, cost no read.
Amendment (2026-10-17, #1055, maintainer-approved via the registry decision) — the unsealed count.
plan partitionsalso requiresourios.compaction.partition.unsealed_count(int, ourios-semconv v0.3.22): the listed partitions planning skipped because their hour pluscompaction_gracehad not yet passed (RFC 0009 §3.3), summed over every tenant planned.
- It is set when the phase ends, with the other counts, and is
0when the store has no tenant.- A sweep that ends
ourios.compaction.result=noopwhile this count is above zero is waiting for the seal; it is not stalled.- It is a new name rather than a reuse, because no
ourios.file_set.*attribute counts what a resolution leaves out.- Tested by
compactor::tests::the_planning_span_counts_unsealed_partitions.
Reusing the ourios.file_set.* names. Planning a tenant is a
file-set resolution in the registry’s sense: for each sealed partition it
reads the manifest and lists the prefix to resolve the live files and
their sizes, which is the per-partition work resolve files does for a
query. The registry defines these three attributes for “one file-set
resolution” and does not tie them to queries. OpenTelemetry’s guidance is
to reuse registered attributes across operations and to tailor the brief
to each use
(attribute registry,
defining attributes),
so this amendment reuses them, with the brief on plan partitions being
“summed over every tenant’s planning”. Reusing them also lets one query
over ourios.file_set.manifest_read_count show both the read path’s and
the sweep’s manifest load. Whether the registry briefs should say so is an
open question (§7).
Amendment (2026-10-16, #1027, follows the approved RFC 0009 planner) — the reuse still holds, on a different footing. Planning no longer reads each sealed partition’s manifest and lists its prefix. It lists the tenant’s data prefix once and reads only the manifests the listing shows where they can change the decision. That is how the querier’s S3 resolution counts: one count per listing call, every object the listing returns before filtering, and one read per manifest the listing showed and the resolution fetched. So the three names still mean the same work on both sides, and the names stay as approved.
What does not get a span. Planning one tenant, examining one
partition, and consolidating one partition are per-item loops, which §3.1
keeps span-free. A tenant whose planning dominates shows up only in the
phase’s total. Nothing else attributes planning cost to a tenant either:
ourios.storage.operation.duration carries only the operation, the
caller (compaction) and error.type, never the tenant. So this
amendment leaves per-tenant planning cost unattributed. Whether that
matters is a §7 question. The consolidation pass after
planning gets no phase span here: its work is already counted by the
existing ourios.compaction.* metrics. The time under
sweep partitions that plan partitions does not cover is the one
tenant listing before it, and the consolidation and RFC 0047 erasure
passes after it.
Amendment (2026-10-16, #1039, maintainer-approved). The orphan GC that ran inside the consolidation loop now runs as its own pass after it, under
reclaim orphans(§3.9). The consolidation pass itself still gets no span.
3.9 The sweep’s orphan-reclaim phase (Amendment 2026-10-16)
This section is new in the 2026-10-16 orphan-GC amendment (#1039, maintainer-approved). It amends §3.1 and §3.5 as the in-place notes there say, and RFC0038.1’s sweep arm as the note in §5 says.
Why. The sweep’s orphan GC (gc_orphans, RFC 0009 RFC0009.4)
deletes files from object storage, and nothing in a trace showed it:
not its duration, not what it deleted, not a delete that failed. It ran
inside the per-partition consolidation loop, which §3.1 keeps span-free,
so it could not get a span there. RFC 0009’s orphan-GC amendment moves it
into one pass after the consolidation pass. That pass has duration and,
on the S3 backend, makes one listing, one manifest read and one DELETE
per orphan for each partition it visits, which is the §3.7 rule for a phase span.
The span.
reclaim orphans | |
|---|---|
| Kind | INTERNAL |
| Parent | sweep partitions |
| Opens / ends | around the GC of every partition the plan marks for GC, for every tenant whose planning succeeded: the candidates, then the sealed non-candidates whose listing shows an orphan (RFC 0009 §3.6, “Which partitions”). It opens after the consolidation pass’s last rewrite and before the RFC 0047 erasure pass, and starts after plan partitions ends. |
| Cardinality | exactly one per sweep whose tenant enumeration succeeds, also when there is nothing to reclaim; none when the enumeration fails, since the sweep then ends before planning. A sweep with no candidate still reclaims from the sealed partitions whose listing shows an orphan (RFC 0009 §3.6, RFC0009.26) and reports them in reclaimed_count; the counts are zero only when no partition the plan marks for GC holds an orphan (for example an empty store) |
| Status | Error iff the GC of at least one partition failed, either its scan (an unreadable manifest or a failed listing) or the delete of at least one orphan. error.type is the first such failure’s class, in pass order: a scan failure takes the class sweep partitions already uses (read, write, manifest, io, _OTHER), and a failed delete is io. Unset otherwise. The sweep goes on either way, so sweep partitions stays Unset (its existing rule). |
| Required attributes | ourios.compaction.orphan.reclaimed_count, ourios.compaction.orphan.failed_count, summed over every partition GC’d |
It carries no ourios.tenant: it covers every tenant. The per-tenant
split is ourios.compaction.orphan.reclaimed (RFC 0009 §3.6).
The counts. Set when the phase ends, including when some partition’s GC failed (the counts up to and after it, since the pass goes on).
reclaimed_countcounts the successful delete responses for orphan files. Only an explicit not-found result is excluded. S3 answers aDELETEof an absent key with success, so a file another actor removed after the listing is still counted there (RFC 0009 §3.6).failed_countcounts the orphan files whose delete failed. A partition whose scan failed deletes nothing and adds to neither count; it shows as the span’sError.
The two counts equal the sweep’s points on
ourios.compaction.orphan.reclaimed without and with error.type,
summed over tenants.
The names. Two new attributes, ourios.compaction.orphan.reclaimed_count
and ourios.compaction.orphan.failed_count (int), in the
ourios.compaction.orphan namespace beside the two orphan counters. The
_count suffix follows the registry’s existing span counts
(ourios.file_set.list_request_count) and keeps the attribute names
distinct from the metric name ourios.compaction.orphan.reclaimed.
Neither existing registered name fits: the ourios.file_set.* counts
describe a resolution’s listings and reads, not deletions.
What does not get a span. Reclaiming one partition, and deleting one
file, are per-item loops, which §3.1 keeps span-free. The pass’s listings
and manifest reads are not counted on the span; the
ourios.storage.operation.* metrics already count them under the
compaction caller.
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.
Amendment 2026-09-28 — alternatives for the query-phase spans (§3.7).
Keep one span; put phase timings on it as attributes. Durations and counts per phase on
POST /v1/querywould cost no new span names. But the object-store requests could only be summarised, never placed on the timeline, and #853’s question was when the time went. A duration attribute also cannot show two phases overlapping, or where a gap sits between them. Rejected. The counts are kept, as attributes on the phase spans (§3.7.2).Span events for phase boundaries. An event is a point in time and carries no duration (§3.1 made the same argument for
commit wal). Rejected.
CLIENTspans from anObjectStorewrapper (Store::wrap_backend). Simpler to write, and it would also see the DataFusion scan’s reads. But anObjectStorecall is not a request: onelistcan take severalListObjectsV2pages, andobject_storeretries inside the call. Spans at that layer would be named for S3 operations while timing something else, and would carry noaws.request_id. Rejected in favour of theHttpConnectorseam (§3.7.3).
CLIENTspans opt-in, or on their own sampling ratio. Rejected for the reasons in §3.7.3. The count follows the query’s work (bounded by the window underresolve files, by audit history underload template_mapuntil RFC 0033 bounds that listing), the trace sampler already governs volume, and a separate knob is the bespoke surface §3.4 rejects.Emit
CLIENTspans for every store request, including ingest and compaction. Rejected for now. It would change RFC0038.1’s ingest and sweep arms, and without a parent the record-sink flush would mint root spans. §7 keeps compaction open.
Amendment (2026-10-16, #1025, maintainer-approved) — alternatives for the planning phase (§3.8).
Counts on
sweep partitionsitself, no child span. It costs no span name, and the counts alone would have pointed #1019 at planning. But the trace would still not show how much of the sweep’s duration planning took, or that it all came before the first partition was rewritten, and a duration attribute is the alternative the 2026-09-28 amendment already rejected for the query phases. Rejected; the counts are kept, on the phase span.One planning span per tenant. It would attribute the cost to a tenant, but it is a per-item loop, and the span count would grow with the tenant count. Rejected under §3.1. That leaves per-tenant planning cost unattributed: no existing signal carries it, since the storage metric has no tenant dimension (§3.8, §7).
New
ourios.compaction.plan.*attribute names. Names scoped to compaction would avoid stretching “one file-set resolution” over many tenants. They would add three registry entries meaning the same work as three that exist. Not chosen; §7 keeps it as the fallback.
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_logscall, a single OTLPExportbatch, and a compaction sweep each execute, Then each produces the expected span(s): one server span for the query, one child-of-serve_innerspan for the MCP call, one internal span for the sweep, and — for the Export — one server batch span with a singlecommit walchild span (and no further sub-spans), And any log record emitted within that operation carries the operation’strace_id/span_id(the correlation the reported gap was about).
Amendment 2026-09-28 — RFC0038.1’s query arm (§3.7). For the logs query, “one server span” is replaced by the §3.7.1 tree. The server span is the root. A query that compiles has exactly one
resolve fileschild (also when it finds no live file and so runs no scan), at most oneload template_mapchild (exactly one iff the query acquires the RFC 0033 map), and the RFC 0040 operator spans. On the S3 backend, each phase span also parents oneCLIENTspan per object-store HTTP request it makes. No other span is produced. The same children appear under the MCPexecute_tool query_logsspan when an agent runs a query through MCP; that tool span stays the one child ofserve_inner. The ingest and sweep arms, and the log-correlation clause, are unchanged. A log emitted inside a phase (on the blocking pool included) carries the query’strace_id. The test for this arm must satisfy RFC0038.9.
Amendment (2026-10-12, #966, maintainer-approved) — RFC0038.1’s query arm gains the §3.7.2 children of
load template_map. The span set above also contains, underload template_map:
- one
wait template_mapper iteration of the single-flight loop that finds another query’s acquisition in flight. A query that finds none opens no wait span. A query whose listed frontier equals the running flight’s waits once and is served its result. A query whose frontier differs waits, lists again and repeats, so it may wait more than once and then acquire itself;- at most one
derive template_map, and exactly one iff the query itself acquires and its lookup folds (outcomemiss,stale,tornorunknown_version). None on ahit, and none for a query served another’s acquisition (joined). A query that waited behind a different frontier and then acquired can carry both wait spans and its one derive.On the S3 backend, the fold’s object-store requests’
CLIENTspans are children ofderive template_map, not ofload template_map. “No other span is produced” now applies to this extended set.
Amendment (2026-10-15, #1014, maintainer-approved) — RFC0038.1’s query arm gains
infer schema. The span set above also contains, under the query’s root, at most oneinfer schema: exactly one iffresolve filesfound at least one live file. On the S3 backend, itsCLIENTchildren are itsHEADand footer reads. “No other span is produced” now applies to this extended set.
Amendment (2026-10-16, #1025, maintainer-approved) — RFC0038.1’s sweep arm (§3.8). For the compaction sweep, “one internal span for the sweep” now reads: one
INTERNALsweep partitionsspan, the sweep’s root, with exactly oneINTERNALplan partitionschild when the tenant enumeration succeeds and none when it fails. No other span is produced. The query, MCP and ingest arms and the log-correlation clause are unchanged. RFC0038.10’s “the sweep produces exactly the spans RFC0038.1 fixes” follows this arm.
Amendment (2026-10-16, #1039, maintainer-approved) — RFC0038.1’s sweep arm, again (§3.9). “Exactly one
INTERNALplan partitionschild” now reads: exactly twoINTERNALchildren when the tenant enumeration succeeds,plan partitionsandreclaim orphans, the second starting after the first ends, and none when it fails. No other span is produced. The other arms are unchanged.
Scenario RFC0038.2 — the ingest hot path mints no per-record spans. Given traces enabled and an always-on sampler, When one
Exportbatch 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’sspawn_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’strace_idequals the span’s (thetokio::spawncontext-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_ARGandOTEL_TRACES_EXPORTERenv vars (no bespoke Ourios config surface), When the sampler is left unset; set via envparentbased_traceidratioat a ratio; andOTEL_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); andOTEL_TRACES_EXPORTER=none(honored byinit()) installs no tracer and stamps notrace_id/span_idon 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 ofOTEL_TRACES_EXPORTER=noneto 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
/mcptool surface with traces enabled, When an agent invokes a tool over an established MCP session, Then theexecute_tool {tool}span carriesgen_ai.operation.name = execute_tool,gen_ai.tool.name(the invoked tool),mcp.method.name = tools/call, andmcp.session.id(the caller’s session), Andweaver registry live-checkover the emitted telemetry reports no violation other than the sanctioned “moved to semantic-conventions-genai” deprecation for thegen_ai.*/mcp.*namespaces — every other drift still fails the gate (§3.6; the exemption’s removal is tracked by #622).
The following scenarios are added by the 2026-09-28 amendment (§3.7) and
are drafted.
Scenario RFC0038.8 — a windowed query on S3 produces the §3.7.1 span tree. Given traces enabled with an always-on sampler, an S3-backed server (localstack through testcontainers, as the
s3 integration (localstack)job’s RFC 0019 tests use), and a tenant with data files in hour partitions both inside and outside a query window, with manifests and audit files present, When a windowed query that matches no rows and whose predicate uses neitherbody ==/!=norresolves_toruns, then a windowed query that returns rows and so acquires the template map, and then a windowed query for a tenant with no live file in the window, Then each trace’s root isPOST /v1/query, with exactly oneresolve fileschild. The zero-row trace has noload template_mapspan, and the row-returning trace has exactly one. The no-file trace has aresolve filesspan withourios.file_set.live_files = 0and no operator span. The phase spans carryourios.tenantand the §3.7.2 attributes.ourios.file_set.listed_objectsandourios.file_set.live_filesequal the counts the fixture implies.load template_mapcarriesourios.template_map.lookup.outcome, And everyCLIENTspan is a child ofresolve filesorload template_map, has a §3.7.3 name (S3.ListObjectsV2,S3.GetObject, …),rpc.system = "aws-api",rpc.service = "S3",rpc.methodequal to the bare operation,aws.s3.bucket,server.address, andaws.s3.keyexactly on the object operations, Andaws.extended_request_idequals the fixture’sx-amz-id-2header on every response that sends one, and is absent where the fixture omits it. The number ofS3.ListObjectsV2spans underresolve filesequalsourios.file_set.list_request_count(the fixture stays under one page per listing). EveryCLIENTspan lies within its parent’s interval, Andresolve filesends before the first RFC 0040 operator span starts, and noCLIENTspan exists for the scan’s own reads. And aCLIENTspan for a request answered404has statusErroranderror.type = "404", while its phase span staysUnset.
Amendment (2026-10-12, #966, maintainer-approved) — RFC0038.8’s parentage. “Every
CLIENTspan is a child ofresolve filesorload template_map” now reads “ofresolve files,load template_maporderive template_map”. On a cold tenant, the row-returning trace has exactly onederive template_mapunder itsload template_map. That span carriesourios.template_map.derive.reasonand lies within its parent’s interval.
Amendment (2026-10-15, #1014, maintainer-approved) — RFC0038.8 and
infer schema. The zero-row and row-returning traces each have exactly oneinfer schemachild of the root, carryingourios.tenant. The no-file trace has none.infer schemastarts afterresolve filesends and ends before the first RFC 0040 operator span starts, which replaces “resolve filesends before the first RFC 0040 operator span starts”. “EveryCLIENTspan is a child of …” gainsinfer schema. TheCLIENTspans under it areS3.HeadObjectandS3.GetObjectonly, withaws.s3.keyequal to one of the resolved files’ keys. Their number is not fixed, since DataFusion decides how it reads a footer. “NoCLIENTspan exists for the scan’s own reads” stands.
Scenario RFC0038.9 — the span harness sees spans opened off the calling thread. Given the harness used by RFC0038.1’s query arm and RFC0038.8, When a canary test opens a span inside
tokio::task::spawn_blocking, and another inside a future run through the store bridge, Then the harness exports both spans, correctly parented. This proves it installs a process-global subscriber and tracer provider, as production does, and not aWithSubscriber-scoped one that is blind there (§3.3 note, §3.7.4), And the harness lives in its own test binary so the global install does not reach other tests (therfc0038_1_mcp_span.rspattern).
Amendment (2026-10-12, #966, maintainer-approved) — RFC0038.9’s scope. RFC0038.9 governs the harness of the server-level tests (RFC0038.1’s query arm, RFC0038.8). The template-map span tests satisfy its intent another way, per the §3.7.4 item 3 amendment:
- Blocking pool:
a_cold_acquisition_spans_the_load_and_its_derive_under_the_queryasserts a span opened insidespawn_blocking(derive template_map) is exported with its parent. It runs under a scoped subscriber, which the acquisition’s dispatcher forwarding reaches.- Store bridge: the
ourios-parquetclient-span tests drive real requests throughblock_on_off_runtimeunder an attached phase context, and assert the parentedCLIENTspan.
Scenario RFC0038.10 —
CLIENTspans appear only under a query phase, and never as roots. Given traces enabled and an S3-backed server, When an OTLPExportbatch is ingested and flushed to Parquet, a compaction sweep runs, and a query runs whose trace is sampled out (parentbased_traceidratioat0.0), Then noCLIENTspan is exported for any of them. TheExportand sweep produce exactly the spans RFC0038.1 already fixes for them, and no exported span is a rootCLIENTspan, And on the local backend, the RFC0038.8 queries produce the sameINTERNALspans and attributes and zeroCLIENTspans.
Scenario RFC0038.11 — live-check covers the new names. Given the
live-check (weaver)CI job, with the §3.7.5 names registered in ourios-semconv at the tagsemconv/REGISTRY_REFpins, When the job runs a second, S3-backed server leg (an S3-compatible container) and drives a windowed query that returns rows, Then weaver’s report contains aresolve filesspan, aload template_mapspan and at least oneS3.*CLIENTspan. The job fails if any of the three is missing, since the S3 leg is deterministic, unlike the best-effort MCP handshake, And the report has no violation for anyourios.file_set.*name or for the upstreamrpc.*/aws.*/cloud.region/server.*/http.*/error.typeattributes (http.*added by the 2026-10-12 amendment), apart from the §3.7.5 exemption: the deprecatedrpc.systemandrpc.serviceonS3.*CLIENTspans, which the pinnedspan.aws.clientdefinition requires. A deprecated-name finding for those two names anywhere else, or for any other name, fails the job.stability: developmentshows only as advice, and the §3.6 genai exemption is not widened to cover any of these names.
The following scenarios are added by the 2026-10-12 amendment (#966, maintainer-approved).
Scenario RFC0038.12 — a query joining another’s acquisition shows its wait. Given two concurrent queries for one tenant at the same audit frontier, the first holding the per-tenant acquisition, When the second waits on it and is served its result, Then the second query’s
load template_maphas await template_mapchild andourios.template_map.lookup.outcome = joined, and has noderive template_mapof its own.
Scenario RFC0038.13 — a failed acquisition fails its spans. Given a tenant whose audit set holds a file the fold cannot read, When a query that acquires the template map runs, Then the query fails, and both
derive template_mapandload template_maphave statusErrorand anerror.typeequal to the failure’s class (storage), per Recording errors.
Scenario RFC0038.14 — an abandoned request is not an error. Given a query phase on the S3 backend, When the caller drops an object-store request before its response arrives (for example, a client timeout that drops the query), Then that request’s
CLIENTspan endsUnsetwith noerror.type, since the HTTP span conventions say an intentional cancellation is not an error. The storage metrics still record the attempt ascancelled.
Scenario RFC0038.15 —
CLIENTspans carry the HTTP client attributes. Given a query phase on the S3 backend, When a request gets a response, Then itsCLIENTspan carrieshttp.request.method,server.address,server.portandhttp.response.status_code(an int) alongside the §3.7.3 AWS attributes. It also carriesaws.request_id, from thex-amz-request-idheader, when the response sends one.
Scenario RFC0038.16 — a failed shared acquisition fails the waiter’s load, not its wait. Given two concurrent queries for one tenant at the same audit frontier, the first holding the per-tenant acquisition, and an audit set holding a file the fold cannot read, When the second waits on the first’s acquisition and the acquisition fails, Then both queries fail. The second query’s
wait template_mapstaysUnset, since the wait itself completed. Itsload template_mapisError, with anerror.typeequal to the shared failure’s class (storage).
The following scenario is added by the 2026-10-16 amendment (§3.8, #1025, maintainer-approved).
Scenario RFC0038.17 — the sweep’s planning phase is a span that counts its listings and manifest reads. Given traces enabled with an always-on sampler, and a store with two tenants, one holding a sealed hour partition with two files and a manifest-less sealed partition, the other an unsealed partition, When a compaction sweep runs, Then
sweep partitionshas exactly oneplan partitionschild, of kindINTERNAL, that lies within its parent’s interval and ends before the sweep reports its first committed partition, And itsourios.file_set.list_request_count,ourios.file_set.listed_objectsandourios.file_set.manifest_read_countequal the counts the fixture implies under §3.8’s rules (one manifest read per sealed partition, none for the unsealed one), and its status isUnset, And when one tenant’s planning fails (an unreadable tenant directory),plan partitionshas statusErrorwith that failure’serror.type, still carries the counts up to the failure, and the sweep goes on to plan and compact the other tenant withsweep partitionsUnset.
Amendment (2026-10-16, #1027, follows the approved RFC 0009 planner) — RFC0038.17’s counts. “One manifest read per sealed partition, none for the unsealed one” now reads: one listing per tenant; every key those listings return, the unsealed partition’s included; and one manifest read per sealed partition listed with two or more committed files and a manifest, so none for the scenario’s fixture, whose files have no manifest. The failure clause is unchanged: the failing tenant’s one listing still counts.
The following scenario is added by the 2026-10-16 orphan-GC amendment (§3.9, #1039, maintainer-approved).
Scenario RFC0038.18 — the sweep’s orphan-reclaim phase is a span that counts what it deleted. Given traces enabled with an always-on sampler, and a store with a sealed candidate partition holding two orphan files a crashed compaction left, When a compaction sweep runs, Then
sweep partitionshas exactly onereclaim orphanschild, of kindINTERNAL, that starts afterplan partitionsends and after the sweep’s last consolidation commit (an RFC 0047 erasure rewrite, which runs after it, is not one), and lies within its parent’s interval, And itsourios.compaction.orphan.reclaimed_countis 2, itsourios.compaction.orphan.failed_countis 0, and its status isUnset, And on a store with no orphan (a candidate without one, or an empty store), the span is still there with both counts 0, And a sweep with no candidate that finds an orphan beside a settled partition reports it inreclaimed_count(RFC0009.26), And when an orphan’s delete fails (its partition directory is read-only),reclaim orphanshas statusErrorwitherror.type = ioandfailed_count1, andsweep partitionsstaysUnset.
6. Testing strategy
Mapped to CLAUDE.md §6.2:
- RFC0038.1 / .3 / .5 / .6 — integration tests in
ourios-server/ourios-ingesterusing an in-memory span exporter (SDK test exporter): drive a query, an MCP tool call, anExport, and a sweep; assert span count/name/parentage and that a co-emitted log’strace_idmatches. 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
criterionguard 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-serverMCP-span integration test asserts thegen_ai.*/mcp.*attributes (including the session id) on the emitted span; thelive-checkCI 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).
Amendment 2026-09-28 — testing the query-phase spans (§3.7).
- RFC0038.1 (query arm) / RFC0038.8 — an
ourios-serverintegration test in its own binary, using the RFC0038.9 global harness over anInMemorySpanExporter. It asserts the exact span multiset, the parentage, the intervals and the attributes of §3.7.1–§3.7.3. The local-backend variant runs in the normal suite. The S3 variant uses the RFC 0019 localstack harness, is#[ignore]d, and joins thes3 integration (localstack)job’s explicit--ignored --exactlist. The existingrfc0038_1_query_emits_one_server_spanasserts the superseded one-span contract. Changing it is the contract change this amendment approves (CLAUDE.md§6.2): it is replaced by the amended-arm test in the same change that adds the spans, never weakened first.- RFC0038.9 — the two canary tests (a span opened in
spawn_blocking, and one opened in a bridged store future) in the same binary. They run in the normal suite, since neither needs a container.- RFC0038.10 — the S3 half joins the localstack list. The local half and the sampled-out case run in the normal suite. The ingest and sweep assertions reuse the existing RFC0038.1 ingest and sweep tests’ exact counts and add “no
CLIENTspan”.- RFC0038.11 — the
live-check (weaver)job gains an S3-backed server leg against an S3-compatible container and a report assertion that the three span kinds are present (a hard failure). The existing filtered-violation gate is unchanged.- Unit tests for the §3.7.3 request → operation-name mapping, over hand-built
HttpRequests, one per row plus the fallback.- No
criterionguard is needed beyond RFC 0040’s: the spans are per query and per request, never per record (RFC0038.2’s invariant). TheParquet → query resultbenchmark runs on the local backend, where only two spans are added.
Amendment (2026-10-12, #966, maintainer-approved) — testing the template-map children and the
CLIENTspan details (#965).
RFC0038.1 (template-map part), RFC0038.12, RFC0038.13 —
crates/ourios-querier/tests/it/rfc0038_template_map_spans.rs, in the crate’s consolidateditbinary (RFC 0028):
a_cold_acquisition_spans_the_load_and_its_derive_under_the_querychecksderiveunderloadunder the query, with outcome and reasonmiss.only_a_lookup_that_folds_opens_a_derive_spanwalks one tenant throughmiss(one fold),hit(no fold) and, after a new audit file,stale(one fold with reasonstale).tornandunknown_versiontake the same fold path as these two outcomes; only the reason value differs. Those values are pinned per outcome by the RFC0033.7derive.reasonmetric test (tests/rfc0033_7_observability.rs).a_query_joining_an_acquisition_spans_its_wait(testingfeature) covers RFC0038.12, including that the joining query has noderive template_mapof its own.a_query_behind_another_frontier_waits_then_folds_itself(testingfeature) covers the differing-frontier path. An audit file lands after the first query lists. The second query waits once, then acquires and refolds the stale map, so itsload template_mapcarries onewait template_mapand onederive template_map(outcomestale). The first query has neither a wait nor the second’s fold.a_failed_shared_acquisition_fails_the_waiters_load_not_its_wait(testingfeature) covers RFC0038.16.a_failed_fold_marks_the_derive_and_load_spans_errorcovers RFC0038.13.These tests use a scoped subscriber over a local tracer provider, as the §3.7.4 item 3 and RFC0038.9 amendments allow.
RFC0038.8 (
CLIENTshape), RFC0038.10 (connector half), RFC0038.14, RFC0038.15 — unit tests incrates/ourios-parquet/src/store/client_span/tests.rsagainst a fake S3 endpoint. Each test uses a tracer of its own, so nothing global is installed:
a_failed_request_in_a_query_phase_is_an_error_client_span: a 404 givesErroranderror.type = "404", with the AWS and HTTP attributes.a_successful_request_leaves_its_client_span_unset.an_abandoned_request_leaves_its_client_span_unset: RFC0038.14’s span clause. Its metric clause (the attempt still recorded ascancelled) is the existingstore::metrics::tests::an_abandoned_s3_request_records_a_cancelled_attempt.no_client_span_outside_a_traced_query_phase: no span without the phase marker, under a sampled-out parent, or under an invalid parent that carries the sampled flag, which would otherwise be a root.- Table tests: request to operation name, and key extraction, the latter including an endpoint configured below a path prefix.
Existing exact-count tests.
rfc0038_1_query_emits_one_server_spanandrfc0038_1_mcp_tool_emits_one_internal_spankeep asserting their exact span counts and are unchanged. Both query an empty tenant, which returns no rows and so acquires no template map, so neither exercises the new children.
Amendment (2026-10-15, #1014, maintainer-approved) — testing
infer schema.
- RFC0038.1 (query arm), local backend — in
crates/ourios-querier/tests/it/rfc0038_file_set_span.rs, next to theresolve filestests and under the same scoped harness:
- a query over live files has exactly one
infer schemaunder the query’s span, carryingourios.tenant, starting afterresolve filesends and ending before the render pass’sload template_mapstarts. The operator spans go to the global tracer provider, which this scoped harness does not install, so the ordering against them is RFC0038.8’s to assert;an_empty_file_set_still_spans_the_resolutionalso asserts that noinfer schemaspan exists. That adds an assertion and weakens nothing;- a data file whose footer does not parse fails the query and marks
infer schemaErrorwitherror.type, whileresolve filesstaysUnset.- The marker on a future — a unit test in
crates/ourios-querier/src/phase.rs, besideonly_an_enabled_phase_span_attaches_the_marker. It asserts that a future run as a phase sees the marker and the phase span’s context while it is polled, and that a disabled phase span attaches no marker.- RFC0038.8, S3 backend — the
CLIENTparentage underinfer schemajoins the RFC0038.8 localstack test, which is still to be written (#986). Until then the local tests and the phase unit test above cover the span, and theourios-parquetclient-span tests cover the connector’s behaviour under a marked context.- The existing exact-count tests (
rfc0038_1_query_emits_one_server_span,rfc0038_1_mcp_tool_emits_one_internal_span) are unchanged. An empty tenant has no live file, so it opens noinfer schema.
Amendment (2026-10-16, #1025, maintainer-approved) — testing
plan partitions(§3.8).
- RFC0038.1 (sweep arm) —
crates/ourios-ingester/src/compactor/tests.rs. The existingrfc0038_1_sweep_emits_one_internal_spanasserts the superseded one-span contract (spans.len() == 1). Changing it is the contract change this amendment approves (maintainer, 2026-10-16) (CLAUDE.md§6.2). It is replaced, in the change that adds the span and never weakened first, byrfc0038_1_sweep_emits_its_span_and_one_plan_phase, which asserts exactly two spans:sweep partitions(INTERNAL, a root) andplan partitions(INTERNAL, its child, in the same trace). The replacement asserts more than the original: the same name and kind on the sweep span, plus the child’s name, kind and parentage.- RFC0038.1 (sweep arm, failed enumeration) — the existing
a_failed_sweep_marks_its_span_erroris unchanged. It already asserts exactly one span (let [span] = …), which is the arm’s “none when the enumeration fails”.- RFC0038.3 —
rfc0038_3_span_context_survives_spawn_boundariescountssweep partitionsspans by name and is unchanged. It gains an assertion that the oneplan partitionsspan is that sweep span’s child acrossspawn_blocking.- RFC0038.17 — in the same file, under the same scoped harness:
the_plan_phase_counts_its_listings_and_manifest_readsdrives the two-tenant fixture throughrun_sweep_committing, asserts the three counts,Unset, the interval within the parent, and that the phase ends before the first commit callback;a_failed_tenant_plan_marks_the_plan_phase_errormakes one tenant’s directory unreadable, and assertsErrorwitherror.type = "io"onplan partitions, the counts up to the failure,Unsetonsweep partitions, and the other tenant still compacted;an_empty_store_still_spans_its_planningasserts the phase and zero counts on a store with no tenant.- The counting itself — unit tests beside
plan_candidatesincrates/ourios-parquet/src/compaction/tests/, over local fixtures: the counts for sealed, unsealed and manifest-less partitions, and the counts kept on the error path.plan_candidates’ existing tests are unchanged; the counted form returns the same candidates.- RFC0038.10 — the sweep clause reuses RFC0038.1’s sweep test, as before; it now expects the two spans above and no
CLIENTspan.- No
criterionguard: one span per sweep, never per tenant or per partition (RFC0038.2’s invariant), and the counting is integer increments beside store calls that each take milliseconds.
Amendment (2026-10-16, #1027, follows the approved RFC 0009 planner) — the expected counts in the tests above. #1030 changes the planner’s I/O, not the counting rules, so the tests keep their names and their assertions other than these counts
(list_request_count, listed_objects, manifest_read_count):
Test Before After planning_counts_its_listings_and_manifest_reads(ourios-parquet)5, 2, 1 1, 3, 0 planning_keeps_its_counts_on_the_error_path(ourios-parquet)4, 0, 1 1, 3, 1 the_plan_phase_counts_its_listings_and_manifest_reads(ourios-ingester)9, 2, 1 2, 3, 0 a_failed_tenant_plan_marks_the_plan_phase_error(ourios-ingester)6, 2, 1 2, 2, 0 planning_a_tenant_with_no_data_makes_one_listing,an_empty_store_still_spans_its_planningunchanged unchanged
list_request_countandmanifest_read_countonly fall.listed_objectsrises where the tenant listing returns keys the per-partition listings never fetched (an unsealed hour’s file) or fetched only after the manifest read (the error-path fixture’s three keys). The error-path test still counts its failing manifest read, because that partition lists two files and a manifest.
Amendment (2026-10-16, #1039, maintainer-approved) — testing
reclaim orphans(§3.9).
- RFC0038.1 (sweep arm) —
crates/ourios-ingester/src/compactor/tests.rs.rfc0038_1_sweep_emits_its_span_and_one_plan_phaseasserts exactly two spans. Changing that to three is the contract change this amendment approves (maintainer, 2026-10-16) (CLAUDE.md§6.2). The test becomesrfc0038_1_sweep_emits_its_span_and_two_phasesin the change that adds the span. It keeps every other assertion (the sweep’s name, kind and root parentage;plan partitions’ name, kind, parentage and trace) and adds the same forreclaim orphans, plus that it starts afterplan partitionsends.- RFC0038.1 (sweep arm, failed enumeration) —
a_failed_sweep_marks_its_span_erroris unchanged: exactly one span.- RFC0038.3 —
rfc0038_3_span_context_survives_spawn_boundariesgains an assertion that the onereclaim orphansspan is the sweep span’s child acrossspawn_blocking.- RFC0038.17 — unchanged. Its tests find
plan partitionsby name.- RFC0038.18 — in the same file, under the same scoped harness:
the_reclaim_phase_counts_the_orphans_it_deleted: two orphans, the two counts,Unset, the interval afterplan partitionsand after the last consolidation commit callback (the fixture has no erasure);an_idle_reclaim_phase_still_spans_with_zero_counts: no orphan anywhere, as a candidate without one and as an empty store;rfc0009_26_a_later_sweep_reclaims_what_an_interrupted_one_left(RFC 0009’s test, also asserting this span): no candidate,reclaimed_count1;a_failed_orphan_delete_marks_the_reclaim_phase_error: a read-only partition directory holding an orphan;Error,error.type = io,failed_count1, andsweep partitionsUnset.- No
criterionguard: one span per sweep, and the counts are integer increments beside store calls.
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-opentelemetryversion alignment with the pinnedopentelemetry0.x; confirm no version-skew with the appender/exporter crates before adding the dependency.
Open questions added by the 2026-09-28 amendment (§3.7).
-
aws-apifor S3-compatible endpoints. §3.7.3 keepsrpc.system = "aws-api"andS3.{Operation}for any endpoint that speaks the S3 API, and identifies the endpoint byserver.address. This matches the convention: the value isRequiredand names the wire protocol, and no alternative is defined for non-AWS endpoints. Confirm. - Which
rpc.*shape to emit while upstream is mid-migration. At the pinned v1.42.0, the AWS SDK span definition requires the deprecatedrpc.system = "aws-api", uses the deprecatedrpc.service, and givesrpc.methodas the bare operation. The registry hasrpc.system.name(noaws-apimember yet) and definesrpc.methodas fully qualified. Three options: (a) follow the span definition (rpc.system,rpc.service = S3, barerpc.method) with §3.7.5’s narrow live-check exemption; (b) the migrated registry shape (rpc.system.name = "aws-api"as a custom value,rpc.method = "S3/GetObject"), which no published AWS convention yet describes; (c) both, in the spirit of the RPC conventions’OTEL_SEMCONV_STABILITY_OPT_IN=rpc/duptransition, although upstream does not say whether that opt-in covers the AWS SDK conventions. Decided (a) by the maintainer on 2026-09-28: it is what the pinned span convention specifies, and the exemption and the emitter both change in the ref bump that migrates it. Revisit at every semconv pin bump. -
CLIENTspans under the compaction sweep. Out of scope here (§3.7.3). A sweep over a large backlog (#807) could make thousands of requests. Revisit with a measurement, possibly with a per-sweep cap recorded as an attribute. - A per-phase duration metric. Upstream span definitions usually
come with a duration metric for the same operation. This amendment
adds none:
ourios.query.duration(RFC 0016) covers the whole query, and the spans carry the phase timing. If a metric is wanted, it is one histogram with a phase attribute, registered through ourios-semconv. It must not duplicate any per-query counters RFC 0058 defines. - Status after the amendment. This draft keeps the frontmatter
acceptedand tracks the amended RFC0038.1 and RFC0038.8–.11 in the banner and §9. The alternative is to move the RFC back tospecifieduntil they aregreen. Maintainer’s call. - RFC 0040 cross-reference. RFC 0040 needs no change (§3.7.6). Should it still get a one-line in-place note pointing at the phase siblings?
Open questions added by the 2026-10-16 amendment (§3.8).
- Reuse of
ourios.file_set.*onplan partitions. §3.8 reads planning as a file-set resolution and sums the counts over tenants. The fallback was three new registry entries (ourios.compaction.plan.list_request_count,ourios.compaction.plan.listed_objects,ourios.compaction.plan.manifest_read_count). Decided: reuse, by the maintainer on 2026-10-16. No registry entries are added. - Registry briefs. Should the briefs of the reused names say they apply to the sweep’s planning too? That is a wording change in ourios-semconv, not a new name.
- Replacing the one-span sweep test. §6 replaces
rfc0038_1_sweep_emits_one_internal_span, whose one-span assertion the amended sweep arm contradicts, withrfc0038_1_sweep_emits_its_span_and_one_plan_phase. Decided: replace, by the maintainer on 2026-10-16 (CLAUDE.md§6.2). - A partition count. §3.8 defines none, since
manifest_read_countequals the sealed partitions examined, and the unsealed ones cost only their share of thehour=roll-ups. A count of all partitions walked would need a new name. Add it only if a trace is found that needs it. Amendment (2026-10-16, #1027, follows the approved RFC 0009 planner): the premise no longer holds.manifest_read_countnow counts only the manifests read, not the sealed partitions examined, so the trace has no count of partitions planned. The question stays open on that basis. - Per-tenant planning cost. With one aggregate span and no
tenant on
ourios.storage.operation.duration, nothing says which tenant’s history dominates planning. Does that matter? If it does, the candidates are a per-tenant count on a metric (a new name, orourios.tenanton an existing compaction metric), not a span per tenant, which §3.1 rules out.
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_SAMPLERenvironment surface. - Amendment 2026-09-28 (§3.7): issue #853 (the uninstrumented query
phases); PR #858 (window-scoped listing, the
resolve files/load template_mapspans it removed, and the scoped-subscriber finding); RFC 0019 (S3-compatible backends), RFC 0033 (template-map acquisition), RFC 0040 (operator spans), RFC 0058 (query resource limits, drafted in parallel);crates/ourios-parquet/src/store.rs(Store::s3,block_on_off_runtime);semconv/REGISTRY_REF,.github/scripts/fetch-semconv.shand thelive-check (weaver)job in.github/workflows/ci.yml. - OpenTelemetry, for the amendment:
Tracing API — Span
(child spans for sub-operations that need more detail; low-cardinality
names, high-cardinality values as attributes);
SpanKind
(
INTERNALin-process,CLIENTfor an awaited outgoing call); defining spans (spans for operations with duration and network calls, not for short in-process work or point-in-time occurrences;{action} {target}names; kind, status, attributes and a companion duration metric); object stores — S3 and AWS SDK (Development;CLIENT,Service.Operation,rpc.system = aws-api,rpc.service, barerpc.method,aws.s3.bucket,aws.s3.key,aws.request_id,aws.extended_request_id,cloud.region); recording errors; attribute naming;db.response.returned_rows(Development, opt-in; not reused, §3.7.5). - Amendment 2026-10-16 (§3.8): issue #1019 (the 300–390 s sweeps with no
children), PR #1020 (plan each tenant once per sweep);
crates/ourios-ingester/src/compactor/mod.rs(plan_tenants),crates/ourios-parquet/src/compaction/plan.rs(plan_candidates); OpenTelemetry attribute registry and defining attributes (reuse registered attributes across conventions, tailoring the brief).
9. Amendment history
- 2026-09-28 — query-phase and object-store client spans (
drafted). Adds §3.7:resolve filesandload template_mapasINTERNALchildren ofPOST /v1/query, and per-requestS3.{Operation}CLIENTspans under them on the S3 backend, scoped to query phases and to sampled traces. Four newourios.file_set.*attributes go to the ourios-semconv registry; the span names are fixed in the RFC. In-place notes in §3.1, §3.3, §3.5, §4, §5 (RFC0038.1’s query arm) and §6. New scenarios RFC0038.8–.11 and new open questions in §7. Motivated by #853, and by #858’s removal of the spans RFC0038.1 then forbade. The seven original criteria staygreenand the frontmatter staysacceptedpending the §7 status question. Refs #853. - 2026-10-12 — the fold and the wait under
load template_map(#966, maintainer-approved). Addsderive template_mapandwait template_mapasINTERNALchildren ofload template_map(§3.5, §3.7.2), and records how theCLIENTspans handle a cancelled request and which HTTP client attributes they carry (§3.7.3). Amends RFC0038.1’s query arm and RFC0038.8’s parentage, adds RFC0038.12–.16 and maps them to #965’s tests (§5, §6). No new attribute names. Matches #965. - 2026-10-15 —
infer schema(#1014, maintainer-approved). Addsinfer schemaas anINTERNALchild of the query’s root around the union-schema footer inference, which no span covered, and lets it install the query-phase marker so itsHEADand footer reads getCLIENTspans (§3.5, §3.7.1–§3.7.3, §3.7.5). Amends RFC0038.1’s query arm and RFC0038.8, and maps the tests (§5, §6). No new attribute names. - 2026-10-16 — the sweep’s planning phase (#1025,
maintainer-approved). Adds §3.8:
plan partitionsas the oneINTERNALchild ofsweep partitions, around #1020’s up-front planning of every tenant, carrying the threeourios.file_set.*work counts summed over tenants. In-place notes in §3.1, §3.5, §3.7.3, §4, §5 (RFC0038.1’s sweep arm) and §6, which names the one-span sweep test the change replaces. New scenario RFC0038.17 and two open questions in §7. No new attribute names; the sweep still emits noCLIENTspans. Motivated by #1019. - 2026-10-16 — the planning counts under RFC 0009’s one-listing
planner (#1027, implemented in #1030). In-place notes correct §3.8’s
count definitions and its reuse argument, RFC0038.17’s expected counts,
the §6 test map’s counts and the §7 partition-count question. The span,
its attributes and the approved reuse of the
ourios.file_set.*names are unchanged. - 2026-10-16 — the sweep’s orphan-reclaim phase (#1039,
maintainer-approved). Adds §3.9:
reclaim orphansas the secondINTERNALchild ofsweep partitions, around the orphan GC pass that RFC 0009’s orphan-GC amendment moves after consolidation. It carriesourios.compaction.orphan.reclaimed_countandourios.compaction.orphan.failed_count(new registry names) and isErrorwitherror.typewhen a partition’s GC failed. In-place notes in §3.1, §3.5, §3.8, §5 (RFC0038.1’s sweep arm: exactly two children) and §6, which names the two-span test that becomes a three-span test. New scenario RFC0038.18.
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 indocs/benchmarks.md§7; elsewherevalidatedis 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/mcpSERVER 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
/mcphad were theexecute_tool <tool>spans, which areINTERNAL— 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 MCPtools/callis JSON-RPC over HTTP, where both conventions require the inbound server span’s kind to beSERVER. So the MCP arm adds one span — aSERVERspan for the inbound/mcprequest — 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.
| # | Span | Site | Carrier & where it is reachable |
|---|---|---|---|
| A | ingest logs (gRPC) | span in IngestPipeline::ingest_bound (pipeline.rs); entry LogsReceiver::export (grpc.rs) | tonic MetadataMap, on the request in export itself (§3.4) |
| B | ingest logs (HTTP) | span in ingest_bound; entry handle_logs (http.rs) | axum HeaderMap, a handle_logs extractor |
| C | POST /v1/query | handle_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 |
| D2 | execute_tool <tool> (×3, INTERNAL) | the three _traced fns (mcp.rs), each via a thin #[tool] delegate | not 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_querywrapper extractscxfrom the requestHeaderMapand awaits the instrumentedhandle_query_tracedunder it —handle_query_traced(..).with_context(cx).await. No tower layer: an earlier revision of this bullet proposed a sharedPropagationLayer, but neither ingest arm can use one (their span is born past atokio::spawn, below), which would leave the query arm as its sole beneficiary — see §4. - gRPC ingest (A): extraction happens in
LogsService::exportitself, from the request’s tonicMetadataMap(§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 drivesexportdirectly (no tower stack), so a layer-extracted context would leave the extraction itself uncovered. See the amendment note below. - The
tokio::spawnboundary (A/B): theingest logsspan is born insideingest_bound, after the spawn inexport/handle_logs, which ambient context does not cross. So the handler extractscxfrom 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 toingest_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 outsidecx. Noingest_boundsignature change, noset_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
exportdirectly 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 aGrpcServerCarrieroverclient_metadata(). The cost of the correction is the ~15-lineMetadataExtractorthat §3.4 had hoped to avoid.
-
MCP (D): two spans, because one cannot honestly do both jobs. A
mcp_server_spanlayer, outermost on the/mcprouter so it also covers an auth rejection, extracts the caller’scxfrom the request headers and opens aSERVERspan under it (D1) — named{method} /mcpviaotel.name, since/mcpserves POST, GET and DELETE and the macro’s staticnamecannot vary. That span then publishes its own context asMcpTraceContextin the request extensions, and each un-instrumented#[tool]delegate reads it back and runsself.<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 atokio::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 becauseAuthBindingalready 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 anhttp::HeaderMap.MetadataExtractor<'a>(&'a tonic::metadata::MetadataMap)— the OTLP/gRPC ingress. gRPC metadata is HTTP/2 headers, but tonic models it asHeaderMap<MetadataValue>, nothttp::HeaderMap, and exposes no cheap&HeaderMapview, 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-opentelemetryis not needed in the ingress crates at all”. True of the ingest and query arms; not of MCP. The/mcpSERVER 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 requiresOpenTelemetrySpanExt::context(). Soourios-servergainstracing-opentelemetryas a production dependency — at the same 0.33 pinourios-telemetryalready uses, so no new version enters the tree. The alternative was building the/mcpspan through the raw OTel API, which hands back aContextdirectly and needs no new dependency, but would have made it the one Ourios span that is not atracingspan (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
TraceContextPropagatorinstalled, When aPOST /v1/queryrequest and an OTLPExport(both gRPC and HTTP) each arrive carrying a valid W3Ctraceparentfor traceTspanS, Then the resultingPOST /v1/queryandingest logsspans each havetrace_id == Tand 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 mintedtrace_idand 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 logsspan is created inside atokio::spawnedingest_bound, When a batch arrives carryingtraceparentfor traceT, Then theingest logsspan (and itscommit walchild) resolve totrace_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
parentbasedsampler, When a request carriestraceparentwith 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 traceT— 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/callover/mcpcarryingtraceparentfor traceTspanS, When the tool executes, Then a{method} /mcpspan of kindSERVERresolves totrace_id == Twith parent span id== S; and theexecute_tool <tool>span resolves totrace_id == Twith its parent being that local server span — retaining kindINTERNAL, 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-ingesterusing the RFC 0038 scoped-InMemorySpanExporterharness: drivehandle_query,handle_logs, and (global-tracer binary, per RFC0038.1 MCP arm) an MCPtools/call, each with an injectedtraceparentheader, then assertSpanData.span_context.trace_id()/.parent_span_id(). The no-context and malformed-context cases assert a fresh, valid root and no error. - RFC0039.3 —
rfc0039_3_ingest_propagation.rs(its own global-tracer binary, per RFC0028.2): callLogsReceiver::exportand the HTTP router directly with atraceparent, and assert theingest logs+commit walspans carry the injectedtrace_id. It gets a binary of its own rather than extendingrfc0038_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.6 —
rfc0039_6_mcp_propagation.rs(likewise its own binary): handshake without atraceparent, then onetools/callwith one, so a single run covers both the propagated and the root case. Asserts theSERVERkind and remote parent of the/mcpspan, and that theexecute_toolspan isINTERNALwith that server span as its local parent. - RFC0039.4 — a sampler test: with
OTEL_TRACES_SAMPLER=parentbased_always_on(default), inject-00vs-01traceparents 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
traceparentthrough aHeaderMapand back to aSpanContext).
7. Open questions
- Resolved (slice 2).
FutureExt::with_contextdoes re-attach the extracted context inside the spawnedingest_boundtask, on both transports —rfc0039_3_ingest_propagation.rspasses, 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 tonicMetadataMap, read inexport(§3.4 amendment); no request extension and noingest_boundsignature change are involved. - Resolved (slice 3): yes, the dedicated
/mcpSERVER 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 definesINTERNALas 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). Meanwhiletools/callis JSON-RPC over HTTP, and both the RPC and HTTP conventions state the inbound server span’s kind MUST beSERVER. So/mcpgains 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 (thetracing-opentelemetrydependency). -
tracestateandbaggage:tracestaterides along withTraceContextautomatically;baggagepropagation 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::spawnboundary), §3.4 (the sampler), RFC0038.3 (spawn-boundary test harness), RFC0038.7 (rmcp=offloop-guard). CLAUDE.md§6.3 (observability of ourselves), §2 (the traces pillar via RFC 0038), §6.1 (nounwrap/expectin 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; noteOpenTelemetrySpanExt::set_parentreturnsAlreadyStartedon an entered span, which is why it is not used). - OpenTelemetry span kinds —
SpanKind(the normative definition ofINTERNALthat §7’s second question turned on), and the RPC / HTTP server-span conventions, both of which state the kind MUST beSERVER. - Pinned:
opentelemetry0.32.0,opentelemetry_sdk0.32.1,tracing-opentelemetry0.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, sovalidatedis vacuous (precedent: RFC 0008) andgreenwas the last verification stage; the flip records maintainer approval of the design as binding. Theourios-df-otelcrate 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 (normativedatafusion.operator.*attributes, asserted as typedValue::I64), RFC0040.4 (untimed nodes skipped and re-parented, covered at the crate level against a realCooperativeExec), 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 acriterionbench measuring ~530 ps/call). Landed across four slices:ourios-df-otelcrate (#632), thedatafusion.operator.*weaver registry entries (#633),ourios-querierwiring + integration tests (#634), and the RFC0040.6 guard (this slice). No thesis-gate applies (this RFC doesn’t touch abenchmarks.md§7 pillar), sogreenis the RFC’s terminal pre-acceptedstage — 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 realrow_groups_pruned/elapsed_compute/output_rowsvalues.
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:
- Post-hoc. Metrics populate only after
collect()returns. Every Ourios query path fully buffers viadatafusion::physical_plan::collect(lib.rs:73; neverexecute_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. - Opt-in metrics.
ExecutionPlan::metrics()returnsNonefor operators that do not useBaselineMetrics(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.
Amendment (2026-10-12, #963, maintainer-approved) — the versioned scope. The querier resolves this tracer as
ourios_core::telemetry::tracer("ourios-df-otel"), the same global provider but under an instrumentation scope versioned with the release (RFC 0001 §6.8’s 2026-10-12 amendment). The workspaceclippy.tomldisallows the unversionedopentelemetry::global::tracer. The operator spans, their names and their attributes are unchanged; onlyotel.scope.versionis now set.
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):
| Attribute | Type / unit | Source (MetricValue) |
|---|---|---|
datafusion.operator.output_rows | int, rows | OutputRows |
datafusion.operator.elapsed_compute | int, nanoseconds | ElapsedCompute (Time::value() is ns) |
datafusion.operator.output_bytes | int, bytes | OutputBytes |
datafusion.operator.row_groups_pruned | int, count | scan PruningMetrics::pruned() |
datafusion.operator.row_groups_matched | int, count | scan 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_rowsfor 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.*(notourios.*) because the semantics are DataFusion’s, not Ourios’s — the crate is built to be extracted (§3.5), and anourios.-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/queryspan, one per timedExecutionPlannode, 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 fromElapsedCompute.
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 valuesfold_metrics/aggregate_by_namereads for the same node — the span and theQueryStatsmetric never disagree about the same operator.
Scenario RFC0040.4 — nodes without metrics are skipped, not faked. Given an
ExecutionPlannode whosemetrics()isNone(noBaselineMetrics), 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 aourios-df-oteltest) over the scoped-InMemorySpanExporterharness: 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 ownaggregate_by_nametimestamps, read independently in the test so the assertion is not self-referential), and attributes. A synthetic plan with ametrics()-Nonenode 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
criterionguard on theParquet → query resulthot-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-checkgate once the fivedatafusion.operator.*names are registered (§3.3, §7). ourios-df-otelunit tests over hand-builtMetricsSets: theMetricValue → attributemapping, 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). Reusingdb.response.returned_rowsper plan node would collide with its normative meaning, so the operator attributes take thedatafusion.operator.*namespace (the instrumented library, notourios.*— the crate is built to be extracted). Fixed in §3.3. Remaining mechanical step: register the five names insemconv/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-tracingdoes exist and was spiked directly rather than judged from its docs. It is alternative (b) (live spans via aPhysicalOptimizerRule), and on Ourios’s real multi-partition plans it has a production-blocking bug:RepartitionExec’s internaltokiospawns are not covered by the crate’s join-set tracer hook (DataFusion 54.0.0), so every operator span is silently dropped unlesstarget_partitions(1)— not viable in production. Its attributes are also pretty-printed strings ("150.71µs","64.0 KB") and a combineddatafusion.nodetext dump, not the normative typeddatafusion.operator.*table. The post-hocourios-df-oteldesign 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 toourios-querierare confirmed (mirrors RFC 0039’s dep-promotion precedent exactly); noContext-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”) — anddb.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 theskip_allPII decision (RFC 0038 §3.5) and the H6 scrubbing rule — it stays out of this RFC and deserves its own (a sanitizingdb.query.summaryis 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/queryspan this nests under), §7 (thedatafusion-opentelemetryfuture-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
BaselineMetricstimestamps 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); pinnedopentelemetry0.32 /tracing-opentelemetry0.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 (validatedvacuous, RFC 0008 precedent). The RFC0041.5 recorded deferral closed the same day: v0.6.0 shipped typed columns and the plugin repositories landed thelatestmatrix leg + wire-levelsume2e (§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 inourios-perses-pluginPRs #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 partial —0.5.0is the declared minimum and the version CI exercises; thelatestmatrix leg and the wire-levelsume2e 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 sincedrafted: 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
draftedframing, 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.
| Grafana | Perses | |
|---|---|---|
| Renders Ourios log lines | ✅ | ✅ |
Time series from count by bucket(w) | ✅ (same plugin) | not built |
| Plugins for logs | 1 datasource | 2 (Datasource + LogQuery) |
| Plugins for logs and time series | still 1 | 3 (adds TimeSeriesQuery) |
| Backend language | none (data proxy) | none (frontend + CUE) |
| Schema language | none | CUE, mandatory |
| Config validated | at render | at write time |
| Measured effort (log parity) | 1–2 days | 3–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
LogQueryfromTimeSeriesQuery, 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.jsonroutes) and Perses’s datasource proxy both forward server-side, injectingx-ourios-tenantfrom 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.bytesExaminedmaps directly onto Ourios’sstats.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_nano | timestamp (÷1e6 → ms for Grafana, ÷1e9 → s for Perses) |
records[].body.line | log line |
records[].severity_text, else OTLP severity_number band | level |
attributes[] + resource_attributes[], AnyValue-unwrapped | labels |
aggregate[] with RFC 3339 keys | time series |
aggregate[] with other keys | table |
stats.bytes_read | query 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)asfrom <= effective < to, matching RFC 0010’s[from, to). Both Grafana’s and Perses’s pickers hand over an inclusive-lookingto, 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
OuriosDatasourceplugin configured against a runningourios-servercontainer - When the datasource health/connection path runs against a server
in open mode (no
authsection) - 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.
- Given a Perses instance with the
- 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)andsum(attr.<k>) by attr.<group_k>, bucket(w)(aggregated numeric key<k>, series-label group key<group_k>) throughOuriosTimeSeriesQuery - 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-schemadocument - 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.
- Given a deployment’s
- RFC0041.5 — compatibility declaration, CI-exercised (partial at
green— recorded deferral, see banner + §9.1)- Given plugin release metadata declaring its minimum
ourios-serverversion - 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.
- Given plugin release metadata declaring its minimum
- 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.
- Given the committed Perses dashboard definition in this
repository — agent spend by model over time (
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
idbelong 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
getLogsVolumeDataProviderovercount 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=0in comparisons is explicitly permitted), Severity Fields (a backend MAY interpret missing severity as INFO), and Logs SDKLoggerConfig(unspecified severity bypasses minimum-severity filtering). - Grafana — logs data frame contract, frontend data proxy.
- Perses — plugin creation;
the bundled
Lokiplugin is the reference for aLogQueryimplementation.
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 (validatedvacuous, 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_requestevents) answeredsum(attr.cost_usd) by attr.modelover the RFC 0027 MCP surface with real per-model spend from the typedFloat64column (claude-fable-5: 35.27878 USD / 10 requests, one row group scanned), andsum(attr.output_tokens)exercised theInt64class 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.acceptedis 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, andcost_usdarrives as adoubleAnyValue, which RFC 0022 §3.1 projects asNULL. Discovered the honest way: a config-only promotion of the key (PR #646, now drafted awaiting this RFC) would have produced an always-NULLcolumn and a silently emptysum— 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:
- Aggregation is impossible. RFC0002.17 scalar aggregates
(
sum/avg/min/max) require a promoted column andtry_castUtf8→Float64. A numericAnyValueprojectsNULL(RFC 0022 §3.1), so the column a numeric key would get is always-NULLand 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. - Ordering never matches.
attr.http.status_code >= 500is typed-arm-only (RFC 0022 §3.3); on aNULL-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):
| Class | Arrow / Parquet type | Projects | NULL when |
|---|---|---|---|
string (default) | Utf8 / STRING | string AnyValue | key absent, or value not a string — RFC 0022 §3.1, unchanged |
i64 | Int64 / INT64 | int AnyValue | key absent, or value not an int |
f64 | Float64 / DOUBLE | double or int AnyValue (int widened) | key absent, or value neither |
- All typed columns are
OPTIONALand 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. f64widens ints because sources are inconsistent (a zero cost may arrive as int0);i64→f64is exact for|v| ≤ 2^53, and a key expected to exceed that belongs ini64.i64does not narrow doubles — no silent truncation.- String-encoded numbers do not parse. A string
"500"under ani64class projectsNULL. Parsing would smuggle in coercion ambiguity ("500"vs"5e2"vs" 500") that RFC 0022 §3.1’s “byte-faithful orNULL” rule exists to keep out; a source that stamps numbers as strings gets thestringclass 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 unknowntypevalues and duplicate keys across both spellings at startup — RFC 0020’s strict-parse posture. type: stringis legal and identical to the bare spelling.- The implicit
resource.service.namepromotion staysstringand 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 (
==!=) oni64: 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.1vs1e-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. TheUtf8try_castpath remains for string-class keys, unchanged.NULLcells stay excluded;sumover an all-NULLgroup returnsNULL, not0(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 forf64(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.sizeinstrument 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
Utf8columns. No schema change,sumworks via the existingtry_cast. Rejected: float formatting makes==unreliable (the exact reason RFC 0022 §3.1 projectsNULLtoday), 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_usdasf64andattr.input_tokensasi64, when records carrying those keys as double/intAnyValues are ingested, then the written file carriesOPTIONALFloat64/Int64columns of those names holding the values, and the JSON attribute columns are byte-identical to an unpromoted run. - RFC0042.2 (projection totality). Given any
AnyValuevariant under each class, when projected, then the cell is the §3.1-table value orNULL— never a panic, never a coerced parse; in particular int widens intof64, double does not narrow intoi64, and strings projectNULLunder 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-NULLgroup yieldsNULL, not0. - 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 currenti64declaration, when equality and ordering predicates and asumrun, then the query does not error,==on thei64key answers correctly across all three files (JSON arm), and ordering/sumcover 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-typedservice.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
stringdeclaration for a key now declaredi64, when compaction rewrites them, then output files carry theInt64column 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.modelruns over the MCP surface, then it returns non-empty per-model spend matching the JSON oracle.
6. Testing strategy
- Property tests (
proptest): RFC0042.2 — arbitraryAnyValuevariants × 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 class —
attr.<key> == truefor flags likecache_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 (validatedvacuous, 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 onematerialize_recordseam), and #669 closed .5 (attr-only records through the real miner into a real store, matched end-to-end byevent_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;acceptedis 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):
- If
LogRecord.event_nameis set and non-empty, it wins. Theevent.nameattribute, 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). - If
LogRecord.event_nameis unset or empty and anevent.nameattribute is present with a non-empty string value, the stored record’sevent_nameis set to that string. The attribute remains inattributesverbatim — derivation, not a move. Non-string and empty-stringevent.namevalues derive nothing. - Neither present →
event_namestays 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) makesbody ==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_nameset and a differingevent.nameattribute - When it is ingested and read back
- Then the stored
event_nameis the wire field’s value - And the attribute is returned verbatim, unflagged.
- Given a record with
- RFC0043.2 — derivation from the attribute
- Given a record with no
event_nameand anevent.namestring attribute - When it is ingested and read back
- Then
event_nameequals the attribute’s value - And the
event.nameattribute is still present, byte-identical.
- Given a record with no
- 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.nameattribute whose value is not a string - When ingested
- Then
event_namestays NULL and the attribute is preserved.
- Given an
- 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 distinctevent.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_iddespite 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.)
- Given three attr-only structured records: two sharing an
- RFC0043.7 — empty is never a value, in either encoding
- Given records with (a) empty-string wire
event_nameplus anevent.nameattribute, (b) an empty-stringevent.nameattribute only, and (c) JSONnullevent.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.
- Given records with (a) empty-string wire
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_nameavailability per tenant (it is population-dependent)? Deferred — the field is already listed as queryable.
8. References
- OTel Logs Data Model —
EventNamefield; 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 (validatedvacuous, RFC 0008 precedent). The one open §7 fork — substring/ordering/regex onbody— 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 noIS TRUEwrap — under a filter NULL ≡ false, and the wrap defeats row-group pruning (!=keepsIS NOT TRUE, where three-valued logic genuinely bites). No thesis-gate applies;acceptedis 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 becauseCLAUDE.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
Lagainst every template in the tenant’s map.- A zero-parameter template matches iff its rendered text equals
Lbyte-for-byte → contribute its id to atemplate_id IN (…)disjunct. Prunable via template_id min/max + bloom. - A parameterized template matches iff
Lunifies with its token structure (anchored, whitespace-exact per §3.3 capture) → contributetemplate_id == T AND param(0) == v0 AND …with the implied parameter values. A literal may unify with several templates; each contributes a disjunct.
- A zero-parameter template matches iff its rendered text equals
- 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 != Lruns - Then they all return despite NULL physical bodies.
- Given mined records with reconstructions ≠
- 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_namederivation); 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.bodypaths; 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:
supersededby 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; theStoredouble-encoding fix of §3.2 andTenantIdopacity survive. The registry entries the detector minted aredeprecated, not deleted. Kept as the record of agreenimplementation 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): theTenantRulekey list +receiver.tenantconfig (.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 Helmreceiver.tenantpassthrough. Two things implementation forced on the spec, both recorded inline: theStoredouble-encoded any tenant id with a reserved character (§3.2 — fixed asfix(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 (validatedvacuous, RFC 0008/0044 precedent);acceptedis 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
rulelist is a startup configuration error. A duplicate key inruleis a startup configuration error. A key listed in bothruleandwatchis accepted and simply not watched (§3.4).watch_capacitymust be an integer ≥ 1 —0and negative values are startup configuration errors (an operator who wants no watching setswatch: []). - Derivation is per-
ResourceLogsgroup fromResource.attributes, unchanged in shape. Every key inruleis required: any group whose resource lacks arulekey, 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 missingk8s.cluster.namethat silently derived plainfluxcdwould recreate the exact collision this RFC exists to close. watchkeys are never required. A group that lacks awatchkey, 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 byrulealone. 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%2Fb → a%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) → rename → fsync(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.nameor stamping a synthetic attribute). Works per deployment, still available, but every deployment must know to do it, and rewritingservice.namecorrupts its semantics. The backend doing the composite once is less operational surface. - A bespoke trusted in-band
tenantattribute. 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.tenantsection, When the server starts, Then derivation uses[service.name]; Givenrule: [], Then startup fails with a configuration error; Givenrule: [service.name, service.name], Then startup fails with a configuration error; Givenwatch_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 shareservice.name: fluxcdbut differ ink8s.cluster.name(cluster1,cluster2), When both are ingested and queried, Then two tenantscluster1/fluxcdandcluster2/fluxcdexist, 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 missingservice.name, and nothing reaches the WAL.
RFC0045.4 — join injectivity. Given
ruleof 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.tenantconfig, 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 aservice.nameofa/b(and of100%), Then the derived tenant is exactlya/b(100%) — no escaping on the single-key path.
RFC0045.7 — divergence detector. Given the default rule and default
watch, When two exports shareservice.namebut differ ink8s.cluster.name, Then a warning naming the tenant, key, and both values is emitted and the divergence counter increments; And Given uniformk8s.cluster.namevalues, Then no warning and no increment; And Given a group lackingk8s.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/fluxcdand the composite rule, When an export derivingcluster2/fluxcdis 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: 1and two tenants that each later diverge onk8s.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 lackk8s.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 whoseafterboundaries 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 name —
ourios.tenant.watch_divergenceis 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-
ResourceLogsderivation 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 toservice.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 indocs/benchmarks.md§7; elsewherevalidatedis 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): the0x03 TenantOtlpBatchframe + 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 a0x03dimension over the RFC 0008 harnesses, .8 in the collector interop job, .9 by grep. Two things implementation forced on the spec, both recorded inline:rejectnames the frame kind (§3.3), and the gRPC non-ASCII caveat is enforced byMetadataValue::to_str(obs-text bytes are admitted, then refused as not-text). No thesis-gate applies (validatedvacuous, RFC 0008/0044 precedent);acceptedis 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.nameincluded) 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_setter→X-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:
| Transport | Carrier | Absent / empty | Not in the credential’s set |
|---|---|---|---|
| OTLP/HTTP | X-Ourios-Tenant request header | 400 Bad Request | 403 Forbidden |
| OTLP/gRPC | x-ourios-tenant request metadata | INVALID_ARGUMENT | PERMISSION_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+0000–U+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 piece | Fate |
|---|---|
TenantRule / composite derivation, receiver.tenant.rule | Removed. No derivation exists. |
Rule-epoch log tenant_rule_epochs.json | Removed (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 trees | Kept. |
Helm receiver.tenant passthrough | Removed (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.tenantset 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
0x01frames 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 withX-Ourios-Tenant: acme(x-ourios-tenantmetadata), Then it is accepted and every record lands in tenantacme.
RFC0046.2 — binding check. Given auth enabled with a token bound to
[acme], When an export selectsacme, Then it is accepted; When it selectsglobex, Then the whole export is rejected403(PERMISSION_DENIED),ourios.ingest.batches{error.type=permission_denied}increments and theingest_deniedaudit 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.nameis just an attribute. Given an export whoseResourceLogscarryservice.namefluxcd,checkout, and none at all, When it is sent with selectoracme, Then all records are queryable underacmeonly,service == "fluxcd"returns exactly the first group, the record withoutservice.namehas 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
acmeandglobexwhose records lackservice.nameentirely — acknowledged meaning the0x03frame was appended and fsynced (WAL-before-ack, unchanged) — When the receiver isSIGKILLed 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 iskind = 0x03.
RFC0046.5 — legacy frames abort loudly. Given a WAL holding a
kind = 0x01frame, 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
0x03frames, 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 asacmeand every other case is rejected400/INVALID_ARGUMENTbefore any WAL append; And Given a selector containing/,%and an interior space, Then it round-trips: the export is accepted, stored undertenant_id=<percent_encode_tenant>and queryable under the same header value; And Given a non-ASCII selector, Then it is accepted over HTTP and rejectedINVALID_ARGUMENTover gRPC.
RFC0046.8 — Collector interop. Given the reference
otelcol-contribpipeline exporting over TLS + OIDC withX-Ourios-Tenantset 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’s400.
RFC0046.9 — derivation is gone. Given the codebase, Then no
TenantRule,fan_out,RuleEpochs,DivergenceWatchorreceiver.tenant.*config remains;ourios.receiver.tenant.divergences, the two events andourios.tenant.watch.*aredeprecatedin 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
0x03payloads. Given CRC-valid0x03frames 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 aSinkRejectedinvalid-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 bound — RFC 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_BYTESin the receiver). - Non-ASCII tenant ids over gRPC — caveat 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-bincarrier 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_valuecarrydeprecated: {reason: obsoleted}in the sharedourios-semconvregistry, 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
Storefix 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.namealways promoted (column exists; value may be NULL). - OpenTelemetry Collector
headers_setterextension andotlphttpexporterheaders; LokiX-Scope-OrgIDas 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,validatedis vacuous — the ladder gates it onbenchmarks.md§7 (compression, query latency, reconstruction) and this RFC is an authorization surface whose whole path is inert unlessauth.openfgais configured, which the benchmark harness never does. The maintainer therefore advances it fromgreen(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-resolverCI 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-scaleListObjectsspike 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-tree — deploy/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 T | conversation: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:
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.- 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 attributesgen_ai.input.messages,gen_ai.output.messages,gen_ai.system_instructions,gen_ai.tool.call.arguments,gen_ai.tool.call.resultandbody) 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. - Otherwise the principal is scoped (it bound the tenant through
scoped_reader, §3.1):StreamedListObjects(P, can_read_content, conversation)— the streamed variant, never plainListObjects: 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 theT/prefix and counts only tenant-T ids towardauth.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) withinauth.openfga.visibility.list_timeout(default2s, 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) becomeattr.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. - 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: enumeratecan_read_metadataconversations 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-level tools additionally require
tenant-wide content read (§3.4 slice-2 rule), so a template_drift is not callable by this
principal in tenant acmecaller 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 store —
erasure/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.
Note (2026-10-16, RFC 0009 late-write amendment #1033, maintainer-approved; #1026). The rewrite above covers only the files a partition’s manifest names, so a late write into a compacted partition is skipped by it. Today that file is also hidden from every reader, so nothing leaks. The RFC 0009 amendment makes late files live and extends this erasure to them. The rewrite’s inputs are the live set, which now includes late files. A confirming pass runs before the marker advances to
tuples. It rewrites any partition that holds a live flush file, or whose manifest generation has moved past the one the erasure checked (the generation it committed, or the one it observed when there was nothing to rewrite), which catches a late file that another compactor’s consolidation folded in. The rows phase does not start until the marker is older thanerasure_settle. The amendment also restates this section’s scope, “rows durable at the rewrite”, as rows published before the last confirming pass checks their partition, and records the residual, which the maintainer accepted (RFC 0009 §7 D3). No fence stops the receivers from publishing across the move totuples. So a pre-request row whose flush publishes after that pass, whether still pending, backlogged, or replayed from the WAL after an outage, is not erased and writes its tuples again. The fences that would close it are listed in RFC 0009 §7 D3. A separate, pre-existing race is tracked in #1034 and is not caused by late data. With more than one compactor, a sweep writes its bufferedobservetuples after another sweep erased the conversation, so the deleted tuples are created again. The OpenFGA model and the tuple shapes do not change. See RFC 0009 §3.4 (erasure note) and RFC0009.20–.23.
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
Checkin 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
ListObjectswith 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
ListObjectstenant-constrained natively instead of the §3.4 prefix filter). Rejected: the multi-tenant guidance is one store with atenantobject (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.openfgaconfigured and tuples grantinguser:alicereader ontenant:acmeandservice_account:collectorwriter ontenant: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 Givenuser:bobwith only aparticipanttuple onconversation:acme/c-1plus its pairedtenant:acme#scoped_readerbinding tuple, anduser:finwith onlymetadata_readerontenant:acme, When each establishes a session, Then each binding’s read set is{acme}(they reach the planner) whileCheck(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 selectorglobex, Then403/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
503and ingest503/UNAVAILABLE, nothing is admitted, andourios.auth.resolutionscountserror.type = upstream_unavailable.
RFC0047.4 — two-step, tenant-wide reader. Given alice with
can_read_contentontenant:acmeand 10 000 conversations inacme, When she queries, Then the plan carries only the tenant predicate, noListObjects/stream call is issued (asserted on the OpenFGA request log / a counter), and every row is returned.
RFC0047.5 — two-step, participant. Given
user:bobparticipant of conversationsacme/c-1andacme/c-2only (tuples, with the binding tuple), When bob queriestrueon tenantacme, Then exactly the rows ofc-1/c-2return; And Given a further conversationc-9whose rows carryattr.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; And Given bob is also participant onc-10on the request, Thenc-10’s rows return tooglobex/c-1(another tenant), Then that id never appears in theacmepredicate.
RFC0047.6 — agent as principal. Given
agent:botactor on 500 conversations andagent:otheractor on 500 different ones, When bot queries, Then exactly its 500 conversations’ rows return and none of other’s; And Given bot is alsodelegateon alice’s conversationc-7, Thenc-7returns for bot; And When the delegation tuple is deleted, Then (after the TTL)c-7no longer returns.
RFC0047.7 — bounded enumeration fails closed, per tenant. Given
visibility.max_objects: 100and an agent actor on 150 conversations inacme, When it queriesacme, Then the query is rejected with the named bound error, no partial result is returned, and the plain (capped)ListObjectsis never used — the streamed call is what the OpenFGA log shows; And Given instead an agent actor on 50 conversations inacmeand 150 inglobex, When it queriesacme, Then the query succeeds with exactly the 50 (only tenant-acmeids count toward the bound); And Givenvisibility.list_timeoutshorter 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:finwithmetadata_readerontenant:acmeonly (no conversation tuples), When fin queriessum(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 onbodyorattr.gen_ai.input.messages, Then rows carry NULL for those columns and a filter on them is403naming the column.
RFC0047.9 — tool gate. Given
agent:botwithcallerontool:acme/query_logsonly, When it callsquery_logs, Then the call proceeds (and §3.4 scopes its data); When it callstemplate_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.hashandgen_ai.agent.id, When the compaction sweep runs, Then theparent,participantandactortuples 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.fgaandstore.fga.yaml, When CI runsfga model validateandfga 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_claimname/value convention; whether an agent’s token may also carry a delegating user (actclaim, RFC 8693) to mintdelegateautomatically. - 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_objectsdefault — 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 thevisibility_boundrefusal 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 backfillfor 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
participanttuple 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 wholetype:idstring, 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
ListObjectsspike: 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).
| Call | Result | Latency |
|---|---|---|
Check(alice, can_read_content, tenant:acme) — the two-step gate | allowed | p50 1.1 ms / p95 1.4 ms |
Check(agent, can_read_content, tenant:acme) | false | p50 0.8 ms |
Check(agent, can_read_content, conversation:acme/c-3) | allowed | p50 2.1 ms |
Check(collector, can_write, tenant:acme) — ingest binding | allowed | p50 1.0 ms |
| ListObjects(bot50, conversation) | 50 | 2.3 ms |
| ListObjects(bot500, conversation) | 500 | 2.5 ms |
| ListObjects(bot5000, conversation) | 1000 (silently capped, HTTP 200) | 3.4 ms |
| ListObjects(bob, conversation) | 1 | 1.6 ms |
| ListObjects(alice tenant-wide, conversation) over 100k | 1000 (silently capped, HTTP 200) | 13 ms |
| StreamedListObjects(bot5000) | 5000 | 0.05 s wall |
| StreamedListObjects(alice tenant-wide) | 100 000 | 0.53 s wall |
What this decided:
- 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. - 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 atmax_objects(§3.4 step 3) rather than emitting a giantIN(). - Session-time checks are ~1 ms → TTL-cached fail-closed resolution is free at the RFC 0029 seam (as the first spike found).
- 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, sovalidatedis vacuous here and the maintainer advances the RFC directly fromgreen(RFC 0008 precedent, restated in RFC 0044): the ladder’svalidatedstage gates onbenchmarks.md§7 — compression, query latency, reconstruction — and these are authorization and operational surfaces. The graph path is inert unlessauth.openfgais 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, thegraph erasuresoutput 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.rsgrammar table + the RFC 0046 end-to-end OTLP arms +rfc0048_grammarquerier/MCP arms + config/claim unit tests), RFC0048.2 (rfc0048_2_verbatim_tenant_on_a_real_graph, real OpenFGA: 256-byte object accepted andReadbyte-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:--fromboundary, idempotent re-run, no Parquet rewrite, refusal-leaves-no-lock, sweep defers under the lock then completes), RFC0048.6 (the sealedContextualTuplescarrier + wire-shape fakes), RFC0048.7 (rfc0048_7_list_deadline_event_at_startup). Container scenarios run in theopenfga-resolverCI 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 underenduser.idor 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 (0x21–0x7E,
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>andtool:<T>/<name>withTverbatim; the/separator is unambiguous becauseTcannot contain it, and the raw conversation id follows (it may contain/).TenantObjectskeeps the one-place naming rule and dropsencode_tenant_segment; theInvalidTenantbranch 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 wholetype:idstring (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 rawgen_ai.conversation.idmay contain it), and the fullconversation:<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--tenantand 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 MCPtenantargument, anauth.tokens[].tenantsentry and an OIDCtenant_claimvalue, Thenacmeis accepted everywhere and every other value is rejected everywhere with the same named reason (400/INVALID_ARGUMENTat 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>withTverbatim (asserted against a real OpenFGA: write,Readbyte-for-byte, streamed prefix filter), andTenantObjectshas 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]andidentities.agent_columns: [attr.bot.name], When rows carrying those attributes are swept, Then the graph holdsparticipant/actortuples for those values and none foruser.hash/gen_ai.agent.id; And Given noidentitiesblock, Then today’s defaults apply unchanged (RFC0047.10 still passes); And Given a non-promoted column or aself_principal_columnoutsideuser_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-7runs, Then the marker exists (graph erasureslists it in therowsphase), a seconderaseis a no-op, and the next sweep completes RFC0047.11 unchanged; When the sweep completes, Thengraph erasureslists 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.openfgawas configured and a scoped principal who is a participant in them, When the principal queries, Then it sees no rows; Whengraph backfill --tenant Truns, 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--fromat 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
participanttuple 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, Thencheck/streamed_list_objectsaccept only theContextualTuplesnewtype 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.openfgaconfigured, When the server starts, Then oneourios.server.graph.list_deadlineevent carrieslist_timeout_msandserver_list_objects_deadline_ms, and (RFC 0047) alist_timeout_msnot below the deadline is still a startup error.
RFC0048.8 — backfill and erasure exclude each other. Given a pending erasure marker for tenant
T, Whengraph backfill --tenant Truns, Then it refuses before reading any partition, naming the pending erasure, and leaves no backfill lock behind (graph erasureslists only the erasure); And Given a backfill lock forT, When a sweep runs with an erasure marker forT, 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 andgraph erasureslists 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=1msand 3 000 matching tuples,streamed-list-objectsreturned 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 confirmOPENFGA_LIST_OBJECTS_DEADLINEapplies to the streamed endpoint (and…_MAX_RESULTSdoes 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 erasuresoutput shape — human lines now,--jsonwhen 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--jsonlater is additive. The machine-readable channels already exist for automation: theconversation_erasedaudit event, theourios.compaction.erasure.completedlog event, andourios.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 (
Readis 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, bothaccepted). This RFC closes a gap left by those RFCs: Ourios derives its principal fromsubalone, 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:
- A token carrying
actis refused by default (401, named reason), because a principal Ourios cannot represent must never be silently downgraded to one it can. - An opt-in
actormode 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. actnever writes to the graph and never becomes a contextual tuple. The group claim stays the single contextual carrier (RFC 0048 §3.5). A blanketact→delegateexpansion 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(theactobject’ssubmember); a missing or non-stringsubinsideactis a refusal; - the principal type applies the existing rule to the
actobject —agent:when the configuredagent_claimappears inside it, elseuser:; - nested
actclaims 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 act ⇒
conversation: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
act → delegate 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
delegatetuples fromact. §3.4. Rejected on the record. - Treat
actas a contextualdelegate(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.oidcconfigured withoutdelegation, When a token carrying a top-levelactclaim 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 kinddelegation_unsupported, a message naming theactclaim and theauth.oidc.delegationkey, and oneourios.auth.resolutionsincrement witherror.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 subjectuser:aliceholding tenant-widereaderand an actoragent:botholding nothing in the tenant, When a token withsub = aliceandact.sub = botqueries, Then the principal isagent:bot, the visibility branch is the oneagent:botwould 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: actorand a token whoseactnests a furtheract(alice→bot→inner), When it is presented, Then the principal is the outermost actor (bot) and the nested actor is ignored for authorization — including when the nestedactis itself malformed, which never affects the outcome; And Given a top-levelactthat is not an object (null, a string, a number, an array), or an object whosesubis 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: actorandagent_claim: ourios_principal_type=agent, When theactobject carries that claim/value, Then the principal isagent:<act.sub>; And when it does not, Then it isuser:<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 —
actnever reaches the graph. Given any delegation token in either mode, Then noWriteis issued and no contextual tuple other than the group-claim tuples is sent (the sealedContextualTuplescarrier 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
actis untouched. Givendelegation: actorand a token carrying noactclaim, When it authenticates on any surface, Then the principal is the top-levelsubtyped byagent_claim, exactly as withdelegation: rejectand 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 anactthe issuer never sanctioned), or is that double-checking the IdP’s own job? - Per-tenant delegation mode.
delegationis 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 indocs/benchmarks.md§7; elsewherevalidatedis 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.1rfc0050_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); .2rfc0050_2_adoption_uses_the_upstream_string(unit) and thecollector-interopjob’sdrainprocessor_annotates_and_ourios_adopts(a real otelcol-contrib 0.159.0drainpass, adoption discriminated by the persistedtemplate_adoptedaudit event); .3 at the cluster and through the served binary (rfc0050_3_served_mixed_stream.rs); .4 the grammar/alignment table inupstream.rsplus therfc0050_properties.rsrender-equals-body proptests (construction and ingest level); .5rfc0050_5_ceiling_stops_adoption_interning+rfc0050_5_byte_limit_rejects_before_parsing, with the counting clause pinned onourios.miner.upstream_template.processedby therfc0050_processed_countermetric binary (successerror.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; .8rfc0050_read_path.rs+ the DTO omit-when-unresolvable tests; .9rfc0050_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 (validatedvacuous — RFC 0008/0044/0046 precedent);acceptedis a maintainer flip. Two §7 outcomes recorded inline: rejections rideourios.miner.upstream_template.processed(§3.2, #732), andlog.record.templatestays 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 ofCLAUDE.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:
- Accept it — per deployment, as a three-mode dial whose default
changes nothing:
ignoreis today’s behaviour; opt-inobservekeeps mining exactly as today but records the upstream string on the mined template’s registry entry, making disagreement between the two clusterings queryable; opt-inadopttakes a record’slog.record.templateas its template instead of mining the body (§3.2). - Reconcile it — an adopted template is interned in the same
registry, gets the same
template_idkey, 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). - 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’smasking_rulesemit these). Each matches exactly one token under the miner’s own tokenisation — a maximal run of bytes containing no Unicode whitespace, whitespace being every codepointchar::is_whitespaceaccepts, exactly asourios-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 everytemplate_idin a live store and move the corpus gates, which is a migration, not a default.observe— coexistence: leverage the string, keep our clustering. The miner mines every record exactly as underignore— sametemplate_ids, same Parquet bytes for the data columns, same corpus-gate numbers — and when a record also carries alog.record.templatethat 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.observeis also the migration on-ramp: run it, look at the associations, then decide whetheradoptis even worth it for this deployment.adopt— a record carrying a usablelog.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 —adoptnarrows 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:
| Origin | Meaning | Trust |
|---|---|---|
mined | Ourios’s Drain tree derived it | inference; confidence scored as today |
upstream_derived | a clustering processor derived it (drainprocessor) | inference made elsewhere, same failure modes |
producer_declared | the emitting library’s own message template | ground 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_templateslike any other. A tenant at its ceiling stops interning new upstream templates and falls back to mining (orNO_TEMPLATE), so a producer emitting a unique template per record cannot grow memory without bound. §3.2 ofCLAUDE.mdin 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:
- 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. - If the alignment reproduces the body byte for byte, adopt: the row carries the upstream template with its parameters and separators, confidence 1.0.
- 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:
| Attribute | Value | Why this name |
|---|---|---|
log.record.template | the template string | the convention (§3.1); a vendor name for a concept the ecosystem is standardising is exactly what CLAUDE.md’s alignment rule forbids |
ourios.template.id | the u64 local key | vendor-namespaced because it is vendor-specific — no convention exists or should |
ourios.template.version | the template’s version | the 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.templatecannot be a weaverref:, because the attribute is not in the upstream registry to reference — verified against the registry, whoselognamespace today holds onlylog.iostream,log.file.*,log.record.originalandlog.record.uid. It is therefore a local definition in our registry:developmentstability, anotenaming #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 alog.*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 aref:the day the proposal lands. - Records returned by the query API: the string is added beside
the existing
template_id/template_versionresponse fields, so a consumer stops needing the secondlist_templatescall. It is not injected into the record’sattributesarray — 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.templatebecause 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_idin 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
ignoreandadopt, 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.observeis 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.stringinstead of the conventional name. Inventing a vendor name for a concept the ecosystem is standardising is exactly whatCLAUDE.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_templatesunset and a corpus whose records carrylog.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. Everytemplate_id, every miner-derived column and the file layout are unchanged.
RFC0050.2 — adoption uses the upstream string. Given
upstream_templates: adoptand records carryinglog.record.template, When they are ingested, Then each record’s template is the upstream string, two records sharing a string share onetemplate_id, the Drain tree gains no leaf for them, and the registry entry’s provenance set containsupstream_derived.
RFC0050.3 — a mixed stream works. Given
adoptand 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 bytemplate_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, amax_templatesof N and a producer emitting a uniquelog.record.templateper 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, thenNO_TEMPLATE), and the ceiling is observable on the existing miner metrics; And Given a record whoselog.record.templateexceedsupstream_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 onetemplate_ideach — 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 theCLAUDE.md§3.1 audit event naming the template and its origin.
Amendment 2026-09-30 (convergence tie-break, #892). Two mined leaves under distinct masked paths can carry the same convergence key
(canonical, severity_number, scope_name)— the RFC 0001 §6.1 template key with the canonical shape in place of the tokens. The convergence lookup then takes the leaf with that key and the lowesttemplate_id, never whichever leaf map iteration reaches first, and the convergence guard counts the leaves carrying each key, so one of them widening away leaves the key findable. RFC 0001 §6.2 step 4 similarity ties break the same way. A snapshot restore rebuilds the tree but not the history or map order that built it, so these choices must be functions of the tree for a restored tenant to converge exactly as the live one would (RFC 0001 §3.5.3). See RFC 0001 §6.9’s 2026-09-30 amendment.
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 insemconv/registry/, and the weaver live-check reports no violation for it; And Given the local key on the same signal, Then it isourios.template.id(withourios.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_versionfields — so no secondlist_templatescall is needed — And the record’sattributesarray is byte-identical to what ingest stored (RFC 0018 fidelity): nolog.record.templateis 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: observeand a corpus whose records carry valid upstream templates, When it is ingested, Then everytemplate_id, every miner-derived column and the corpus-gate numbers are identical to the same corpus underignore— 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.templateis 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 logicalattributesarray — 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_limitcaps 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.templatefor 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 asupstream_derived, and a producer-declared template gets less confidence than it deserves. The deferredlog.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
paramsduplicates 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 ifrender(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; perfeedback: ai-disclosure-at-top-when-posting- externally, any post is maintainer-approved first.
8. References
- collector-contrib
drainprocessor—log.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 (theourios-servingcrate
- 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.
validatedis vacuous for a placement RFC (RFC 0008 precedent);acceptedis 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 — oneourios-servingcrate; the OIDC client moves with the OpenFGA client (core endsreqwest-free); theourios_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:
-
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 underourios-ingester. The querier role consumes all of it (ourios-server’squerier.rs,mcp.rs,visibility.rsimport fromourios_ingester::receiver::*), so a querier-only deployment compiles and links the entire ingest pipeline to get an auth check and a TLS acceptor.LISTENER_QUERIERbeing defined inside the receiver’s TLS module is the one-line picture of the problem. -
HTTP clients live in the foundational-types crate.
ourios-corecarries the OpenFGA HTTP client (auth/openfga/client.rs, 1 628 lines) and the OIDC verifier (auth/oidc.rs, 1 078 lines), bothreqwest-backed behind theopenfga/oidcfeatures. 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 ofCLAUDE.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 inourios-ingester— only role-independent serving plumbing moves. - No public-API redesign of the moved modules (the tower
AuthLayerfor HTTP and theTenantDeniederror 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:
| From | To | Contents |
|---|---|---|
ourios-ingester/src/receiver/auth.rs | ourios-serving/src/auth.rs | AuthResolver, AuthBinding, AuthError, token/OIDC resolution |
ourios-ingester/src/receiver/tls.rs | ourios-serving/src/tls.rs | TlsSettings, ALPN constants |
ourios-ingester/src/receiver/tls_serve.rs | ourios-serving/src/tls_serve.rs | reloading acceptors, LISTENER_* labels, handshake metrics |
ourios-ingester/src/receiver/propagation.rs | ourios-serving/src/propagation.rs | W3C trace-context extraction (RFC 0039) |
ourios-core/src/auth/openfga/client.rs | ourios-serving/src/openfga.rs | the OpenFGA HTTP client |
ourios-core/src/auth/oidc.rs (client half) | ourios-serving/src/oidc.rs | JWKS 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 types —
OpenFgaConfig, 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-servingwith modulesauth,oidc,openfga,tls,tls_serve,propagation. One new crate in §7’s layout; the dependency diamond isingester → serving,server → serving, (querierstays serving-free — its role wiring lives inourios-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 laterourios-authzsplit remains cheap because the module boundaries land clean now. - Option B: two crates —
ourios-authz(auth, oidc, openfga) andourios-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-servingdepends onourios-core(types),ourios-config,ourios-telemetry,ourios-semconv— never onourios-ingester,ourios-querierorourios-parquet.ourios-ingesterandourios-serverdepend onourios-serving.ourios-querierdoes not (its role wiring inourios-serverdoes).- §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-ingesterand 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-querierand 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::propagation— or for the moved names formerly re-exported at the receiver root (AuthBinding,AuthError,AuthResolver,GraphIdentity,authenticate_bearer,HeaderExtractor,MetadataExtractor,extract_context,TlsSettings) reached through anyourios_ingester::path, Then no match exists, And the querier role’s modules build againstourios-servingonly. (The nested-path and root-re-export forms are both in scope — a brace-import literal alone would missourios_ingester::receiver::AuthResolver::static_only(..)-style call sites.)RFC0051.2 — core carries no HTTP stack. Given
ourios-core/Cargo.tomlafter the move, When inspected, Then it declares noreqwestdependency and noopenfga/oidcfeature, Andcargo tree -i reqwestlists 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 warmcargo check -p ourios-serverand-p ourios-querierare timed before and after the move, Then the after-times are recorded in §9, And the querier-role path no longer rebuildsourios-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 reqwestcheck; 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 --timingsunit 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-servingcrate. A laterourios-authzsplit stays cheap because the module boundaries land clean now. - The OIDC client moves with OpenFGA — it is what empties
reqwestout 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 inourios-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.rsedit):cargo check -p ourios-serverbefore 1.37 s (rebuilds ingester + server) vs after 1.82 s (serving + ingester + server);cargo check -p ourios-querierafter 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-querierinvocation 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-querierhas noourios-ingesteredge in its unit graph before or after (cargo build --timingsunit 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 onourios-serving— so the criterion’s “querier-role path no longer rebuilds ourios-ingester” holds structurally (ourios-querierhas no ingester edge; the RFC0051.1 gate keeps it that way) rather than as a warm-check delta forourios-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 theourios_servingpaths 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.
RFC 0052 — WAL reclamation and quiesce recovery
rfc: 0052 title: WAL reclamation and quiesce recovery status: green author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-09-12 supersedes: — superseded-by: —
RFC 0052 — WAL reclamation and quiesce recovery
Status:
green(2026-09-30, maintainer-approved flip). Every live §5 scenario passes. RFC0052.17’s twoPUBLISHEDlegs moved to RFC 0055 as RFC0055.5 before the flip, because RFC 0055 (publication frontiers) owns thePUBLISHEDwriter and format. Their stub stays#[ignore]d under that criterion, as .6, .8 and .9 moved to RFC 0053. The recorded long soak run §6 requires beforevalidatedis still to come. The history of theredstage follows.(
red— test stubs existed and failed (docs/rfcs/README.md§Lifecycle).) Every live §5 scenario was stubbed as one or more#[ignore]dtodo!stubs, per leg where §6 separates the legs, inourios-wal/tests/it/rfc0052_*(.2, .4, .5, .11, .12, .13, .16, .17),ourios-ingester/tests/it/rfc0052_*(.1, .10, .13’s startup leg, .14, .15),ourios-ingester/tests/rfc0052_7_telemetry/(.7, its own binary per RFC0028.2) andourios-bench/tests/rfc0052_3_bounded_growth.rs(.3). Slices A–E have landed and discharged their stubs, .7 included, and slice F’s first half (F1) has discharged .10: aSIGKILLofreceiver_reclaim_crash_fixturewith the barrier and housekeeping on a 100 ms cadence loses no acknowledged record, and recovery now gates the replayed records onmax(X, S)and the regenerated audit events onX(§3.7), withholding and counting the rest. The barrier still retains a cut whose snapshot write failed (RFC0052.7 pins that), so theS < Xleg reconstructs that state on disk rather than reaching it through a cut. Slice F2 has discharged .3 on the reclamation soak harness (ourios-bench/src/soak/reclaim.rs), which runs the receiver’s barrier and housekeeping ticks on a stepped synthetic clock: a capacity-balanced run over three tenants rolls twelve segments under the 300 s / 60 s cadences and never holds more than the bound its config derives (four segments), and an idle node reclaims every closed segment onebarrier_secsplus onehousekeeping_secsafter its last append, and its last segment withinsegment_age_secsmore. The recorded long soak run §6 requires beforevalidatedis still to come. The only stub still ignored is .17’sPUBLISHEDrow. That row is recorded as partial at green and handed to RFC 0055, as RFC 0041 handed on its deferred rows: this RFC turns green on the other rows once slice F lands, and RFC 0055’s writer discharges the row. Implementation proceeds in six green slices, each un-ignoring the stubs it discharges. Where a criterion’s legs span slices, the mapping below is by leg and each stub’s#[ignore]reason names its slice: A reclaim record and sidecars (§3.2RECLAIM,CHECKPOINTv2,SEGMENT_VERSION2, open-time reconciliation → .16, .11’s open-time leg, .17’s format, sidecar-matrix and geometry legs: the corrupt record, the deleted record on a post-RFC root, the overlong replayed tenant, the both-sidecars-missing rows, the geometry rebuild, the at-open allocation failure, the fresh-open ordering, the torn-slot fallback and thecheckpoint_armed/checkpoint_seenrows); B housekeeping (ledger,RetainFloor, capped passes, prepare/commit split → .2, .12, .13’s floor legs, .17’s pass legs and every leg that reads a snapshot, sinceSnapshotHorizonsarrives athousekeeping_prepare: the halt-or-pin rows, the empty-record pin, entry monotonicity, the record-then-unlink crash, the legacy-root migration window (whose stale-gap leg compares a tenant’s oldest surviving frame with its last recorded horizon), the consumer-mode rows, the migration-window skip, failed or uncertain unlink, failed record write). .17’sPUBLISHEDseeding and slot-id row is not slice B’s: both its legs read thePUBLISHEDsidecar, and while §3.2 defines the twopublished_seeded_*header bits as RFC 0053’s amendment to this header, the file’s writer and format are RFC 0055’s (publication frontiers,drafted). The stub’s earlier reason misattributed it to RFC 0053; it stays ignored, named for RFC 0055, and the slice that lands that writer discharges it; C rotation (temporary name, bounded retry, terminal state, thehold/794-wedged-classificationreintroduction → .4, .5, .15, .11’s post-RFC rotation leg, .17’s legacy-root rotation leg); D barrier (guard-at-submit, publisher thread carrying detached partitions only, epoch latch, ingest exclusion → .1’s cut legs, .14, .13’s startup leg); E timer and telemetry (§3.5 instruments and events → .7, .1’s cadence-tick panic leg); F crash and soak (.10 on the rfc0014_5 fixture, .3 on the extended soak harness). A before B; B before D and E; F last. Snapshot v2 (#877) sits outside the six slices and has landed as its own breaking change: §3.1’s per-tenant folded horizon inwal_high_water(each ingest turn and each replayed frame folds its tenant’s horizon, and the cut, the post-recovery and shutdown writes and the ledger carry it per tenant) andSNAPSHOT_VERSION1 → 2, a version-1 artefact taking the unknown-version discard path. The same landing restates recovery’s stale-gap check on theRECLAIMrecord (§3.2): an absent horizon segment is explained when the tenant’s reclaimed-through is at or aboveS. The §8 amendment of RFC 0001 §6.9’s global high-water wording is live from that landing. Stage 1 of two. Motivated by a production incident (issue #791) and the defects found tracing it (#791, #793). Amends RFC 0008 §6.5 and §6.7 with the policy those sections left to a caller that was never written, amends RFC 0001 §6.9 (per-tenant own-frame snapshot horizons and the tenant-aware retain rule supersede its global high-water wording; §8), amends RFC 0018 §3.2 (whose transient class lists “post-rotation quiesce”, which #791 disproved), and exports the WAL telemetry the incident showed is missing. TouchesCLAUDE.md§3.4 throughout, which is why it is an RFC and not patches.Staging. An earlier draft also carried explicit backpressure and unwind safety (#796). Six review rounds found most defects at the seams between the four parts, and the maintainer directed a split: this RFC is reclamation and rotation recovery — the pair that today guarantees every node eventually wedges — and RFC 0053 is backpressure and unwind, which depend on this one and land after it. Section numbers are kept stable so the review history still resolves; §3.4 and §3.6 are pointers.
1. Summary
The WAL has never reclaimed a segment in any deployment: checkpoint
and housekeeping are called only from tests, so wal_housekeeping_secs
times nothing and the WAL grows monotonically for the life of the volume.
When the volume fills, rotation fails and sets a quiesced latch that no
code clears, so every subsequent write is refused until the process is
restarted — and the refusal carries no log, no metric, and (until #794)
no reason. This RFC commits to: advancing the checkpoint at the existing
publication barrier and running housekeeping on the existing timer; making a
rotation failure recoverable in place under a bounded retry rather than
permanent; and exporting the WAL state an operator needs to see either
happening. Explicit backpressure and unwind safety, which depend on both, are
RFC 0053.
2. Motivation
2.1 The incident
A transient object-store outage wedged a single node for eight hours
(#791). Ingest answered 503 with an empty body for every tenant, in
3 ms — a local refusal, not a timeout — and kept doing so for the last
eight of those hours, during which the object store was verified healthy.
systemctl restart cleared it in 21 seconds. The downstream collector
dropped 4,526 journal records and 177 application log records before
anyone noticed, and what noticed was an alert on the collector’s queue
depth, one hop away. Ourios itself emitted no signal at all.
That is a direct contradiction of the promise §3.4 makes. WAL-before-ack is supposed to mean a flaky object store degrades querying and delays compaction while writes keep landing on local disk. Here a backend blip took writes down and kept them down after the blip ended.
2.2 Two defects, one question
Tracing it turned up defects that are all the same question seen from different sides — when is it safe to declare WAL frames reclaimable, and what happens when we cannot? The two this RFC takes are the pair that guarantees the wedge; a third, #796, is RFC 0053’s.
#793 — the WAL never truncates. Wal::checkpoint (§6.7) and
Wal::housekeeping are sound, specified, and tested. They are also
dead: every call site is a test. Production reads last_checkpoint()
during recovery and never writes one, and housekeeping is documented
as a no-op before the first checkpoint, which never comes. The incident
node held 1,113 segments totalling 42 MB after five days — all age-rolled
(the minimum segment size is 17 MiB), none ever reclaimed. On a
long-enough timeline every node fills its volume.
#791 — the quiesce latch is permanent. Wal::rotate sets
quiesced = true at four sites, and no code anywhere sets it back. The
field doc says “until an operator intervenes” and §6.5 says “operator
intervention is required”, but no intervention exists: there is no WAL
verb in the CLI and no API to clear the flag. Wal::open is the only
reset, so the intervention is, in practice, a restart. ENOSPC from #793
is the most likely way to reach it, but any of the four I/O steps
failing is sufficient.
#796 — a publish unwind loses drained batches is the same question from the sink’s side rather than the WAL’s, and is specified in RFC 0053 §2.2. It is mentioned here only because #795’s stopping the sweep on a panic — the behaviour this RFC’s reclamation must coexist with — exists to bound it.
2.3 Why at this layer
Each of these could be patched locally, and two of them were (#794 makes
the rejection legible, #795 makes a dead cadence alertable). Neither
patch could touch the behaviour, because both decisions turn on the same
invariant: CLAUDE.md §3.4 says an acknowledged record must not be lost, and
every candidate fix either declares frames reclaimable (checkpoint,
truncation) or decides whether to keep accepting them at all (quiesce
recovery). Getting either wrong loses acknowledged data. That is the
definition of an RFC-level change.
The reclamation half is also not a new design. RFC 0008 §6.7 already
specifies the mechanism precisely — monotonic checkpoint, sidecar,
min(checkpoint, snapshot_floor) truncation bound, whole segments only,
never the current one. What it deliberately left out is the policy:
which offset, on what condition, on whose timer. This RFC supplies that,
and the answer turns out to be constrained almost to a single option by
code that already exists.
3. Proposed design
3.1 Checkpoint at the existing publication barrier
flush_then_snapshot already computes exactly the predicate a checkpoint
needs. It returns true only after: in-flight encodes are quiesced, the
coordinator’s drained-in-flight publishes have settled
(quiesce_publishes), the audit sink has fully flushed, and the record
sink reports zero buffered records. Its own comment states the
consequence — “every acked record at or below the mark is durably
captured”. That is the checkpoint precondition, already proven, already
under the miner lock.
So the checkpoint advances behind that barrier and nowhere else. But the barrier must also be reachable without an append. Today it runs from the rotation hook, which fires only after a successful append observes a segment change, and from shutdown. If reclamation depended on that alone, a node that stops receiving traffic would never advance its checkpoint again — no append, no rotation, no barrier — and an idle node is exactly the one whose retained segments have the least reason to exist. RFC 0053’s backpressure sharpens the same gap into a deadlock (a bound that rejects every append can then never be cleared by one), which is why it depends on this timer rather than adding its own. The age sweep is not a substitute: it deliberately takes no snapshot and so establishes nothing about durability.
The barrier therefore also runs on §3.2’s timer, which is append-independent
by construction. One predicate, three callers (rotation, shutdown, timer) —
and the timer caller carries the same prologue the other two already do:
flush_then_snapshot does not quiesce encode submissions itself, its
callers do it first, so a timer caller that skipped quiesce_encodes() would
stamp across in-flight encodes (the barrier’s own class-1 case) and lose
exactly what it is meant to protect. “One predicate, three callers” means the
whole sequence, not the inner function.
Calling the prologue is still not sufficient, and this is where the timer
differs from the other two callers. Rotation calls quiesce_encodes() while
holding the pipeline’s miner lock, and shutdown has already stopped the
listeners; each therefore has an exclusion keeping new submissions out between
the quiesce and the stamp. A bare timer has neither, so ingest can submit a
fresh encode in that window and the barrier stamps across it — the same loss,
reached by a narrower race.
And the exclusion is the ingest gate, not the miner lock. That distinction
is the whole of it: ingest_bound releases the miner lock before
pool.submit(mined) and before advancing last_durable, so a timer holding
only that lock can quiesce the pool and then have an already-past-the-lock
ingest submit an encode and advance the mark underneath it. The barrier would
stamp at a mark whose encode was never quiesced — the same loss again, one
layer down.
What actually makes rotation safe is that it runs inside an ingest’s gate turn, and the gate serializes turns in order: “every frame ≤ this seq submits its encodes before any later seq reaches the rotation check”. So the timer must become a participant in that ordering rather than a concurrent holder of a narrower lock.
The existing gate cannot give it one, so this is a change to the pipeline,
not a use of it. ingest_gate is a watch counter that append callers advance
with their own commit sequence; await_ingest_turn waits for a sequence, and
a timer has none to reserve. Inventing a synthetic sequence would interleave
with real ones and could stall an ingest behind a barrier that never arrives.
Instead the pipeline gains an explicit barrier exclusion: a lock
ingest_bound holds across the span that matters — from the miner work through
pool.submit and the last_durable update — and that the timer takes
exclusively to capture a cut, and for nothing else. It is strictly wider
than the miner lock and strictly narrower than the gate, so it does not
change ingest ordering, and it is the smallest thing that closes the window.
Rotation’s exclusion needs no change — it already runs inside an ingest’s
own span — but its I/O does. Today rotate_for_segment_change runs under
the miner lock and the gate and performs the whole of flush_then_snapshot
there, PUTs included; under this RFC that span is also the barrier
exclusion, so leaving the hook as it is would hold the exclusion across
store I/O at every rotation — exactly the stall the next paragraph takes the
timer out of, and with §3.3’s retry it would recur on every retried
rotation during an outage. So the rotation hook becomes capture-only:
inside the turn it quiesces the encodes, takes the mark (prev, the
rotation point), drains both sinks into owned batches registered as in
flight, serialises the snapshot bytes, and hands that cut to the barrier
task, which runs the same store-I/O sequence the timer’s own cuts run
(§3.2’s pseudocode from cut_ok on). The handoff is a single pending
slot, not a queue, so it is O(1) and never blocks the turn and cannot
grow during an outage: the barrier task holds at most one pending cut
beside the one it is running, and a rotation capture that arrives while a
cut is pending coalesces into it — the new drained batches are
appended to the pending cut’s set (they are disjoint by construction, each
drain emptying the buffers), the mark advances to the newer prev, the
snapshot bytes are replaced per tenant by the newer capture’s (latest
wins; a tenant absent from the newer capture keeps the older bytes, which
are still cut-consistent at its own horizon), and the epoch becomes the
newer capture’s, which is what makes the coalesced cut fail on any guard
registered between the two captures. A capture that arrives while nothing
is pending fills the slot. The slot bounds metadata, not payload, and
that distinction matters during an outage: coalescing appends drained
batches, so a slot that keeps absorbing captures accumulates acknowledged
records outside the sink’s byte accounting, which is the one place the
bound is measured. So coalescing is itself bounded — the pending cut
holds at most one cut’s worth of batches, capped by the sink’s ceiling
byte figure (ceiling_bytes, the limit the buffers already answer to) —
and a capture that would take it past that ceiling does not coalesce at
all: it parks every batch it drained back into the sink’s buffers as
ready partitions under their accounting, with the same epoch-dated park
§3.1 gives every other park, and advances nothing — the pending cut
keeps its own earlier mark, its own snapshots and its own epoch. Parking
only the excess would break the invariant the whole design rests on: the
pending cut’s mark would have moved to the newer capture’s prev while
some of the frames at or below it sat in the buffers rather than in the
cut’s batches, and run_cut would checkpoint through them. All or
nothing per capture keeps “every frame at or below the mark was drained
into this cut’s batches” literally true, and costs only that the parked
partitions wait for the next cut, which drains them at a mark that does
cover them. The bound
is deliberately the sink’s own, not a new number: RFC 0053 owns the
client-facing limit, and nothing here refuses a client. Cuts are strictly ordered, and a pending cut
inherits the running cut’s outcome. The pending cut was captured while
the running cut’s batches were still in flight, so its snapshot bytes
already fold frames whose only durable copy is the running cut’s batches:
if it installed or stamped first — or after the running cut had failed —
those frames would be suppressed on replay while sitting requeued in the
buffers. So the pending cut never installs or checkpoints before the
running cut’s outcome is known, and when the running cut fails (a
partition’s flush fails and requeues) the pending cut is invalidated:
its snapshot bytes are discarded, its batches — none of them flushed yet,
the task being sequential — are merged into the buffers with the requeued
ones under the sink lock, its mark is withdrawn, and its epoch is spent;
the barrier task then re-captures a fresh cut from the buffers under the
exclusion, which covers everything either cut held. The same rule reaches
the timer’s own cuts through prior_ok (§3.2): a failed publish is
consumed by its requeue, which records the barrier_epoch current at the
requeue, and a cut is valid only if no publish registered before its
capture requeued at an epoch above the cut’s — a cut captured after the
requeue covers the records and is valid, one captured before it is not.
At shutdown the receiver joins the barrier task after the running
run_cut finishes and the pending slot, if filled, has been run once — a
store failure there requeues into the buffers as any cut’s does, and
shutdown’s own final flush covers the requeue. The ingest
turn does no store I/O; the mark invariant is unchanged, because every cut
was captured under the exclusion; and there is one owner of barrier I/O
rather than two. The
rotation hook and the timer are then the same barrier with two triggers,
which is also what lets §3.7’s Journal::rotate fire a rotation from the
timer without a second code path.
The cut is captured under the exclusion; the store I/O is not. Under the
exclusion the timer stops new submissions and waits for the encode phase
of every batch submitted before the cut to finish — not for store I/O. The
unit is the batch, not the worker: a batch is encode-complete when each of
its records has been appended to the buffers or detached into a registered
publish, and the pool already counts exactly that (submit increments the
pending count, BatchGuard decrements it when the worker’s loop over the
batch ends), so quiesce’s unit stays what it is. Waiting on anything
finer is unsound: a worker takes a batch record by record, and
emit_concurrent detaches and publishes partitions one at a time, so a
quiesce that returned once a worker had registered a publish would leave
the rest of that worker’s pre-cut batch unemitted — records at or below the
mark that the cut’s drain cannot see and the checkpoint would stamp past.
What changes is that the worker no longer performs store I/O:
EncodePool::quiesce today waits for a worker even while it is inside a
put_blocking, and the sink’s in-flight guard today covers only
PublishCoordinator::drain_*, not the publish_owned calls
emit_concurrent makes itself, and today there is no publisher to hand
anything to: publish_owned and write_ordered are both synchronous on
their caller. So this RFC adds one, and its lifecycle is stated here and
nowhere else. The publisher is one dedicated thread owned by the
PublishCoordinator — the object that already owns both sinks and runs the
age sweep’s write_ordered — fed by a bounded queue of detached batches.
The worker never blocks on that queue: a bound the worker waited on
would put a stuck PUT back in front of the barrier — the queue fills, the
enqueue blocks the worker, and quiesce_encodes() waits on that worker
while the exclusion is held. So on a full queue the worker parks the
detached partition back into the sink’s buffers as a ready partition
— still under the sink’s byte accounting, excluded from the size trigger
(it is already past it), and taken by the publisher, oldest first, as the
queue drains, or by the next drain or flush like any buffered partition —
and moves on. A park is a settlement with a date on it, exactly like a
requeue: it records the barrier_epoch current when the records re-entered
the buffers, and quiesce_publishes().all_ok(cut.epoch) is false for any
cut captured before that epoch. Without that, parking would be the one way
out of §3.1’s predicate — a pre-cut guard could park its partition after
this cut had already drained the buffers, the guard would settle
successfully, and the barrier would checkpoint over acknowledged records
that exist only in the buffers it no longer holds. Every one of the three
park sites takes the rule: the full-queue park here, the publisher’s
unwind park, and the closed-channel park. A cut captured after the park
drained those records itself and stamps normally. A ready partition is
not a plain record partition: it
keeps its audit_watermark, carried in the buffer entry beside the
records, because parking must not strip the dependency that makes the
records publishable. Every consumer of a ready partition — the
publisher, an age drain, the barrier’s own flush — honours the same
wait-or-write rule §3.1 states below: the events up to that watermark must
be durable before the records are written, and a Drained that includes a
ready partition carries the maximum watermark over the partitions it
took. Parked records are in the buffers, so they are covered by
every cut’s drain and need no publish guard; the queue depth and the parked
partition count are exported (an UpDownCounter and a gauge, registry
names in the one bump) as the mechanism’s only signal — RFC 0053 owns the
client-facing bound, and nothing here refuses a client. Ownership of a
detached batch that does enqueue moves through three hands in a fixed
order, and the first hand is not the worker. append_off_lock takes
the ceiling and size partitions out of the buffers — dropping their bytes
from the accounting — and returns them, all inside one sink-lock section
and before emit_concurrent could register anything; the worker runs
after pool.submit released ingest_bound, so a barrier can capture in
the window where a pre-cut partition is out of the buffers and not yet in
the in-flight set. Taking the exclusion in the worker to close that window
would deadlock against quiesce_encodes, which holds it while waiting for
that same worker. So the publish guard follows the route the BatchGuard
already takes: it is created in submit, under the exclusion, with the
same epoch, and travels with the queued batch; the worker’s detach
consumes that pre-made guard instead of creating one, and a batch whose
records detach nothing releases it unused at the end of the batch. No
detach therefore happens outside a guard’s lifetime: the guard exists
before the turn releases the exclusion, so every partition the batch
detaches is in the in-flight set from the instant it leaves the buffers,
and a cut captured at any point either drains it or waits for it. One
guard per batch covers however many partitions that batch detaches — and
a batch detaches them one at a time, each going to a different queue slot,
so the guard cannot be moved into any single item. It is therefore a
shared completion: an Arc created at submit holding the publish
guard and a count, incremented once per detach and decremented once per
completion (durable, requeued, parked, or dropped), settling the guard
when the count returns to zero and the batch’s encode phase has ended —
so a batch that detaches nothing settles at the end of its own loop, and
one that detaches five settles when the last of the five finishes,
whichever order they finish in. Each Detached item carries a handle to
that completion, which is also why emit_concurrent needs no registration
of its own; the queue carries the handle with the records.
What the queue carries is not a Drained: that value is the audit
buffer’s own snapshot taken under the miner lock beside the records, and a
worker holds neither — it sees MinedRecords after their template events
were already emitted into the audit sink. So the queue carries detached
partitions only: its item is a Detached partition, which holds the
detached records with their audit watermark and a handle to the batch’s
shared completion. A Drained never enters it. The age sweep and the
barrier keep writing theirs through write_ordered, synchronously on
their own tasks, unchanged. The barrier must: run_cut needs its flush’s
outcome (cut_ok) before it decides whether to stamp. The sweep stays
there so that a write_ordered unwind keeps happening on the sweep’s own
task, where #795’s stop-on-panic (§3.2) stops it and its JoinError
counts cadence_panic; on the publisher thread the sweep would tick on
under the latch, and whether the sweep survives an unwind is RFC 0054’s
(publish unwind safety) decision to make, not this one’s.
An earlier revision gave the item a Drained arm as well; slice D shipped
without it (#867) because no producer fitted §3.2, and #875 settled the
question by dropping it. The item carries the audit-sink position
observed under the miner lock at detach time — the count of events the sink has accepted for that
tenant when the partition left the buffers, which is at or above every
event the partition’s records produced, since emission precedes the
append. Before writing a detached partition the publisher requires the
events up to that watermark to be durable, not merely gone from the
buffer: a concurrent age drain can be holding exactly those events in an
unfinished store write while this publisher sees an empty buffer, and
publishing then would put records in Parquet ahead of their template
events — the failure issue #302 fixed. So the audit sink tracks
audit_durable_through, advanced only after a store write returns
success — and advanced only over a contiguous prefix, which is the
part a scalar alone cannot give: audit writes run unlocked and overlap,
so a later write completing first must not carry the mark past an earlier
one still in flight. The sink therefore keeps the set of in-flight ranges
beside the mark and advances audit_durable_through to the end of the
longest completed prefix with no in-flight gap below it; a write that
completes above a gap records its range as done and moves nothing until
the gap closes. A publisher whose audit_watermark is at or below
audit_durable_through proceeds; one above it either writes the missing
events itself, when it still holds them in the buffer (under the sink
lock, ordered ahead of its records, the audit-first step write_ordered
gives Drained), or waits on the sink’s condvar, which the mark’s
advance notifies, re-testing its watermark on each wake until the mark
covers it or the covering write fails. A failed in-flight write leaves
audit_durable_through where it was and requeues its events, and every
dependent record publish is refused and requeues with it — the same
refusal write_ordered already makes when its audit half fails, now
reaching publishes that did not take the events themselves. The publisher then settles the
guard on the outcome: durable, or requeued into the
buffers under the sink lock on a transient failure (where the next
drain or flush covers it), or quarantined per record on a poison rejection,
or, on an unwind, latched (§3.1’s guard rule) and settled. An audit write
that fails transiently is the same refusal write_ordered already makes:
the records are not published, they requeue, and the guard settles on the
requeue.
The publisher thread needs a runtime handle, or it silently drops the
graph feed. write_ordered hands every published batch to the RFC 0047
§3.3 flush-cadence graph emitter through
tokio::runtime::Handle::try_current(), spawning the update off the
publish path — and a bare std::thread has no current runtime, so
try_current fails and the update is skipped with no error. Today that
call always runs on a runtime worker; moving publishes to a dedicated
thread would make every detached partition skip graph emission on exactly
the deployments that configure OpenFGA. So the publisher carries a
Handle cloned at construction and enters it (handle.enter()) for the
duration of each write, which makes try_current succeed and leaves
write_ordered untouched. The alternative — queueing graph updates back
to the runtime through a receiver-owned channel — was rejected as a second
queue with its own shutdown and overflow semantics for a feed that is
already fire-and-forget; entering the handle is one line and keeps one
code path for both callers. The verification plan covers it: the publisher
test asserts the emitter is fed for a detached partition, which fails on a
thread that never entered a runtime. The worker moves
to its next record as soon as the enqueue returns, so it is never inside a
PUT, the quiesce waits on encodes alone, and the PUTs settle outside the
exclusion under quiesce_publishes like any other. At shutdown the order
is the one the barrier already needs: stop the workers, quiesce the
encodes, quiesce_publishes (which now also means the queue is empty),
then join the publisher — a publish still queued at that point is never
dropped, because the join waits for it. The publisher’s own unwind is
defined, because a bare thread panic would strand every guard still in the
queue and quiesce_publishes with them: the thread body runs each publish
under catch_unwind, so a panic settles only the failing batch through
its guard’s drop — latched (§3.1’s guard rule; its records stay in the WAL
until a restart re-mines them, the stage-1 posture for every unwind) — and
the same thread then drains every batch still queued back into the
buffers as ready partitions, each park recording the current
barrier_epoch (§3.1’s park rule) and releasing its guard as its records
land, and only then exits; the coordinator respawns it on the next enqueue.
quiesce_publishes therefore always terminates: every guard is settled
by a durable write, a requeue, a park, or the failing batch’s own drop.
The worker’s side of that window is defined too, because a send can fail
after the publisher drained the queue and dropped the receiver, and the
batch is then in no panic at all — a bare guard drop would settle it
silently and the checkpoint would stamp over records in neither the
buffers nor the store. So a failed send returns the item to the
worker, which parks the batch into the buffers as a ready partition
under the sink lock before releasing its guard — the same park the full
queue takes, recording the current barrier_epoch with it — and then asks
the coordinator to restart the publisher. Two rules make that safe.
First, the slot is closed before the queue is drained: a publisher
about to exit marks the slot closed under the publisher-slot mutex and
only then drains its receiver, so a worker cannot succeed at an enqueue
into a channel that is about to be abandoned — it observes the closed
mark, parks, and joins the restart gate below. Second, the queue item’s
destructor is the backstop: dropping an item that never settled
requeues its records into the buffers under the sink lock and settles its
share of the batch’s completion, so even an item lost to a drop no code
path anticipated is data-safe rather than silently gone. The interleaving
— enqueue racing the drain — is an acceptance leg, not an argument.
Ownership is single at every instant: the worker parks first and only
then retries, and a retry that succeeds removes the parked partition
from the buffers under the same sink lock that put it there, so the
records are in the buffers or in the queue and never in both. A retry that
fails leaves the park standing and loops back through the gate. That ask is a claim, not a
spawn, because several workers can observe the same closed channel: the
coordinator holds the publisher slot — the channel sender and the thread
handle — behind one mutex, and a worker that finds the channel closed
takes the lock and re-reads the slot. The claimant is whichever worker
finds the slot still holding the closed sender: it creates the new
channel and thread, stores both, and releases. Every other worker finds a
sender that is no longer the one it failed on, and simply retries its send
against it — its batch is already parked, so a retry that fails again
parks nothing twice and loops back through the same gate. The queue is
owned by the slot throughout: the old receiver dies with the old
publisher, which has already drained it into the buffers (§3.1’s unwind
drain), so no item is stranded in a channel being replaced, and an item
enqueued after the new sender is stored is the new publisher’s. No
path releases a publish guard without the records being durable,
requeued, or parked.
RFC 0053’s requeue-on-unwind replaces only the failing batch’s arm of
this; the thread, the queue, the drain-on-unwind and the
guard-before-enqueue order stay. The barrier — reads the mark, drains
both sinks into owned batches, registers that publish as in flight, and
serialises each tenant’s miner snapshot state — the bytes write_snapshots
would write — then releases. The flush of those batches, the snapshot file writes from
those bytes, and the checkpoint all run outside the exclusion. Snapshot
installation is serialised even so: every snapshot file carries that
tenant’s own last folded frame from the cut (per-tenant marks, never the
checkpoint’s global mark — a tenant with no frame near the cut must not be
stamped past frames it has not folded). Those marks have a receiver-side
source, since the WAL ledger says which frames exist and not which were
folded — a frame whose group fsync failed is in the WAL, never acknowledged
and never mined: each ingest turn advances the tenant’s folded horizon
to its own frame offset after its mining step, the cut captures those
horizons, and the WAL ledger stays the eligibility source only. The field
that carries the mark is the artefact’s existing wal_high_water, and its
meaning changes: today every tenant’s artefact holds the one global mark,
after this RFC it holds that tenant’s own folded horizon. The snapshot
format version is bumped (SNAPSHOT_VERSION 1 → 2) so the two readings can
never meet in one reader: a version-1 artefact takes the existing
unknown-version path — it is never a horizon; its tenant is discarded, logged
by tenant and version byte, and fully replayed from the WAL — and that path
is safe for one reason, which startup checks rather than assumes (§3.2’s
RECLAIM record): a version-1 artefact can only predate this RFC, nothing
was ever reclaimed before it (#793), so its tenant has no reclaim entry and
pins at its oldest surviving frame like any tenant without a snapshot.
Reading the old global mark as a folded horizon would in fact be sound —
that mark was stamped only after every tenant’s frames at or below it had
been drained and mined under the miner lock — but one byte with two meanings
across two versions is a rule the next change breaks, and pre-production a
persisted layout is broken rather than dual-read: the implementing PR
carries the ! marker for the version bump, and no migration tooling is
written. Each writer
uses a unique temp
name (<stem>.<mark>.snap.tmp, where <stem> is the percent-encoded tenant
id snapshot_store already derives for <stem>.snap — the raw id is not a
safe path segment — and <mark> is the byte offset; since today’s one fixed .snap.tmp could
be truncated or interleaved by a concurrent cut before either rename — and
the writer unlinks its own temp on any write, fsync or rename failure, with
load_all_durable() removing any *.snap.tmp left by a previous process,
so a failed barrier cannot leak one snapshot per attempt), the whole temp
write, fsync and rename runs under a snapshot-install lock
shared by the timer, the rotation hook and shutdown, and a writer installs
only when its mark is not below the installed snapshot’s — so an older cut
can neither overwrite a newer snapshot nor share its temp file, and the
in-memory horizon advances only on an install that happened.
Capturing the snapshot bytes at the cut is what makes S cut-consistent: it never
runs past the mark, and every row at or below it is in the detached
batches, so a turn admitted after the cut is neither in the snapshot nor
suppressed by the max(X, S) gate on replay. The scope of that guarantee is
stated, because it is narrower than “no duplicates”: a frame published
after the cut and before the next checkpoint — by a size or age flush —
sits above max(X, S) and is re-published on replay. That is RFC 0008 and
RFC 0014’s existing at-least-once contract above the checkpoint, unchanged
here and not claimed otherwise; a published high-water mark persisted with
every flush would bound it and is recorded in §7 as the follow-on. Detaching only the file I/O
keeps that property, and it means a slow or unavailable object store stalls ingest
for the length of a drain and never for a PUT; holding the exclusion across
flush_then_snapshot’s PUTs would let every retry during an outage recreate
the local ingest outage, and would keep RFC 0053’s admission bound from
running at all. The mark stays correct because every frame at or below it
was drained into the detached batches, and every append after the release
lands above it. The checkpoint is taken only after the detached publish
and every publish registered before the cut have succeeded — quiesce_publishes
returns the outcome of those registered publishes rather than merely
waiting, and a failure in any of them means no stamp even though the cut’s
own flush succeeded — by re-taking the journal mutex for the stamp alone; a
failed publish requeues (RFC 0053 §3.2 makes that hold on unwind too) and no
stamp happens. A failure is not sticky: the requeue consumes it by
recording the barrier_epoch current when the records re-entered the
buffers, and only a cut captured before that epoch — one whose drain could
not have seen them — is refused by it. A cut captured after the requeue
drained them itself and stamps normally; without that rule one transient
failure would refuse every later cut for the life of the process.
Two cadences, so the barrier is not a force-all flush cadence. A cut
drains every buffered partition, and running it on the 60-second
housekeeping_secs would create a sub-target Parquet object per low-volume
partition per tick — RFC 0014’s small-file hazard reintroduced by the
reclamation path. So the barrier (cut, flush, snapshot, checkpoint) runs on
its own barrier_secs, defaulting to the sink’s age trigger (300 s) and
living beside housekeeping_secs in WalConfig, surfaced by wal_config()
like the other WAL knobs, validated to at least one second, and owned by the
receiver’s barrier task. For a partition holding data that old the age
trigger would flush it anyway; a partition that received its first data just
before the tick is written young, so the barrier does add at most one
sub-target object per active partition per barrier_secs beyond RFC
0014’s policy — the explicit trade-off for a checkpoint that needs every
buffered frame durable, and RFC0052.3 records the objects written per
interval so it is measured rather than assumed. A busy partition still
flushes on size between barriers. Housekeeping runs on housekeeping_secs
against the last checkpoint, and an eligible closed segment is reclaimed
by the first capped pass that reaches it — at most barrier_secs + housekeeping_secs after its publication when the backlog is within the
cap, later ones waiting through further passes, and segments a pinned
tenant or a failed snapshot holds retained by design. The current append
segment is never unlinked, and rotation is checked only on an append
today, so the barrier task also calls Journal::rotate(Discretionary) when the current
segment’s age exceeds segment_age_secs and it holds a frame — an idle
node’s last segment then rotates, and its bytes are reclaimed within
barrier_secs + housekeeping_secs + segment_age_secs, which RFC0052.3’s
idle leg asserts. That rotation is ordered with the cut, not beside it:
Journal::rotate(Discretionary) returns nothing the cut could use, so the idle path
rotates first, under the barrier exclusion, then captures the cut, and
the cut’s mark is what every cut’s mark is — last_durable, the last
acknowledged turn’s own frame offset — never the rotation boundary. The
exclusion is what makes the order mean something: no turn can run between
the rotate and the mark read, so every frame at or below the mark is in the
closed segment and every frame appended after the release is in the new
one, above it. A frame the WAL holds but never acknowledged — a turn whose
group sync failed — is never a mark, and if an earlier such frame sits
below a later acknowledged turn’s offset it is suppressed on replay like
any frame below the gate, which loses nothing acknowledged: the client was
told to retry it. RFC0052.14 asserts the rotate-before-cut order and the
mark’s source.
Acquisition order is fixed, or the two locks deadlock. ingest_bound
takes the miner lock today before the pool submission and the last_durable
update; if it took the miner lock first and then waited for the exclusion
while the timer held the exclusion and waited for the miner lock, neither
would proceed. So every ingest turn acquires the exclusion before the
miner lock, the timer follows the same order — exclusion, then miner lock,
then the last_durable mutex — and the exclusion is never requested while
the miner lock is held.
The age sweep participates in the same exclusion. It drains under the
miner lock alone today, so a sweep could begin after the timer’s quiesce and
before its stamp, leaving a drained-but-undurable batch outside the buffers
that the barrier then reads as empty; quiesce_publishes waits only for a
sweep already in flight and prevents no new drain. The sweep’s
drain therefore takes the exclusion in shared mode — before the miner
lock, which means the drain helper changes rather than the shared hold being
added inside today’s with_miner(drain_aged): taken inside it, the sweep
would hold the miner lock waiting for the shared exclusion while the timer
held the exclusive lock waiting for the miner, and both would deadlock — held only across the
drain and the in-flight registration, and released before the off-lock
publish, whose PUTs can block on the store — and the timer takes it
exclusively: no sweep drains inside the barrier, a drain in progress
completes before it, and quiesce_publishes then waits for the publishes
already registered. A slow or unavailable store stalls ingest for the length
of a drain, never of a PUT, which is the WAL-local durability this RFC
exists to preserve.
The mark is then read inside that turn and after the quiesce — last_durable()
at that point, the offset the receiver’s acks are gated on — so every frame
at or below it has finished its turn: mined under the miner lock the barrier
now holds, drained, and its publish waited for. Frames above it may well
exist — appended and synced by turns admitted after the cut, waiting on the
exclusion — and that is the point: they have not touched the miner state the
snapshot serialises, so the snapshot excludes them and replay re-mines them.
Reading the mark before the quiesce, or outside the turn, reopens the window
from the other side: a frame at or below the mark whose encode or publish is
still in flight.
And the stored mark must not over-cover. The group-commit sync reports
the WAL’s EOF, and CommitCoordinator::flush captures covered_seq before it
locks the journal, so waiter A’s outcome can carry an offset that includes
frame B, appended later by a turn that has not yet run — while B is made
durable by that same outcome and will be acknowledged once its own turn
completes. If A stored that EOF as last_durable, a barrier between
A’s turn and B’s would checkpoint across B, and recovery would suppress a
frame nothing had mined. So last_durable advances to the turn’s own
frame offset — the WalOffset its append returned, carried through
CommitOutcome beside the durable EOF — and never to the EOF itself. Turns
are sequential, so the stored mark is a contiguous-success high-water by
construction, and that is the only mark the barrier reads.
Contiguity is over acknowledged frames, which is the property §3.4 needs. A
turn that fails still releases the gate — IngestGateGuard drops on every
exit — so its frame can sit below a later successful turn’s mark, and a
checkpoint at that mark covers it. That is safe: a failed turn’s batch was
never acknowledged (the handler answers 5xx and the client re-sends), so
suppressing its frame on replay loses nothing acknowledged, and the re-sent
copy is a fresh frame above the mark. Tracking the highest contiguous
successful sequence instead would hold the checkpoint back for a frame
nobody was promised.
The cost is explicit: ingest stalls for the cut’s duration — a quiesce and
two drains, no I/O — once per barrier_secs, comparable to the stall
rotation already imposes, now on a timer. barrier_secs is therefore the
knob trading reclamation latency against that stall; housekeeping_secs
schedules only the capped unlink pass, whose own stall §3.7’s per-pass cap
bounds.
See §3.7 for the ownership path and the exact signatures, which the barrier does not have today:
if barrier_succeeded and high_water is Some(mark):
journal.checkpoint(mark) // §6.7, monotonic; Result
on Err -> log, do NOT advance; nothing past the PREVIOUS mark is reclaimed
Three properties come free from existing code and must not be reimplemented:
- Monotonicity and idempotence —
checkpointrejects a regression and no-ops on a re-assert, so a repeated stamp at the same mark is safe. - Fail-closed — on a sidecar write error the in-memory checkpoint is not advanced, so nothing past the previous mark is reclaimed — segments already eligible under that mark still are (RFC0052.1) — rather than risk a post-crash duplicate.
- Skip-on-retain — when either sink retains anything,
flush_then_snapshotreturnsfalseand no checkpoint is attempted. The WAL keeps the frames and the next start re-mines them.
A cadence panic latches the barrier until RFC 0053. #795 stops the age
sweep after a panic, which was a safe interim only while nothing else could
stamp: this RFC’s timer can, and after a panic inside write_ordered the
drained batches are gone and the publish guard has dropped, so a timer
barrier would see empty buffers and no in-flight publish and stamp past
records that never reached Parquet. So a panic on any path that holds
records outside both the buffers and Parquet sets a cadence_failed latch —
set on the unwind path itself, by a guard’s Drop that runs before the
publish guard is released, never by the joining task observing the panic
later, or a barrier could acquire the exclusion, see empty buffers and stamp
in that window. Three paths hold records that way, and all three set it:
the age sweep’s step, from the in-flight registration drain_aged takes
through write_ordered — one guard wraps the whole step, so a panic
mid-drain, with records in a half-built batch, latches too; the barrier’s
own detached flush; and an encode worker. The worker is the one the
earlier drafts missed: emit_concurrent runs after ingest_bound has
acknowledged the frame, and the pool’s BatchGuard today only settles the
pending count on unwind, so a worker panic mid-batch leaves quiesce seeing
no pending work while the batch’s unemitted remainder is in neither the
buffers nor Parquet — a barrier after it would stamp across the frame. The
BatchGuard therefore sets the latch when it drops unwinding, before it
decrements, so worker unwind feeds the same failed-cut path as a cadence
panic in this stage; RFC0052.10 is not gated on RFC 0053 for it. The
signal has to be concrete, because nothing else can carry it: the pool’s
workers are detached std threads whose join handles are kept only for
drop, so a worker panic is invisible to the receiver while it serves. The
latch is two shared atomics owned by the pipeline (SharedPipeline holds
the Arc) and handed at construction to the encode pool, to the sink’s
publish guards and to the barrier task: barrier_epoch: AtomicU64, the
number of the next cut, incremented at every cut capture under the
exclusion; and one packed AtomicU64 carrying the failure state —
the failed epoch in the high 32 bits, a failure generation in the low 32 —
rather than two words. Its clear encoding is u32::MAX in the high half
with the generation zero, and that matters: a default-zero word would
read as “epoch 0 failed”, which is at or below every cut’s epoch and
would refuse every cut on a node that had never panicked. Epoch
u32::MAX is reserved and never assigned to a cut — barrier_epoch stops
before it, as the overflow note below says — so the sentinel cannot
collide with a real failure, and a clear restores exactly that word
(sentinel high, generation zero), not merely a high half. Two words would not be safe to clear: a report
landing between a reader’s two loads can be missed, and a clear written
against the epoch alone would then erase a failure that was never acted
on. Packed, the operations are exact. A guard reports with a CAS loop
that lowers the epoch to its own and bumps the generation in the same
word, so no report is lost to a concurrent one. A cut captures the
state with a single acquire load and decides against that word. A
clear is a CAS on the exact word the cut captured, so a report that
landed in between makes the CAS fail and the latch stays set. Stage 1
never clears — only a restart does, as §3.1 says below — so the clear arm
exists for RFC 0053 §3.2, which defines when a cut may perform it; what
stage 1 needs from the packing is that its capture and its two checks read
one indivisible value. The generation wraps at u32::MAX and that is
harmless: it is compared only for equality inside a CAS, never ordered,
so a wrap can at worst make one clear spuriously fail, which leaves the
latch set — the safe direction. The epoch half bounds a node at 2^32 cuts,
about 40,000 years at the 300-second default, and a node that somehow
reached it would stop stamping rather than wrap into a stale comparison. A guard reads barrier_epoch when
it is registered — submit for a BatchGuard, begin_publish for a
publish guard, both under the exclusion, so the read is ordered against the
capture: a guard registered before cut E’s capture carries E, one
registered after it carries E + 1. For the pool that means the
BatchGuard is constructed in submit, with its epoch, and travels
with the queued batch — today the worker constructs it after dequeue,
and a batch queued before cut E’s capture but dequeued after it would
then read E + 1 and a later panic in it would fail the wrong cut.
submit already increments the pending count under the exclusion; the
guard and epoch are assigned there too, and the worker only drops what it
received. Its Drop tests
std::thread::panicking() and, if so, lowers failed_epoch to its own
epoch (a CAS keeping the minimum) with Release ordering before it
decrements its count — the pending count for BatchGuard, the in-flight
count for the publish guard — and it is that decrement, under the count’s
mutex and condvar, that wakes quiesce or quiesce_publishes.
flush_then_snapshot loads failed_epoch with Acquire at both of its
checks, and a cut with epoch E fails when failed_epoch ≤ E. The
epoch is what keeps the latch honest about scope: a guard carries the
epoch of the first cut whose mark could cover its records, so a panic in
a publish registered after cut E (its frames are above E’s mark,
§3.1’s invariant) fails no cut at or below E — the cut in flight
proceeds — and fails every cut from E + 1 on. The race contract follows
from the order: a barrier that observes a count at zero after a panic
observed a decrement that happened after the store, so it observes the
epoch; there is no schedule in which the count settles and the latch is
still clear. RFC0052.1 holds that race directly, with the panic injected
in a worker and the barrier started concurrently. In this stage the
dropped batch’s records exist only in the WAL, so nothing can clear the
latch short of re-mining them: every barrier with an epoch at or above
failed_epoch neither checkpoints nor snapshots — the frames stay in the
WAL and a restart replays them — the state is exported (RFC0052.7) and
only a restart clears it. That is the conservative behaviour, stated as
such; RFC 0053’s requeue-on-unwind is what makes a later cut able to clear
it, by draining the requeued batch, and it defines that clear. The check lives inside flush_then_snapshot,
the one function the timer, the rotation hook and shutdown all call, so
every stamping caller honours it rather than the timer alone, and it is made
twice: before the cut, and again immediately before the snapshot install
and the checkpoint, after quiesce_publishes has returned. The second check
is not redundant. A publish registered before the barrier began can panic
while the barrier is waiting on it, and the latch it sets lands after the
first check; a latch observed at the recheck is a failed cut — no
install, no stamp; the cut’s own batches are in the store already and the
WAL still holds every frame, so a restart replays them into the max(X, S)
gate like any other. The same unwind guard also records that publish’s
outcome as failed, so quiesce_publishes().all_ok() is false for it
independently of the latch: the recheck defends the ordering, the outcome
defends the data, and either alone refuses the stamp. RFC 0053’s
requeue-on-unwind is what removes the latch, by making every one of those
panics lose nothing to stamp past.
A snapshot write failure is deliberately not a checkpoint blocker:
flush_then_snapshot logs it and still returns true, because the data
is in the store and the snapshot only governs replay depth. The
truncation floor below is what keeps that safe for the WAL. It is safe for
Parquet only if recovery honours the checkpoint on the Parquet side: after a
restart, frames at or below X may be re-fed to the miner when the
snapshot lags (S < X), but must never be re-published to the record sink,
or every row between S and X lands in Parquet twice. recovery.rs
describes exactly that split and today gates only the miner on S; the
Parquet-side gate on X is a requirement of this RFC, not an assumption, and
RFC0052.10 asserts it — a restart after a failed snapshot write and an
advanced checkpoint produces no duplicate rows.
3.2 Housekeeping on the timer that already exists
WalConfig::housekeeping_secs (default 60) is set in every test and
read by nothing. RFC 0008 §6.7 says “the timer lives in the caller”.
The caller is the receiver role, on its own interval:
every barrier_secs (default: the sink age trigger, 300 s):
if failed_epoch <= barrier_epoch: skip // §3.1's latch; housekeeping below still runs
// (RFC 0053 §3.2 replaces this guard with
// proceed-and-decide once its requeue exists)
cut = with_barrier_exclusion: // a NEW pipeline lock, not the miner
// lock and not the gate — see §3.1
quiesce_encodes() // the barrier's prologue: every pre-cut
// batch encode-complete (§3.1)
mark = last_durable() // read AFTER the quiesce, INSIDE the turn
batches = drain both sinks // owned; registered as in flight
snaps = serialise miner snapshots // cut-consistent S (§3.1)
epoch = barrier_epoch.fetch_add(1) // this cut's epoch (§3.1)
(mark, batches, snaps, epoch) // release the exclusion here
run_cut(cut)
on a rotation (inside the ingest turn that observed the segment change):
cut = capture as above, mark = prev // the turn already holds the exclusion;
// no store I/O in the turn (§3.1)
hand cut to the barrier task // one pending slot: fills it, or coalesces
// into the pending cut (§3.1); never blocks
run_cut(cut):
cut_ok = flush(cut.batches) // store I/O OUTSIDE the exclusion
prior_ok = quiesce_publishes().all_ok(cut.epoch)
// publishes registered BEFORE the cut: outcome,
// not a wait; always evaluated, so a failed cut
// flush cannot skip their outcome and requeue
// path. False iff one of them failed and requeued
// at an epoch ABOVE cut.epoch — a requeue this
// cut's drain could not have seen (§3.1); a
// publish registered after the cut holds only
// frames above cut.mark, so it is neither waited
// on nor stamped past
ok = cut_ok and prior_ok and failed_epoch > cut.epoch
// the recheck (§3.1): a panic in a guard of
// this cut's epoch or lower is a failed cut;
// a later epoch's panic fails later cuts
if not ok and pending slot is filled: // strict ordering (§3.1): the pending cut
invalidate pending // folded this cut's frames; discard its snaps,
// merge its batches into the buffers, withdraw
// its mark, re-capture under the exclusion
if ok and cut.mark is Some(_):
install_snapshots(cut.snaps) // under the install lock, monotonic in mark;
// failure logged, not a blocker (§3.1); with
// no mark the previous snapshots stay
if ok and cut.mark is Some(m):
coordinator.checkpoint(m) // re-takes the journal mutex for the stamp alone
on Err -> log, do NOT advance (fail-closed, §3.1)
every housekeeping_secs (default 60 s), in its own task:
coordinator.maintain(snapshot_horizons(), config.max_unlinks_per_pass):
plan = journal.lock().housekeeping_prepare(horizons, cap)
// ledger half under the guard: horizons applied
// (capped), floor derived, partials then segments
// popped and marked reclaiming (§3.2, §3.7)
outcome = file half on plan // guard RELEASED: RECLAIM write + fsync + rename +
// parent fsync, then unlinks, then parent fsync
journal.lock().housekeeping_commit(outcome)
// ledger half under the guard: removed entries
// leave the ledger, failed ones return/stay (§3.2)
on Err -> log; the next pass retries (nothing was unlinked past the bound)
Housekeeping does nothing until the witness exists. The two timers are
independent, so on a legacy root the housekeeping timer can fire before the
first barrier has upgraded the sidecar — and reclaiming there would unlink
segments under a version-1 checkpoint, the one shape §3.2’s matrix reads as
“nothing was ever reclaimed”. So a pass whose root still has a version-1
CHECKPOINT, or no RECLAIM record, plans no segment and writes no
record: it returns a progress with zero segment counts, counted as a
skipped pass with its reason on the existing cadence counter (error.type
carries the reason, §3.5’s rule). The gate is on segment planning and the
record write, and on nothing else: the partial sweep still runs on every
pass, witness or not. A .wal.partial is debris from a rotation that
never installed a segment — no reader has ever been able to depend on it,
no reclaimed_through accounts for it, and removing it loses nothing —
so gating it would let rotation debris created before the first checkpoint
survive forever, against RFC0052.4’s one-pass clearance and RFC0052.16.
Partials are popped first within the cap as always, and the cap’s
remainder simply goes unused while the gate holds. The first barrier’s
checkpoint establishes the witness and the next pass reclaims segments
normally. This is not a new gate so much as a narrower one: today’s
housekeeping is already a no-op before the first checkpoint.
The timers have the age sweep’s shutdown lifecycle but not its task.
Both loops are owned by the receiver, signalled by the same shutdown watch
the sweep observes, and joined before the final flush and snapshot, in
the order ReceiverHandle::shutdown already joins the sweep; they hold no
pipeline or journal handle after the join. They are separate tasks from
the sweep, and from each other: the sweep’s stop-on-panic (#795) must not
stop reclamation, and a panic in a barrier step — or in the sweep’s own
step, or in an encode worker (§3.1’s three latching paths) — sets
cadence_failed and leaves the housekeeping loop running housekeeping-only
passes, which is the branch the pseudocode’s skip relies on. That promise
needs an owner, since the per-batch guards cover only what a batch holds
and a panic between them would still kill the task: each task runs every
tick under catch_unwind. A barrier tick that panics stores
failed_epoch = min(failed_epoch, this tick's epoch) — the same CAS the
guards use, so the cut and every later one are refused until §3.1’s clear
— invalidates the pending slot by the rule above, and the task takes the
next tick. A housekeeping tick that panics counts on the existing cadence
counter with error.type = cadence_panic (§3.5’s naming rule: the class
rides error.type, not a new metric) and takes the next tick; any
uncommitted plan is re-planned by the next housekeeping_prepare, which
is exactly the case §3.7 already defines. At shutdown a JoinError from
either task is logged and read as a failed cut — no stamp, nothing
assumed drained — rather than as a clean join. The sweep keeps #795’s
stop-on-panic until RFC 0054 (publish unwind safety) makes it
survivable, and keeps its writes on its own task for that reason (§3.1: the publisher queue carries detached
partitions only); what changes here is
that stopping the sweep no longer stops reclamation or the barrier. The sweep’s
panic is the one #795 already stops the sweep on; what this RFC adds is that
the same unwind now latches before the sweep’s publish guard drops, so the
barrier that #795 could not foresee cannot stamp past the batch the panic
dropped, whether the barrier was waiting on that publish or arrives later. A timer that was not joined at
shutdown could race the shutdown reclamation or keep the WAL alive past the
handle, which is why the ordering is part of the design rather than of the
implementation.
retain_floor is the MINIMUM over per-tenant snapshot horizons, not the
latest one. write_snapshots persists one snapshot per tenant and can
partially succeed, so there is no single “latest” mark: taking the highest
would let housekeeping unlink frames above a lagging tenant’s horizon, and
that tenant’s miner state could then not be rebuilt. CLAUDE.md §3.7 is
explicit that every path touching data is tenant-scoped, and a per-node floor
is exactly the bolted-on form it forbids.
The minimum is already this codebase’s stated contract, not a new invention —
recovery.rs’s stale-gap detector names it:
the §6.7 retain floor (min over tenant horizons) keeps any segment holding frames above the floor, and a lagging tenant’s own
S.segmentis protected by its own membership in the min
Under tenant-aware retention that argument survives only if each tenant’s
snapshot horizon S is that tenant’s own last folded frame offset, not
the cut’s global mark — a global mark’s segment can hold none of the
tenant’s frames and be legitimately unlinked, and the detector would then
report external mutation for S < X. So snapshots record per-tenant
horizons, and the detector is restated on the RECLAIM record rather
than on segment presence, because under per-tenant horizons S < X is the
normal case and S’s own segment is legitimately unlinked once the
tenant’s horizon covers it (§3.2’s inclusive rule). Today’s predicate —
S < X and S.segment unseen — would fire on every idle tenant after its
last segment is reclaimed. The amended detector asks the record instead:
S.segment absent is explained when the tenant’s recorded
reclaimed-through is at or above S, and it is S > reclaimed-through
with the segment absent that is external mutation. The record’s own
comparison (a restorable horizon below its reclaimed-through halts) is
the other half of the same check, so the two read one witness.
That detector’s no-false-positive property depends on the minimum, so the earlier draft’s “latest” wording would have broken stale-gap detection as well as losing data.
A tenant with WAL data and no valid snapshot pins the floor at its oldest
surviving frame. It does not halt reclamation outright — an earlier draft
said “reclaim nothing”, and with shared segments and tenant churn that is a
permanent halt: a tenant that wrote once and whose snapshot never landed
would block every other tenant forever. What hazard #5 actually requires is
that that tenant’s frames survive, since with no snapshot a restart must
re-mine all of them. So eligibility is per segment and tenant-aware,
not a single global bound — a global “highest offset below the minimum”
rule would let one unsnapshotted frame pin every later segment, even those
holding only other tenants, which is the permanent growth this section
exists to end. The rule, stated once: a closed segment is reclaimable when
its highest offset is at or below the checkpoint (inclusive, a
post-append horizon), and for every tenant with a frame in it that tenant’s
last offset in that segment is at or below its valid horizon —
inclusive at the horizon, because the horizon frame is the last frame the
snapshot folded and the snapshot recording it is durable: SnapshotHorizons
is built from the SnapshotLedger, which holds only marks whose install
returned Ok after the parent fsync, so every horizon housekeeping_prepare
ever sees is a durably installed one, and “inclusive once the install is
durable, strict while it is not” collapses to inclusive. An earlier draft
compared strictly so that S‘s segment stayed present for the stale-gap
detector; that left the segment holding an idle tenant’s horizon frame
permanently ineligible — the idle node RFC0052.3 requires to reclaim its
last segment could not — and the detector no longer needs the segment,
because the RECLAIM record (below) says why it is gone. A pinned tenant
has no horizon, so exactly its own segments are retained — the pin is
strict: a pinning tenant’s last offset in a segment at or above its pin
is a frame the tenant needs, and equality can never unlink it — while
segments holding only other tenants’ covered frames are reclaimed whatever
their offset. RetainFloor is the reported summary of that rule (the
minimum over horizons and pins), not the predicate. The pin lifts the
moment a valid snapshot for the tenant is written, since its horizon then
replaces the pin. So the three comparisons are: checkpoint inclusive,
horizon inclusive, pin strict.
RetainFloor::Min carries no comparison of its own — it is the reported
minimum, and today’s min(checkpoint, floor) bound in Wal::housekeeping,
which compared a segment’s highest offset inclusively against that
minimum, is replaced by the per-segment rule rather than kept beside it.
None is passed only where no snapshot consumer exists at all.
The ledger is WAL-owned, so the floor is derived where the frames are.
Neither the Journal API nor RecoveryReport could otherwise tell the
receiver which tenants have surviving frames or at what offset. The WAL
learns a frame’s tenant from the TenantOtlpBatch prefix it already
frames in append_batch, and rebuild_ledger() rebuilds the ledger
from every surviving frame’s prefix at the end of recovery; the receiver
passes only what it knows — the per-tenant snapshot horizons, or “no
consumer” — and the WAL derives RetainFloor from the two and reports it.
The accounting is frame-kind-specific: only TenantOtlpBatch frames carry
tenant membership and drive the per-tenant horizon rule, because they are
what a restart re-mines. AuditEvent frames are governed by the checkpoint
alone — their consumer is the audit sink, which the barrier drains before it
stamps, so an audit frame at or below X is published and reclaimable and
one above X is retained by the checkpoint rule with no snapshot horizon
involved; a segment holding only a pinned tenant’s audit frames is
reclaimable once below X, since the pin protects re-mining input and audit
frames are not that.
Membership is per surviving segment, so the ledger has a lifecycle. Each
segment carries, per tenant, the WalOffset of that tenant’s first and
last frame in it — the first is what Pinned.offset needs, the last is
what the eligibility rule compares the tenant’s horizon with, since a
horizon between the two must not unlink the later frames; a membership set
alone could recover neither, and a segment-wide boundary would be a
different contract — rebuilt at open from the recovery walk, the last
updated on every live append; a tenant’s oldest surviving frame is its
first offset in its oldest
surviving segment. A tenant leaves the
ledger when its last surviving segment is unlinked, which can only happen
once its snapshot horizon has passed those frames; nothing else removes it,
and nothing needs to. recover returns entries only for tenants that have
a .snap, so without this ledger an empty or partial snapshot set would be
indistinguishable from “no consumer” and read as None.
And the floor may only be derived from snapshots known to be durable.
snapshot_store::write renames the new snapshot into place before its
parent-directory fsync, so a failure there leaves the file visible to this
process while the write returned an error. A floor derived by listing .snap
files would then trust a horizon that may not survive a crash, and housekeeping
would unlink segments on the strength of it — losing exactly the frames the
floor exists to retain.
So the derivation uses the in-memory record of snapshots whose write returned
Ok, not the directory contents. That record has an owner: a receiver-side
SnapshotLedger, restored from load_all_durable() at startup, updated by
write_snapshots’ per-tenant report — returned instead of
short-circuiting on the first error into a single Result<()> — so it
advances exactly the tenants whose new snapshot completed and keeps the
previous durable horizon for the rest, and read as the SnapshotHorizons
handed to maintain. No path re-reads the files after a failed fsync. A tenant whose snapshot write failed keeps
its previous durable horizon if it has one and otherwise pins the floor at
its oldest surviving frame, per the rule above — conservative, and
self-correcting on the next successful write. Reading the directory is
legitimate only at startup, before anything has been reclaimed in this
process’s lifetime — and even then only after one more step.
The startup listing is revalidated by a directory fsync before it is
trusted. The in-process rule covers failures this process observed, but a
previous process may have renamed a snapshot into place, failed the
parent-directory fsync, and exited cleanly; the entry is then still visible
to a restarted process, which would load it as a horizon, reclaim on the
strength of it, and lose the frames when a later machine crash drops the
never-durable entry. So before any listed .snap is used as a horizon the
snapshots root and its parent are fsynced once — snapshot_store::write
creates the directory with create_dir_all and fsyncs only the child, so
the directory’s own entry in wal_root is not durable either until this
RFC adds a parent fsync on creation — which makes every entry the listing
saw durable, the one property the listing lacks. This is an explicit
operation, snapshot_store::load_all_durable(), which fsyncs before it
lists, treats a missing snapshots_root as the empty store (a cold start
has no directory yet, and that is not a durability failure), and fails
startup when either fsync fails, as a RecoveryDriverError — so an
implementation cannot keep trusting the bare listing. An earlier draft
discarded every snapshot and started empty instead; that is not fail-closed
once reclamation is live, because a tenant’s older durable snapshot may be
the only thing that can rebuild its state, the frames below that horizon
being gone, and a node that starts empty then replays a tail that cannot
restore it. A disk that cannot fsync the snapshots directory at startup is
a fault to surface, not to continue past. One directory fsync rather than a manifest, because a manifest
would need the same fsync to be trustworthy itself. Durability is necessary
and not sufficient: snapshot_store::load_all returns raw bytes, and a
horizon is admitted only from a snapshot that decodes and restores. A listed
file that fails either is never a horizon — but whether the node may then
continue needs durable evidence, because once reclamation has run, the
frames below that tenant’s previous horizon are gone and a pin at its oldest
surviving frame cannot rebuild the state they held. So housekeeping
persists, per tenant, the horizon it reclaimed under — a RECLAIM sidecar
beside CHECKPOINT, under the same contract as that file and not a looser
one, because startup’s fallback is exactly as trustworthy as this record:
-
A fixed format, in the house style.
CHECKPOINTis a 32-byte record — 4 B magicb"OWCK", 2 B little-endian version, 2 B reserved flags, then its payload — andRECLAIMfollows it rather than inventing a shape. The file is two fixed-size slots behind one 32-byte file header: 4 B magicb"OWRC", 2 B version (= 1), 2 B reserved flags, 8 B slot length, then the two capacities the geometry was computed from —u32 max_tenants,u32 max_unlinks_per_pass— thenu32CRC32-C over bytes[0..24)of the file, the header’s own preceding bytes, and 4 B reserved, 32 B in all. The capacities are in the file because the configuration can change across restarts while the file cannot grow:Wal::opencompares the configured pair against the stored one and rebuilds the file at the larger geometry when either configured value exceeds what it was built for, rather than refusing: refusing would strand a root whose operator legitimately raised a limit the other sidecars can grow into. The rebuild is crash-safe and is the one place this record does use a temp: the new geometry is written whole toRECLAIM.new— both slots, the live one’s contents copied into the wider dictionary stride at the same index, so every slot id survives unchanged, and the other zeroed — fsynced, renamed overRECLAIM, and the parent fsynced, before anything else reads it. A crash at any point leaves either the old file or the new one, both complete, since the rename is atomic and the temp is never read; aRECLAIM.newleft by a crash is truncated by the next rebuild and swept by §3.2’s selector, which already reserves that namespace. Growth is therefore an open-time cost paid once, not a per-pass allocation, and the steady-state commit still never allocates. A configured value at or below the stored capacity opens normally and simply uses less of each slot; the file is never shrunk, since a smaller geometry buys nothing and a rewrite risks something. Each slot is[u64 generation][u32 entry_count][u32 planned_count][u16 header_flags][u16 consumer_mode][u32 next_slot_id](24 B), then three fixed arrays — the dictionary, the entries and the planned records — then au32CRC32-C and 4 B reserved (8 B). The checksum covers bytes[0 .. slot_len - 8)of that slot — everything before the trailer, never the trailer itself, since a checksum cannot cover its own bytes.Nothing in a slot is walked by a count: a position is an identity. The entry array holds
max_tenantsentries at a fixed 32 B stride, and entryibelongs to dictionary recordi— the same id, by position — so an entry carries no tenant field at all and occupancy is the entry’s ownoccupiedflag. That is what makes an id survive a tombstone: a removed tenant leaves its entry zeroed in place, the ids above it do not move, and nothing renumbers until a rebuild. A dense[0, entry_count)prefix cannot do both — a hole either skips an id or shifts every id above it — which is whyentry_countis a count of live entries for reporting, never a length to walk.The planned array is the same shape for the same reason, and costs nothing to make so:
max_unlinks_per_passrecords at a fixed24 + 32 × max_tenantsstride, each with a fixed array ofmax_tenantspairs where pairiis dictionary recordi’s, so a pair carries no tenant field either and its presence is its own flag. The worst case was already preallocated — a variable stride would occupy the identical bytes while making a record findable only by walking its predecessors — soplanned_countand a record’stenant_countare likewise reported counts, not lengths. A reader that trusted either as a length would be reading a torn or stale slot’s arithmetic; it reads the fixed strides instead, and the slot’s CRC is what says the bytes are the ones the writer wrote.header_flagscarries four witness bits and nothing else:checkpoint_armed(bit 0),checkpoint_seen(bit 1),published_seeded_armed(bit 2) andpublished_seeded_confirmed(bit 3), with bits 4–15 reserved and zero. The second pair is RFC 0053’s, amending this header: itsPUBLISHEDseeding needs a durable two-state witness and this is where the other witnesses live, so it takes two bits of the reserved range rather than a field of its own — no width change. Its meaning is the one the checkpoint pair already establishes: armed is written durably on the accepting start before any batch is admitted, confirmed at the next record write after the firstPUBLISHEDwrite succeeded, and once confirmed it never clears. The crash rows follow the checkpoint witness’s exactly — armed withPUBLISHEDabsent is the window before that write, and the next start seeds again correctly; armed withPUBLISHEDpresent is the ordinary post-write state, promoted to confirmed at open — while RFC 0053 §3.2 owns what the seeding itself does.consumer_modeis0unrecorded,1Known,2NoConsumer(§3.2), so “unrecorded” is a value rather than an absence.Tenants are referenced through a per-slot dictionary, not inline.
TenantIdis not a UUID:ourios-coredefines it as a validated string of 1 toMAX_TENANT_BYTES= 128 bytes of ASCII graphic characters, so a 16-byte key would either truncate it or collide two distinct tenants onto one entry — and this record is read as proof of loss, where a collision silently pins the wrong tenant. Each slot therefore opens with a dictionary ofmax_tenantsfixed records,[u16 len][128 B key bytes][2 B reserved](132 B),lennaming how many of the key bytes are significant and the rest zero; an unused dictionary record haslen = 0. The key field is 128 bytes, and the governing spec is RFC 0048 §3.1, whose title says what it does — “Tenant id grammar (amends RFC 0046 §3.1)” — setting a tenant id at 1 to 128 bytes of ASCII graphic characters with:,#and/excluded. RFC 0046 §3.1’s 256 is the amended text, not the live bound;ourios-core’sMAX_TENANT_BYTES = 128is the spec, not a stricter local choice, so sizing the record to 256 would double a preallocated file for a length no boundary admits.The frame codec is amended to match.
ourios-wal’sTenantBatch::MAX_TENANT_BYTESis256today, its comment still citing RFC 0046 §3.1, so a frame can carry a 129-to-256-byte tenant that no request boundary would have produced and that a 132 B dictionary record cannot represent. This RFC lowers it to 128 on encode and decode, and a replay that yields a longer tenant fails closed at open, naming the frame offset and the length rather than truncating a key or silently dropping a frame. Pre-production, that is the whole migration — no tooling, no dual-read — and RFC 0053 states it the same way. Every other reference is au16slot id into it. The mapping is injective by construction — each id names one record, each record holds the exact bytes and length of one tenant id — so it round-trips to the originalTenantIdwith no normalisation and no lossy comparison.An entry is then
[u16 flags][u16 mode][4 B reserved][24 B WalOffset](32 B), its position naming its tenant and bit 0 of its flags marking itoccupied— the mode per entry as well as per root, since §3.2 reads entries by the mode they were written under; aWalOffsetis its 16 B segment UUID plus its 8 B byte offset, 24 B, wherever it appears. A planned record is[16 B segment UUID][u16 tenant_count][4 B reserved] [u8 flags][1 B reserved](24 B) followed by its fixed array ofmax_tenantspairs, each[u16 flags][6 B reserved][24 B WalOffset](32 B). The record’s flags are at byte 22: bit 0uncertain, bit 1occupied— a planned array position that no segment occupies is zeroed, which is how a pass shorter than the cap is represented. A pair’s flags bit 0 ispresent, and a pair’s position is its tenant, so an absent pair is simply a zeroed 32 B run.Every field is at a fixed offset, little-endian, and every reserved byte is zero and checked. Two implementations must produce the same bytes, so the layout is pinned rather than described. All multi-byte integers are little-endian, matching the
OWCKrecord and the segment header, which encode theiru16s withto_le_bytestoday. Offsets, relative to the start of each structure:Structure Offset Size Field File header 0 4 magic b"OWRC"4 2 u16version (= 1)6 2 reserved, zero 8 8 u64 slot_len16 4 u32 max_tenants20 4 u32 max_unlinks_per_pass24 4 u32CRC32-C over bytes[0..24)28 4 reserved, zero Slot header 0 8 u64 generation8 4 u32 entry_count12 4 u32 planned_count16 2 u16 header_flags(bit 0checkpoint_armed, bit 1checkpoint_seen, bit 2published_seeded_armed, bit 3published_seeded_confirmed, bits 4–15 zero)18 2 u16 consumer_mode(0 unrecorded, 1Known, 2NoConsumer)20 4 u32 next_slot_id— the id high-water markDictionary record 0 2 u16 len2 128 key bytes, lensignificant, remainder zero130 2 u16flags: bit 0tombstoned, bits 1–15 zeroEntry (array of max_tenants, entryi= dictionary recordi)0 2 u16flags: bit 0occupied, bits 1–15 zero2 2 u16 mode4 4 reserved, zero 8 24 WalOffset: 16 B segment UUID thenu64bytePlanned record (array of max_unlinks_per_pass, stride24 + 32 × max_tenants)0 16 segment UUID 16 2 u16 tenant_count(reported, not a length)18 4 reserved, zero 22 1 flags: bit 0 uncertain, bit 1occupied, bits 2–7 zero23 1 reserved, zero Planned pair (array of max_tenants, pairi= dictionary recordi)0 2 u16flags: bit 0present, bits 1–15 zero2 6 reserved, zero 8 24 WalOffsetSlot trailer 0 4 u32CRC32-C over[0 .. slot_len - 8)of the slot4 4 reserved, zero A UUID is its 16 bytes in RFC 4122 order, as the segment header and
CHECKPOINTalready store them, not a little-endian integer.A non-zero reserved byte is a format error, refused at open like a bad checksum. That is the rule that makes the reserved space usable: a future version may define a field there, and a reader of this version must refuse an artefact carrying it rather than parse around it and act on a record whose meaning it does not know. The
len = 0dictionary record is the one place a zero value is meaningful rather than reserved — it marks an unused slot id — and slot id 0 is a usable id, so a dictionary position is never reserved as a sentinel. Because slot ids areu16,max_tenantshas a format ceiling of 65,536, refused at config validation with that reason (§3.8).The two files share one id space, and that needs saying rather than assuming. RFC 0053’s
PUBLISHEDcarries its own copy of this dictionary — both files must be readable alone — but a copy is not agreement: if each compacted its own tenant list on write, the same tenant would take different ids in the two files and every cross-reading of them would be wrong. So the id space has an owner and a lifetime.RECLAIMowns it: the WAL assigns a tenant its slot id when that tenant is first recorded in either sidecar, inRECLAIM’s dictionary, from the in-memory table open seeds.That table is seeded from the union of both dictionaries, not from this file alone, and the difference is not tidiness. RFC 0053’s
publish_markswritesPUBLISHEDwithout touchingRECLAIM, so a tenant can be assigned an id and recorded only there; seeding fromRECLAIMalone would lose that id at the next open and hand it to another tenant, crossing two tenants’ marks in the file that still names the first. So a record in either file — live or tombstoned — reserves its id. A high-water mark backs it: the slot header carriesu32 next_slot_id, the lowest id never yet assigned, written by whichever file is written and taken at open as the maximum of the two, so allocation never goes backwards even if one file lags the other by a write. It lives in the four bytes the slot header already reserved at offset 20, so the geometry does not move. That is what makes the ordinary one-file-alone case safe rather than merely usually safe. Ids are never renumbered and never reused while the tenant is recorded in either file — neither file may compact on its own, and a tenant that leaves one but remains in the other keeps its id.PUBLISHEDis written from that same in-memory table, so its dictionary is this dictionary, at the same ids, by construction rather than by convention. No operation reassigns an id, not even a rebuild. An earlier draft of this paragraph made the rebuild the one place ids could move; that was wrong, and wrong in a way the resize depends on. A rebuild copies each dictionary record into the wider stride at the same index, so ids are identical before and after — which is exactly what makes a crash between the two files’ renames recoverable, since the half-rebuilt pair still agrees on every id it names. A rebuild that renumbered would have to rewrite every reference in both files atomically, and there is no third witness that could make that crash-safe. A rebuild does still rewrite both files together, from the one in-memory table — that is what keeps their dictionaries the same dictionary across a geometry change — but it copies rather than reassigns, so a pair caught mid-rename comes up at the larger geometry with every id unchanged.A tenant introduced by one file alone is the ordinary case, not an error: the two are written at different moments —
publish_markstouchesPUBLISHEDwithoutRECLAIM, a pass touchesRECLAIMwithoutPUBLISHED— so at any instant one may name an id the other does not, and the file lacking it simply has no entry for that tenant and reads as “nothing reclaimed” or “no frontier” accordingly. What is fail-closed is narrower: an id that both dictionaries name with different keys, which no correct writer can produce and which silently crosses two tenants’ marks if read past —OpenError::Corrupt, naming both files and the id. A crash between the two writes therefore leaves a readable pair, never a halt.Removal follows from the same stability. Dropping a tenant by compacting the dictionary would renumber every id above it, so a removed tenant is tombstoned instead: its dictionary record keeps its key with the
tombstonedbit set (bit 0 of the record’s flags field at offset 130), its entries are dropped, and the id is retired for the life of the root — no later tenant takes it, and no rebuild reclaims it, since reclaiming a tombstoned id means renumbering. An operator who exhausts the id space raisesmax_tenants, which the rebuild path already handles, or starts a fresh root. This design would rather spend ids than invent the witness renumbering would need.RECLAIMis the fourth Invariant row in §3.8; the polynomial is RFC 0008 §6.9’s Castagnoli, as everywhere else in the WAL. -
Committed without allocating, because ENOSPC is the case this exists for. A temp-write-and-rename commit needs new blocks, and a full disk is exactly when reclamation must run — writing the record would fail, the pass would unlink nothing, and the WAL would wedge at the one moment the operator needs it to drain. So the record is preallocated once and rewritten in place, never renamed:
Wal::opencreates (or extends) it to its full two-slot size and fsyncs it, and every pass writes the inactive slot, fsyncs, and the highergenerationmakes it live — the reader takes the valid slot with the greater generation, so a torn in-place write leaves the previous slot intact and there is no rename, no temp and no new block on the commit path.RECLAIM.tmptherefore does not exist, and §3.2’s temp-sweep selector is unchanged by this record.slot_lenis fixed for the life of the file, and that is what makes the two-slot protocol safe: an earlier draft grew the file as the tenant set grew, which moves the second slot’s offset — a crash mid-extension would leave the previously valid slot unparseable at its new position, destroying exactly the torn-write guarantee the two slots exist for. So the slots are sized once, at their bounded maximum, from two configured numbers rather than from the live tenant set:max_unlinks_per_pass(§3.8) andmax_tenants, the ceiling RFC 0053 §3.1 introduces with a default of 1024 — the two RFCs land as one pair, and until 0053’s knob exists the implementation uses that same constant. A slot is then32 + 164 × max_tenants + 24 × max_unlinks_per_pass + 32 × (max_tenants × max_unlinks_per_pass)bytes — the one normative definition ofslot_len: its 24 B header and 8 B trailer, a 132 B dictionary record and a 32 B entry per tenant, andmax_unlinks_per_passplanned records at a24 + 32 × max_tenantsstride. Fixing those strides changed no arithmetic — the worst case was always what the file reserved — so the figures stand: at the defaults, 1024 tenants and a cap of 128, a slot is 4,365,344 B and the file 8,730,720 B, about 8.3 MiB, next to segments of 128 MiB each. It scales with the product of the two knobs, so an operator who raises both sees it grow; that is the price of a geometry that never moves.Wal::openallocates it once and no later write ever extends it: a tenant set that reachesmax_tenantsis refused by RFC 0053’s admission guard, not by this file.The limit is worth stating plainly rather than claiming more than the design gives: this removes allocation from every pass, not from the WAL’s lifetime. Once the file exists, no pass needs a block, whatever the tenant set does — which is the case that matters, a node whose disk filled reclaiming its way out. A volume that is already full when the WAL is opened cannot allocate it at all, and that failure has nowhere to hide: it happens before any receiver, timer or rotation state exists, so it cannot be “reported” as the rotation path’s terminal class the way an earlier draft claimed. It is its own startup failure —
OpenError::Ionaming the allocation and the file, the same shape every other open-time I/O failure takes — and the node does not start. The operator frees space and restarts; there is no in-process recovery, because there is no process yet. No design that needs a durable record before unlinking can do better on a volume with no room for the record. RFC0052.17 covers both the at-open failure and the steady-state pass. -
Durable before the unlinks — and off the writer position. The record’s slot write and fsync complete before the pass unlinks anything. The record is on disk before any frame it accounts for is gone; a torn write leaves the previous slot live. None of that file work happens under the journal mutex: serialising every tenant’s entry and three fsyncs is O(tenant count) plus disk latency, which would put fsync time in front of every concurrent append and break RFC0052.12’s O(cap) claim. The pass therefore has two halves. Under the writer position it pops at most the cap’s worth of eligible segments and stale partials from the ledger’s structures — marking each popped entry reclaiming, so it cannot be popped again, while it stays in the ledger and in the byte and segment accounting — and notes the horizon each popped segment is to be reclaimed under per tenant, then releases. Off the writer position it merges those horizons into the record, writes and fsyncs it, and only then unlinks the popped files and fsyncs the parent. The ordering rule is what the startup contract needs and it is unchanged: the record is durable before the segments it covers are gone. What the record says about them must not run ahead of the unlinks, though, so it keeps planned and completed apart: a
plannedlist — one entry per popped segment, its uuid and each tenant’s last offset in it, written before the unlinks — andreclaimed_through, per tenant, which is the proof of loss and is advanced only byhousekeeping_commit, after the unlink and the parent fsync succeeded, and made durable by the next record write (the next pass that plans, or the pass’s own second write when nothing is pending). A failed unlink or parent fsync therefore leavesreclaimed_throughbehind the segment — and makes no promise about the file. Anunlinkwhose parent fsync failed is an uncertain deletion: the entry may or may not be visible after a restart, and this process cannot tell. The file half reports it asReclaimOutcome::Unlinked { fsync_failed: true }, and since one parent fsync covers every unlink of the pass, the flag marks every path inremoveduncertain:housekeeping_commitkeepsreclaimed_throughbehind all of them, marks each segment’splannedentryuncertain, and keeps their bytes in the in-process accounting — the whole batch stays reclaiming; the next pass re-verifies presence — anunlinkthat finds the file gone completes the reclamation, one that finds it present retries it — and only that verified outcome moves the entry. At open theuncertainentry is reconciled like any planned one (below): present ⇒ retained and re-planned; absent ⇒ the data is gone and is treated exactly like a completed reclamation,reclaimed_throughraised, no halt — a planned segment held only covered frames by construction, so its loss is a reclamation that finished, not data lost. An entry leaves the ledger and its accounting only when its deletion is verified, under the writer position athousekeeping_commit(§3.7), and itsplannedentry is dropped in the same commit. The failure points differ: a failed record write or fsync means nothing was unlinked, so every popped entry is put back to eligible under the writer position and the next pass pops it again — the entry was never out of the ledger, so the no-listing rule costs nothing here; a failedunlinkafter a durable record leaves the entry reclaiming, still counted, retried by the next pass’s off-lock half within its cap; a failed parent fsync after theunlinkis the uncertain case above. Nothing a pass has touched can become undiscoverable, which is what a ledger with no directory scan behind it needs. Housekeeping’s ledger half is the only half that takes the writer position, which is what “runs on rotation’s ownership path” means below. -
Monotonic per tenant, by merge.
reclaimed_throughonly rises: the commit raises each tenant to the highest last-offset among the segments whose unlink succeeded, never lowers an entry, and a tenant absent from it has never had a frame reclaimed.plannedentries come and go with their segments. One writer holds the writer position, so the read-modify-write cannot interleave. -
Reconciled at open. A crash between the record write and the commit, or an uncertain deletion, leaves
plannedentries whose segments may or may not be gone. Open reconciles every planned entry against the directory before anything reads the record: a planned segment still present is retained — it is in the ledgerrebuild_ledger()builds, still eligible, and the next pass re-plans it — and its entry is dropped; a planned segment absent is gone, and is treated exactly like a completed reclamation: its per-tenant last offsets raisereclaimed_throughas the commit would have, and nothing halts on it, since a planned segment held only covered frames by construction. The reconciled record is written durably before the first pass, and onlyreclaimed_throughis ever read as proof of loss. -
Created at open, and witnessed by
CHECKPOINT. The WAL writes an empty record, durably, when it opens a root that has none — before the first pass can run — so the record exists on every root this RFC’s code has ever reclaimed from. Its ordering insideopenis part of the contract, not an implementation detail:Wal::openreads the sidecars and only then creates the initial segment, so a record written after that step would leave a first-ever node one crash away from a root holding a segment and neither sidecar — the state the matrix below rejects. The empty record and its parent fsync therefore come beforecreate_fresh_segment, so every directory that has ever held a segment has held a record too, and the fail-closed row can never fire on a node’s own first start. “Missing means pre-RFC” is not safe on its own: a root that has reclaimed and then lost its record (an operator deleting sidecars, a restore from a partial backup) would open, recreate an empty record, and turn a missing snapshot into a pin over frames already gone. So the record has a second witness, one that needs no new file: theCHECKPOINTsidecar’s format version. Presence alone would not do —Wal::checkpointis unreachable from the pipeline today (#793), but the RFC0008.7 tests and the crash fixture write one, and a root with a version-1CHECKPOINTand noRECLAIMis a legitimate pre-RFC root, not a lost record. The legacy branch rests on a deployment invariant worth stating, becauseWal::housekeepingis public and does unlink under a version-1 checkpoint when called: no production path calls it — the only callers in the tree areourios-wal’s own tests and the crash fixture (#793 is exactly that finding), so no served root can have reclaimed under version 1. The invariant is belted rather than trusted: on a legacy root, a tenant whose snapshot is missing and whose oldest surviving frame is above its last recorded snapshot horizon fails closed, naming the tenant — the stale-gap shape, which is what reclamation under a version-1 checkpoint would leave behind — so a hand-built or test-derived root that did reclaim is caught rather than silently pinned. So this RFC bumps the sidecar’sVERSIONfield (theu16at bytes 4–6 of the 32-byteOWCKrecord, 1 today) to 2, and the read path accepts both: a version-1CHECKPOINTwithRECLAIMabsent is a pre-RFC root — nothing was ever reclaimed on it (#793) — opened without creating a record, and itsCHECKPOINTis rewritten at version 2 by the first checkpoint, the record being created (empty, armed) on that same path. That upgrade is version-aware, not mark-aware:Wal::checkpointreturnsOk(())without writing when the offered mark equals the current one, which on an idle node is every barrier after the first, so a root whose first barrier stamps an unchanged mark would never reach version 2 and housekeeping would stay skipped forever. So an equal mark still rewrites the sidecar — and arms the record — whenever the on-disk version is 1; only an equal mark on an already-version-2 sidecar keeps the no-write fast path. Creating it eagerly at open would leave a state the matrix cannot read — a version-1CHECKPOINTbeside an unarmedRECLAIM— for as long as the node runs before its first barrier; deferring the creation to the upgrade keeps a restart in that window on the legacy branch, which is exactly where it belongs. “Created at open” below therefore means on a root with no version-1 checkpoint to migrate. A version-2CHECKPOINTwithRECLAIMabsent is a post-RFC root that has lost its record — every unlink is gated on a checkpoint, and on a post-RFC root the record is created at open, before the first checkpoint can exist — and open fails closed on it asOpenError::Corrupt, naming the missing file; both absent is the pre-RFC layout with no checkpoint — and that is the shape every live pre-RFC root has, sinceWal::checkpointhas no production caller (#793): a served node today holds segments and neither sidecar. An earlier draft of this row failed such a root closed, which would have refused to start every existing deployment; the rule is therefore scoped to roots that carry a post-RFC witness — and the witness has to be one every post-RFC root has, which a snapshot artefact is not: aNoConsumerroot (§3.2) writes no snapshots at all, so keying on them would read a WAL-only root that lost both sidecars as legacy and recreate an empty record over real reclaimed-through proof. The witness is therefore the segment header’s format version, which every root with any segment necessarily carries:SEGMENT_VERSIONis bumped from 1 to 2 for segments created under this RFC, and the reader accepts both — version 1 is a segment written before this RFC, version 2 one written after. A root can be upgraded without ever rotating, though — the checkpoint arrives on a barrier, rotation on an append — so the witness cannot wait for a segment: the first pass that reclaims writes the version marker before it unlinks anything. In practice that is the record itself, which §3.2 already requires to be durable before the first unlink and which no pre-RFC root has; the segment version is the witness that survives when the record does not, and a root that reclaimed before it ever rotated is covered because its reclaim wrote the record first. If an implementation finds a root that can reclaim with neither a version-2 segment nor a record, that is a bug in the ordering, not a case for the matrix to absorb. A visibleCHECKPOINTis not yet a durable one: its rename’s parent fsync can fail after the entry is visible to this process, exactly as §3.2 says for the snapshots root, so startup revalidates the WAL root the same way — one directory fsync before the sidecars are read, which makes every entry the listing saw durable — and fails startup when that fsync fails, as aRecoveryDriverError, rather than trusting a version-2 sidecar that may not survive the next crash. The same fsync coversRECLAIM, which lives in the same directory. The ordering rule that makes this witness safe is the one the fresh-root case already established, generalised: no version-2 segment is ever created before the record is durable. A legacy root rotates before it ever checkpoints — rotation is append-driven and the first barrier may be minutes away — so a first rotation that installed a version-2 segment while the root still had no sidecar would leave exactly the shape this row rejects, and the node would refuse to open after a restart: a live root bricked by rotating. So the first rotation on a legacy root writes the record (armed, mode unrecorded) and fsyncs it before it creates the fresh segment, the same orderWal::openuses on a fresh root. A root holding any version-2 segment with both sidecars absent has run under this RFC and its missing sidecars are a loss —OpenError::Corrupt, naming both files, because reading it as “fresh” would replay frames whose Parquet rows may already exist. Only version-1 segments and neither sidecar is the legacy shape and takes the legacy branch, belted by the same stale-gap check every legacy root gets (a tenant whose snapshot is missing and whose oldest surviving frame is above the horizon its snapshot records fails closed). That check needs a horizon on a root whose snapshots are version 1, which §3.1 discards as unknown-version — so for this check only, the legacy artefact’s globalwal_high_wateris decoded: it is a mark the old writer stamped only after every tenant’s frames at or below it were drained and mined, which makes it a sound lower bound on what that tenant had folded, and reading it here restores nothing and feeds no miner. A snapshot that cannot be decoded even for that field, or is absent entirely, has no horizon to compare and fails closed naming the tenant rather than being read as “nothing reclaimed” — the one direction that could replay past reclaimed data. Accepting both versions is the whole migration, as with the other two bumps, and a fresh root of either mode stays bootable because its record is durable before its first segment exists. A fresh root under this RFC never reaches that row at all: its record is written before the first segment is created (the bullet above). Per the pre-production layout policy that read path is the whole migration — no tooling — and the implementing PR carries the!marker for the sidecar version bump beside the snapshot one. The matrix is symmetric: aRECLAIMwith anyreclaimed_throughorplannedentry beside a missingCHECKPOINTis also fail-closed, naming both files — every unlink was gated on a checkpoint, so the checkpoint existed and is gone, and withoutXthe Parquet-side suppression horizon cannot be rebuilt and replay would republish. An empty record is not enough to tell the other way: a post-RFC node can checkpoint many times while reclaiming nothing, and deleting that checkpoint would otherwise read as a fresh root and replay published rows. So the record header carries a two-state witness, because one flag written before the checkpoint would make the window between that write and the checkpoint’s rename read as a lost checkpoint — a crash there is ordinary, not corruption.checkpoint_armedis written, durably, before the first version-2CHECKPOINTattempt;checkpoint_seenis written at the next record write after that checkpoint succeeded (the next pass’s write, or an immediate one when no pass is pending), and once seen the flag never clears. The matrix reads them in that order:seenwithCHECKPOINTabsent is a lost checkpoint and fails closed, naming both files, whatever the entries say;armedwithoutseenandCHECKPOINTpresent at version 2 is the ordinary post-upgrade state — the checkpoint is durable, the crash simply landed before the record’s next write — so open promotes it, settingseendurably before anything reads the matrix, and it is never a fault;armedwithoutseenandCHECKPOINTstill at version 1 is the migration’s own retry state, which this ordering creates when the upgrade write fails between the arming and the rename — the record is armed, no version-2 checkpoint exists, and nothing can have been reclaimed, since housekeeping is a no-op until the witness exists — so the root opens normally on the legacy branch and the next checkpoint retries the upgrade, re-using the arming already on disk;armedwithoutseenandCHECKPOINTabsent splits on whether the directory holds segments. With no segments it is a genuinely fresh root whose arming preceded a checkpoint that never landed: the record is opened empty and re-armed by the next attempt. With segments present it is not fresh at all — the first rotation on a legacy root writes the record before it creates its version-2 segment (below), so this is that root, mid migration, with the upgrade still owed — and the state is retained, not reset: the record keeps its arming and its mode, the root opens on the legacy branch, and the next checkpoint retries the upgrade. Emptying the record there would discard a mode the first pass may already have adopted; neither flag withCHECKPOINTabsent is the post-RFC root before its first barrier, which is what open creates. Entries andseencannot disagree, since every unlink is gated on a checkpoint that must have succeeded to produce them. RFC 0053 addsPUBLISHEDto this matrix on the same footing: its §3.2 makes a missingPUBLISHEDbeside a version-2CHECKPOINTfail-closed like a missingRECLAIM. -
Corrupt is fatal. The record carries a version byte and a checksum; a record failing either at open fails startup as
OpenError::Corrupt, naming the file. It is not read as missing: missing means “never reclaimed”, damaged means “reclaimed, extent unknown”, and only the first is safe to proceed from.
NoConsumer means no miner state exists, and that is a precondition,
not a hint. The previous draft read a NoConsumer reclaim as
“checkpoint-covered” and let a later start pin instead of halting. That is
unsafe wherever a miner is running: hazard #5’s retain rule (RFC 0001
§6.9) makes a missing snapshot survivable because the WAL still holds
the frames to re-mine, and a pass that reclaimed by checkpoint alone has
removed exactly those frames — the tree cannot be rebuilt, and wiring
snapshots up afterwards does not bring it back. Nothing in the code
prevents that combination today: Wal::housekeeping’s retain_floor
(what SnapshotHorizons replaces) is a plain Option, its only None
callers are ourios-wal’s own tests, and the one WAL-bearing role the
server ships — the ingester — always holds a MinerCluster.
So the mode is given a precondition rather than a reading. NoConsumer
means the caller holds no miner state at all: a WAL used without a
miner (the crate’s own tests and benches today, a WAL-only role if one is
ever added). The ingest receiver is never in that position and therefore
always passes Known — a tenant with no valid snapshot is expressed
by the Pinned floor §3.2 already defines, never by NoConsumer, which
is the distinction RFC0052.13 exists to hold. The precondition is
asserted where SnapshotHorizons is built, and the WAL enforces it from
durable state rather than from inference: the RECLAIM header records
the root’s consumer mode, and any pass whose mode disagrees with the
recorded one is refused as a ReclaimError naming both modes, before
anything is planned or unlinked. An earlier draft refused only a
NoConsumer pass on a root that already held a Known entry, which is
not fail-closed: a miner-bearing root that has checkpointed but never
reclaimed has no entry at all, so the first mistaken NoConsumer pass
would pass the check and delete frames the miner needs. The header does
not depend on an entry existing. It does depend on knowing the mode, and
Wal::open does not: the mode arrives with SnapshotHorizons at
housekeeping_prepare and nowhere else, and threading it into WalConfig
would put a receiver-side fact into the WAL’s construction — where a
mismatch would have to be an open-time error on a root that has not yet
reclaimed anything. So the record is created with the mode unrecorded,
and the first pass adopts its own mode and persists it durably before
unlinking anything. The crash matrix for that window is one row: a root
that dies between the adoption write and the first unlink comes back with
a mode, no entries and every segment present, which is exactly a
first-pass-never-ran root and is read as such. §3.7’s surface list matches
— no mode on Wal::open, the mode on housekeeping_prepare’s horizons —
and the first
reclamation on any root is already governed by a durable mode.
Entries still record their mode too, and recovery reads them by it: an
entry written under Known whose tenant’s restorable horizon is below it —
including a tenant with an entry and no restorable snapshot at all — is
unrecoverable state and recovery halts, naming the tenant; a
NoConsumer entry is checkpoint-covered, which is sound because the
header’s mode made every pass on that root a no-miner pass. A tenant with
no entry has lost nothing and pins at its oldest surviving frame. An
undecodable or missing snapshot is therefore safe to fall back from
exactly when the record proves it is, and RFC0052.17 holds each of those
cases.
An entry whose tenant has no surviving frame is satisfied by absence.
A tenant leaves the ledger when its last surviving segment is unlinked,
but its reclaimed_through entry stays in the record; were the entry
still pending, every later Known pass would need a restorable horizon
for a tenant with nothing left in the WAL, and one lost snapshot after
ordinary churn would halt reclamation for every other tenant on the root.
The entry exists so that recovery never rebuilds a tenant’s miner state
from a log missing its oldest frames; with no surviving frame there is no
log to rebuild from and nothing a pass could unlink on that tenant’s
behalf, so the check above applies only to a tenant the ledger holds. The
ledger is the witness, since rebuild_ledger() rebuilds it from every
surviving frame’s prefix at the end of recovery and every live append
updates it — and only once that walk has run: before it, the ledger knows
only frames appended since open, a tenant missing from it proves nothing,
and the entry stays pending. The entry is not
removed: absence is not retirement — “no surviving frames” is not “will
never write again” — and the WAL is the wrong place to decide the latter.
The dictionary tombstone stays in the format for a caller that can decide
it; nothing in this RFC writes one.
A churned-out tenant that writes again halts reclamation until its first
new snapshot installs. Its new frames put it back in the ledger, so the
entry is pending once more, and with its snapshot lost there is no
restorable horizon to satisfy it: the pass fails closed with
HousekeepingError::Unrecoverable naming the tenant. That is intended.
Treating the return as a fresh tenant would discard the entry that makes a
missing snapshot loud, and a later restart would rebuild that tenant’s
miner state from frames that begin after the ones already reclaimed,
silently. The pause lasts until the tenant’s next snapshot installs and a
pass runs — at default tunables about one barrier_secs (300 s) plus one
housekeeping_secs (60 s) — and only reclamation pauses: ingest, acks and
queries are untouched. If that tenant’s snapshots keep failing to install,
the pause and the WAL’s growth last as long as the failures do; every pass
meanwhile names the tenant, so the stall is loud rather than silent.
The floor is also the reason §3.1 can tolerate a failed snapshot write.
housekeeping reclaims only segments every tenant’s horizon covers, and
never past the checkpoint, so a stale floor makes truncation conservative — it retains frames a snapshot has not
captured, which degrades the next start to a fuller replay and never to loss
(hazard #5’s retain rule, RFC 0001 §6.9).
Housekeeping’s ledger half takes the WAL’s single-writer position, so it runs on the same ownership path as rotation rather than concurrently with it; its file half — the record write, the unlinks, the parent fsync — runs after that position is released, on the housekeeping task alone.
3.3 Rotation failure becomes recoverable, under a bounded retry
A quiesce must stop being permanent. The five sites — the four the code has
today and the rename(partial, final) step the temporary name adds — are
not equally retryable, and the design treats them by what they leave behind:
| Site | Failure | State left behind | Retry |
|---|---|---|---|
closing-segment fdatasync | sync_file_data | nothing new; current segment unchanged | directly |
| create fresh segment | create_fresh_segment | at most a .wal.partial, never a segment | directly; the partial is swept |
fresh-segment header fsync | sync_file_data | a .wal.partial with possibly-torn bytes | directly; the partial is swept |
rename(partial, final) | rename | the .wal.partial only; nothing installed | directly; the partial is swept |
parent-dir fsync (post-rename) | sync_parent_dir | a complete, installed segment whose entry may not be durable | re-fsync it; never unlink |
The last row is the one to read carefully. Because the rename happens before
that fsync and the install happens before it too, there is no orphan to clean up
there — the file is the live append target. Unlinking it would destroy the
segment the WAL is currently writing to. The retry is the directory fsync alone,
carried by dir_fsync_pending.
One rotation obligation at a time. A Rotation-origin
dir_fsync_pending outlives the rotation that created it, and a later
append or timer tick can find the segment due for rotation again. Starting
that second rotation would install a segment whose predecessor’s directory
entry is still not durable, and a crash there loses the ordering the first
obligation exists to restore. So any path that begins a rotation — an
append that observes the size or age trigger, and Journal::rotate of
either kind —
first discharges a pending Rotation obligation, and refuses the rotation
if the discharge fails. The refusal is not a new class: it consumes one
unit of the retry budget like a failed rotate, so it is reported
transient while the budget holds and server-terminal, client-retryable
once it is exhausted (§3.3’s classification, RFC0052.15) — and no batch is
acknowledged in either case. An Open-origin obligation does not gate a
rotation: it is an ordinary retryable sync failure outside the budget, and
the rotation’s own parent fsync discharges it.
The orphan is why the current code quiesces rather than retrying, and the
existing comment says so: a surviving directory entry whose header bytes
were lost fails the next Wal::open’s header read, turning a benign crash
into OpenError::Corrupt.
Unlinking the orphan before each retry is not sufficient, and this RFC
does not rely on it. A remove_file is not durable until the parent
directory is fsynced, so a crash in between can bring the entry back; and
after the final failed retry a header-only orphan under the final name
can still be sitting there, which Wal::open may select as the newest
segment — that is the legacy shape RFC0052.11 handles; a .wal.partial is
never selectable, since list_segments returns only *.wal. Either
case contradicts RFC0052.4/.5’s restart property, so the design removes the
orphan class instead of cleaning up after it:
A fresh segment is created under a temporary name, its header fsynced, then
renamed to its final .wal name, and only then is the parent directory
fsynced. The ordering matters in both directions:
- the header must be durable before the rename, or a surviving
.walentry can have unreadable header bytes — today’s hazard; - the parent fsync must come after the rename, because the rename creates the directory entry and an fsync taken before it does not make that entry durable.
And the new segment is installed before that fsync, not after. This is what
makes the failure path reachable rather than a description of a state the code
cannot be in: rotate sets current_segment/path/uuid to the renamed file
first, then fsyncs the parent, and on failure sets dir_fsync_pending and
returns without quiescing. The new segment is then genuinely current, so
sync — which fsyncs current_segment and discharges dir_fsync_pending —
closes the obligation, exactly as it already does for the segment open
creates. Since no batch is acked until sync returns Ok, no frame is ever
acked in a segment whose directory entry is not durable.
The retry is the group-commit sync, not the next append, and saying
“a retry re-attempts the fsync” blurred that. Once the new empty segment is
current, the next append’s rotation check is false, so it simply writes its
frame; nothing re-enters rotate. What retries is the sync that follows,
which is the right place: it is the operation acks are gated on, so a repeated
directory-fsync failure repeatedly refuses to ack rather than accumulating
unacked frames behind a rotation that never reruns. Those appends are not lost
either — they are in the segment, and the next successful sync covers them.
That retry draws on the same budget and reaches the same terminal state —
when the obligation came from a rotation. dir_fsync_pending starts
true on every Wal::open, fresh or reopened, so the flag alone cannot
say where the obligation came from; it therefore carries its origin,
Open or Rotation. A failed discharge of an Open obligation is an
ordinary retryable sync failure outside the budget, exactly as today. A
failed discharge of a Rotation obligation consumes one unit of the
rotation retry budget, exactly as a failed rotate does, and exhausting it
enters the terminal state whichever operation gets there — which is what
keeps RFC0052.15’s narrowness true: an ordinary fsync failure never
becomes terminal. Both
AppendError and SyncError carry a typed terminal variant, and
IngestFailure::classify maps both under the terminal-only rule. The
plumbing through group commit is stated too, because today it would lose
the type: CommitCoordinator::flush collapses every sync error into
SyncFailure { detail: String } and waiters rebuild a generic
ReceiveError::WalSync. FlushOutcome therefore gains the error’s class —
terminal or transient — beside the detail, and waiters rebuild a
ReceiveError that carries it, so the classifier sees the terminal state
rather than a string; §6 drives both a rotation-origin terminal sync
failure and an ordinary transient one through flush and the waiter, so
the production path cannot erase the class while the classifier tests pass. The retry hint is settled honestly: an append, sync
or still-retrying rotation failure has no scheduled retry on the server
— the next attempt is the client’s next request — so those transient
classes carry no hint and the client backs off as OTLP prescribes, exactly
as the merged #794 left them; a hint appears only where a real server-side
cadence exists, which in stage 1 is nowhere and in RFC 0053 is the
backpressure state’s housekeeping cadence. The transient/terminal
distinction therefore rides the classification and the Status message,
not a header — so a
persistent parent-directory fsync failure is not left as an ordinary
WalSync that RFC0052.5 and RFC0052.15 could never observe.
Installing after the fsync, as the draft implied, leaves the renamed file
owned by nobody: rotate would still point at the old segment while a complete
.wal sits beside it, which is the orphan case all over again.
A rotation that fails before the rename therefore never leaves a file that
looks like a segment. This amends RFC 0008 §6.5 explicitly, and the
amendment is wider than the create sequence: §6.5 required the new
segment’s directory entry to be durable before any frame landed and a
permanent refusal after a rotation failure, and Scenario RFC0008.6 asserts
both. Under this RFC the segment is installed before its parent fsync,
frames may land while dir_fsync_pending is retried (none acked until
sync discharges it), and the refusal is bounded rather than permanent —
so RFC0008.6 is superseded by RFC0052.4/.5 and §6.5’s durable-entry-first
wording by §3.3 here. It is the one on-disk behaviour change in this RFC;
it needs no format or schema change, because the temporary name never
becomes a segment.
The post-rename window is not covered by that, and needs its own rule. If
the final sync_parent_dir fails, the .wal name already exists — so the
“never looks like a segment” property does not hold there, and the temporary
-name sweep does not reach it. That file is a complete, header-durable
segment whose directory entry may not survive a crash, which is exactly the
state dir_fsync_pending already exists to describe. So it is kept, not
unlinked, and the rotation records that the directory fsync is still owed: the
next sync discharges it before acking anything, as it already does for the
segment open creates. A retry after this failure re-attempts the fsync
rather than creating a second segment.
Temporary files are swept explicitly, not implicitly. list_segments
returns only *.wal, so a temporary file is invisible to it — which is the
point for Wal::open, and means housekeeping would not remove it either.
Left at that, a persistently failing rotation would leave one temporary file
per retry, consuming disk that reclamation exists to free. So
housekeeping gains a second, explicit step — but “temporary files” is far too
broad a selector to state loosely. The WAL root already holds CHECKPOINT.tmp
and the snapshots directory holds *.snap.tmp; a sweep matching *.tmp could
delete an in-progress checkpoint or snapshot, which is a worse failure than the
debris it collects.
So the name is reserved and exact: the in-progress segment is
<uuid>.wal.partial, and every file of that shape in the WAL root belongs to
the WAL — the sweep cannot tell rotation debris from a file an operator
placed under a reserved name, so it does not try, and unlinks any stale one.
A file that does not match the shape (foo.wal.partial, anything else) is
ignored, as non-segment files are everywhere else in the WAL, and *.tmp is
never matched: it stays the checkpoint and snapshot namespace. Two further
rules make it safe:
- there is no in-flight partial to protect: housekeeping takes the single-writer position, so no rotation attempt is in progress while it runs, and every partial it sees is debris — an earlier draft’s name-based “skip the live one” rule had no ownership path and is withdrawn;
- the unlinks are followed by a parent-directory fsync, as
housekeeping’s segment unlinks already are, so a crash cannot resurrect swept debris.
The step runs inside the same per-pass cap as segment unlinking (§3.7) and counts against it, because it pops from the same ledger under the same single-writer position — a backlog of stale partials would otherwise make a “bounded” pass do unbounded directory work and break RFC0052.12; the unlink itself runs in the pass’s off-lock half like a segment’s (§3.2). Within the cap, partials are popped first and segments take what remains: a partial is bounded per rotation by the retry budget and holds no frame, while a segment backlog is unbounded, so the other order would let the incident’s backlog consume every pass’s cap and leave rotation debris on disk for as long as the backlog lasts — contradicting RFC0052.4’s “one rotation’s debris clears in one pass”. An unlink failure is logged and retried on the next pass like any other.
Two more properties of the sweep are stated because the current
housekeeping has neither. It runs on every pass, regardless of the
checkpoint precondition: today housekeeping returns at once when no
checkpoint exists, and a rotation that fails before the first checkpoint
would otherwise leave partials that every later pass skips; only the
segment-unlink portion is gated on a checkpoint. And it lists nothing on
the pass: partials left by a previous process are seeded into an
in-memory partial list by rebuild_ledger(), which walks the
directory at the end of recovery anyway, live rotations register the
partial they create, and the sweep pops from that list under the same cap
— so restart debris is found without a directory scan under the writer
lock. Its error path still
makes completed removals durable: when a pass fails part-way — a header
read, a stat, a later unlink — the parent-directory fsync covers every unlink
that already succeeded before the error is returned, and
HousekeepingProgress reports the partial count. Today’s single fsync after
the complete scan would let a crash resurrect a partial the pass had already
removed.
Pre-existing *.wal orphans need their own answer, and neither changing
future rotations nor guessing at open is it. A node wedged before this RFC
lands may already hold a segment whose header fsync failed, so its header
bytes may be partial — Wal::open validates the newest segment’s header
and can return OpenError::Corrupt on it, while older segments are checked
by Wal::replay and surface as recovery errors; either path halts. Claiming these are “real zero-frame segments” was
wrong.
An earlier draft of this section had Wal::open unlink such a file when it was
the newest and the only unreadable one. That is withdrawn. An unreadable
newest segment is indistinguishable from genuine header corruption, and
RFC0008.5’s contract is that corruption halts rather than being cleaned up;
a heuristic that unlinks it can silently discard real data, which is a worse
failure than the one it avoids. No shape-based guess is safe here, because the
shape carries no evidence of which cause produced it.
So: these nodes keep today’s behaviour and still halt at open. What this RFC adds is that the halt is actionable — the error names the file, describes the observable shape (newest segment, unreadable header) and notes that a failed rotation is one way to produce it, without claiming that it did, so an operator can weigh removing it. Whether that deserves a WAL verb rather than a documented manual step is in §7; either way the decision is a human’s, because only a human can weigh “this is probably rotation debris” against “this might be my data”. Future rotations cannot reach this state at all, so the population is finite and shrinking.
A pre-rename retry is attempted on the next append, and a post-rename
directory-fsync retry by the next sync (above); neither runs in a
background loop of its own: the WAL is single-writer and has no task, and a
rotation is only needed when there is something to write. The one
append-independent caller is the barrier task’s idle rotation (§3.2,
Journal::rotate(Discretionary)), and it is not exempt: a timer-triggered rotation that
fails draws on the same budget and can reach the terminal state without an
append ever arriving, which RFC0052.4/.5 cover. quiesced becomes a typed state
carrying the attempt count and the first underlying error, so the
distinction between “retrying” and “given up” is representable rather
than a bool. The budget is a count: three consecutive failed attempts
by default (rotation_retry_attempts, beside the other rotation knobs),
counting a failed rotate and a failed Rotation-origin fsync discharge
alike, reset to zero only when the pending obligation itself succeeds — the
rotation, or the directory-fsync discharge — never by an unrelated
successful operation, or a parent fsync failing behind succeeding data syncs
would never reach the terminal state; with no backoff of its own — each
retry rides the next append or sync, whose cadence is the backoff. After
the budget is exhausted the WAL enters a terminal state that is still
reported distinctly (see §3.5) and still refuses appends — a disk that has failed the same fsync a
dozen times is not going to be fixed by a thirteenth attempt, and
hammering it obscures the real fault.
The retry must not widen the ack surface: a rotation that has not completed means the append did not happen, so the batch is not acked, exactly as today.
This is a contract change against a passing test, and says so.
rfc0008_6_rotation_failure_quiesces_the_wal asserts that after a rotation
failure “every subsequent append is refused … even after the underlying
condition clears”. That is precisely the behaviour §3.3 replaces, so the
test is not inconvenient — it is the specification of what we are choosing to
change. Per CLAUDE.md §6.2 the implementation must surface that explicitly
and get approval before editing it, rather than quietly adjusting it to pass.
The replacement asserts the new contract on both sides: a transient failure
recovers (RFC0052.4) and a persistent one still refuses forever
(RFC0052.5), so the protection the original test provides is kept, not
dropped.
This RFC amends RFC 0018 §3.2, and that amendment belongs here, with the
retry it depends on. §3.2’s transient class lists “post-rotation quiesce”
alongside WAL append I/O and fsync failures, all mapping to UNAVAILABLE /
503 “with an optional Retry-After header”. Classifying the quiesce as
transient was right when the latch was assumed to be a momentary condition;
#791 showed it is permanent until a restart, so the classification is wrong in
the one way that matters — it tells a client the node will recover on its own.
The amendment is narrow and does not touch §3.2’s binding rule, which is
that a transient failure must never carry a non-retryable code. The status
stays UNAVAILABLE / 503 for exactly the reason §3.2 gives: the batch was
not acked, and every non-retryable OTLP status also tells the client to drop
it. OTLP’s retryable axis is about the data — whether the client may
resend the same batch — and here it may and must: the batch is valid and
will be accepted once an operator has cleared the node, so a non-retryable
mapping would be wrong on the spec’s own terms. What changes is the class,
and §3.2’s two classes cannot say it: the state is neither transient (no
delay fixes it) nor permanent in §3.2’s sense (nothing is wrong with the
batch). So this RFC adds a third class to §3.2’s table — server-terminal,
client-retryable: the node needs an operator; the client keeps its data
and retries with exponential backoff, which is what OTLP prescribes when no
Retry-After / RetryInfo is sent, and none is, because the server cannot
predict when the state clears. Under this section’s bounded retry, only the
terminal state enters that class. A rotation failure still within its
retry budget genuinely is transient: a later append can succeed, so it stays
in the transient class and its message says it is retrying. Only once the
budget is exhausted is the node in a state no delay fixes, and only there
does the classification change, with the message naming the state. No retry
hint is carried on either — the server schedules no retry of its own
(§3.7) — and RFC0052.15 pins both halves on class and message.
3.4 Backpressure — moved to RFC 0053
The explicit local bound, its pre-append reservation, its Retry-After
contract and its livelock fix are RFC 0053 §3.1. They depend on this RFC’s
timer (§3.1), barrier exclusion (§3.1) and ReclaimState (§3.7), which is why
they follow it rather than share a document with it. The number is kept so
the review history’s section references still resolve.
3.5 The state becomes observable
WalMetrics already carries unflushed_bytes, disk_bytes and
segment_count, and none of the three is exported: the only WAL metric
names in the registry are ourios.wal.append.duration and
ourios.receiver.wal.truncated. unflushed_bytes is read only
internally, for the fsync-batching decision. So “the WAL stopped
growing” — the incident’s one observable symptom — was unobservable even
though the numbers existed in memory.
This RFC exports the existing three and adds the state the other sections
actually depend on. ReclaimState (§3.7) carries, and the exporter surfaces:
- unreclaimed bytes and the age of the oldest unreclaimed frame — the
bytes are what RFC 0053’s bound is measured on, and the age is what an
operator alerts on; “all unreclaimed” means including the post-checkpoint
tail, the current segment and anything the floor retains. No frame carries
a timestamp, so the age is a defined proxy: the oldest surviving segment’s
UUIDv7timestamp, its creation time, which is older than or equal to every frame in it and therefore conservative — andNonewhenever unreclaimed frame bytes are zero, since the header-only current segmentWal::opencreates or retains holds no frame and an idle WAL must not report a growing age. An earlier draft said “below the checkpoint”, which would have exported the one number that does not grow during an outage; - the retain floor and its lag —
lag_bytes, the frame bytes in the segments at least one tenant still holds back, less the current append segment, andlag_segments, their count; both are reported as zero while the floor isUnknown(no pass has derived one, so no lag is reported yet, whatever the ledger holds) orNone(the checkpoint alone governs, so no tenant holds anything back). The figures are conservative by construction: equal to the bytes and segments the floor retains below the checkpoint whenever the checkpoint covers every held segment, larger by the held post-checkpoint tail otherwise, never smaller. An earlier revision bounded them by the checkpoint, which made the held set move whenever the checkpoint does — a range count on every advance, the eager promotion RFC0052.12 forbids — and a retention gauge errs high safely. The current segment is excluded for the reasonunlink_remainingexcludes it (§3.7): no pass can pop it, and counting it would hold the gauge above zero on a healthy node.WalOffsetis a(UUIDv7, byte)pair and has no subtraction of its own, and the figures come from per-segment frame-byte accounting the WAL keeps incrementally (seeded byrebuild_ledger(), updated on every append and unlink) — never from an inspection, which §3.7’s cap would bound and a large backlog would make untrustworthy — including whether it isRetainFloor::Pinnedand by how many tenants — without which RFC0052.13’s “an operator can tell a lagging tenant from an unexplained stall” is not achievable, since that is the case where reclamation stops with a healthy store; - the rotation-failure state: retrying, with its attempt count, versus terminal;
- the housekeeping backlog —
horizon_remainingandunlink_remainingfrom the last pass’sHousekeepingProgress(§3.7), exported as two asynchronous gauges in{segment}: the proposed names areourios.wal.housekeeping.horizon.remainingandourios.wal.housekeeping.unlink.remaining— dotted namespace under the existingourios.wal.*, snake_case components, not pluralised, no unit in the name — with the registry bump settling the final spelling. They are what turnscappedfrom a bool into a trend: a backlog that is draining and one that is growing read alike oncapped; - the
cadence_failedlatch — pipeline-owned rather than aReclaimStatefield, because it is set by the ingester’s unwind guards (§3.1: the encode pool’sBatchGuard, the sink’s publish guard, the barrier’s own step) and no WAL call observes it; exported as the failed epoch (absent when clear) beside the current barrier epoch, so an operator can see that the latch is set and which cuts it fails; the receiver exports it beside theReclaimStatefields, and RFC0052.7 names which surface each item comes from.
A log event is emitted on entering and on leaving a refusing state — for
transitions observable within one process; the terminal rotation state’s
only exit today is a restart a fresh WAL cannot observe, so it has an entry
event and no leave event until §7’s operator verb exists to emit one
(RFC0052.7 says the same). Names come
from the shared ourios-semconv registry in one bump, not hand-written, and
error.type continues to carry the failure class on existing counters rather
than spawning per-error metrics.
Transitions have an observer, so “exactly one event” is implementable.
A state snapshot alone cannot emit anything. The receiver’s timer task is
the single emission owner for the states that change on ticks: it compares
a categorical projection of ReclaimState — floor kind and latch, never
the byte, age or lag figures, which change on every pass — before and after
each maintain call and emits on change (floor pinned or lifted, latch
set). Rotation-state transitions are not sampled that way, because a
rotation can fail on one append and recover on the next before either
sample and the sampler would see no change: the coordinator emits those
edges synchronously at the transition, under the journal mutex, as RFC
0053 does for its refusal latch, so entry into Retrying or Terminal and
the recovery out of Retrying each emit exactly once at the point they
happen. The channel is defined: the coordinator records the WAL’s
rotation state after every append and sync outcome into a shared
IngestState cell — written by the WAL itself under its own mutex, in the
order it observed the outcomes, never from flush outcomes that publish
monotonically by covered_seq rather than by completion, so an older
transient outcome cannot overwrite a newer terminal one — and the
projection is built from that cell, from
ReclaimState, and from the task’s own cadence_failed latch — an explicit
input, not a ReclaimState field. Rotation-state edges, including the
terminal entry, are emitted synchronously at the transition as stated
above — never sampled, never late. One owner per state, real call sites,
registry names.
3.6 Requeue on unwind — moved to RFC 0053
The duplicate-versus-lose decision, the consuming-call ownership rule and
the age-sweep survival that follows from them are RFC 0053 §3.2. They are
deferred rather than dropped: #795’s stop-on-panic plus §3.1’s
cadence_failed latch is the safe interim, and it is what RFC 0053 replaces. The
number is kept so the review history’s section references still resolve.
3.7 Ownership and API surface
§3.1 and §3.2 are not callable as written today, and saying so precisely is part of this RFC’s job.
The barrier has no WAL handle. flush_then_snapshot receives the record
sink, the audit sink, the snapshots root, the miner, an
Option<WalOffset> high-water mark and a cadence label. The WAL itself is
boxed as a Box<dyn Journal> inside a Mutex in the commit coordinator,
which is where the single-writer position lives (RFC 0008 §3.1).
Journal has no reclamation surface. The trait is append_batch,
sync, unflushed_bytes. Wal::checkpoint and Wal::housekeeping are
inherent methods on the concrete type, so the pipeline cannot reach them
through the trait object.
So the design is:
-
Journalgains the two reclamation methods and a state accessor, with a single object-safe error type rather than the concrete WAL’s two:enum ReclaimError { Checkpoint(..), Housekeeping { progress: HousekeepingProgress, source: .. }, } // object-safe, one type struct HousekeepingProgress { removed_segments: usize, // what the forced-rotation trigger reads (RFC 0053) removed_partials: usize, capped: bool, // "more to do" versus "backlog drained" horizon_remaining: usize, // segments a received horizon may not yet have // been applied to: per tenant, the segments it // has frames in above its cursor, summed over // tenants (a segment counts once per tenant behind // on it) — a counter the ledger keeps INCREMENTALLY, // raised when a tenant's frame first lands in a // segment above its cursor and lowered as the // cursor moves, so reading it is O(1) under the // guard, never a per-tenant sum on the pass, and a // rising horizon costs no range count. Conservative: // equal to the cursor-to-horizon span whenever each // tenant's horizon covers its newest segment, // larger otherwise, never smaller; zero before // the first Known pass (an empty Known map // counts as one) and under NoConsumer unlink_remaining: usize, // empty-set segments not yet popped (waiting on the // checkpoint or on a pass — counting only those at // or below the mark would cost a range count on // every checkpoint advance, which §3.7 keeps O(1)) // plus entries reclaiming or uncertain — likewise an // incremental counter, moved when a set empties and // on every entry state change (eligible, reclaiming, // uncertain, removed), O(1) to read. // Both are the values as of the END of the ledger // half that produced this struct (prepare's plan // or commit's return), never re-derived off-lock floor: RetainFloor, // derived on this pass (§3.2) lag_bytes: u64, // §3.5's units, from per-segment accounting — lag_segments: usize, // never from an inspection the cap would bound } // carried on Err too, so partial work, floor and // lag stay observable on the failure path /// Why a floor is or is not available — `Option` cannot carry this. enum RetainFloor { Unknown, // no pass has derived it yet: reclaim nothing, report as such None, // no snapshot consumer exists: checkpoint alone governs Min(WalOffset), // the minimum over every tenant's horizon Pinned { offset: WalOffset, tenants: usize }, // some tenant has no valid snapshot: the minimum // includes its oldest surviving frame } fn append_batch(&mut self, payload: &[u8]) -> Result<WalOffset, ReceiveError> // the ReceiveError boundary the // coordinator and both transports // classify against is kept; only // the offset Wal::append already // produces is added (§3.1's mark) fn checkpoint(&mut self, durable_to: WalOffset) -> Result<(), ReclaimError> enum SnapshotHorizons { NoConsumer, Known(HashMap<TenantId, WalOffset>) } fn housekeeping_prepare(&mut self, horizons: &SnapshotHorizons, max_unlinks: usize) -> Result<ReclaimPlan, ReclaimError> // the ledger half, under the guard: // applies horizons (capped), derives // RetainFloor, pops partials then // segments, marks them reclaiming struct ReclaimPlan { // owned paths: the file half needs segments: Vec<PlannedUnlink>, // no guard and no WAL handle partials: Vec<PathBuf>, record: ReclaimRecord, // the merged per-tenant horizons root: PathBuf, // for the RECLAIM slot write and the // parent fsync after the unlinks progress: HousekeepingProgress, // floor, lag, capped — as of prepare } enum ReclaimOutcome { // what the file half did RecordFailed(io::Error), // nothing unlinked Unlinked { removed: Vec<PathBuf>, // unlink returned Ok failed: Vec<(PathBuf, io::Error)>, // unlink returned Err fsync_failed: bool, // the parent fsync after the }, // unlinks failed: every path in } // `removed` is uncertain (§3.2) fn housekeeping_commit(&mut self, outcome: ReclaimOutcome) -> Result<HousekeepingProgress, ReclaimError> // the ledger half again, under the // guard: removed entries leave the // ledger and its accounting unless // fsync_failed, in which case the // whole removed set stays reclaiming // as uncertain, re-verified next pass; // failed ones return to eligible // (record failed) or stay reclaiming // (unlink failed), per §3.2 fn reclaim_state(&self) -> ReclaimState // §3.5's export surface enum RotationKind { Discretionary, Owed } // §3.3: the cap applies to // Discretionary only fn rotate(&mut self, kind: RotationKind) -> Result<(), ReceiveError> // §3.3's retried rotation, // callable without an append: // discharges a pending directory // fsync first, no-op when the // current segment holds no frame; // Wal::rotate is private today. // RFC 0053 uses this definition, and // its segment cap defers only a // Discretionary rotation — an Owed // one (a seal, or a recovery // requirement) always proceeds.The own-frame mark needs plumbing, stated so it cannot be skipped.
Journal::append_batchdiscards theWalOffsetWal::appendreturns today, andCommitOutcomeexposes only the group-sync result. Soappend_batchreturns the offset, the coordinator records each waiter’s own offset by sequence at append time,CommitOutcomecarries it beside the durable EOF, and every test double returns a monotonically increasing offset per append. The call site uses that own offset for everything it compares withlast_durable— the segment-change detection that fires the rotation hook included — so a flush spanning an old-segment turn and a new-segment turn fires the hook once, on the new-segment turn, with the old segment’s mark, rather than on both. An implementation that kept storing the EOF fails RFC0052.14’s two-turn flush, which is what that criterion is for.RetainFloorexists becauseOption<WalOffset>cannot carry what §3.2 requires:Nonemeans no snapshot consumer exists and the checkpoint alone governs, which is safe to reclaim;Minis the complete case;Pinnedsays the minimum is being held down by tenants without a valid snapshot, at their oldest surviving frames, which is correct but must be visible. AnOptioncollapses the last two, and a signature that hides the pinned case is the wrong signature.The floor the export reports is the floor the WAL derived on its last pass. The receiver passes
SnapshotHorizons— what it knows — and the WAL combines them with its own ledger (§3.2) intoRetainFloor, returns it inHousekeepingProgressand keeps it forreclaim_state(), with its lag (§3.5) and itsPinnedcount. Between passes that is by definition the floor governing retention, so the export is never stale and needs no hidden coupling; before the first pass it reportsUnknown, whichmaintaintreats as “reclaim nothing” and the export shows as its own state — notNone, which would claim no consumer exists.The cap’s value is WAL configuration —
WalConfig::max_unlinks_per_pass, proposed default 128, validated atWal::opento be at leastrotation_retry_attempts(RFC0052.4’s one-pass debris clearance depends on it), refusing to open withOpenError::InvalidConfiglike the other §6.9 tunables, because only the WAL knows both numbers — and the coordinator passes it tohousekeeping_prepareexplicitly rather than the trait reading it, so a test can drive any cap through a double and the trait stays free of configuration.HousekeepingProgressreports whether the pass hit the cap, so the caller can tell “backlog drained” from “more to do” without re-deriving it. Without both, RFC0052.12’s bounded-stall contract has no way to be implemented.The concrete impl maps
CheckpointErrorandHousekeepingErrorintoReclaimError; aBox<dyn Journal>cannot infer an associated error type, which is why one enum rather than two.ReclaimStateis a plain snapshot struct carrying what §3.5 exports — the existingunflushed_bytes,segment_countand best-effortdisk_bytes(a diagnostic, and the only path the trait object has to it), all unreclaimed bytes and the age of the oldest unreclaimed frame (the bytes being what RFC 0053’s bound is taken on, and not a below-checkpoint figure), the retain floor with its lag andPinnedstate, and the rotation-failure state — so the server reads it without reaching past the trait.disk_bytesis not reused for the admission measurement.WalMetricsdocuments it as best-effort: its directory walk skips unreadable entries and reports no error when the walk itself fails, so a silent undercount would let admission sail past the bound — the one place a best-effort number is unacceptable. The unreclaimed-byte figure is maintained incrementally from appends and unlinks, as the WAL already does forunflushed_bytes, anddisk_bytesstays what it is: a diagnostic. The figure is seeded after recovery, not atWal::open: open runs beforerecovery::recoverreplays and heals the newest segment, so a seed taken there would count torn bytes. Recovery therefore ends — after every successful replay, and after the heal when there was a torn tail to heal, since a clean replay never enters that path — by callingWal::rebuild_ledger(), a concrete method since recovery holds the WAL before it is boxed. A size figure is not enough for it: the ledger §3.2 relies on needs, per surviving segment, which tenants have frames in it and each one’s first and last offset, and the partial list needs the directory’s.wal.partialentries. Sorebuild_ledger()is a scan, not astat: it walks every surviving segment oldest-first, validates each frame prefix (length, checksum) exactly as replay does, decodes the tenant from theTenantOtlpBatchprefixappend_batchframed, and records the per-tenant offsets and the frame bytes into the ledger; the unreclaimed-byte figure is the sum of the validated frames’ lengths — never file size less header, which would count a torn tail — and the current segment’s own frame bytes are seeded from the healed newest segment in the same pass. Replay already reads every frame, so an implementation may build the ledger as a by-product of the replay pass rather than a second scan; the contract is the ledger’s content, not the number of passes. The coordinator is constructed after recovery (the ordering below), so no append can precede the seed, and a restart mid-outage resumes from the true backlog rather than from zero.unflushed_bytesstays its own method: the group-commit coordinator reads it per batch and must not allocate a snapshot struct on that path.On the trait rather than via a downcast, because RFC0052.1 and RFC0052.2 need test doubles that can observe reclamation.
-
Recovery gains the Parquet-side gate §3.1 requires.
recovery::DriverSinkowns only the miner today and merely recordsparquet_horizon— andMinerCluster::ingestalready emits every mined record to the sinkservewired in before recovery, so a second sink handle in the driver would emit replayed rows twice. Recovery therefore drives the miner through a capture path (ingest_mined, or the configured sink disconnected for the replay) so that replay emits nothing implicitly, feeds the miner only above that tenant’s snapshot horizonS, as now, and forwards the captured records to the record sink only abovemax(X, S)per tenant, and captures the miner’s audit events the same way — through a replay capture sink the driver installs, which receives both mined records and audit events tagged with the offset of the frame being replayed, sinceAuditEventcarries no offset andingest_mineddiverts only records — forwarding events only for frames aboveX(the barrier drains the audit sink before it stamps, soXis the audit horizon): events for(S, X]are suppressed and counted, events aboveXforwarded, and neither duplicated nor lost. Regeneration by the miner is the only source of replayed events: storedAuditEventframes are not replayed — nothing writes them today (encode_audit_eventisunimplemented!(), RFC 0008 §9’s deferral) and recovery keeps ignoring the kind — and an encoder that lands later must either keep replay on regeneration or amend this leg, never feed both, or an event would be injected twice; RFC0052.10 gains the audit leg —max, because the snapshot is written before the checkpoint is persisted, so a successful snapshot followed by a failed checkpoint write leavesS > Xwith(X, S]already in Parquet. Frames below that gate are suppressed on the Parquet side and counted, so a restart never republishes a row, and RFC0052.10’s no-duplicate leg — including theS > Xordering — is what proves the gate exists rather than assumes it. -
The barrier reaches them through the coordinator, which already owns the journal mutex, rather than taking a second handle to the same WAL. A second handle would put two owners on a single-writer resource, which is the one thing RFC 0008 §3.1 forbids. Concretely,
CommitCoordinatorgainsmaintain(&self, horizons: &SnapshotHorizons, cap: usize) -> Result<HousekeepingProgress, ReclaimError>— the documented housekeeping contract, no separate report type — andcheckpoint(&self, mark: WalOffset) -> Result<(), ReclaimError>;servereaches it from the timer throughSharedPipelineand runs it on the blocking pool because it does file I/O.maintainis where §3.2’s two halves become a protocol, because the coordinator’s journal isMutex<Box<dyn Journal>>and a singleJournalcall would keep every unlink and fsync inside that guard, in front of every concurrent append:maintainlocks the journal forhousekeeping_prepareand releases it; runs the file half itself on the plan’s owned paths — merge and write the inactiveRECLAIMslot in place, fsync, then the unlinks, then the parent fsync — with no guard held; and locks again forhousekeeping_commitwith the outcome. Between the two the WAL keeps serving appends and rotations, which never touch an entry marked reclaiming. One plan is outstanding at a time by construction (one housekeeping task), and a plan that is never committed — the task panicked between the halves — strands nothing: the nexthousekeeping_preparere-plans every entry still marked reclaiming ahead of anything newly eligible, the record merge is idempotent and monotonic, and an unlink that finds the file already gone counts as removed. A crash between the halves reconciles at open the same way, with no special case:rebuild_ledger()rebuilds the ledger from the segments that survive, so a segment unlinked before the crash is simply absent and one not yet unlinked is present and still eligible under the horizons the durableRECLAIMrecord already carries, since the record was written before the first unlink; the open-time directory fsync makes the surviving set definite before the walk. -
Option<WalOffset>skips, and the post-recovery call is an explicit exception.Nonemeans no high-water mark is known, and there is then nothing to declare reclaimable.Noneis not the same as “post-recovery”, which the earlier draft conflated. The post-recovery call passes a mark derived from replay, and notreport.max_deliveredon its own: a frame can be delivered by replay whose group sync never completed — its bytes survived the crash, its batch was never acknowledged — and §3.1’s rule is that such a frame is never a mark. The seed is therefore the highest offset that is both delivered and covered by a sync known to have succeeded — and the only such offset that survives a crash is theCHECKPOINTmark.last_durableis in-memory state the running process rebuilds, persisted nowhere, so it cannot seed anything after a restart; an earlier draft said “the checkpoint, or thelast_durablethe sidecar carries”, and the second half of that was wrong. So the seed is the checkpoint mark when one exists andNoneotherwise — a node whose first barrier never ran — with the first successful turn establishing it, which costs one barrier and never stamps across an unacknowledged frame. The call runs before the commit coordinator exists, so there is no journal owner to checkpoint through. That call therefore deliberately advances no checkpoint; the first timer pass after the coordinator is built does it instead, from the same mark or a later one. The post-recovery snapshot write stamps each tenant at its own folded horizon (§3.1), read fromMinerCluster::folded_horizonand written byrecovery::write_folded_snapshots, the one writer the shutdown point shares: restoring a snapshot sets the tenant’s horizon to the one it carries, and replay advances it to each frame it feeds. So when replay delivers nothing — the normal shape once housekeeping has reclaimed every closed frame and the node restarts idle — each tenant keeps its restored horizon rather than havingNonewritten over it, which would discard the snapshots on the next restart and force a full replay every time; and a replay that delivered frames for tenant A only records A’s own last frame and keeps B’s restored horizon, which no globalmax_deliveredcould express. The snapshot ledger is seeded from what that write installed, and fail-closed: a skipped or partly failed write falls back to the horizons recovery restored, never to the higher ones it attempted, so the ledger is never above what is on disk. Before any of it — inside recovery, after the ledger rebuild — a pre-RFC root runs the §3.2 legacy stale-gap check against the marks its version-1 artefacts recorded, since the post-recovery write replaces those artefacts and would destroy the evidence. §3.1’s “behind the barrier and nowhere else” holds — this is a caller that has the mark but not yet the owner, not a second checkpoint site. -
Errors are fail-closed and never swallowed. A failed
checkpointlogs and leaves the in-memory checkpoint unadvanced, which is alreadyWal::checkpoint’s own behaviour; the WAL then reclaims nothing past the previous mark, which stays usable — RFC0052.1 requires exactly that, not that every segment is kept. A failedhousekeepinglogs and the next pass retries, since nothing was unlinked past the bound. Neither failure fails the barrier: the data is in object storage either way, and refusing to ack over a reclamation error would turn a disk-space problem into an availability one.
One consequence worth naming. Housekeeping takes the single-writer
position for its ledger half, so every append stalls for that half’s
duration — which is why §3.2 and §3.7 put the whole file half (the
RECLAIM write, the unlinks and the parent fsync) outside the journal
mutex, between housekeeping_prepare and housekeeping_commit, and why
the ledger half does no directory walk at all. Running the file half on
the blocking pool while still holding the lock would bound nothing: the
stall is caused by holding the journal mutex, not by occupying a runtime
worker. What remains under the lock is the capped ledger work RFC0052.12
bounds.
The pass is therefore capped on the unlinks, and the eligibility
evaluation costs no I/O at all. The current path enumerates and sorts every
*.wal and reads each candidate’s header before unlinking any, so a
1,113-segment backlog did 1,113 header reads under the mutex however few files
it then removed — which is why an earlier draft capped the inspected
candidates too. That cap is withdrawn, because it conflicts with tenant-aware
eligibility: the oldest segment may be pinned while a later one is eligible,
and a pass that inspected only the oldest few would stop at the pinned one
every tick and never reach the eligible one. It is also no longer needed:
everything a header read supplied — the segment’s highest offset, each
tenant’s last offset in it, its frame bytes — lives in the per-segment ledger
the WAL maintains in memory (§3.2). An O(n) walk of that ledger under the
writer lock would still not be a bound — n is exactly what a pinned tenant
or a store outage leaves unbounded — so eligibility is indexed
incrementally rather than evaluated per pass: the WAL keeps, per segment,
the set of tenants whose horizon is still below their last offset in it;
applying new horizons (the SnapshotHorizons input, under the mutex)
shrinks those sets for the segments those tenants span — lazily and
capped: at most the cap’s worth of segments per pass, resumed from a
per-tenant cursor on the next, so a tenant whose snapshot catches up after
the incident’s 1,113-segment outage cannot hold the writer position for the
whole backlog — and empty-set segments sit in an ordered structure keyed by
highest offset. The cursor is defined once, here. Per tenant it holds the
highest segment offset up to which that tenant’s horizon has been applied;
horizons are monotonic per tenant and a tenant’s last offsets rise with the
segments, so application is a prefix walk oldest-first, and a higher
horizon only extends the walk’s target — the cursor never moves backwards
and is never reset by a checkpoint advance, which touches no set. Each pass
spends its application budget round-robin across the tenants whose cursor
is behind their horizon, so one tenant cannot starve another’s. The only
rebuild is at open, when rebuild_ledger() rebuilds the ledger and
every cursor starts at the oldest surviving segment; the first pass after
startup applies from there. The eligible head needs no cursor of its own:
it is re-evaluated from the ordered structure on every pass. There is no promotion pass: a checkpoint advance stores
the new mark, O(1) under the mutex, and a segment is eligible when it is
empty-set and its key is at or below the stored mark — a predicate the
pass evaluates lazily at the head of the ordered structure, popping while
the head’s key is at or below the checkpoint and at most max_unlinks
times. An earlier draft promoted every newly covered empty-set segment into
an eligible queue on the checkpoint advance, in one ordered pass; that pass
is O(backlog) under the journal mutex — on the incident’s 1,113 segments it
stalls every append before the capped unlink even starts — and contradicts
the bound this section exists to give, so it is withdrawn. Nothing is
stranded without it: a segment whose set empties above the checkpoint waits
in the ordered structure and is at its head once the mark passes it, and a
segment the mark passed while its set was non-empty is found once a horizon
application empties it. The pass therefore does at most the cap’s worth of
horizon application plus at most the cap’s worth of pops — O(cap) under the
writer lock, with no name listing, since segments are known from the ledger
— and HousekeepingProgress reports capped from either half hitting its
budget, plus the two remaining-work figures horizon_remaining and
unlink_remaining (§3.7 defines their aggregation), both exported (§3.5).
RFC0052.12 is worded to that guarantee. The next tick continues where this one left off — the
per-tenant cursor is the resume point for horizon application, and the
eligible head is re-evaluated from the ordered structure, oldest first, so
it needs none. That matters most on
the first pass of a node that has never reclaimed — the incident node held
1,113 segments, and an uncapped pass would have stalled ingest for as long as
1,113 unlinks take. In steady state, after §3.2 is running, a pass has a
handful of segments to consider and the cap never binds.
3.8 Tunables classification
RFC 0008 §6.9 classifies every operator-visible WAL knob as exactly one of
Tunable or Invariant, with a startup-validated range and the
CLAUDE.md §3 invariant it lives inside (RFC 0004 §3.1’s schema). This RFC
adds knobs and gives two existing ones their first caller, so it carries its
own rows in the same schema. Names are the WalConfig fields; §6.9’s
wal_-prefixed spellings are the same knobs on the operator surface. All
are process-global, as §6.9 requires — the WAL is one workspace-wide
log — and every range below is validated in Wal::open, which refuses to
open with OpenError::InvalidConfig rather than clamping, exactly as the
existing tunables do.
| Knob | Class | Default | Validated range | Inside invariant |
|---|---|---|---|---|
barrier_secs (new, §3.1) | Tunable | 300 — the record sink’s age trigger, so the barrier adds no sub-target object a size or age flush would not already write | 1..=3_600 | §3.4 — sets how long an acknowledged frame can sit unpublished before the checkpoint can advance past it, and with it the recovery replay depth |
housekeeping_secs (RFC 0008 §6.9; first caller here, §3.2) | Tunable | 60 (unchanged) | 1..=3_600 (unchanged) | §3.6 — bounds local-disk pressure; this RFC supplies the pass the knob was always meant to drive |
segment_age_secs (RFC 0008 §6.9; second caller here, §3.2’s idle rotation) | Tunable | 600 (unchanged) | 1..=86_400 (unchanged) | §3.4 — bounds recovery window; the idle rotation makes an idle node’s last segment reclaimable within segment_age_secs + barrier_secs + housekeeping_secs |
rotation_retry_attempts (new, §3.3) | Tunable | 3 consecutive failed attempts | 1..=16 — a budget of zero would make the first transient failure terminal, and a large one delays the terminal state an operator must act on | §3.4 — decides when a rotation failure stops being retried and starts refusing appends; no batch is acknowledged in either state |
max_tenants (RFC 0053 §3.1; sizes this RFC’s record, §3.2) | Tunable | 1024 (RFC 0053’s default) | 1..=65_536 — the format ceiling, since RECLAIM’s dictionary is addressed by u16 slot ids and slot id 0 is usable; a larger value is refused at config validation naming that reason, and Wal::open separately refuses a value above the capacity the file was built for | §3.6 — a raised value needs a longer slot than the preallocated file has, so it is an open-time decision rather than a free edit |
max_unlinks_per_pass (new, §3.7) | Tunable | 128 proposed (§7 leaves the value open; the knob, its class and its range are settled here) | rotation_retry_attempts..=65_536, validated against the retry budget in the same config so RFC0052.4’s one-pass debris clearance holds | §3.6 — bounds how long one pass holds the journal mutex, and so the stall an append can see |
RECLAIM sidecar format (§3.2) | Invariant | per §3.2 — b"OWRC" magic, u16 version 1, a 32 B file header and two slots each closed by a u32 CRC32-C over its own preceding bytes, with a u64 generation, a per-slot tenant dictionary (u16 slot ids over 128 B keys, RFC 0048 §3.1’s bound), the two capacities it was built for and rebuilt at when they rise, header_flags (checkpoint_armed, checkpoint_seen, and RFC 0053’s published_seeded_armed / published_seeded_confirmed), consumer_mode, per-tenant entries and the planned list; preallocated at a fixed slot_len from max_tenants × max_unlinks_per_pass and rewritten in place, never extended | n/a | format compat — startup’s fail-closed reading is only as trustworthy as a fixed shape, and the in-place rewrite is what keeps the commit allocation-free on a full disk |
CHECKPOINT sidecar format version 2 (§3.2) | Invariant | 2 (was 1; RFC 0008 §6.9’s row is amended) | n/a | format compat — the version is the witness that separates a pre-RFC root from a lost record |
| Segment-header format version 2 (§3.2) | Invariant | 2 (was 1; the reader accepts both) | n/a | format compat — the version every root with a segment carries, and so the witness that separates a pre-RFC root from one that lost both sidecars, including a NoConsumer root with no snapshots |
| Snapshot artefact format version 2 (§3.1) | Invariant | 2 (was 1) | n/a | format compat — wal_high_water changes meaning from a global mark to a per-tenant folded horizon, and one byte must not carry both |
max_unlinks_per_pass is the one row whose validation reads another knob,
which is why it lives in the WAL rather than in the caller: only Wal::open
sees both numbers. The four Invariant rows are the persisted shapes this
RFC introduces or bumps; each carries the ! marker for the implementing
PR under the pre-production layout policy, and none is operator-settable.
4. Alternatives considered
Checkpoint on its own timer, independent of the publication barrier. Rejected: it would need to re-derive “everything at or below this offset is durable in the store”, which is precisely what the barrier computes under the miner lock. A second derivation is a second thing to get wrong, and the two could disagree.
Checkpoint at every successful cadence flush rather than at the barrier. Rejected: a cadence flush is per-partition and best-effort, and does not establish the whole-sink condition. It would advance the checkpoint past partitions that had not drained.
Leave the quiesce permanent and add an operator verb to clear it. This is what the current comments promise, so it deserves a real answer: it is honest and simple, and it puts a human in the loop before writes resume after a durability fault. Rejected as the primary mechanism because the fault it most often fires on is transient and self-correcting, and requiring a human for a condition that has already cleared is how the incident produced eight hours of downtime. A bounded retry that gives up into a distinctly-reported terminal state keeps the human in the loop for faults that are actually persistent. An explicit verb remains worth adding later for the terminal state.
Tolerate a torn header in Wal::open instead of unlinking the orphan.
Rejected: it weakens the corruption detection RFC0008.5 depends on in
order to avoid one remove_file, and a zero-frame segment with an
unreadable header is indistinguishable from a real corruption at open
time.
Retry rotation from a background task. Rejected: the WAL is single-writer by design (RFC 0008 §3.1) and owns no task. A background retry would need to take the writer position away from the append path, which is a far larger change than retrying where the need arises.
The alternatives to backpressure and to requeue-on-unwind — a hard sink ceiling, dropping on unwind — moved with those designs to RFC 0053 §4. The sink-ceiling finding there (memory can OOM before any disk bound fires) is a prerequisite of RFC 0053, not of this RFC: nothing here bounds memory, and nothing here claims to.
5. Acceptance criteria
Scenario RFC0052.1 — The checkpoint advances only behind a proven publication barrier
- Given a WAL with acknowledged frames and a record sink whose store is healthy
- When the publication barrier completes with both sinks fully drained
- Then the journal’s checkpoint is advanced to the barrier’s high-water mark
- And when either sink retains anything, no checkpoint is attempted and
last_checkpoint()is unchanged- And when the high-water mark is
None, no checkpoint is attempted- And while the
cadence_failedlatch holds an epoch at or below the cut’s, the barrier neither checkpoints nor snapshots, however many timer passes run, until a restart- And an age-sweep publish registered before the barrier began, which panics while the barrier waits in
quiesce_publishes, leaves the checkpoint and every snapshot unchanged: the latch set after the barrier’s first check is observed at its recheck before stamping, and the publish’s outcome is reported failed independently of it- And an encode worker panicking mid-batch, followed by a barrier, leaves the checkpoint and every snapshot unchanged, and the batch’s unemitted records are replayed on restart
- And the same holds when the barrier is started concurrently with the worker’s panic, under every schedule: a barrier that observes the pool’s pending count at zero has observed the latch, because the unwinding guard stores it before the decrement that settles the count
- And a panic in the barrier tick itself — outside any batch guard — lowers
failed_epochto that tick’s epoch, invalidates the pending slot, and the barrier task takes the next tick; a panic in a housekeeping tick counts witherror.type = cadence_panic, leaves the checkpoint untouched, and the next pass re-plans the uncommitted plan; aJoinErrorfrom either task at shutdown is logged and read as a failed cut- And a batch queued before cut
E’s capture and dequeued after it carries epochE— assigned insubmit, not at dequeue — so a panic in it fails cutE, not only later ones- And a partition detached by that batch’s first record, with the cut captured between the detach and the enqueue, is covered: the publish guard was created in
submitbefore the exclusion was released, so the cut waits for it rather than stamping past it, and a batch that detaches nothing releases its guard unused- And a batch that detaches several partitions settles its guard only when the last of them completes, in any completion order, so a cut started after the first completes still waits for the rest
- And a publisher panic with batches still queued behind the failing one latches only the failing batch’s epoch, parks every queued batch in the buffers with its guard released, and a
quiesce_publishesstarted during the panic returns; the next enqueue finds a respawned publisher- And a worker whose send fails on a closed channel parks that batch in the buffers under the sink lock before releasing its guard, so the records are covered by the next drain rather than silently settled, and the publisher is respawned
- And a detached partition whose
audit_watermarkis covered by another writer’s in-flight audit write is not published until that write is durable: an empty audit buffer is not read as a durable prefix, and when that write fails the dependent partition requeues rather than landing in Parquet ahead of its template events- And when the checkpoint write fails, the barrier still reports success,
last_checkpoint()is unchanged, and the next housekeeping pass reclaims nothing that was not already eligible under the previous mark —Wal::checkpointleaves that mark intact on failure, so forbidding all reclamation would reject the fail-closed behaviour §3.1 specifies rather than test it
Scenario RFC0052.2 — Segments are reclaimed, and never past the minimum tenant snapshot floor
- Given a checkpoint advanced past several closed segments and two tenants whose snapshot horizons differ, the lagging one below that checkpoint
- When housekeeping runs
- Then only segments at or below the checkpoint (inclusive) whose every tenant’s last frame in them is at or below that tenant’s horizon (inclusive) are unlinked, the current append segment survives, and the WAL’s segment count falls
- And a segment whose only frame for an idle tenant is exactly that tenant’s horizon frame is unlinked — the horizon is durably installed by construction, so its segment is not needed — and a restart afterwards restores the tenant from its snapshot with no stale-gap report, the
RECLAIMentry at or aboveSexplaining the absent segment- And a segment holding a frame above a tenant’s horizon is retained until the tenant’s horizon reaches it
- And a frame above the lagging tenant’s horizon is still present after the pass, so a restart re-mines it rather than losing that tenant’s miner state
- And when a tenant with WAL data has no valid snapshot, the pass retains exactly that tenant’s segments (RFC0052.13’s pin) and still reclaims a later segment holding only other tenants’ covered frames, while the temp sweep still runs
Scenario RFC0052.3 — Sustained ingest does not grow the WAL without bound
- Given a node ingesting continuously at a rate its publish and reclamation path can sustain (the soak is capacity-balanced and pinned as such), with a healthy store and every tenant with WAL data holding a valid and advancing snapshot (a complete floor), run long enough to roll many segments
- When the reclamation cadence has had time to act
- Then the WAL’s on-disk byte total and segment count are bounded rather than monotonically increasing — the #793 signature (1,113 segments, nothing ever unlinked) cannot reproduce
- And while the floor is
Pinnedthe pinning tenants’ frames are retained by design and the state is visible (RFC0052.13); a healthy store alone does not bound the WAL, a complete floor does- And an offered rate above that capacity grows the WAL by construction until RFC 0053’s admission bound exists; this criterion claims no bound there
- And after the last append, with no further traffic, one
barrier_secsplus onehousekeeping_secslater the checkpoint has advanced and every eligible closed segment is reclaimed — the append-independent path, which continuous traffic alone cannot prove — and withinsegment_age_secs + barrier_secs + housekeeping_secsthe last segment has rotated on a barrier tick and been reclaimed by the following pass
Scenario RFC0052.4 — A transient rotation failure recovers without a restart
- Given a WAL whose rotation fails once with a transient I/O error — triggered by an append, or by the barrier task’s idle rotation
- When the condition clears and, for a failure before the rename, a later
appendarrives — or, for the post-rename directory fsync, the nextsyncruns- Then the pre-rename case re-enters
rotate, which succeeds, and the append is accepted and acked; the post-rename case discharges the pending directory fsync insyncwithout re-enteringrotate, and the batches behind it are acked- And when the failure was before the rename, no file a subsequent
Wal::openwould select as a segment remains from the failed attempt — asserted by opening the WAL again, not by inspecting the directory, since the temporary name is an implementation detail- And when the failure was the post-rename directory fsync, the installed segment is selected by a subsequent open — it is complete and valid — and the first
syncafter open discharges its pending directory fsync- And the temporary files left by the failed attempts are gone after successive capped housekeeping passes —
WalConfig::max_unlinks_per_passis validated atWal::opento be at leastrotation_retry_attempts(open refuses otherwise), so one rotation’s debris clears in one pass — and a persistently retrying node cannot fill its disk with retry debris
Scenario RFC0052.5 — A persistent rotation failure gives up distinguishably, and never acks
- Given a WAL whose rotation fails on every attempt — in
rotateitself, or in the directory-fsync discharge thatsyncretries- When appends continue past the bounded retry count
- Then every append is refused, no batch is acknowledged, and the refusal is reported as the terminal state rather than as a transient one
- And the first underlying I/O error is still recoverable from the reported state, not replaced by a generic “quiesced” message
- And
Wal::openon the resulting directory succeeds rather than reporting corruption, including when the cleanup of the last attempt’s temporary file never completed
Scenario RFC0052.11 — A node already wedged before this RFC halts actionably at open
- Given a WAL directory in the state today’s rotation failure leaves: a newest
*.walwhose header bytes are partial, written before this RFC landed- When the WAL is opened
- Then it still reports
OpenError::Corruptrather than unlinking anything — §3.3 withdrew the shape-based heuristic, because an unreadable newest segment is indistinguishable from real corruption- And the error names the file, describes the observable shape and names a failed rotation as one possible cause without asserting it, so an operator can decide deliberately
- And a node whose rotations all happened under this RFC cannot reach that state at all, so the population is finite and shrinking
Scenario RFC0052.12 — A reclamation pass bounds its per-file work
- Given a backlog far larger than
max_unlinks_per_pass(the incident’s 1,113 segments is the shape)- When a housekeeping pass runs
- Then it unlinks at most the cap and returns, having read no segment header and listed no directory — eligibility comes from the incremental index, and the pass pops at most the cap from the eligible queue — so an append taken concurrently waits for O(cap) ledger work under the writer lock whatever the backlog, and never for the
RECLAIMwrite, an unlink or an fsync, which run betweenhousekeeping_prepareandhousekeeping_commitwith the journal guard released — asserted by an append that completes while the file half is held at a fault-injection point; applying changed horizons is itself capped — at most the cap’s worth of segments per pass, resumed from a per-tenant cursor next pass, so a tenant catching up after a long outage cannot hold the writer position for the whole backlog — and an unchanged pinned backlog costs none- And a checkpoint advance is O(1) under the mutex — it promotes nothing eagerly, whatever the backlog — and a segment whose set emptied above the old checkpoint is reclaimed by the first pass after the mark passes it, so it is not stranded when horizons stop changing
- And stale partials left by a previous process are removed from the list seeded at recovery, without a directory listing on the pass
- And a pinned oldest segment does not shadow a later eligible one: the pass reclaims the eligible segment on its first tick
- And successive passes drain the backlog to the same end state an uncapped pass would reach
- And a backlog of stale temporary files is also bounded by the same cap, so temp sweeping cannot make a “bounded” pass do unbounded work
- And with a segment backlog larger than the cap and stale partials present, the first pass removes the partials and spends only the remainder of its cap on segments
Scenario RFC0052.16 — The temp sweep touches only files of the reserved partial shape
- Given a WAL root holding a
CHECKPOINT.tmpand aRECLAIM(which has no temp of its own — it is rewritten in place, §3.2), a snapshots directory holding a*.snap.tmp, and a stale<uuid>.wal.partial- When a housekeeping pass runs
- Then only the partial is unlinked: the checkpoint temp, the reclaim record and the snapshot temp survive
- And the unlink is followed by a parent-directory fsync, so a crash cannot resurrect it
Scenario RFC0052.15 — Only the terminal rotation state is reported server-terminal, client-retryable
- Given §3.3’s bounded retry, a rotation failure that is still within the budget, and one that has exhausted it
- When each is reported on both transports
- Then all of them carry
503/UNAVAILABLE— RFC0018.3 still holds, and a non-retryable code would tell the client to drop an unacked batch- And a failure still within the retry budget is classified transient and its message says it is retrying, because §3.3 means a later append genuinely can succeed; no retry hint is carried, since the server schedules no retry of its own
- And only the terminal state is classified server-terminal, client-retryable — RFC 0018 §3.2’s third class — with the message naming that state and still no retry hint, so a conforming client keeps the batch and backs off exponentially
- And an ordinary append or fsync I/O failure stays transient, so the reclassification is narrow rather than a blanket change to RFC 0018 §3.2’s transient class
#794 originally classified every rotation failure as wedged, which was correct for today’s permanent latch and wrong the moment §3.3 makes rotation retryable. That half of #794 was split out and is held on
hold/794-wedged-classification; it lands with this RFC, rewritten to the terminal-only rule above. The merged half of #794 is theStatusbody conformance alone and stays correct under both.
Scenario RFC0052.14 — The timer cannot stamp across a concurrent submit
- Given the reclamation timer firing while ingest submits continuously
- When the timer runs its sequence
- Then no checkpoint is advanced past a frame whose encode had not finished its sink emit, under any interleaving
- And the mark used is the one read after the quiesce under the same exclusion, not one read before either
- And that mark is a turn’s own frame offset, never the sync’s reported EOF, and nothing replay delivered is ever a mark: a restarted node has no mark until its first acknowledged turn establishes one, so a replayed frame whose group sync failed before the crash is never checkpointed across (a replayed seed, where a caller still supplies one, stays distinct from a mark; whether that seed survives is #883) — so a later frame made durable by the same flush but not yet mined (nor acknowledged) is never covered — asserted by a flush whose sync covers two turns and a barrier between them
- And a rotation capture that would take the pending cut past the sink’s ceiling parks every batch it drained and advances neither the mark, the snapshots nor the epoch, so the checkpoint that pending cut eventually stamps covers only frames its own batches held, and the parked partitions are covered by the next cut
- And an idle rotation on the barrier tick rotates before the cut, under the exclusion: the mark is the last acknowledged turn’s frame offset in the closed segment, never the rotation boundary, a frame appended after the release lands in the new segment above the mark, and a frame written but never acknowledged is never a mark
- And a pre-cut batch whose first record detached a partition mid-batch has its remaining records in the cut, under any interleaving: the quiesce waits for the batch’s encode phase, not for a worker to register a publish, and the detached partition’s PUT is not waited on
- And with several partitions detached from one pre-cut batch and completing in any order, the cut covers every record: the batch’s shared completion holds the in-flight count until the last of them finishes
- And a rotation-fired cut performs no store I/O inside the ingest turn: the turn hands the captured cut to the barrier task, and an append admitted after the turn is neither in that cut’s checkpoint nor in its snapshot
- And cuts are strictly ordered: with cut A’s flush held at a fault-injection point and cut B captured behind it, B neither installs nor checkpoints before A’s outcome; when A then fails, B is invalidated — no snapshot of B’s is installed, no mark of B’s is stamped, A’s and B’s records are all in the buffers — and the re-captured cut covers them all; when A succeeds instead, B runs unchanged
- And a failed publish refuses only cuts captured before its requeue: the first cut captured after the requeue stamps, so one transient failure cannot refuse every later cut
- And an age-sweep publish registered after the cut — between the barrier’s
quiesce_publishesand its checkpoint — that fails transiently or panics is neither covered by that checkpoint nor lost: every frame it holds is above the cut’s mark, a transient failure requeues it, and a panic — carrying the next epoch — fails no cut at or below the current one, which proceeds, and fails every later barrier until restart; the barrier does not re-take the exclusion around its store I/O to reach that
Scenario RFC0052.13 — A tenant without a snapshot pins the floor, and is never read as unbounded
- Given a snapshot consumer exists and one tenant with WAL data has no valid snapshot
- When housekeeping runs
- Then every frame of that tenant survives, segments below its oldest surviving frame that hold only other tenants’ covered frames are reclaimed, and the floor is reported as
Pinned— distinguishable in the API from the no-consumer case, which reclaims by checkpoint alone- And once a valid snapshot for that tenant is written, the pin lifts and the next pass reclaims what it had held
- And a tenant leaves the ledger only when its last surviving segment is unlinked, so tenant churn cannot leave a permanent pin
- And a segment holding exactly one frame for the pinning tenant survives the pass — a pinned tenant has no horizon, so equality can never unlink its frame — while a later segment holding none of its frames is reclaimed
- And a snapshot listed at startup is used as a horizon only after the snapshots root and its parent have been fsynced in this process; a failed startup fsync fails startup rather than discarding snapshots whose frames reclamation may already have removed, so a horizon whose directory entry may not be durable never governs reclamation and no state that only a snapshot could rebuild is thrown away
- And the state is exported (§3.5), so an operator seeing a WAL that will not shrink can tell it is a pinning tenant rather than an unexplained stall
Scenario RFC0052.6 — moved to RFC 0053 as RFC0053.1 (backpressure). The number is retained so review-history references resolve; it carries no obligation in this RFC.
Scenario RFC0052.7 — The WAL’s state is exported
- Given a running node
- When metrics are collected
- Then the WAL’s unflushed bytes, on-disk bytes, segment count, all unreclaimed bytes and the age of the oldest unreclaimed frame (the bytes being what RFC 0053’s bound is taken on, including the post-checkpoint tail), the retain floor with its lag and
Pinnedstate, thecadence_failedlatch, the rotation-failure state, and the housekeeping backlog (horizon application remaining and unlinks remaining, in segments) are all present in the exported stream under registry names- And a run whose checkpoint never advances still reports growing unreclaimed bytes — exporting the below-checkpoint figure instead would read as flat during exactly the outage it exists to show
- And entering the terminal rotation state emits exactly one log event, named from the registry; leaving it is a restart today, which a fresh WAL cannot observe, so no leave event exists until §7’s operator verb does, and that verb is where it would be emitted — the refusing state’s events are RFC0053.5’s
Scenario RFC0052.8 — moved to RFC 0053 as RFC0053.2 (unwind keeps the records). Number retained; no obligation here.
Scenario RFC0052.9 — moved to RFC 0053 as RFC0053.3 (the cadence survives a panic). Number retained; no obligation here.
Scenario RFC0052.17 — The reclaim record is the only startup witness, and it is fail-closed
- Given a WAL root housekeeping has reclaimed from under per-tenant horizons, so
RECLAIMholds an entry per reclaimed tenant- When the node restarts with one tenant’s snapshot undecodable
- Then recovery halts naming that tenant when the record holds an entry for it and the tenant has a surviving frame, and proceeds with that tenant pinned at its oldest surviving frame when the record holds none
- And a root with no record and no checkpoint at all — the pre-RFC layout on a node that never checkpointed — opens, gains an empty record durably before its first housekeeping pass, and pins rather than halts; a root with a version-1
CHECKPOINTinstead takes the migration path above, where the record is created by the upgrade and not at open- And a record failing its checksum or version byte fails open as
OpenError::Corruptnaming the file, and is never read as missing- And a pass reclaiming under a higher horizon for one tenant leaves every other entry unchanged, and no pass ever lowers an entry
- And a crash injected between the record’s write and the first unlink leaves a record whose entries every restorable snapshot satisfies, so the restart proceeds; a crash injected inside the inactive slot’s write, or between that write and its fsync, leaves the previous slot live — the torn slot fails its CRC or carries the lower generation, and the reader takes the other one — so the next pass re-plans from the record the previous pass left
- And a root that has reclaimed and whose
RECLAIMis then deleted fails open naming the missing record — itsCHECKPOINTis version 2, so the root is post-RFC — rather than recreating an empty record and pinning- And a replay that yields a tenant longer than 128 bytes — a frame written before the codec was amended — fails open naming the frame offset and the length, rather than truncating the key or dropping the frame
- And a root holding segments and neither sidecar fails open naming both when any segment header carries version 2, while a root whose segments are all version 1 — every live pre-RFC root — opens on the legacy branch, and an empty directory with neither sidecar opens as a fresh root
- And the same holds for a
NoConsumerroot, which has no snapshot artefacts to witness with: its version-2 segments are the witness, so losing both sidecars fails closed rather than recreating an empty record over its reclaimed-through proof- And a legacy root that rotates before its first checkpoint stays openable: the rotation writes and fsyncs the record before creating the version-2 segment, so no restart ever finds a version-2 segment beside no record — asserted by injecting a crash between the two steps
- And a configured
max_tenantsormax_unlinks_per_passabove the capacity stored in the header rebuilds the file at the larger geometry throughRECLAIM.new— write, fsync, rename, parent fsync — so a crash at any point leaves either the old file or the new one complete and the entries survive; a configured value at or below the stored capacity opens without rewriting- And a full volume at
Wal::openfails the allocation asOpenError::Ionaming the file, so the node does not start — not as a rotation state, which no running process exists to report — and it starts normally once space is freed- And every pass writes its slot without allocating, whatever the tenant set does, so a node whose volume filled after open still reclaims its way out;
slot_lennever changes for the life of the file- And a node’s own first start is never that state: the empty record is durable before the initial segment is created, so a restart immediately after a fresh
Wal::open— with or without a crash between the two — finds the record and opens normally- And a root holding a version-1
CHECKPOINT(an RFC0008.7 fixture) and noRECLAIMopens as pre-RFC without creating a record, and its first checkpoint rewrites the sidecar at version 2 and creates the record armed on the same path- And that first checkpoint upgrades the sidecar even when its mark equals the one on disk — the idle-node case — while an equal mark on an already-version-2 sidecar still takes the no-write path
- And on a legacy root a tenant whose snapshot is missing and whose oldest surviving frame is above its last recorded horizon fails closed naming the tenant, so a root that reclaimed under a version-1 checkpoint is caught rather than pinned
- And a restart in that migration window — still a version-1
CHECKPOINT, still no record — takes the legacy branch again rather than any fail-closed row- And a housekeeping pass that fires in that window plans no segment, writes no record and is counted as a skipped pass with its reason, while still sweeping stale
.wal.partialfiles, so rotation debris from before the first checkpoint does not survive the window; the pass after the first checkpoint reclaims segments normally- And a root whose
RECLAIMcarriescheckpoint_seen— written after its first version-2 checkpoint succeeded, entries or none — and whoseCHECKPOINTis missing fails open naming both files- And a root whose
RECLAIMcarriescheckpoint_armedbut notcheckpoint_seenwithCHECKPOINTabsent splits on whether segments exist: with no segments — a crash between arming and the checkpoint’s rename on a root that never held one — it opens as a fresh root with an empty record and is re-armed by the next attempt, as does aRECLAIMwith neither flag beside a missingCHECKPOINT; with segments present it is a legacy root mid migration and the record is retained with its arming and its mode, the root opening on the legacy branch with the upgrade retried by the next checkpoint- (The two
PUBLISHEDlegs — slot ids surviving aPUBLISHED-only write, and thepublished_seeded_*flags resolving at open — moved to RFC 0055 as RFC0055.5, since RFC 0055 owns thePUBLISHEDwriter and format. No obligation here.)- And the same record beside a present version-2
CHECKPOINT— the crash after the rename and before the record’s next write — opens normally and is promoted toseendurably at open, never read as a fault- And an armed record beside a still-version-1
CHECKPOINT— the upgrade write failing between the arming and the rename — opens on the legacy branch, reclaims nothing until the next checkpoint retries the upgrade, and that retry succeeds against the arming already on disk; asserted by restarting the node in that state- And a WAL used with no miner state reclaims under
SnapshotHorizons::NoConsumerand restarts without a snapshot without halting: its entries carry that mode and are checkpoint-covered- And a pass whose mode disagrees with the mode recorded in the
RECLAIMheader is refused as aReclaimErrornaming both modes, before anything is planned or unlinked — including on a root that has checkpointed but never reclaimed, where no entry exists to infer from- And a root whose header carries no mode adopts the first pass’s mode and persists it durably before that pass unlinks anything, so no reclamation is ever governed by an unrecorded mode
- And the ingest receiver never passes
NoConsumer: a tenant with no valid snapshot is expressed asPinned(RFC0052.13) and its frames are retained, so no miner-bearing tenant can be reclaimed past its replay need- And a segment whose
unlinkfails after the record was written stays on disk withreclaimed_throughbehind it: a restart with that tenant’s snapshot undecodable pins the tenant rather than halting- And a segment whose parent fsync fails after its
unlinkis an uncertain deletion:reclaimed_throughstays behind it and its bytes stay counted; the next pass re-verifies presence and completes or retries; a restart reconciles it — present ⇒ retained and re-planned, absent ⇒reclaimed_throughraised and no halt, as a completed reclamation — under both outcomes of the injected fsync failure- And a crash between the record write and the commit is reconciled at open the same way: a planned segment still present is retained and re-planned, an absent one raises
reclaimed_throughas the commit would have, and the reconciled record is durable before the first pass- And a record write or fsync that fails unlinks nothing, leaves the WAL’s byte and segment accounting unchanged, and the segments that pass popped are reclaimed by a later pass once the write succeeds — they are never lost to the ledger
Scenario RFC0052.10 — No acknowledged record is lost across the whole cycle
- Given a node killed with
SIGKILLmid-batch while reclamation and rotation retry are both live- When it restarts and recovery completes
- Then every acknowledged record is present in Parquet, including those whose segments were candidates for reclamation at the moment of the kill
- And recovery itself republishes nothing at or below
max(X, S): a kill that follows a failed snapshot write and an advanced checkpoint replays frames at or below the checkpoint into the miner only, never into the record sink — a client retry that wrote a second equivalent frame (RFC0003.2’s at-least-once contract) is unchanged by this RFC and is not what this leg counts- And the same holds when the snapshot write succeeded and the checkpoint write then failed, so a tenant restarts with
S > X: nothing in(X, S]is republished- And the audit stream is gated the same way: no template event for a frame at or below
Xis forwarded again on replay, and every event the miner regenerates for a frame aboveXis forwarded exactly once, in frame order, through the capture sink, with storedAuditEventframes ignored as a source; the test mines the same frames from the same snapshot with the same injected clock as a reference and asserts the forwarded set equals the reference’s events for(X, tail]- And (2026-10-06, coordinating with RFC 0059 §3.4, maintainer-approved) the one exception is an event at or below
Xof a template replay minted afresh: its id lies above RFC 0059’s template-id high-water, so no published event binds it, and it is forwarded once, in frame order; the test’s reference forwards the(X, tail]events plus those(S, X]events, andaudit_events_suppressedcounts every other(S, X]event
6. Testing strategy
Per CLAUDE.md §6.2, mapped to the §5 ids.
-
Checkpoint policy (RFC0052.1) — unit tests over the barrier with a healthy store and with a store that fails a partition write, asserting
last_checkpoint()advances in the first case and is untouched in the second; plus a third leg that fails the checkpoint sidecar write and its directory fsync with a healthy store, asserting the barrier still reports success, the previous mark stays usable and the next pass reclaims only under it — the fail-closed branch a partition-write failure cannot reach. Two unwind legs use seeded interleaving, as RFC0052.14 does: a test sink whose publish panics once the barrier is insidequiesce_publishes, asserting no install and no stamp and a failed outcome; and a record that panics the encode worker mid-batch, asserting the next barrier stamps nothing and a restart replays the remainder — run once with the barrier after the panic and once started concurrently with it, the seeded scheduler covering the store-before-decrement order, so a latch set after the count settles would fail the test rather than pass by timing. The retain path is already exercised by the existing skip-the-snapshot tests, so these extend them rather than duplicating. -
Truncation bounds (RFC0052.2) — an integration test building a WAL with several closed segments, a checkpoint above them and a snapshot floor deliberately below it, asserting exactly which files survive. The floor case is the important one: a test that only checks “segments disappear” passes on a bound that ignores the floor.
-
Bounded growth (RFC0052.3) — the
ourios-benchsoak harness on its synthetic clock, asserting the WAL’s byte total and segment count stay bounded over a long run. This is the one criterion a unit test cannot express, because the defect is the absence of a periodic call; only elapsed cadence reveals it.The harness cannot do this today and has to be extended first. It builds a bare WAL and coordinator whose sampler only flushes the sink and runs compaction — there is no snapshot, checkpoint or housekeeping cadence in it at all, and its synthetic clock advances record timestamps rather than driving a timer. So the extension is part of this criterion’s cost, not an assumption behind it: the soak loop gains the §3.2 sequence on the synthetic clock, which is also the only way to reach a multi-hour backlog in a test.
-
Rotation retry (RFC0052.4, RFC0052.5) — fault injection at each of the five rotation steps (the rename included), once-failing and always-failing, asserting recovery in the first case and a distinguishable terminal refusal in the second, plus
Wal::opensucceeding afterwards in both (the temporary-name property) and the temp files being swept. Aproptestover which step fails and how many times keeps the five sites from being tested only one way. -
Pre-existing orphans (RFC0052.11) — a directory fixture hand-built in the shape today’s failure leaves, asserting that open still halts with
OpenError::Corruptand that the error names the file and the rotation-remnant shape. A fixture rather than fault injection, because the state predates the code under test. -
Capped passes (RFC0052.12) — a backlog well past the cap, asserting the per-pass unlink count and that repeated passes reach the uncapped end state; a leg seeds stale partials beside that backlog and asserts the first pass removes every partial and only cap-minus-partials segments. The concurrent-append half is asserted by timing out an append against an uncapped pass in a regression guard rather than by measuring wall clock, which would be flaky.
These replace
rfc0008_6_rotation_failure_quiesces_the_wal, whose “even after the underlying condition clears” assertion is the contract §3.3 changes. That replacement needs explicit approval (CLAUDE.md§6.2) and must keep both halves of what the original protected: no ack on an incomplete rotation, and a permanent refusal when the fault is persistent. -
Reclaim record (RFC0052.17) — directory fixtures for the four record states (absent, empty, valid with entries, corrupt) crossed with a restorable and an undecodable snapshot, asserting halt-or-pin per case and the
Corrupterror’s file name; fault injection at the two crash points (before rename, before first unlink) through the same hook the rotation tests use; and a two-pass monotonicity leg reading the record back. -
Temp sweep (RFC0052.16) — a directory fixture holding all four file kinds (
CHECKPOINT.tmp,RECLAIM, a*.snap.tmpand the partial; the record has no temp of its own), asserting exactly one is removed. A fixture rather than a live rotation, because the point is the selector, and the dangerous cases (CHECKPOINT.tmp,*.snap.tmp) are produced by other subsystems. -
Reclassification (RFC0052.15) — the classifier unit tests held on
hold/794-wedged-classification, rewritten to the terminal-only rule and extended to assert the narrowness: an ordinary append or fsync I/O failure and a still-retrying rotation stay in the transient class while only the terminal rotation state leaves it; no arm carries a retry hint. Without that third assertion the test passes on a blanket removal ofRetry-After, which would contradict RFC 0018 §3.2 rather than amend it. -
Floor pinning (RFC0052.13) — unit tests over the three
RetainFloorcases, asserting a tenant without a snapshot pins the minimum at its oldest surviving frame, that segments below it holding only covered frames are reclaimed, that the pin lifts when its snapshot lands, and that the pinned case is not expressible as the no-consumer case. A churn leg writes one tenant once, snapshots it, and asserts it leaves the ledger when its last segment is unlinked. The startup leg uses a snapshots-root fixture whose directory fsync is made to fail (the file-in-place-of-directory technique from the rotation tests, since read-only permissions do not bind under root), asserting every tenant is pinned rather than any listed horizon used. -
Timer exclusion (RFC0052.14) — a seeded-interleaving test rather than a timing one: the window is narrow, and a wall-clock test that happens to pass proves nothing. Failing that, hold the timer artificially between the quiesce and the end of the cut while driving ingest, assert the submit blocks until the cut is captured and proceeds afterwards, and assert the records admitted after the cut are in neither the checkpoint nor the snapshot that barrier writes — the lock is never held across store I/O.
-
Telemetry (RFC0052.7) — the in-memory metric exporter pattern already used for the ingest/sink instruments, asserting every name is in the exported stream; a runtime tracing assertion that drives each transition — terminal entry on the append path, floor pinned and lifted, latch set — and verifies event count, name and state, since the live-check validates definitions rather than emission; plus a
weaver registry live-checkpass over the new log events, since an event the tests never emit is an event the live-check never checks (that is how #795’s un-named event passed CI). -
No loss (RFC0052.10) — extends the existing
SIGKILLcrash-recovery test rather than adding a parallel one, with reclamation configured on a short cadence so the kill lands in the regime this RFC introduces. The #791 regression tests (refuse-then-resume with no append) move with the bound to RFC 0053.
Maturity, per docs/rfcs/README.md: green is every §5 criterion passing
with the unit, property and corpus tests green — none of the live scenarios
is optional. RFC0052.3’s recorded soak run and RFC0052.10 are additional
incident-regression gates this RFC requires before validated, since those
two demonstrate the incident cannot recur rather than that a unit behaves;
they are not substitutes for the rest.
7. Open questions
- Whether §3.3’s count budget (three consecutive attempts) should become a time budget with backoff. A count is simpler to test; a time budget degrades better on a disk that is slow rather than broken.
- Whether the terminal rotation state also gets an operator verb to clear it without a restart, or whether a restart remains the documented recovery for a persistent durability fault.
- Whether
disk_bytesshould be computed per pass or cached.WalMetricsdocuments the directory walk as best-effort; exporting it on every collection may not be free on a WAL with many segments — though after §3.2 there should be far fewer. -
max_unlinks_per_pass’s default value only, which trades first-pass stall against how long a large backlog takes to clear. The knob is settled — §3.8 classifies it, with its class, proposed default and validated range — and the soak run (RFC0052.3) is what should confirm or move the number. - Whether the actionable halt for a pre-existing rotation remnant (§3.3) deserves a WAL verb to remove the file, or stays a documented manual step. The decision must remain a human’s either way — no shape-based heuristic can tell rotation debris from real corruption — so this is about ergonomics, not safety.
- Whether a stale tenant floor blocking reclamation indefinitely should itself escalate (a second, louder state) or stay a visible metric an operator alerts on. §3.5 makes it visible; it does not decide.
- Whether to persist a published high-water mark with every flush, so that frames published between two barriers are not re-published on replay. Today that is RFC 0008/0014’s at-least-once contract above the checkpoint; this RFC narrows nothing there and bounds nothing there.
8. References
-
Issue #791 — the incident: ingest wedged at
503, no self-recovery, no observability. -
Issue #793 —
checkpoint/housekeepingcalled only from tests. -
Issue #796 — a publish unwind drops drained batches and the snapshot guard reads empty buffers as fully drained; specified by RFC 0053 §3.2.
-
PR #794 — the OTLP
Statusbody on ingest rejections (conformance only; the wedged classification was split out tohold/794-wedged-classificationand lands with §3.3). -
PR #795 — counts a cadence panic (makes a dead sweep alertable; deliberately still stops, pending RFC0053.2).
-
RFC 0053 — WAL backpressure and unwind safety; the stage that depends on this one, and which amends this RFC in these places:
- RFC 0053 §3.2 amends §3.7’s
Journalsurface and this RFC’s sidecar layout:checkpointtakes a per-tenant published map,fn checkpoint(&mut self, durable_to: WalOffset, published: &HashMap<TenantId, PublishedMarks>)withPublishedMarks { records, audit }— a per-tenant pair, not a bare offset — persisted to aPUBLISHEDsidecar written afterCHECKPOINTin the same call, on the checkpoint’s own durable path, and joining §3.2’s sidecar matrix on the same fail-closed footing asRECLAIM. - RFC 0053 §3.2’s
PUBLISHEDhas its own pinned layout under its own magicb"OWPB", sharing this record’s rules rather than being identical to it — the two-slot generation protocol, the per-slot dictionary, the fixed-stride arrays and the position-is-the-id rule — and keys on the same tenant key, so the dictionary and itsu16slot ids apply there too — the derivation has to be injective over the admissible tenant set, which a 16-byte digest of a 128-byte id is not — and its key field is sized to RFC 0048 §3.1’s 128-byte bound, which amends RFC 0046 §3.1 and is whatMAX_TENANT_BYTESalready encodes. - RFC 0053 §3.1 makes
max_segmentscount closed retained segments, with the current segment and the segment an owed rotation creates outside the cap, so an owed rotation always has room. - RFC 0053 §3.2 adds
Journal::publish_marks(&HashMap<TenantId, PublishedMarks>)— aPUBLISHED-only write with its own crash ordering, used by settlement and by the migration startup path — socheckpointis not called merely to advance a publication watermark. - RFC 0053 §3.2 makes a permanently failed audit write a distinct outcome that refuses the dependent record publish, naming RFC 0005’s audit-sink contract; §3.1’s wait-or-write rule here covers only the transient case, which requeues.
- RFC 0053 §3.2 defines the clear arm of §3.1’s packed failure word — which cut may CAS it back, and against which captured value — since stage 1 never clears and only a restart does. The packing itself is §3.1’s, adopted here so a same-epoch report cannot be lost between a reader’s loads.
- RFC 0053 §3.2 adds an eligibility case §3.2’s predicate cannot express: a closed segment holding no valid frame — one its seal truncated back to a header — has no tenant offsets and no highest frame offset to compare, so it is unconditionally eligible and popped ahead of the horizon-driven candidates. That state cannot arise under this RFC alone, since rotation closes a segment only when it holds a frame, which is the assumption §3.2’s predicate rests on; it is RFC 0053’s sealing that creates it, so the case belongs there.
- RFC 0053 §3.1 also amends §3.7’s
append_batch: it takes aRotationDecision(Rotate/Reuse) produced once by the reservation under the journal mutex, so the age-sensitive rotation predicate is decided in one place rather than recomputed inside the append. - RFC 0054 §3 replaces the timer’s pre-cut guard — §3.2’s
if failed_epoch <= barrier_epoch: skip— with proceed-and-decide, once its requeue makes the panicked batch’s records available to the next cut’s drain; the “only a restart clears it” wording in §3.1, RFC0052.1 and RFC0052.7 is amended with it, and RFC0054.4 asserts it. The unwind half of RFC 0053 moved there in the 2026-09-15 split.
- RFC 0053 §3.2 amends §3.7’s
-
RFC 0008 §6.5 (rotation), §6.6 (recovery horizon), §6.7 (checkpoint and housekeeping), §6.8 (counters), §6.9 (tunables) — the mechanism this RFC supplies the policy for; §6.5’s durable-entry-first rule and Scenario RFC0008.6’s permanent refusal are superseded by §3.3 and RFC0052.4/.5. §6.7’s single-call
Wal::housekeeping(retain_floor)is superseded by §3.2’shousekeeping_prepare/housekeeping_commitpair — a pass that records its plan before it unlinks cannot be one call. The single-call form survives behind alegacy-housekeepingfeature that nothing but this crate’s own dev-dependency enables, so the RFC0008.7 tests keep their subject while the deployment invariant the legacy-root branch above rests on — that no production path calls it — becomes a property of the build rather than of a grep. -
RFC 0046 (the tenant-prefixed frame) — amended by §3.2:
This RFC amends RFC 0046’s replay validation and criterion RFC0046.11 from a 256-byte tenant length to 128, the grammar RFC 0048 §3.1 pinned and which RFC 0046’s own resolved-questions note records as “the one tenant grammar every boundary applies at”. The amendment is a consistency fix rather than a new decision: the replay clause and RFC0046.11 were left at the superseded number, and the frame codec was left with them.
-
RFC 0018 §3.2 (retryable error mapping) — amended by §3.3: its transient class lists “post-rotation quiesce”, which #791 disproved, and its two classes gain a third, server-terminal, client-retryable, for the terminal rotation state. RFC0018.3 stays satisfied, since the status is unchanged; only the class and the optional
Retry-Aftermove. -
RFC 0001 §6.9 — the miner snapshot high-water mark, and the hazard-#5 retain rule that makes it the truncation floor — amended by §3.2: horizons are per tenant (each tenant’s own last folded frame), the retain rule is tenant-aware and inclusive at a durably installed horizon (pins strict), and the stale-gap detector is restated on the
RECLAIMrecord rather than on segment presence. -
RFC 0014 — the record sink and its flush triggers.
-
CLAUDE.md§3.4 (WAL-before-ack), §3.6 (object storage is the source of truth), §6.3 (observability of ourselves). -
docs/hazards.md#3 (WAL durability versus latency), #4 (small files), #5 (template schema evolution across deploys — the retain rule).
RFC 0053 — WAL backpressure
rfc: 0053 title: WAL backpressure status: drafted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-09-13 supersedes: — superseded-by: —
RFC 0053 — WAL backpressure
Status note.
drafted. Stage 2 of two, split out of RFC 0052 at the maintainer’s direction — and since split again: this RFC is the WAL backpressure bound alone. Publish unwind safety is RFC 0054, publication frontiers and tenant settlement RFC 0055, and the RFC 0005 §3.7 audit-durability amendment RFC 0056. The wording moved into each is the reviewed wording of this document at#802@30a21f80, which stays the quarry. This RFC depends on RFC 0052 and cannot land before it: backpressure clears only when reclamation removes bytes, and its livelock fix needs the timer RFC 0052 introduces — §3.2 lists what this RFC asks of that one rather than specifying it here. It also depends on RFC 0055 for the tenant guard, the admission order and the lock order §3.1 defers to and RFC0053.1/.5 assert. TouchesCLAUDE.md§3.4 throughout. It amends no accepted RFC.
1. Summary
One thing RFC 0052 deliberately leaves open: the only limit on local accumulation during an object-store outage is the volume itself — an implicit limit with an undefined failure mode, which is what turned the #791 outage into a wedge. This RFC declares an explicit, configurable local bound and makes crossing it a stated rejection with a transport contract, cleared by reclamation rather than by restart.
2. Motivation
RFC 0052 makes the WAL reclaim segments and makes a rotation failure
recoverable, so a node no longer fills its volume in steady state or wedges
permanently on one failed fsync. It does not say what happens when the object
store is unreachable for long enough that the WAL cannot be reclaimed —
frames accumulate above a checkpoint that cannot advance — and the only thing
that eventually stops accepting them is ENOSPC. That is the incident’s shape
with the permanent quiesce removed: the node degrades into a rotation-failure
state rather than an unrecoverable one, but still gets there by running out of
disk, with no warning and no stated limit.
3. Proposed design
3.1 Backpressure becomes explicit
Today the only limit on local accumulation is the volume — an implicit limit with an undefined failure mode. The WAL gains a declared local bound, and crossing it is a stated rejection, specified as a transport contract.
The transport contract. ReceiveError gains two variants, because
§3.1 refuses for reasons of two different kinds. WalBackpressure carries
a typed cause and the delay to advertise, for the two bounds that
reclamation clears: BackpressureCause::Bytes { pre, projected: Option<u64>, limit } — the pre-reservation unreclaimed total and the
projected total including this batch’s framed_len (or, when the
projection overflows, None in its place), since a per-request refusal
can happen with the live total still below the limit and a single
“measurement” would be ambiguous — and Segments { count, limit } for the
max_segments ceiling; the Status message renders the cause’s own
measurements and names its limit, so a refusal always says which bound it
hit. TenantCapacity { count, limit } is its own variant, not a cause,
because the tenant-capacity guard RFC 0055 defines is capacity: no
pass clears it, so it
carries no delay and sets no latch (§3.1). IngestFailure::classify maps
the first to a new Backpressure outcome and the second to a new
TenantCapacity outcome, which that exhaustive match then forces both
transports to handle; both render 503 / UNAVAILABLE with the limit
named in the Status message, and only Backpressure carries the delay.
503 because the batch was not acked — RFC 0018 §3.2’s reasoning — and
distinct outcomes rather than reusing Unavailable because the remedy
differs: for backpressure, waiting genuinely helps; for capacity, only an
operator does, and the client backs off as OTLP prescribes when no
Retry-After / RetryInfo is sent.
The wire shape is stated, because an empty body is how #791 hid for eight
hours. On HTTP the rejection is the google.rpc.Status body the OTLP spec
requires and #794 established — binary protobuf with
application/x-protobuf whatever the request’s encoding, because the
Collector’s exporter decodes every failure body as protobuf regardless of
Content-Type, with the message naming the limit and the measurement — and
Retry-After is a response header, present for Backpressure and for
RFC 0055’s SettlementInProgress — both self-clearing — and absent for
TenantCapacity, which only an operator clears. On gRPC it is the
Status message plus, for those two, a RetryInfo detail carrying the
same delay. A bare status code with no body,
which is what the HTTP arm returned before #794, does not satisfy this
contract, and RFC0053.1 asserts the body on both transports.
Retry-After is the next reclaim opportunity, not a function of the
limit. A byte bound yields no delta-seconds value, and a client cannot
implement against it. What the server actually knows is when it will next
be able to remove bytes, and under RFC 0052 that is two cadences, not one:
the barrier (barrier_secs, default 300 s) is what advances the checkpoint
and so makes segments eligible, and housekeeping (housekeeping_secs,
60 s) is what unlinks them. A refusal just after both ticks therefore
cannot be cleared by the next housekeeping pass alone. So the advertised
delay is an upper bound on the next reclaim opportunity the coordinator
can see, computed at the refusal from two inputs. The first is a
trait-visible eligibility signal: RFC 0052 withdrew the eligible queue, so
this RFC adds ReclaimState::reclaimable_now: bool — true when the head
of RFC 0052 §3.2’s ordered structure satisfies the lazily evaluated predicate (empty
set, key at or below the checkpoint) or an entry is still reclaiming from
a pass whose unlink failed — an O(1) read of the head under the journal
mutex the refusal already holds. The second is the schedule, which the
timer owns: the coordinator is constructed with a ReclaimSchedule handle
that the barrier and housekeeping tasks update with their next due instants
each tick, so the coordinator never derives a due time from a cadence
alone. The delay is then the least of the opportunities the state admits:
when reclaimable_now, the time to the next housekeeping due instant; when
§3.1’s forced-rotation predicate holds — the latch set by a byte refusal,
nothing reclaimable, the current segment the only holder — so the next
pass rotates and the one after reclaims, the time to the next housekeeping
due instant plus one housekeeping cadence; otherwise the
time to the next barrier due instant plus one housekeeping cadence, since
nothing becomes reclaimable before the barrier runs. Seconds are rounded
up, with a minimum of one. The value is a non-binding hint: whether
the pass frees enough depends on the store and the tenant floor, and a
client that arrives earlier is simply refused again with a fresh hint.
Nothing finer would be honest; a backoff estimator would be inventing a
prediction the server cannot make.
The delay travels in the error. The transport mappers see only a
&ReceiveError and their handler state knows nothing of the WAL’s cadence, so
the coordinator — constructed with the schedule handle alongside the bound —
stamps the delay into WalBackpressure at the refusal, and both mappers read
it from there. No new state is threaded through either receiver.
gRPC carries the same number as a RetryInfo detail, which is what
OTLP’s throttling section
specifies for the backpressure case, and the code is UNAVAILABLE — chosen
here rather than left open, so both adapters carry one retry semantics and
RFC0053.1 can assert it. RFC 0018 §3.2’s RESOURCE_EXHAUSTED option is
not taken: it is the saturation code, and a client that maps it to a
different backoff than the 503 its HTTP twin receives would treat the
same condition two ways.
The bound is a byte bound over ALL unreclaimed bytes — not over bytes
retained below the checkpoint, and not an age. During the outage this is
meant to bound, the checkpoint is precisely what stops advancing, so new
frames pile up above it: a below-checkpoint limit would measure the one
quantity that is not growing and never fire. “Unreclaimed” therefore means
every byte housekeeping has not removed, including the post-checkpoint tail,
the current append segment and anything the tenant floor is retaining. RFC
0052 §3.7’s ReclaimState carries exactly this figure, maintained
incrementally rather than from the best-effort disk_bytes walk, for the
reason given there.
Three boundaries of that figure are stated so the reservation and the
measurement cannot drift apart. It is over frame bytes, the unit RFC
0052 §3.7 gives rebuild_ledger() — the sum of the validated frames’
lengths, never file size — so the live figure and the restart figure are
the same number and this RFC amends nothing there. Segment headers are outside the
measurement and the reservation alike: a pre-write rotation adds one, and
the coordinator cannot know whether an append will rotate. Headers are
bounded separately, because frame bytes do not bound them — §3.1’s forced
rotation and age-based rotation both close near-empty segments, so an
outage on a low-volume node accumulates headers while its frame bytes
stand still: WalConfig gains max_segments, a ceiling on retained
closed segments — the current segment is outside the cap, for the
reason §3.1 gives below — defaulting to
unreclaimed_bytes_limit / segment_size_bytes + 16 so a bound’s worth of
full closed segments always fits with slack for the near-empty ones, and a
reservation whose append would rotate, while closed_retained is at the
ceiling, is refused under the same backpressure class with the Segments
cause. Whether the append would rotate is the WAL’s question, not the
coordinator’s, and its predicate is size or age (§3.2’s
rotation_due); the reservation takes a segment slot exactly when it is
true: closed_retained + 1 ≤ max_segments, else refuse. The cap counts
closed retained segments, and the current segment is outside it, which
is what keeps an owed rotation from deadlocking: housekeeping never
unlinks the current segment, so a cap that counted it could be reached in
a state no pass can relieve — every older segment pinned or ineligible
while the current segment’s own frames are checkpoint-covered — and
the deferred owed rotation would wait for a slot that never comes, with
the WAL unable to accept anything at all. Capacity is therefore reserved
rather than hoped for. The invariant, stated once: an owed rotation
always has room. The segment it closes enters the cap ungated — the one
admission the cap does not gate, since refusing it would mean refusing to
close a segment that must never take another frame. A discretionary rotation is gated as before, so the cap
still bounds retained header overhead and still refuses an append that
would grow it. validate_config rejects max_segments < 1:
a ceiling of zero admits no retained closed segment at all, so the first
closed segment would refuse every later discretionary rotation and the
node would stall on any frame needing one. The derived default is far above it. RFC0053.1 asserts the
validation. At the ceiling a discretionary due rotation is refused,
never squeezed in — an owed rotation is the stated exception and
proceeds regardless, since closing the current segment necessarily adds a
closed one and refusing that is the deadlock §3.1 removes; the exception
is why the ceiling reaches no rotation-failure state at all. For the discretionary case: the request path runs no housekeeping; the refusal sets the latch, the next maintain pass
reclaims what it can, and the client returns after the Retry-After §3.1
computes — so with a reclaimable segment the request is refused once and
admitted after the pass, and with nothing reclaimable it stays refused,
and no forced rotation runs at the ceiling (below). A reservation that would not rotate
is unaffected, since it creates no header. A segment stays in it
until its unlink succeeds. RFC 0052 §3.2 marks a popped ledger entry
reclaiming and keeps it in the byte and segment accounting until the
off-lock unlink has succeeded, so the bytes of a segment whose RECLAIM
write or unlink failed are still unreclaimed here and a refusal is never
computed against bytes that are still on disk. It survives a
restart by being rebuilt, not persisted, in the same unit. Once replay
and heal have settled the newest segment’s tail — a torn frame there is
truncated by heal and must not be counted — the figure is initialised as
the sum over every surviving segment, closed and current, of its
validated frame lengths, so the rebuilt number is frame bytes like the
live one and matches what the reservation adds to it. The rebuild hook is §3.2’s, run after recovery and before the
coordinator is constructed, so the seed completes before any append is
admitted and a node restarted mid-outage resumes refusing at the same bound
rather than admitting from zero. The same scan seeds the current segment’s
frame bytes (§3.1’s rotation trigger) from the healed newest segment. And
it fails closed: a listing, read or frame-validation error from the scan
fails startup before the coordinator or any listener is constructed,
because a partial or zero seed is precisely a node that admits past its
bound. RFC0053.4’s restart asserts both.
The age of the oldest unreclaimed frame is exported beside it (RFC 0052 §3.5) and is the right thing to alert on, but it is not an admission rule: an age does not grow with an append, so an age limit could only fire from the timer, which makes it a state flip with different clearing semantics rather than a reservation. An earlier draft left “bytes, age, or both” open; this one is byte-only, and an age-driven refusal, if ever wanted, is a separate decision.
The check is a pre-append reservation, not a post-append test — and it has an owner. The commit coordinator appends under the journal mutex before waiting for the sync, so a limit checked after the append would leave the supposedly-rejected batch sitting in the WAL, and a client retrying on the rejection would have it replayed or published twice.
The check therefore lives in CommitCoordinator, inside the same journal
mutex acquisition that performs the append, reading the measurement from
Journal::reclaim_state() immediately before calling append_batch. Not in
the receiver and not in a layer above: anywhere outside that mutex races
concurrent appends, so two batches could each observe room and both be
written. append_batch’s responsibility is unchanged — it appends, and per
RFC 0052 §3.7 now returns the frame’s WalOffset — and the reservation is
the caller’s responsibility because the caller is what holds the mutex.
- The limit is the coordinator’s, not the WAL’s.
CommitCoordinator::newtakes it alongside the batch window and segment size it already receives, so it sits with the other admission policy rather than inside a journal whose job is durability.Journalreports the measurement; the coordinator owns the threshold. That also keeps the limit configurable without widening the trait. - An oversize batch is still
TooLarge, never backpressure — and the coordinator makes that so. Because the reservation now runs beforeappend_batch, a refused request never reachesWal::append’s ownMAX_FRAME_BYTEScheck, so the ordering cannot be left to the WAL.Journalgives the coordinator the number at construction rather than on the path:CommitCoordinator::newtakesmax_frame_bytesbeside the batch window, the segment size and the byte limit it already receives, and the coordinator rejectspayload_len > max_frame_bytesasTooLargeunder the admission mutex, before any journal lock is taken — the WAL’s own check remaining as the backstop. The full admission order is one sequence, stated once, and it spans the two locks RFC 0055 fixes rather than living in either alone: under the admission mutex, max-frame validation, then the tenant checks RFC 0055 defines; then, under the journal mutex and in the same hold as everything else that reads journal state, the terminal-rotation check, then the bound, then the rotation decision, the reservation and the append. So an oversize payload against a terminal WAL isTooLarge, a legal payload against a terminal WAL is the terminal classification without areclaim_state()read, and only a legal payload against a healthy WAL reaches the reservation. Terminal still precedes backpressure — both are read in the same journal hold, terminal first, so a terminal WAL is never reported as a bound. That check has a named source:Journalgainsfn rotation_state(&self) -> RotationState(Healthy,Retrying { attempts },Terminal), a cheap categorical read with no snapshot struct on the append path, taken under the journal mutex in the same hold as the reservation and the append — one acquisition, so it can neither race outside the mutex nor be confused withreclaim_state(). RFC0053.1 covers the combined case.
There is no rollback path, deliberately: truncating an appended frame is a second way to corrupt the tail, so the only safe reservation is one taken before the write. Two append failures need their own rules. A failure the WAL rolls back cleanly leaves the figure and the refusing state unchanged. A failure after bytes reached disk whose best-effort truncate-back also fails is not something the counter alone can absorb: the frame’s full framed length is added to the unreclaimed figure at once — an over-count is safe, an under-count admits past the bound.
Repeated rollback failures are bounded. The torn bytes are inside the
bound for as long as they exist: the failed frame’s full framed length is
added, and at a restart the heal truncates them before rebuild_ledger()
runs, so the rebuilt figure excludes what is no longer on disk and no
sequence of failed rollbacks grows the frame bytes past the limit.
The bound is configuration, and it has a home: WalConfig gains
unreclaimed_bytes_limit, and it is the first WAL knob the deployment
surface exposes — ReceiverSection carries only wal_root today, so it
gains wal_unreclaimed_bytes_limit under its existing
deny_unknown_fields struct, taking the config file’s ${env:VAR}
substitution (RFC 0020) rather than a bespoke environment path, and the
Helm chart exposes it under the receiver’s config block. validate_config rejects an explicit value below segment_size_bytes: the
smallest useful bound is one full segment, since housekeeping never unlinks
the current segment and a bound smaller than one would refuse before a
rotation could ever reclaim anything — which also puts it above the largest
legal frame, because the segment floor already is. The default is
derived, max(1 GiB, segment_size_bytes), so an implicit default can
never fail that validation whatever segment size is chosen (the range allows
up to 2 GiB); at the default segment size that is eight segments, far above
the 16 MiB frame ceiling, a few hours of a busy node’s frames and a few
weeks of the incident node’s. Whether it should instead scale with the
volume is §7’s question; the acceptance run uses the derived default. The rejection is the
contract: ingest keeps accepting while the object store is unreachable until
the declared limit, then refuses with a reason that names the limit it hit.
Crucially, crossing the limit does not set RFC 0052’s rotation-failure
state — it is a pressure state, not a fault. The converse precedence is
stated too: once the WAL is in RFC 0052’s terminal rotation state — which
the timer’s own rotate can reach after the bound is crossed — the
pre-append check reports that terminal classification — RFC 0052 §3.3’s
server-terminal, client-retryable class, RFC 0018 §3.2’s third: the
client keeps its data and backs off exponentially, and neither
Retry-After nor RetryInfo is sent, since the server cannot predict when
an operator clears the node — before it consults the bound at all.
It clears when a housekeeping pass actually removes bytes, not when the checkpoint advances. Advancing the sidecar declares frames reclaimable without reclaiming them, and the bound is measured in bytes still on disk, so the clearing path is the whole RFC 0052 §3.2 sequence — barrier, checkpoint, housekeeping — which that RFC’s timer drives without an append.
Admission is per request; the reported state is a latch with a defined
leave condition. Each reservation is its own decision — unreclaimed + framed_len ≤ limit — so a smaller batch can be admitted while a larger one is
refused, and no batch is ever refused on the strength of an earlier refusal.
The comparison and every accounting update use checked arithmetic and
fail closed: a projected total that would wrap is a refusal, never an
admission — saturating would not do, since a sum saturated to u64::MAX
still passes ≤ u64::MAX — and validate_config rejects a limit of
u64::MAX outright, so the boundary is unreachable from both sides. An
overflowing refusal has no projected total to report, and the shape says so
rather than inventing one: WalBackpressure::projected is an
Option<u64>, None on overflow; the message names the limit and the
pre-reservation total and says the projected total overflows; the
last_refusal gauge records only its pre_reservation datapoint; and the
entered event carries ourios.wal.unreclaimed without a projected value.
The refusal latch and Retry-After behave as for any other refusal. Only
capacity_remaining (§3.3) saturates, being a report, not a decision.
The state §3.3 exports is entered by the first refused reservation and left
by whichever comes first: a housekeeping pass that actually removed a
segment (removed_segments > 0) and left the unreclaimed total strictly
below the limit — a no-op pass cannot clear it, since a refusal can occur
with the total already below the limit — evaluated by maintain under the guard it
holds for housekeeping_commit, from the HousekeepingProgress that
commit returns (which is how the state leaves with no append arriving), or
an append that is acknowledged — append_batch returning Ok is not
enough, since it returns before the group sync that makes the frame
durable (RFC 0052 §3.7’s CommitOutcome), and a batch whose covering sync
then fails was never acked and its frame is not durable; so the leave
transition is the ack path, the append and its covering sync succeeding,
and the .left event says the state left on an acknowledged batch. A
reservation that passes its check but whose append then fails on rotation
or write I/O, or whose sync fails, leaves the state unchanged, so
neither the preflight check nor a bare append_batch is the leave
transition. The figure is another matter, and the two must not be
conflated: an append that reached the segment and whose covering sync
then failed leaves those bytes on disk — the frame is in the segment,
unacknowledged but present, and the ledger rebuild will count it — so the
reservation stays charged until the segment is reclaimed, exactly as for
an acknowledged frame. Only an append that never reached the segment (a
rotation failure, or a write whose truncate-back succeeded) releases its
reservation. Charging a present frame is what keeps the bound honest
about disk; leaving the latch alone is what keeps it honest about acks. The enter
and leave events fire on exactly those transitions, once each, and the gauge
follows them.
It also needs the timer to be able to force a rotation, or it deadlocks on its own. Housekeeping never unlinks the current append segment, and a crossed bound rejects the appends that would trigger size- or age-based rotation. If an outage’s whole backlog sits in that one segment — the normal case for a low-volume node, whose segments roll on age — then every pass finds nothing reclaimable, the bound never clears, and no append will ever arrive to roll the segment. A livelock built out of two individually-correct rules.
So the housekeeping pass, through CommitCoordinator::maintain,
rotates only in the single-segment livelock, stated as one predicate
over what the coordinator holds after the commit: the refusing state is
set and its retained cause is Bytes — the latch keeps the cause of
the refusal that set it, so a Segments refusal never
rotates, since a rotation at the ceiling would breach it, and a
TenantCapacity refusal sets no latch at all — the HousekeepingProgress that
housekeeping_commit has just returned reports removed_segments == 0
and the pass actually ran — RFC 0052 §3.2 makes a pass a no-op before
the version-2 witness exists, returning zero counts, and a skipped pass is
“no progress”, not “nothing left to reclaim”; forcing a rotation on the
strength of it would rotate a legacy root that has not yet upgraded, so
the trigger requires a pass that planned segments. The trigger also requires that
reclaimable_now is false, current_segment_frame_bytes equals the whole
unreclaimed_bytes — the current segment holds the only unreclaimed
bytes, which is the case no other rule can clear — and the retained count
is below max_segments. The trigger is the latch and its cause, never
unreclaimed_bytes >= limit, since a per-request refusal can happen with
the live total still below the limit. A pending rotation fsync is its own trigger, ahead of that predicate.
RFC 0052 §3.3 keeps a post-rename parent-fsync failure as
dir_fsync_pending with origin Rotation, discharged by the next sync
or rotate; under a crossed bound no append reaches sync, and the
predicate above cannot call rotate either, because a forced rotation
that hit exactly that failure has left an empty current segment while
the backlog sits in the closed one — current_segment_frame_bytes is no
longer the whole unreclaimed_bytes, so the only-holder leg is false for
good and nothing acks again. So, as an amendment to RFC 0052 §3.3 and
§3.7, the housekeeping pass discharges a pending Rotation-origin fsync
before it evaluates the forced-rotation predicate, whenever
ReclaimState reports the obligation outstanding — a discharge, not a new
segment. A failed discharge draws on the same rotation retry budget
it does on the append path and can reach the terminal state, which the
next request reports; a successful one lets the next append ack. The
predicate then runs as stated, on a WAL that owes nothing. RFC0053.1
asserts the wedge: a forced rotation whose parent fsync fails is
discharged by a later pass with no append arriving. The rotation itself runs under the journal mutex alone — the guard
maintain still holds for the commit — with the admission mutex
released, matching the lock order RFC 0055 states: the journal hold is
what keeps an append from interleaving between the commit’s verdict and
the rotation, and holding admission across directory work would queue
every request behind the rotation’s fsync.
That last
condition needs a state surface the inherited ReclaimState lacks —
unflushed_bytes resets on every sync, so it cannot tell a synced current
segment from an empty one — and this RFC adds one field to it:
current_segment_frame_bytes, maintained by the same append accounting and
seeded from the healed newest segment by the post-recovery ledger rebuild. The predicate is the only-holder case exactly. Rotation is a WAL operation rather than an
append, so backpressure does not
block it; the segment closes, the next pass can reclaim it, and the state
clears. Nothing is acked by that rotation, so the no-ack-on-refusal property
is untouched.
A second way the state can persist, which is not a deadlock and is handled
differently: a stale tenant horizon keeps the segments holding that
tenant’s uncovered frames ineligible. The unlink rule is per segment
and per tenant, so this is not “the whole WAL is ineligible”: segments holding only other tenants’ covered
frames are still removed, and the bound stays crossed only when the
segments that remain are the ones the lagging tenant’s frames hold — a
node where one tenant’s frames are spread across the backlog, which is the
fixture RFC0053.1 builds for this leg. In that case housekeeping removes
nothing further and the bound stays crossed even though the object store
recovered. That is not a bug to paper over — a tenant whose
snapshot is not advancing is a real problem — but it must be visible rather
than presenting as an unexplained refusal, which is why RFC 0052 §3.5 exports
the floor and its lag alongside the bytes. RFC0053.1 asserts the healthy-store
resume; the held-floor cases are normative — a tenant with no valid
snapshot pins the floor and is reported Pinned, a valid but lagging
horizon is Min with lag_bytes reported beside it and in both the bound stays crossed for
a batch of that size after the store returns, while the refusal latch
itself still follows §3.1 and may leave on a smaller successful append
(RFC0053.1) — and only the escalation policy is the §7 question.
3.2 Amendments requested of RFC 0052
The bound above is measured and enforced through WAL surfaces that do not
exist today. They are RFC 0052’s to specify; this RFC asks for them and
says only what it needs from each, and the wording is filed on
#798. If RFC 0052
cannot land without them, they were never this RFC’s.
Journal::framed_len,Journal::rotation_due, and aRotationDecisiontoken onappend_batch— so the reservation and the append act on one evaluation of a predicate whose age half moves with wall time.- A
RotationKindargument onJournal::rotate(discretionary versus owed) and a typed result — so a refusal at the segment ceiling is an outcome rather than a rotation failure, and an owed rotation always has room. .wal.sealand the torn-tail heal — the durable marker for an append whose rollback also failed, and the narrowed recovery rule that reads it.rebuild_ledger()/remeasure_unreclaimed()as the restart seed for the unreclaimed figure, failing closed on a scan error.HousekeepingProgress.removed_segments,reclaimable_now, and aReclaimSchedulehandle — whatRetry-Afterand the latch’s leave condition are computed from.- Retirement of
cadence_failed, andfailure_generationif RFC 0052 still needs it. - A ceiling on sealed segments, reached only by a fault and so
terminal, unlike
max_segments: the segment cap governs discretionary rotations and never becomes a fault, while a seal happens only when an append’s write and its truncate-back both failed. It is the one unit this RFC cannot bound, an owed rotation enteringmax_segmentsungated (§3.1).
Unwind safety, publication frontiers and the audit-durability amendment that shared this document are RFC 0054, RFC 0055 and RFC 0056; each states its own dependency on this one.
3.3 Telemetry
RFC 0052 §3.5 already exports the measurement this RFC’s bound is taken on and
the floor state that explains a bound that will not clear. This RFC adds the
backpressure state itself, named for what it is: a refusal latch (set
by a refused reservation, cleared as §3.1 defines), the limit and
measurement at the last refusal, and a separate capacity_remaining gauge,
saturating at zero — the live figure may legitimately sit above the limit
after a lowered limit or a conservative over-count —
— because admission is per request, a cleared latch at 900 of 1,000 bytes
still refuses a 200-byte batch, and a dashboard must not read 0 as “every
request is admitted” — and a log event on entering and leaving the latch.
Transitions can happen entirely on request paths between ticks (a refusal
and then a smaller successful append), and a queue that had to be both
lossless and bounded in exactly that failure mode would be the wrong
shape — so there is no queue: the coordinator emits the enter and leave
events itself, synchronously at the transition, under the journal mutex.
The order is the mutex order and every transition emits exactly once at a
real call site; RFC 0052 §3.5’s tick-based projection is amended by this
RFC to exclude the backpressure refusal latch explicitly. The two
latches are distinct and this RFC never merges them: the refusal latch is
§3.1’s, set by a refused reservation and left by reclamation or an
acknowledged append, and it is what the tick-based projection must stop
carrying. So there is one emitter per
transition and never two events for one. RFC0053.5 counts every transition.
The contract is enumerated here, as RFC 0009 §3.6 does, rather than
deferred to the registry bump; the names are proposals for the shared
ourios-semconv registry (one bump with RFC 0052’s, through that
repository’s review) and nothing is hand-written in the code:
| Signal | Instrument | Unit | Attributes |
|---|---|---|---|
ourios.wal.backpressure.refusing | gauge (int) | 1 | ourios.wal.backpressure.cause ∈ {bytes, segments} (1 while the refusal latch is set, on the cause that set it; a TenantCapacity refusal is capacity and never sets the latch) |
ourios.wal.backpressure.limit | gauge | By | — (the byte limit; the segment and tenant limits are the gauges below, in their own units) |
ourios.wal.backpressure.last_refusal | gauge | By | ourios.wal.backpressure.cause = bytes, ourios.wal.measurement ∈ {pre_reservation, projected} |
ourios.wal.backpressure.last_refusal.segments | gauge (int) | {segment} | ourios.wal.backpressure.cause = segments (the retained count at the last segment refusal) |
ourios.wal.tenant_capacity.last_refusal | gauge (int) | {tenant} | — (the held count at the last TenantCapacity refusal; capacity, not a backpressure cause) |
ourios.wal.capacity_remaining | gauge | By | — (saturating at zero; byte headroom) |
ourios.wal.segments.usage | gauge (int) | {segment} | ourios.wal.segment.state ∈ {retained, free, over_cap} — retained + free = limit + over_cap, with free saturating at zero and over_cap carrying the overrun an owed rotation may create |
ourios.wal.segments.limit | gauge (int) | {segment} | — (max_segments) |
ourios.wal.backpressure.entered / .left | log events | — | ourios.wal.backpressure.cause, plus the cause’s measurements: ourios.wal.limit (By), ourios.wal.unreclaimed (By), ourios.wal.measurement for bytes; the count and limit for segments |
That pair follows the OTel instrument-naming rule for a measured
amount out of a known total — entity.usage with a state attribute
whose values sum to entity.limit, beside entity.limit itself, as
system.memory.usage / .limit do — rather than carrying the limit as an
attribute of the usage gauge, which reads as a dimension rather than a
total.
error.type continues to carry the failure class on existing counters
rather than spawning per-error metrics: the refused batch is counted on
the ingest counter with error.type ∈ {wal_backpressure_bytes,
wal_backpressure_segments, wal_tenant_cap} — the last a capacity
refusal, not backpressure, per §3.1. The names follow the registry’s rules — ourios.* is the system
namespace, dotted, snake_case — and go through ourios-semconv like the
rest.
4. Alternatives considered
Make the sink ceiling a hard cap instead of adding a WAL bound (§3.1).
Worth stating because SINK_CEILING_BYTES looks like the natural place: the
drain loop exits when flush_largest() fails and buffers the record anyway,
so the ceiling is a hint and memory grows unbounded when the store is down.
Blocking there instead would apply backpressure in the wrong unit — buffered
Parquet bytes rather than unreclaimed WAL bytes — and would stall ingest on a
condition that does not threaten durability.
But rejecting it as the signal is not the same as leaving it alone. Both sinks retain past their ceilings whenever a store flush fails, so an outage grows memory without bound and can OOM the process before the WAL bound is anywhere near reached — in which case §3.1 never fires and this RFC has bounded the wrong resource. That makes it a prerequisite, not a neighbour.
The decision is not open, though — it is unimplemented. RFC 0014 §3.4
is accepted and specifies exactly this: the sink tracks buffered bytes
against a hard ceiling, force-flushes the largest or oldest partitions
under soft pressure, and when early flush cannot keep up emit blocks
until an in-flight flush frees memory, so the buffer never exceeds the
ceiling. Today’s retention past the ceiling is a gap against that contract,
not a gap in the design. So this RFC does not decide
block-versus-spill-versus-drop — RFC 0014 §3.4 decided it — and states
instead the scope of its own claim: it bounds local disk, not process
memory. §3.1’s bound is the operative limit for the WAL directory and
nothing else, the OOM path during a long outage stays open, and the
incident’s end-to-end property — ingest that stays bounded and keeps
refusing rather than dying — needs both RFCs. The sink decision is
therefore the blocking follow-up for the end-to-end claim: §6 keeps it
as the gate on validated and §5 asserts only the disk bound, with
RFC0053.1’s unreachable-store leg run long enough to show which limit is
hit first, which is what makes the follow-up concrete.
Backpressure as a rotation-failure state. Rejected: it would reuse RFC 0052’s terminal-state reporting for a condition that is not a fault and clears on its own, telling clients a node is broken when it is merely full. The two stay distinct so each advertises the true remedy.
5. Acceptance criteria
Scenario RFC0053.1 — Backpressure is a stated limit, and clears itself
- Given an unreachable object store and a configured local retention bound, on a node running RFC 0052 — the scenario asserts the local disk bound only, memory growth in the sinks being §4’s separate follow-up and §6’s gate on
validated- When ingest continues until a reservation would exceed the bound
- Then earlier batches were accepted and acked, and the rejecting batch is refused with a reason naming the bound it hit and that cause’s measurements (for bytes, the pre-reservation total, and the projected total when representable or the overflow indication otherwise), on both transports, and a
Retry-Aftercomputed from the reclaim schedule §3.1 states — asserted with the barrier and housekeeping cadences set apart, against the tasks’ published due instants: the time to the next pass whenreclaimable_nowis set, the next pass plus one housekeeping cadence when the latch is set with nothing removed, and the next barrier plus one housekeeping cadence otherwise; rounded up, never below one second — not a value derived from the limit, which yields no delta-seconds- And on HTTP the reason is a binary protobuf
google.rpc.Statusbody (application/x-protobuf, whatever the request’s encoding) withRetry-Afteras a header, and on gRPC the status code isUNAVAILABLEwith aRetryInfodetail — a bare status code with an empty body, or any other gRPC code, fails this scenario- And the refused batch is not present in the WAL — asserted by replaying after a restart, so a post-append check that left the frame behind fails here
- And the bound fires on bytes accumulated above a stalled checkpoint, not only below it: a run where the checkpoint never advances must still reach the limit
- And the refusal transition itself does not set the rotation-failure state; a forced rotation that later fails may enter RFC 0052’s retrying or terminal state on its own account
- And when the store returns and no tenant pins the floor (RFC 0052 §3.2), ingest resumes with no append and no restart — the timer-driven sequence reclaims, which is the only path that can clear a state that rejects every append
- And when a tenant pins the floor (no valid snapshot), or a valid horizon lags so that the bytes cannot be reclaimed, the bound stays crossed for a batch of that size after the store returns, by design; the floor is reported
Pinnedin the first case andMinwith a nonzerolag_bytesin the second, rather than as an unexplained refusal, and the latch itself still leaves on a smaller successful append as §3.1 defines- And when the whole backlog sits in the current append segment, the timer’s forced rotation lets the next pass reclaim it, so the state clears without an append ever arriving. The cases are explicit: only a latch whose retained cause is
Bytescan force a rotation; aSegmentsrefusal sets the latch with causeSegmentsand forces none, since a rotation at the ceiling would breach it; aTenantCapacityrefusal sets no latch and forces none; and aByteslatch with closed segments holding part of the backlog, or with the retained count atmax_segments, forces none either- And a fitting frame whose segment has aged past
segment_age_secsreserves a segment slot: below the ceiling it is admitted and rotates, at the ceiling it is refused with theSegmentscause — once and then admitted after the next pass when a segment was reclaimable, and on every retry while nothing is reclaimable- And under concurrent submits the live unreclaimed total — admitted frame bytes not yet reclaimed, not a cumulative sum reclamation would legitimately let grow — never exceeds the limit, so two batches cannot both observe room
- And an oversize payload against a terminal WAL is
TooLarge, and a legal payload against a terminal WAL is the terminal classification with noreclaim_state()read — the admission order §3.1 states- And the lagging-floor leg above is built so the surviving segments are the ones holding the lagging tenant’s frames; segments holding only other tenants’ covered frames are reclaimed even then
- And a housekeeping pass skipped for a missing version-2 witness forces no rotation: a legacy root that has not yet upgraded is left alone
- And
max_segments < 1is rejected at config validation, naming the reason: a ceiling of zero admits no retained closed segment, so the first closed segment refuses every later discretionary rotation- And the segment gauge stays consistent across the overrun an owed rotation creates:
freesaturates at zero,over_capcarries the excess, andretained + free = limit + over_capholds while the rotation is outstanding and after the next pass reclaims- And no rotation-failure state is entered from the segment cap at all (§3.2’s sealed-segment ceiling is a fault and is not this cap):
Terminalis reached only by an exhausted rotation retry budget, and noDeferredstate exists- And a forced rotation whose post-rename parent fsync fails does not wedge the node: a later pass discharges the pending
Rotation-origin fsync before it evaluates the predicate, with no append arriving, and the next append acks- And under repeated rollback failures the byte accounting stays within the limit — the torn bytes count inside it — while the fixed overhead outside it is capped for discretionary rotations only: at the ceiling
max_segmentsrefuses a reservation needing one, naming it, and admits one that fits the current segment. The seals a rollback failure leaves sit outside that cap — each forces an owed rotation, which enters it ungated (§3.1) — so their ceiling is §3.2’s request of RFC 0052, and this leg asserts only the frame bytes
Scenario RFC0053.4 — No acknowledged record is lost with backpressure live
- Given a node killed with
SIGKILLmid-batch while reclamation, rotation retry and backpressure are all live, with the bound small enough that the kill lands in the refusing regime- When it restarts and recovery completes
- Then every acknowledged record is present in Parquet, and no refused batch is present in Parquet or in the WAL
- And the restarted node’s admission starts from the rebuilt unreclaimed figure, so a batch the bound refused before the kill is refused after it
- And a restart whose ledger rebuild fails does not come up: no coordinator, no listener, and no batch admitted
Scenario RFC0053.5 — The backpressure state is observable
- Given a node that enters and then leaves the refusing state
- When metrics are collected and logs are read across both transitions
- Then the refusal-latch gauge with its cause, the limit and measurement at the last refusal per cause (bytes with its measurement, the retained count for segments), the held count at the last
TenantCapacityrefusal on its own gauge, andcapacity_remaining— equal tomax(limit − unreclaimed, 0)over the live unreclaimed figure at the moment of the read, not a stale value — are present in the exported stream under registry names- And entering and leaving each emit exactly one log event, named from the registry, on the transitions §3.1 defines and on no other tick
- And a
weaver registry live-checkover the emitted events passes, since an event the tests never emit is an event the check never sees
6. Testing strategy
Per CLAUDE.md §6.2, mapped to the §5 ids.
- Backpressure (RFC0053.1) — an integration test with an unreachable
store asserting the accept-then-refuse-then-resume sequence, the reason
text, the
Retry-After, and that the rotation-failure state was never entered. Three legs carry most of the value: the refused batch must be absent after a restart-and-replay (a post-append check would leave it); the resume must happen with no append at all, which is what proves the timer-driven sequence can clear a state that rejects every append; and the single-segment backlog must clear through the forced rotation, which is the livelock case. A fourth leg is concurrent rather than sequential: aproptestdriving many tasks that submit arbitrary-sized batches at once against a small limit, asserting after every admission that the live unreclaimed figure — admitted minus reclaimed, since housekeeping legitimately lets a cumulative sum grow — never exceeds the limit. It is the only test a reservation taken outside the journal mutex fails, the sequential flow passing it. A fifth leg fills the limit and submits an oversize payload, assertingTooLargewith noreclaim_state()read and no append — the reversed-check regression. The sequence is driven through both adapters: on HTTP the protobufStatus,application/x-protobufand the exactRetry-After; on gRPCUNAVAILABLEwith theRetryInfodetail carrying the same seconds. A sixth leg pins a tenant’s floor (a lagging or invalid horizon) and asserts that refusal persists after the store returns and the state is reportedPinned, as RFC0053.1 makes normative. - Configuration (RFC0053.1’s precondition) — resolver tests for an
explicit value and an
${env:VAR}-substituted one, plus a Helm render leg that the chart value reaches the config file; a missing field underdeny_unknown_fieldswould otherwise leave the limit at its default while every scenario passed. The derived default at a 2 GiB segment and the invalid below-segment value areWalConfigvalidation tests, since the resolver has no segment-size input (wal_config()hardcodes it). - No loss (RFC0053.4) — extends RFC 0052’s
SIGKILLcrash-recovery extension rather than adding a parallel one, in both restart shapes — a clean replay and a torn newest tail, since the existing crash test has no torn tail and a rebuild hook placed only on the heal path would pass it — with a small backpressure bound configured so the kill lands in the refusing regime, and a post-restart append that asserts the rebuilt figure still refuses, and an exact-figure assertion against a fixture with several closed segments, a header-only current segment (contributing nothing) and a torn newest tail — a refused-stays-refused check alone would pass a rebuild that counted headers or torn bytes; a fault-injected leg makes the rebuild’s scan fail and asserts startup refuses to construct the coordinator. - Telemetry (RFC0053.5) — the in-memory metric exporter pattern used for
the ingest instruments, driving one enter and one leave and asserting the
latch gauge, the last-refusal figures,
capacity_remainingagainst the live headroom, and exactly one event per transition — including a refusal and a smaller successful append inside one tick; plus theweaver registry live-checkpass over those events.
Maturity, per docs/rfcs/README.md: green is RFC0053.1, .4 and .5 all passing in
CI with the unit, property and corpus suites green, and this RFC touches no thesis gate in docs/benchmarks.md §7. It does
not proceed to validated on those alone: §4 records that the sinks can
exhaust memory before the WAL bound fires, so a green run could mark a bound
validated that is never the operative limit. validated therefore also
requires RFC 0014 §3.4’s hard ceiling to be implemented (§7 tracks the
confirmation, not a decision) and RFC0053.1’s
unreachable-store leg, run under default configuration for long enough to
show the WAL bound refusing before either sink exceeds its ceiling. Until
that decision lands the RFC stops at green, and says so.
7. Open questions
- Whether the byte bound’s default should scale with the volume rather than be the fixed 1 GiB §3.1 sets. The incident node held 42 MB over five days, so a default tuned for it would be far too small for a busy node; a fraction of the volume may be the honest default.
- Confirm RFC 0014 §3.4’s hard ceiling is implemented — the blocking
emitthat keeps buffered bytes under the ceiling. Not a design question (§3.4 decided it; today’s sinks retain past it when a flush fails), but §4 and §6 gatevalidatedon it, so it must land before this RFC can be more thangreen. - Whether a stale tenant floor blocking reclamation indefinitely should itself escalate (a second, louder state) or stay a visible metric an operator alerts on. §3.1 makes it visible; it does not decide.
- Whether backpressure should be per-tenant rather than per-node. It is a local-disk property, so per-node is the natural unit, but a single noisy tenant can then refuse every other tenant’s writes.
8. References
- RFC 0052 — WAL reclamation and quiesce recovery: the stage this one depends
on, for the timer, the barrier exclusion,
ReclaimState,RetainFloor, and the terminal-only classification of a rotation failure. - Issue #791 — the incident; this RFC is its “no stated limit” half.
- RFC 0018 §3.2 (retryable error mapping) — the reasoning for
503on an unacked batch; §3.1 takesUNAVAILABLEover itsRESOURCE_EXHAUSTEDoption. - RFC 0014 — the record sink and its flush triggers; its §3.4 sink memory ceiling is the governing contract for the memory half of the bound, and §4 and §6 make its implementation a prerequisite for the end-to-end claim.
CLAUDE.md§3.4 (WAL-before-ack), §6.3 (observability of ourselves).docs/hazards.md#3 (WAL durability versus latency), #4 (small files).
RFC 0053 split — history and file map
RFC 0053 originally specified WAL backpressure, publish-time unwind
safety, publication frontiers and tenant settlement, and an RFC 0005 §3.7
audit-durability amendment together, in one 3172-line document
(rfc/0053-wal-backpressure-unwind @ 30a21f80). It was split into four
RFCs so each could reach its own maturity gate independently; this page
records what moved where. That original draft is the source of the
reviewed prose each of the three new RFCs (0054–0056) extracts into its
own §9 (cited there as “the quarry”); RFC 0053 itself ends at §8.
Resulting files
| File | Owns | Source in the original draft |
|---|---|---|
docs/rfcs/0053-wal-backpressure.md | Bound, reservation, wire contract, latch, forced-rotation livelock, max_segments as a header cap, backpressure telemetry. | §1 first half, §2.1, §3.1 minus seal/owed-rotation essay, §3.3 backpressure rows, RFC0053.1 / .4 / .5, §6 backpressure legs |
docs/rfcs/0054-publish-unwind-safety.md | Invariant: no acknowledged batch becomes unreachable on panic; duplicates over loss; sweep may continue. | §2.2, §3.2 ownership / drain / worker / publisher panic, RFC0053.2 / .3 |
docs/rfcs/0055-publication-frontiers.md | PUBLISHED sidecar, per-tenant record+audit watermarks, settlement rebuild, tenant slots, object-key intent, sidecar geometry. | The rest of §3.2, restart/sidecar legs of RFC0053.4 |
docs/rfcs/0056-audit-durability.md | Amends RFC 0005 §3.7: permanent audit failure is not a successful drop; dependent records stay unpublished; tenant becomes terminal. | Status-note amendment + the three-way write_owned result |
What moved to RFC 0052 instead
Some material in the original draft was a request of RFC 0052 rather than specification belonging to 0053, and landed there (on #798) instead of being kept here:
Journal::framed_len,rotation_due,RotationDecisiontoken onappend_batchJournal::rotateresult /RotationKind(discretionary vs owed).wal.sealand the torn-tail healrebuild_ledger()/remeasure_unreclaimed()as the restart seedHousekeepingProgress.removed_segments,reclaimable_now,ReclaimSchedulecadence_failedretirement,failure_generation
What RFC 0053 kept and what it dropped
RFC 0053 itself kept: pre-append reservation; frame-byte unreclaimed
total; checked add; TooLarge first; 503/UNAVAILABLE + protobuf
Status + Retry-After hint; per-request admission vs refusal latch;
forced rotation predicate; unreclaimed_bytes_limit / max_segments;
RFC0053.1, .4 (WAL restart figure only), .5; §4 sink-ceiling as a
validated prerequisite.
It dropped, to the three new RFCs: RecoverableBatch / pool supervisor;
PUBLISHED and settlement; the RFC 0005 / 0046 / 0048 amendments;
max_tenants slot reservation (now RFC 0055). .wal.seal is not among
these — it is requested of RFC 0052, per the section above.
Review discipline applied during the split
The split preserved the original draft’s already-reviewed wording by
cutting and moving paragraphs rather than rewriting them from memory, and
a Copilot review was deferred until all four files existed and RFC 0053
itself was under 800 lines — so review comments would land on the
settled shape rather than on an intermediate one. Every new file started
at status drafted; RFC 0053 itself dropped from specified back to
drafted until its §3 held only backpressure.
Result
Four markdown files exist, indexed in docs/SUMMARY.md, and RFC 0053 is
at drafted. The split was announced on
#802, which lists
what moved where.
RFC 0054 — Publish unwind safety
rfc: 0054 title: Publish unwind safety status: drafted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-09-15 supersedes: — superseded-by: —
RFC 0054 — Publish unwind safety
Status note.
drafted. Split out of RFC 0053 at the maintainer’s direction. The reviewed prose lives in#802@30a21f80, §2.2 and the ownership / drain / worker / publisher parts of §3.2. Extract that wording. Do not invent a new protocol.Depends on RFC 0052 (barrier / sweep) and RFC 0053 (the node still accepts or refuses; this RFC does not decide the bound). It amends RFC 0052: §3 replaces that RFC’s timer pre-cut guard with proceed-and-decide, and amends its “only a restart clears it” clause, RFC0052.1’s restart-only wording and RFC0052.7’s assertion of it. It amends no accepted RFC.
1. Summary
A panic inside publish can drop batches the age sweep has already taken out of the sink. The snapshot barrier then reads empty buffers as “fully drained” and can stamp a WAL mark over frames that never reached the store (#796). Today the sweep stops after a panic (#795) so that does not repeat.
This RFC chooses duplicates over loss. Ownership of a drained batch stays recoverable across unwind. Only then may the sweep keep running.
It does not specify the PUBLISHED sidecar, settlement rebuild, or
audit-durability policy. Those are RFC 0055 and RFC 0056.
2. Motivation
See current RFC 0053 §2.2. The non-panic paths already requeue. Only
unwind does not. CLAUDE.md §3.4 forbids losing acknowledged data and
says nothing against delivering it twice.
3. Proposed design
Invariant. No acknowledged batch becomes unreachable because a consumer panicked. After unwind the records (and any unsettled audit events) are back in their buffers. Settled audit events stay out.
Budget. At-least-once. Duplicates are tolerated. The bound is “bounded by snapshot age / in-flight partitions,” not “exactly one extra object per panic.” The stronger claim in the 0053 draft fought the snapshot-rebuild path; this RFC keeps the weaker honest one.
Mechanism (shape, not types). Each consuming call takes the batch
in a form whose Drop requeues what it has not settled. A caller-side
guard does not cover panic points inside the writes. Named types
(RecoverableBatch, pool supervisor, publisher respawn) are
implementing-PR details that must satisfy the invariant; they are not
normative API.
Sweep. Once the invariant holds, a recovered panic does not permanently stop the cadence. Cancellation still terminates it.
Amendment to RFC 0052 — the timer’s pre-cut guard. The stop (#795) is
retired here and RFC 0052 §3.1’s epoch latch is kept, and the transition is
explicit: with requeue-on-unwind a recovered panic no longer strands
records, so the latch stops being a restart-only fault and becomes a signal
a cut can clear. The epoch pair stays as it is — what changes is the
consequence. So the timer’s pre-cut guard — RFC 0052 §3.2’s pseudocode
opens every tick with if failed_epoch <= barrier_epoch: skip — is
replaced by this RFC with “proceed: the cut’s drain takes the requeued
records first, and the ok verdict and the epoch CAS decide”; the “only a
restart clears it” clause, RFC0052.1’s restart-only wording and RFC0052.7’s
assertion of it are amended with it, and a latch set by a pre-RFC 0054
process clears on the restart that deploys this. The cadence_panic
counter #795 added stays, now meaning “a step panicked and was retried”
rather than “the cadence is dead”. Only a panicking JoinError continues
the sweep; a cancelled one still terminates it. §9.6 carries the reviewed
reasoning in full.
Out of scope. PUBLISHED watermarks, tenant settlement, encode-pool
channel policy except as needed to not drop a mined batch on worker
death.
4. Alternatives considered
- Keep stopping the sweep (#795). Rejected: the node then requires a restart after every publish panic.
- Idempotent object keys. Open in RFC 0055 §7; not required to state the invariant.
5. Acceptance criteria
RFC0054.1 — Recoverable on unwind
- Given a drained batch and a publish double that panics at the consumer boundary (before audit write, inside it, inside record publish)
- When the panic is recovered
- Then records and unsettled audit events are back in buffers; settled audit events are absent; no WAL mark crosses unrequeued data
RFC0054.2 — Sweep survives
- Given RFC0054.1
- When the next cadence tick runs
- Then the sweep continues; a later healthy barrier publishes
RFC0054.3 — Duplicate budget
- Given a panic after the store accepted at least one partition
- When the batch is published again
- Then every record id is present at least once; extras are allowed and counted; absence is a failure
RFC0054.4 — A cut clears the latch
- Given a recovered publish panic that set RFC 0052’s epoch latch, and no guard outstanding
- When the next barrier tick runs
- Then it proceeds rather than skipping: its drain takes the requeued records first, the
okverdict and the epoch CAS decide, and the latch is cleared without a restart- And a latch set by a process predating this RFC clears on the restart that deploys it
6. Testing strategy
Fault injection at the consumer boundary, parameterised per consumer (record vs audit). Exact handle types are the implementing PR’s.
7. Open questions
- Idempotent drain-time object keys (cost: a requeued batch must stay a unit). Owned with RFC 0055 if the sidecar needs them.
- Publisher-thread respawn vs disconnected-queue fallback. Implementation.
8. References
- RFC 0052 — amended by §3: the timer’s pre-cut guard
(
if failed_epoch <= barrier_epoch: skip) is replaced with proceed-and-decide, and with it the “only a restart clears it” clause, RFC0052.1’s restart-only wording and RFC0052.7’s assertion of it. RFC0054.4 asserts the replacement. - RFC 0053,
#795,#796,CLAUDE.md§3.4 - Source quarry: RFC 0053 draft on #802 @
30a21f80
9. Extracted wording (RFC 0053 draft, 30a21f80)
Moved here unedited from the RFC 0053 quarry so the reviewed sentences
survive the split rather than being rewritten from memory. §3 above is
the shape of this RFC and §5 above governs acceptance; the section
numbers inside these paragraphs (“§3.1”, “§3.2”) and the RFC0053.n
ids are the draft’s own.
9.1 The unwind defect (draft §2.2)
#796 — a publish unwind loses drained batches. drain_aged removes
batches from the sink buffers and write_ordered consumes them, so a panic
inside the publish drops them: no longer buffered, never written.
flush_then_snapshot then reads the empty buffers as “fully drained” and can
stamp a WAL high-water mark over frames that never reached the store, after
which recovery suppresses them. The non-panic paths requeue correctly; only
the unwind does not. #795 counts the panic and stops the sweep, because
continuing would repeat that loss every tick; this RFC is what makes
continuing safe.
9.2 Duplicates over loss (draft §3.2)
write_ordered requeues on every error arm already. It must do the same on
an unwind, which means the handling lives inside write_ordered rather than
at the call site, because the call site sees a partially moved Drained.
The decision the unwind forces is duplicate-versus-lose: a panic part-way
through the audit write may have made some rows durable, so requeueing risks a
duplicate and dropping risks the loss in §2.2. This RFC chooses
duplicates. CLAUDE.md §3.4 forbids losing acknowledged data and says
nothing against delivering it twice, and the audit-ordering barrier is
preserved either way because the record flush is skipped whenever the audit
sink has not drained.
9.3 Two settlements: ownership and publication
Two settlements are meant by that word, and this RFC keeps them apart.
Ownership settlement answers “is anything stranded?” — the recoverable
batch has released this partition, so no destructor will resurrect it and
no panic can lose it without a defined state. Publication settlement
answers “are the rows durable?” — the put succeeded and the publication
watermark may advance. Every path records both, and they are not always
the same: a put that succeeded is settled on both counts; a requeue that
completed is ownership-settled and publication-unsettled, the buffer
holding it; a record the sink quarantined (RFC 0025 §3.3) is
ownership-settled and publication-unsettled until its
record_quarantined event is durable, per §3.2’s watermark rule; and a
permanently failed audit write is ownership-settled — the batch is
not stranded, its records are back in the buffer — and emphatically
not publication-settled, since §3.2 requeues those records and makes
the tenant terminal. So each consuming call removes a partition from the
recoverable batch when its put has succeeded, its requeue has
completed, or the sink has permanently dropped it under its own
policy (the audit sink’s derivation-failure path and the record sink’s
RFC 0025 §3.3 quarantine) — that is the ownership boundary and nothing
more; what advances the watermark is publication settlement alone.
9.4 The per-put ownership boundary
The record sink’s
quarantine has a second boundary of its own: publish_owned can quarantine
some records and then issue a second put for the remainder, so the
quarantined records are settled the moment they are quarantined and the
remainder is settled when its put returns; a panic in that put requeues
the remainder alone, and the one-duplicate bound holds per put — a
partition whose
put failed transiently is requeued at once, under the sink lock, before the
next put starts, never parked in a local vector to be requeued after the
loop as the record and audit paths do today, since a panic in a later
partition would drop that vector. An unwind then requeues only the
partition in flight and the ones not yet started — one ambiguous put, hence
one possible duplicate.
9.5 Mechanism: ownership, drain, encode workers, publisher
Choosing the policy is not the same as making it hold, so the mechanism is
specified too. write_ordered moves drained.audit into write_owned
before it can touch drained.records, so a panic inside that call drops
whatever is still owned as Drained unwinds — the policy alone changes
nothing. An outer guard alone is not sufficient either, and this is the
subtlety that makes the fix structural rather than local: write_owned and
publish_owned take their Vecs by value, so the moment either is called
the guard no longer owns the batch and cannot requeue it if that callee
panics. A guard around write_ordered covers the panic points between the
writes and none of the ones inside them — which are the likely ones, since
that is where the store I/O and encoding live.
So the requirement lands on the consuming calls, not on their caller, and
the ownership shape is one state machine rather than an either/or: a
RecoverableBatch handle holding each partition in one of Pending,
InFlight, Settled { durable | dropped } or Requeued. The consumer that
holds the handle moves a partition to InFlight when its put starts, to
Settled when the call returns Ok or the sink drops it permanently, and
takes it out of the handle only in Settled; the handle’s Drop requeues
every Pending and InFlight partition and marks it Requeued, so after
any panic the batch is still reachable and requeued, and nothing settled is
requeued twice. A barrier can observe a sink only after the handle has been
dropped or fully settled. The points
outside the consuming calls — before the audit call, and between it and the
record publish — need an owner too, and today’s Drained has none:
its _guard is a PublishGuard that only tracks the in-flight count and
holds neither batch. So Drained itself gains the destructor, and it exists before either
take: today drain_aged and drain_all take the audit buffer, then the
record partitions, and only then construct Drained, so a panic inside
the second take unwinds with the audit batch owned by nobody, and a panic
part-way through the record drain leaves the partitions already removed
from the buffer on the drain’s own stack — a window RFC 0052’s
epoch latch covered by failing the cut, and this RFC covers by ownership. So the coordinator builds
an empty Drained (guard and sink handles only) first, and each take
moves what it removes into it as it goes: the record sink’s drain takes
&mut the Drained’s partition slot and pushes each partition the moment
it leaves the buffer, and the audit take moves its buffer in on return, so
at no instant does a removed batch live only on a stack frame. Its two
batches are Options the consuming calls take partition by partition as
they settle — and every normal requeue arm (the audit-failure arm
that requeues the records today included) take()s what it requeues, so
Drop handles remaining ownership only and nothing is requeued twice — it carries clones of the two shared sink handles
(SharedParquetSink and SharedParquetAuditSink are Arc-shared already)
so that Drop can call the record sink’s requeue and a new public
SharedParquetAuditSink::requeue_ahead — prepending the recovered events
ahead of concurrent emits and restoring the buffered gauge, which today only
the private BufferingAuditSink::requeue_ahead does — on whatever is still
present, audit events first and records second: the two buffers are
locked independently, the audit barrier treats an empty audit buffer as
“nothing pending”, and a concurrent inline or size publish that observed
the record buffer restored before the audit buffer would make the
recovered rows visible ahead of their template events; and that Drop
path is non-panicking — it recovers a poisoned sink lock rather than
unwinding inside an unwind. A pre-call or
inter-call unwind then requeues everything through the destructor, and a
panic inside a call requeues the unsettled remainder through the call’s own
handle. What the design requires is that no panic arm anywhere in the
sequence can drop an un-requeued batch; neither mechanism delivers that
alone, and neither can a catch_unwind at one call site.
The encode workers and the publisher are in the contract too. Under
RFC 0052 §3.1 a worker performs no store I/O: emit_concurrent appends
each record to the buffers, and a size- or ceiling-detached partition is
enqueued to the publisher — the one dedicated thread the
PublishCoordinator owns behind a bounded queue, which publishes each
detached partition through Feed::publish and settles its share of the
guard as durable, requeued, quarantined or, on an unwind, latched. The guard is not the worker’s
to create: RFC 0052 §3.1 has submit create it under the exclusion with
the batch’s own epoch. Nor is it moved into one item, since a batch can
detach several partitions into several queue slots; it is a shared
completion, an Arc holding the guard and a count incremented per
detach and decremented per completion (durable, requeued, parked or
dropped), settling the guard when the count reaches zero and the batch’s
encode phase has ended. Each queued Detached item (RFC 0052 §3.1’s queue carries detached
partitions only) carries a handle to that completion — so no
partition is ever outside both the buffers and the in-flight set, and the worker moves
to its next record with quiesce waiting on encodes alone. Everything this section says about write_ordered — the Drained
destructor, the RecoverableBatch handle, the unwind arm — applies to the
age sweep’s step, which calls write_ordered with a Drained on its own
task. The publisher thread runs a different path for detached partitions
(Feed::publish; RFC 0052 §3.1’s queue carries detached partitions
only), whose unwind is owned by the queue item’s destructor and the shared
completion above, so the Drained-specific rules stay the sweep’s
concern; nothing attaches to a worker-side PUT, because there is none. What a worker panic can still drop is
the unappended remainder of its mined Vec, and RFC 0052 has
BatchGuard lower failed_epoch for exactly that; the latch stays, and
the worker’s batch takes the same recoverable shape so a cut can clear it —
held in a RecoverableBatch whose Drop requeues the remainder to the
record sink before BatchGuard decrements pending, so quiesce can
never observe idle while records sit in a panicking worker’s dropped
iterator. The inner emit path has two transfer boundaries, both explicit:
the worker’s guard owns a record only until append_off_lock inserts
it — the handoff happens inside that call, before its post-append trigger
step, so a panic in the trigger cannot requeue a record the sink already
holds; a permanent failure in PartitionKey::derive, which returns before any
insertion, comes back as an explicit Settled::Dropped result of the
handoff rather than something the guard has to guess at — but dropped
here means quarantined, never discarded: RFC 0025 §3.3 requires every
permanent encode rejection, a timestamp that will not derive included, to
emit a record_quarantined audit event carrying the tenant, the partition
key and the error text, with the WAL retaining the record and the
flush-error counter taking the BatchError variant as error.type. So
this handoff routes through that same path rather than beside it, and the
partition is settled only after the quarantine emission returns; a
failed emission leaves it unsettled and requeued, so no record leaves the
system without its operator-facing pointer. RFC0053.2 asserts it — and a detached
partition passes from the worker to the publisher’s own RecoverableBatch
at the in-flight registration, before the enqueue and before the worker
continues, so a panic in the worker after that point cannot touch it and a
panic inside the publisher settles it per partition exactly as the sweep’s
batches are. The one-duplicate bound is claimed only under those two
transfers. RFC0053.2 asserts it with a panic inside a worker’s encode loop,
and with a panic inside the publisher on a batch a worker detached. Requeueing
the batch is worthless if the pool then swallows the next one, so both
halves of worker recovery are stated: a worker whose emit_concurrent
panics exits its OS thread today, and a pool-owned supervisor
respawns it: the supervisor, not the worker closures, holds the receiver,
so when a worker dies the batches still queued in the sync_channel are
drained and requeued and their Pending accounting settled rather than
dropped with the last worker’s receiver; it respawns only when the join
result reports a panic — never on the normal exit a closed tx produces —
and it stops before teardown, so Drop can join; the panic is counted.
The publisher thread gets the same treatment, because a publisher
that died would leave quiesce_publishes and shutdown waiting on guards
nothing will ever settle: the PublishCoordinator runs each batch under
catch_unwind, settles a panicking batch through its RecoverableBatch
(requeued, latched per §3.1’s guard rule), counts it with error.type = publisher_panic, and continues. The publisher enters the cloned Tokio Handle RFC 0052 §3.1 gives it, so
a requeue taken on that thread — RFC 0047’s graph update included — is not
silently skipped for running off-runtime; this RFC’s requeue-on-unwind
inherits that entry rather than adding one of its own. RFC 0052 §3.1
already defines what a dying thread does — it drains every batch still queued back into the
buffers as ready partitions, releasing each guard as its records land,
and the restart is a claim behind the coordinator’s publisher-slot
mutex rather than a spawn by whoever noticed: whichever worker still
sees the closed sender takes that mutex and replaces channel and thread,
the others retry against the new sender with their batches parked
meanwhile. This RFC’s requeue-on-unwind goes through that same gate and
adds no spawn of its own; it changes only the failing batch’s arm, from latched-and-settled to requeued
through its RecoverableBatch; and a worker
whose enqueue finds the queue disconnected (the publisher gone for
shutdown, or between death and respawn) follows RFC 0052 §3.1’s park
rather than a settle: the failed send returns the item to the worker,
which puts the batch back into the buffers as a ready partition
under the sink lock before releasing its guard — recording the current
barrier_epoch on the parked partition exactly as RFC 0052 §3.1 has a
requeue record it — and parking is all-or-nothing per capture, as
that RFC settles it: a capture that would carry the sink past its ceiling
parks everything and advances nothing, never a partial take, which the
capture a forced rotation triggers inherits unchanged — so a cut captured before the park fails its
all_ok(cut.epoch) and cannot stamp across records it never saw; keeping
its audit_watermark in the buffer entry, since a park that stripped the
dependency would let a later drain publish the records ahead of their
template events, and a Drained that takes it carries the maximum
watermark over the partitions it took — and signals the
coordinator to respawn — a park, not a requeue-and-settle, because a guard
released before the records are back in the buffers is a window in which a
cut would read them as neither buffered nor in flight. Parked partitions
are covered by every drain, so no guard is left registered with no owner
and no wait is unbounded.
And EncodePool::submit on a disconnected pool is not a failure the
client sees at all: the mined batch goes into the record sink’s buffer
through an append-only handoff (SharedParquetSink::buffer_only: no
trigger and no detached publish, since this runs on the async ingest path
and must neither register a publish nor wait on one) — the durability handoff the
pool would have reached, published by the next barrier or sweep — the turn
acknowledges normally, since the frame is WAL-durable and the batch will be
published by the next barrier, and the event is counted on a new registry
counter, ourios.ingest.encode_fallback (added in the same semconv bump),
since no existing instrument fits an accepted batch on a degraded path. A
full channel is not a fallback case: submit keeps blocking on the
bounded sync_channel as today, because that bound is what caps in-flight
memory during exactly the outage §4 says the sink cannot yet bound, and
the respawn keeps the pool from staying disconnected; a submit after the
pool has closed for shutdown is the disconnected case and takes the sink
path. An
earlier draft refused the turn before the ack; that would have told a
client to re-send a frame that was already durable and already on its way
to Parquet, and it still needed the requeue, because mine_batch_ordered
has already mutated the miner and a later successful append can carry
last_durable past the frame. The catch_unwind arm of
mine_batch_ordered, which submits its partially mined out and then
resumes the panic, takes the same path for out — but the prefix is not
enough, because the frame’s unmined suffix still exists only in the WAL
and a later successful append would carry the mark past it. Today that
arm exists only on the pool branch: the no-pool branch calls
miner.ingest inline with no catch, so a panic there leaves the same
suffix with nothing recording it. This RFC collapses the two into one
loop: both branches drive ingest_mined under the same catch arm, the
pool branch collects the returned records for submit and the no-pool
branch emits each returned record inline as it is mined, so there is one
unwind arm and one entry, whichever branch is configured.
9.6 What is requeued; the sweep and the barrier task
What is requeued depends on where the panic lands. The audit events are
requeued only while the audit write has not completed. Once write_owned
has returned fully durable — the only one of its three outcomes that
lets the record phase proceed, per the amendment above — every event it
covered is settled, durable in the audit store. The other two are not
settlement: retained requeues the events and refuses the record publish
as it always has, and permanently failed refuses it too and makes the
tenant terminal, so no path treats a lost event as final while publishing
the records that depended on it. A later panic inside the record publish therefore requeues the records
alone. Requeueing settled audit events would manufacture a duplicate the
policy tolerates but does not seek, and the ordering barrier does not need it:
the next flush skips the record publish only when the audit sink has
undrained events, which after a completed audit write it does not. So the
recoverable audit handle need only live until that call returns; the
recoverable record handle must live until the record publish returns. The
boolean is enough for that boundary; the permanent-drop count the sink already
exports is what distinguishes the two settled outcomes for an operator.
With that settled, the age sweep can survive a panic and keep sweeping. #795
deliberately stops, because without this it would repeat the loss every tick,
and RFC 0052 §3.1 adds the epoch latch (failed_epoch) that keeps its
timer from stamping past a dropped batch. The stop is retired here and the
latch is kept, and the transition is explicit: with requeue-on-unwind a
recovered panic no longer strands records, so
the latch stops being a restart-only fault and becomes a signal a cut can
clear, and RFC 0052’s contract is amended by this RFC in exactly that
respect. The epoch pair stays as it is — barrier_epoch, failed_epoch,
the guards’ Release-before-decrement stores and the barrier’s Acquire
loads — because it is the only thing that says which cut a dropped
batch could have been stamped past. What changes is the consequence. RFC
0052’s rule is that a cut with epoch E fails when failed_epoch ≤ E
and that only a restart clears it, because in stage 1 the dropped records
exist only in the WAL. Here the panicking guard’s RecoverableBatch has
requeued its records into the buffers before the guard decrements, so
they are drainable, and the clear is defined: a cut with failed_epoch ≤ E is not failed by the latch — it proceeds and stamps iff cut_ok and
prior_ok, as every cut does. A BatchGuard drop completes its requeue
before quiesce_encodes returns, so cut E’s own drain took those
records; a publish guard that settles after the capture leaves its
requeued records in the buffers for cut E + 1, and
quiesce_publishes().all_ok(cut.epoch) — RFC 0052 §3.2’s epoch-scoped
verdict — counts the panicked publish as not-ok for E, so E does not
stamp and its pending slot is invalidated as that RFC defines. Whichever cut
stamps having drained them clears the latch, and the clear cannot erase a
failure it never drained. A CAS on failed_epoch alone would: a cut that
captured E and observed failed_epoch = E can be overtaken by a guard
reporting E + 1, which leaves failed_epoch at E — the minimum — so
the CAS would succeed and the newer failure would vanish. So the epoch latch
carries a generation beside it, failure_generation: AtomicU64, and
the publication order is stated once and holds everywhere. At report, a
guard increments failure_generation first, then lowers
failed_epoch and the generation together. Two atomics cannot carry
this, whatever the ordering between them: a cut that reads the epoch and
then the generation can be overtaken by a report at the same epoch,
which bumps the generation and leaves the epoch unchanged — the cut’s
second load picks up the new generation, its captured pair equals the
current pair, and its CAS erases a failure it never drained. Ordering the
stores the other way only moves the window. So the latch is one
atomic, not two: a single AtomicU64 packing the epoch in the high 32
bits and the generation in the low 32, and report, capture and clear are
each a single-word operation on it. A report is a CAS loop that lowers the
epoch half to the minimum of its own and what it read and increments the
generation half in the same word, with Release, before it decrements its
count; a capture is one Acquire load of the word, which is by
construction a coherent pair; the clear after a stamp is a
compare_exchange against the exact word the cut captured, so any
report in between — a lower epoch, the same epoch, or neither — changes
the word, the CAS fails, and the failure survives for the next cut to
drain and clear. The clear is written as RFC 0052 §3.1 encodes it — u32::MAX in the
epoch half with the generation half zero, never a zero word — and epoch
u32::MAX is reserved, so no real cut’s epoch can collide with the clear
value and a CAS to it is unambiguous. Overflow is given a defined path rather than argued away: a wrap is
reachable in principle — four billion reports is a large number, not an
impossible one — and equality on a wrapped value would let a clear erase
a failure it never drained. So the generation half is sticky at its
maximum: a report that would carry it past u32::MAX - 1 leaves it at
u32::MAX instead, and no clear may CAS from a word whose generation
half is u32::MAX — the latch holds, the state is exported, and a
restart is what resets it, which is the same fail-closed posture every
other unclearable state here takes. The epoch half needs no such rule: it is the barrier’s own counter, but “one cut per barrier_secs” is not
the whole story — a rotation capture takes an epoch too, and a busy node
rotates far more often than it ticks — so the epoch is given a reset
rather than an argument. When barrier_epoch reaches u32::MAX - 1 the
next cut performs an epoch reset instead of an increment: under the
barrier exclusion, after quiesce_encodes and quiesce_publishes have
returned, no guard is outstanding — that is exactly what those two
calls establish — so no epoch is held by anything, and the counter may be
set back to zero and the latch word cleared without any comparison losing
meaning. The reset is therefore an ordinary cut with one extra step, not
a special mode, and it keeps u32::MAX reserved as the clear sentinel
that no live epoch can collide with. An implementation that needs either
half wider moves to a 128-bit word or a short mutex rather than splitting
the pair again. RFC0053.3’s leg drives a
same-epoch report between a capture and its clear. So the timer’s pre-cut guard — RFC 0052 §3.2’s pseudocode opens every
tick with
if failed_epoch <= barrier_epoch: skip // §3.1's latch; housekeeping below still runs
— is replaced by this RFC with “proceed: the cut’s drain takes the
requeued records first, and the ok verdict and the epoch CAS decide”;
the “only a restart clears it” clause, RFC0052.1’s restart-only wording
and RFC0052.7’s assertion of it are amended with it, and a latch set by a pre-RFC 0053 process clears on the restart
that deploys this — while the cadence_panic counter #795 added stays,
now meaning “a step panicked and was retried” rather than “the cadence is
dead”. Only a panicking JoinError continues the sweep; a cancelled one is
the runtime going away and still terminates it, exactly as today, so an
implementation that merely deleted the break — and let shutdown spin — would
not satisfy this section. The barrier task gets the same semantics, and RFC 0052 §3.2 already
runs the barrier and housekeeping ticks under catch_unwind, so what this
RFC adds is the consequence rather than the mechanism: in each tick’s body
— the capture and the run_cut — a panicking cut is dropped, and
dropping it is what requeues it, since the cut’s batches are held in a
Drained whose destructor requeues and the snapshot bytes are a
rebuildable cache; the task continues on the next tick, and the panic is
counted on the flush-error counter with error.type = cadence_panic like a
sweep panic. A rotation-captured cut handed to the task is the same shape
and takes the same path. RFC0053.3 drives the barrier task as well as the
sweep.
9.7 Draft acceptance legs (source for §5)
Scenario RFC0053.2 — A publish unwind keeps the records
- Given a cadence step whose publish panics after the batches have been drained out of the sink
- When the unwind completes
- Then the records of the in-flight and not-yet-started partitions are back in their buffer, and a partition whose put had already returned
Okstays out — a put the store had accepted but that had not returned is the ambiguous case and is requeued- And the audit groups not yet settled are back in theirs; a group that had settled — durable, or dropped under the sink’s own permanent-failure policy — is not requeued, so a later panic neither duplicates nor resurrects it
- And a panic in the third of several partition puts requeues only the third and any not yet started; the two accepted objects are not written again
- And a transient failure on the first put followed by a panic on the third leaves the first partition requeued too: a failed partition is never outside the recoverable batch while it awaits requeue
- And a panic inside an
EncodePoolworker’s encode loop, after the WAL ack, requeues the unappended remainder of that worker’s mined batch, and the barrier does not read the pool as idle across it; a panic inside the publisher thread on a batch that worker detached settles it per partition, and the worker’s own guard never touches it- And a submit after that panic is either published by a respawned worker or placed in the record sink’s buffer and acknowledged; it is never dropped behind a success, and a later successful append never carries the mark past it
- And the partially mined batch a panicking
mine_batch_orderedsubmits from its unwind arm reaches the sink before the panic resumes, on the pool branch and the no-pool branch alike- And this holds for a panic raised at each point the publish can reach it — during the drain itself, while taking the second of the two batches, before the audit write, inside it, and inside the record publish — since the partial-move shape means only the last of those is caught by a naive guard
- And the publication barrier does not read the buffers as fully drained, so no high-water mark is stamped across them
- And a subsequent barrier with a healthy store publishes them
- And a panic raised after the store accepted an object but before the publish returned leaves at most one duplicate object per panic, the rows are present in every case, and the panic is counted
Scenario RFC0053.3 — The cadence survives a panic once unwinds are safe
- Given RFC0053.2 holding
- When a cadence step panics
- Then the sweep counts the panic and continues on the next tick
- And a repeating panic loses no records, however many ticks it spans
- And the same holds for the barrier task: a panic inside a cut — timer-triggered or rotation-captured — requeues the cut’s batches, advances no checkpoint, is counted, and the task captures the next tick’s cut on schedule; it installs no snapshot when it precedes the first install, and a panic after one leaves that tenant’s snapshot ahead of the checkpoint, which is the
S > Xcase recovery already handles by replaying frommax(X, S)per tenant — nothing is lost and nothing is republished, so the criterion asserts the pre-install points rather than an undo that cannot exist- And the same holds for the publisher thread: a panic inside a batch requeues it and the thread continues; a dead publisher is respawned and settles the batches still queued; a worker enqueuing on a disconnected queue buffers its batch instead, and
quiesce_publishesand shutdown return- And the epoch latch clears without a restart: a worker panic bumps the packed word in one CAS — the epoch half lowered to the minimum and the generation half incremented together — the cut that drains the requeued records stamps and clears by a
compare_exchangeagainst the exact word it loaded at its capture, and a panic raised during that cut — at the same epoch or a later one — changes that word so the clear fails and the failure survives for the next cut; a same-epoch report between a capture and its clear is caught, which two separate atomics would miss
9.8 Draft testing legs (source for §6)
- Unwind safety (RFC0053.2, RFC0053.3) — a publish double that panics on demand at each reachable point (before the audit write, inside it, inside the record publish), asserting the records and any unsettled audit events are back in their buffers, settled audit events are absent, the no mark crosses unrequeued data (a healthy barrier may publish the restored batches in the same call, so an unconditional refusal is not the contract), and a later healthy barrier publishes. Parameterising the panic point is the whole test: the partial-move shape means a guard that only covers the last point passes a single-point test. The point is also parameterised across partitions, for each consumer separately — record partitions and audit groups each get a drained batch of several with the panic in the first, a middle and the last put — asserting the accepted objects are not written again, the store holds at most one duplicate, the corresponding buffer is restored and the other consumer’s settled items stay out; each consumer also gets a leg that fails the first put transiently and panics on a later one, asserting the failed partition or group is back in its buffer. RFC0053.3 drives many consecutive panicking ticks and asserts logical no-loss by unique record ids plus at most one extra object per panic — not an exact row count, which the accepted duplicate would fail.
RFC 0055 — Publication frontiers and tenant settlement
rfc: 0055 title: Publication frontiers and tenant settlement status: drafted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-09-15 supersedes: — superseded-by: —
RFC 0055 — Publication frontiers and tenant settlement
Status note.
drafted. Split out of RFC 0053. The reviewed prose is the non-ownership half of §3.2 on#802@30a21f80(PUBLISHED, frontiers, settlement, tenant slots, object-key intent, sidecar geometry). Extract. Do not grow 0053.
1. Summary
Unwind safety (RFC 0054) is an in-process property. Across a restart, recovery must know which record and audit frames already reached the store, and a mining panic must not leave a tenant tree between a widening and its audit event.
This RFC specifies:
- a durable per-tenant publication frontier (
PUBLISHED) - settlement that rebuilds a panicked tenant from its last installed snapshot
- tenant-slot admission (
max_tenants) as the open-mode backstop
It does not specify the WAL byte bound (RFC 0053) or the unwind invariant (RFC 0054).
2. Motivation
RFC 0054’s “one extra object per panic” claim does not survive a pre-checkpoint crash: recovery replays frames and can regenerate a durable audit event. Frontiers are the missing watermark. Settlement is hazard #5 (template-state recovery) for the in-process panic.
3. Proposed design
Extract from the 0053 draft, then stop:
PUBLISHEDsidecar: per-tenant{records, audit}offsets, one durable write, geometry sized frommax_tenants, never grown in place; raise-capacity rebuilds both sidecars through.newtemps.- Record replay gated on the record watermark; audit replay gated on
max(checkpoint, audit watermark). - Settlement: rebuild from last installed snapshot; replay frames; discarded tree’s buffered records dropped under the sink lock in the same section as the swap.
- Tenant slots:
Reserved/Held; countHeld + Reservedagainstmax_tenants; seedHeldfrom recovered tenants before admit. - Object-key intent as specified in the last commits on #802 (normative in-process; cross-restart claim already dropped).
- Tombstone a removed tenant’s dictionary slot.
Amendments this RFC makes: none. The 128-byte tenant key its dictionary is sized to rests on the RFC 0046 / RFC0046.11 length amendment (256→128, matching RFC 0048 §3.1, already recorded as a resolved question in 0046) — and that amendment is RFC 0052’s, stated normatively in its §8. This RFC cites it and must not restate it: one clause amended by two documents is the contradiction this split exists to prevent. §9.7’s extracted prose predates the split and reads as though this RFC makes it; it does not.
4. Alternatives considered
- Rely on recovery’s WAL suppression horizon alone. Rejected: it does not see in-process duplicates or durable-but-unmarked audit objects.
- Keep this in RFC 0053. Rejected: it is why 0053 cannot converge.
5. Acceptance criteria
Stage note. This RFC is
drafted; fullGiven / When / Thencriteria are written at thespecifiedgate. The five labels below are the plan for that gate, not the final criteria text.
- RFC0055.1 restart does not republish a settled audit group
- RFC0055.2 mixed-geometry sidecar restart comes up at the larger geometry with ids unchanged
- RFC0055.3 settlement rebuild does not publish under a
template_idthe replay did not allocate - RFC0055.4
max_tenantsholds across unwind and restart - RFC0055.5 (moved from RFC0052.17, whose
PUBLISHEDlegs need this RFC’s writer): a tenant introduced by aPUBLISHED-only write — no interveningRECLAIMwrite — keeps its slot id across a restart (the table is seeded from the union of both dictionaries andnext_slot_idis the maximum of the two headers); and aRECLAIMrecord carryingpublished_seeded_armedwithoutpublished_seeded_confirmedleaves the next start free to seed again whenPUBLISHEDis absent, and is promoted to confirmed durably at open, never read as a fault, whenPUBLISHEDis present. The stub isourios-wal/tests/it/rfc0052_17_reclaim_record/matrix.rs’srfc0055_5_slot_ids_survive_a_published_only_write_and_seeding_flags_resolve.
6. Testing strategy
Sidecar fixtures (including half-resized pair and tombstones) plus a settlement race with two triggers. Details in the quarry draft §6.
7. Open questions
- Idempotent object keys across restart (explicitly dropped on #802; reopen here or leave closed).
- Whether settlement blocks the global ingest gate or needs a per-tenant gate (RFC 0052). Pick one; do not claim both.
8. References
- RFC 0005, 0048, 0053, 0054
- RFC 0046 — not amended here. The RFC0046.11 length amendment (256→128) is RFC 0052’s, in its §8; this RFC’s 128-byte key cites it.
- RFC 0052 — owns that amendment, the
RECLAIMrecord this RFC’sPUBLISHEDshares rules with, and the tenant id space. - Source quarry: #802 @
30a21f80
9. Extracted wording (RFC 0053 draft, 30a21f80)
Moved here unedited from the RFC 0053 quarry so the reviewed sentences
survive the split rather than being rewritten from memory. §3 above is
the shape of this RFC and §5 above governs acceptance; the section
numbers inside these paragraphs (“§3.1”, “§3.2”) and the RFC0053.n
ids are the draft’s own. Where this prose reads as though this RFC
amends RFC 0046 / RFC0046.11, it predates the split: §3 above makes no
amendment, and RFC 0052 §8 owns that one.
9.1 The tenant-slot guard (draft §3.1)
A second knob rides with unreclaimed_bytes_limit (RFC 0053 §3.1), for a different growth: in open mode (RFC 0026
§3.1, no auth configured) tenant ids are client-chosen, so the per-tenant
RECLAIM entries and the SnapshotLedger’s retained states (§3.2) would
grow with traffic rather than with an operator’s tenant set. ReceiverSection
gains max_tenants (default 1024, same ${env:VAR} and Helm path) —
a knob that sizes two persisted layouts, not just an admission rule:
RFC 0052 §3.2’s RECLAIM slots are sized from it and
max_unlinks_per_pass (5,295,136 B a slot, 10,590,304 B the file at the
defaults), and PUBLISHED below is sized from it alone, both fixed for
the life of the file. Neither file can grow, so the admission guard is
what prevents record overflow: a new tenant id at the cap is refused
here rather than overrunning a slot there, which is why the two RFCs land
as a pair and why lowering the knob is an open-time decision (below):
admission of a tenant id the miner does not yet hold, when the guard is
reached, is refused as TenantCapacity { count, limit } naming the tenant
— ordered with §3.1’s tenant check, after the terminal-rotation check and
before the bound — and existing tenants are unaffected. The refusal is
capacity, not reclaimable backpressure, and is its own error rather
than a BackpressureCause: a Held slot is never released by
reclamation, so no pass can clear the state and a Retry-After would
advertise a wait that ends nowhere. So TenantCapacity carries no
Retry-After and no RetryInfo — the client backs off exponentially, as
OTLP prescribes when none is sent — it does not enter the refusal latch,
it is counted on the ingest counter with error.type = wal_tenant_cap,
and ourios.wal.tenants.usage with no free left is the alert. The operator path is
to raise max_tenants; a verb that releases a tenant is deferred to §7,
with the note that under RFC 0047/0048 tenants are an out-of-band
decision and the guard is not the place to make it. What the guard is
and is not. In open mode a client can fill it with chosen ids and deny
a later, legitimate tenant its first write. That is not a hole the guard
introduces and not one it can close: open mode has no tenant isolation at
all — any client that can reach a listener may write to, and read from,
any tenant, which is exactly what warn_if_open_mode says at startup
(RFC 0026 §3.1) — so cross-tenant availability is not a property open
mode offers, and the cap exists to bound the node’s disk and memory, not
to arbitrate fairness. Open mode is not a production posture; the
warning exists so that running without auth is a visible choice. In an
authenticated deployment RFC 0047 §3.1 binds tenant ids to principals and
RFC 0048 authorises them, so a client can exhaust only the tenants it is
entitled to, and the guard is the backstop it was meant to be. No
eviction, rate limit or per-principal quota is added here; the admin
release verb is the §7 follow-up. Idle
tenants are not evicted in this stage: a Held slot lasts for the
process lifetime, and a restart rebuilds the table from what recovery
restored. A check before the append is not enough on its own, since
concurrent first writes for different new ids would all observe the count
below the guard; so the coordinator keeps a tenant admission table under
the same mutex the reservation takes, with two states: a new id takes a
Reserved slot there before its append — refused when Reserved + Held is at the guard — and the slot is reference-counted per in-flight
first write, so concurrent first writes for one id share it; it moves to
Held when any of them has its sync return Ok and the tenant is
installed in the miner — and the transition has a stated home, because
both of those happen outside the admission mutex and the lock order
forbids reaching back for it while the miner lock is held. So the turn
records the transition after it releases the miner lock, reacquiring
the admission mutex alone, which the order permits precisely because
nothing else is then held; the same reacquisition carries the rollback,
releasing the reservation when the sync failed or the install did not
happen. The table and the exported count cannot diverge because they are
one write: ourios.wal.tenants.usage’s held and reserved states are
derived from the table under that same hold, never counted separately, so
a reader sees Reserved or Held for a given id and never a total that
disagrees with the table it came from. The slot is released only when the
last in-flight first write for that id has settled without a success
and left no frame behind — and that id has no unresolved unmined entry. The
frame clause matters because the two failure shapes differ: an append that
failed never reached the segment, so nothing survives and the slot is
released; a sync that failed leaves the frame in the segment
unacknowledged, and replay after a restart re-mines it and seeds that
tenant Held, so releasing the slot on a sync failure could let another
id take it and put the recovered set over max_tenants. So a sync
failure keeps the reservation for as long as its frame can be replayed —
until the frame is reclaimed or a later write for that id succeeds and
converts the reservation to Held — which is exactly the retain rule the
entry case below uses. The recovered set can therefore never exceed the
cap by this path, and the other way it could — an operator lowering
max_tenants between runs — is refused at Wal::open, the single
rule §3.2’s sidecar states: the file is sized from the knob, so a recorded
set larger than the new limit cannot be represented at all, and with no
eviction in this stage it could never drain. The node does not start; the
operator raises the limit back or starts a fresh root. An earlier draft
said the set was seeded whole and only new ids refused, which contradicted
that and left an over-limit set with no way down; the open-time rejection
is the rule everywhere — admission, sidecar and the
ourios.wal.tenants.usage states alike, which can therefore never report
held above limit. The exception matters because settlement
installs a tenant outside any admission path: a first write that appends,
panics in mining and unwinds would otherwise release its slot, another id
could take it and become Held, and the rebuild would then install the
first tenant past the guard. So an unwind that leaves an unmined entry
retains the reservation, in a Reserved { pending_settlement } form
that the guard counts like any other; the entry’s settlement converts it
to Held when it installs the tenant, and releases it when the entry
resolves without installing one. No reacquisition is needed, because the
slot was never given up. ourios.wal.tenants.usage carries held and
reserved as separate states, since admission consumes both. Recovery seeds the table: after restore and
replay, and before any listener is constructed, every tenant the miner
holds is entered as Held, so a restarted node neither admits past the
guard nor refuses a tenant it already serves; RFC0053.4 asserts it.
Authenticated deployments bound the set out of band (RFC 0047 §3.1’s
binding, RFC 0048’s authorisation), so the guard is the open-mode
backstop, not the tenancy model; the held and reserved counts are exported
(ourios.wal.tenants.usage, §3.3) and RFC0053.1 asserts the refusal.
9.2 The deterministic key and the publication frontier
The duplicate this creates is a new class, and it is stated rather than
handed to recovery. Record and audit publishes write fresh UUIDv7 object
keys, so a panic after the store accepted an object but before the publish
returned leaves that object in place and requeues its rows; the next publish
writes them again under a new key, and two query-visible objects carry the
same rows, in the same process. Recovery’s replay suppression is a
restart-time mechanism over WAL frames and never sees this. That is bounded within a process, but a crash after the ambiguous PUT
turns it unbounded: the rows are requeued, a normal drain republishes them
under a third key, and a restart replays the frame and publishes a fourth
— the intent-and-frontier protocol §3.2 defines covers an unmined
settlement and reaches none of this. So the deterministic key covers
every ambiguous retry, not settlements alone: when a publish is
requeued after its put may have been accepted — the ambiguous arm, which
the RecoverableBatch already distinguishes from a clean failure — the
retry names its object by a name-based (RFC 4122 v5) UUID, rather
than a fresh UUIDv7, derived over the subspan, not the frame range:
the tenant, the partition key, the frame range and the record-index
range within the first and last frames. Two implementations must derive
the same name from the same rows, so the derivation is normative rather
than descriptive — a sketch would let two builds, or a rebuilt client,
produce different names for one span and defeat the whole point. The
namespace UUID is a constant of this RFC,
6f757269-6f73-5055-424c-000000000001, fixed for the life of the format
and never derived from configuration. Its bytes spell the name: 6f 75 72 69 and 6f 73 are ourios across the first two fields, 50 55 and 42 4c are PUBL across the third and fourth. The name is the canonical serialisation below,
hashed as RFC 4122 v5 prescribes; integers are little-endian, as
everywhere else in this stack, and every variable-length part carries its
own u16 length before its bytes so no two field sequences can alias:
| # | Field | Bytes |
|---|---|---|
| 1 | tenant id | u16 len then len bytes, the id verbatim |
| 2 | partition key | u16 len then len bytes, the key’s canonical string form |
| 3 | first frame | 16 B segment UUID (RFC 4122 byte order) then u64 byte offset |
| 4 | first record index | u32 |
| 5 | last frame | 16 B segment UUID then u64 byte offset |
| 6 | last record index | u32 |
Fields are concatenated in that order with no padding and no separators,
the lengths making the encoding unambiguous. Nothing else enters the
name — not a timestamp, not a retry count, not the record contents —
because a retry must reproduce it exactly. The index range is what makes the identity stable, because a
frame can be partially published: §3.2’s emit_from means a retry may
carry records [emit_from, end] of a frame whose earlier records went
out in another object, and a key over the frame range alone would derive
one name for two different sets of rows — the store would overwrite one
with the other rather than collapse a duplicate, which is worse than the
duplicate it was meant to prevent. It survives replay because the
index range is reconstructed, not remembered: emit_from is recorded in
the unmined entry and the replay emits from exactly that index, so a
restart re-derives the same first index, the same last index and the same
name for the same rows. Every later attempt at those rows writes that same name.
Three limits on that claim are stated here rather than discovered later, because each of them bounds what the key can buy.
It bounds keys, not objects. Whether two PUTs under one key leave one
object is the backend’s business, and the accepted storage contract does
not promise it: RFC 0013’s conditional PUT is specified for
create-if-absent (If-None-Match: *) and its compare-and-swap half for
manifest generations, while the plain Store::put documents no overwrite
semantics at all. So the bound this RFC claims is one object per
distinct key, with every retry of the same rows producing the same key —
on a backend whose same-key PUT overwrites, that is one object; on one
that does not, it is one object per attempt but never a different set
of rows under a name already used. The RFC does not require overwrite
semantics of a backend, and does not test for them.
It is a records bound. The identity is a record subspan, and audit groups have no record indexes to range over, so the key does not apply to them. Audit retries are bounded differently and separately: an audit group is regenerated per frame by the miner (RFC 0052’s regeneration-only replay), and the per-tenant audit frontier §3.2 defines is what stops a restart re-emitting a group already durable. Within a process an ambiguous audit write costs at most one duplicate group, which the sink’s own permanent-drop counters make visible.
It is an in-process bound, and the cross-restart half is dropped. The
ambiguity lives in the RecoverableBatch — it knows the put may have been
accepted — and a crash takes that knowledge with it, so a retry after a
restart could only re-derive the same name if something on disk recorded
which subspan was ambiguous. Recording it would mean a sidecar write
before every potentially ambiguous PUT, which is every PUT: a cost on
the normal publish path, paid always, to bound a case that arises after a
panic or a store timeout. That trade is not worth making, so this RFC does
not make it, and it does not claim what it cannot back — the key bounds
retries within a process; across a restart the bound is the per-tenant
publication frontier, which is durable and which §3.2 already relies on:
a frame at or below it is not re-emitted, and one above it may be
published again, at most once per restart that replays it. The one place a
cross-restart identity is durable is the settlement’s own intent span,
where publish_marks writes the marks before the rebuild publishes — that
path keeps its stronger claim precisely because it already pays for it. Only a publish that has
never been ambiguous keeps its UUIDv7. This RFC accepts the one object,
counted through the cadence_panic counter beside the flushed-partition
counters, and asserted by RFC0053.2. The bound holds only because settlement is
per partition, stated below: a drained batch spans several record
partitions and audit groups, and requeueing the whole batch after a panic in
the third put would duplicate the two objects already accepted.
9.3 An ambiguous requeue keeps its own identity
An ambiguous requeue keeps its own identity, which the deterministic key of §3.2 depends on: that key is derived over the partition’s frame range, so a requeue prepended into the live buffer would take whatever arrived during the in-flight PUT along on the retry, changing the range, changing the key, and leaving both objects behind — the very duplicate the key exists to collapse. So a batch requeued from the ambiguous arm is held as its own unit against that partition rather than merged into it, and the next publish writes that unit first and alone, under the key its original range derives; records that arrived meanwhile stay behind it and publish as their own batch after. Only the ambiguous arm needs this — a clean failure wrote nothing, so its records may merge freely. Making the publish idempotent — a drain-time object key a requeued batch reuses — is a §7 question rather than part of this design, because a requeued batch is re-drained together with whatever arrived since and the key would have to survive that merge.
9.4 unmined entries, settlement, PUBLISHED, lock order
The unwind arm records an unmined entry for any panic inside that
loop — inside ingest_mined, or after it returned and before the inline
emit or the collected submit completed — with three things. The
frame’s span, FrameSpan { segment, start, len }: RFC 0008 defines
WalOffset as the post-frame byte, so the offset append_batch returns
cannot locate the frame’s start by itself, and the caller computes the
span from what it already holds — start = offset.byte − (frame header + encoded payload length), len the same sum — rather than adding a
per-frame index to the WAL. The tenants whose records the frame holds.
And clamp, below.
Settlement is a rebuild, not a resume at an index, because a panic
inside ingest leaves the tenant’s tree in an unknown state: a leaf can
have been widened or created before the audit event that records it was
emitted, and resuming against that tree would match the widened leaf
cleanly and emit no event — a template version with no audit history,
which RFC 0017’s versioned rendering cannot fold. So settlement rebuilds each of the entry’s tenants: from
its last installed snapshot, replaying that tenant’s frames from the
snapshot’s mark through the end of the span; or, when the tenant has no
installed snapshot, from empty with a full replay of the tenant’s
surviving WAL history. The installed snapshot is never re-read from the
directory. A visible .snap is not an authority in-process: its rename
can precede the parent-directory fsync, and after a failed write it can
be newer than the durable horizon RFC 0052 §3.2’s SnapshotLedger holds.
So the ledger, which already records per tenant the horizon of the last
durably installed snapshot — renamed and parent-fsynced, recorded at
the ledger’s horizon update — retains beside that horizon the decoded
SnapshotState it was installed from, and settlement rebuilds from that
retained copy. The cost is one decoded state per tenant, at most the size
of the tenant’s live tree, itself bounded by RFC 0023’s per-tenant
ceiling; the copy is replaced when a newer install completes and dropped
with the tenant. A tenant restored at startup has its copy from
load_all_durable(), the one directory read RFC 0052 permits — from its oldest surviving frame, which RFC 0052
§3.2’s no-snapshot pin guarantees is still there, through the span — never
the span alone, which would rebuild a tree missing every template the
earlier frames defined. The replay is in WAL order, through the same
per-tenant restore-and-replay recovery::recover performs at startup,
factored to run for one tenant in-process; a snapshot that fails to
restore falls to the empty-plus-full-replay arm, as it does at startup.
Settlement has two triggers, so the byte bound cannot starve it, and
one claim protocol, so they cannot both settle. The
next ingest turn settles before its own batch, under the ingest gate, so
later turns do not overtake the gap — but a frame that fills the bound is
followed by refusals, not turns, and an entry that waited for an admitted
turn would never settle while the bytes it holds are never reclaimed. So
the barrier task settles every unresolved entry at the start of each tick,
before it captures its cut. Settlement never touches the ingest gate: RFC
0052 §3.1 is explicit that ingest_gate is a watch counter of append
sequences, that a timer has none to reserve, and that inventing one would
interleave with real turns — so both triggers take only the barrier
exclusion, then the miner lock, in that fixed order; an admitted turn
already holds both when it finds an entry for its own tenant, and the
tick takes them as it does for a cut. The claim makes the serialisation
explicit and lets the later one observe it: an unmined entry has a
state, Unresolved or Settling, and the settler moves it to Settling
under the miner lock before it touches the tenant. The claim alone does
not serialise the loser with the rebuild, because the winner releases the
locks for the bounded reads (below) and takes them again only for the
final replace_tenant; so the entry also carries a completion signal, and
the protocol is: the winner holds the exclusion and the miner lock for the
claim only, performs the reads with no pipeline lock held, re-takes the
two in order for replace_tenant, removes the entry under the miner lock
and signals. A panic inside the settlement has its own arm, because
the replay runs the same mining path that panicked in the first place and
replace_tenant runs under the miner lock: without one, the barrier
tick’s catch_unwind would resume the task with the entry still
Settling, its waiters parked and the tenant refused for the life of the
process. So the settlement runs under its own catch_unwind inside the
tick’s: an unwind returns the entry to Unresolved — or marks the tenant
unrecoverable, when the rebuild had already reached §3.2’s
reclaimed_through rule — and signals the waiters before the panic is
counted and the tick resumes, so the next trigger claims the entry and a
waiting request is refused with a fresh hint rather than held. The miner
lock’s poisoning is recovered the way the pipeline already recovers it
(lock_miner takes PoisonError::into_inner), and the tenant’s tree is
whatever the failed rebuild left, which the next settlement replaces
wholesale. RFC0053.2 drives a panic in the replay and in
replace_tenant. A turn for a settling tenant must not wait inside the gate,
where it would hold every other tenant’s sequence behind it; instead the
coordinator keeps the settling set under its admission mutex, and a
request for a Settling tenant is refused at admission, before it takes
a sequence. One protocol, stated once and the same in both sections. The work
splits by which state it reads, not by convenience. The admission
mutex covers the coordinator’s own policy — max-frame validation, the
tenant checks, the settling set, and the tenant slot — and is released
before the journal is touched. The journal mutex covers everything
that reads or mutates journal state, and that includes the
terminal-rotation check, which an earlier draft put under admission:
rotation_state() reads the journal, so reading it under the admission
mutex leaves the state free to change before the reservation. So the
journal hold is the terminal check, the rotation-due evaluation, the byte
and segment reservation taken from reclaim_state(), and the
append_batch that consumes the resulting RotationDecision, all in
one hold, which is what §3.1 requires and
what makes the token sound. The binding is therefore atomic where it must
be — a reservation cannot be made against a journal another append has
moved — and the rollback is local to that hold: an append that fails,
whether on rotation or on write, releases its byte and segment
reservation before the mutex is dropped, and a rotation inside the append
does its create, rename and parent fsync under the journal mutex alone.
That is not an exception to the rule but the reason for its wording: the
journal mutex is the WAL’s single-writer lock, so a rotation
necessarily holds it while it touches the directory, and no arrangement
of locks can change that. What the rule forbids is a lock above it
being held across that work, and the admission mutex is released before
the append for exactly that reason — so an fsync inside a rotation
delays that one append and not every other request’s admission.
That keeps the admission mutex clear of directory I/O, which this RFC’s
lock order requires, and keeps reservation and append inseparable, which
§3.1 requires; the earlier draft that held the admission mutex across the
append satisfied the second at the cost of the first. The claim that carries the correctness is the one taken under the
barrier exclusion and the miner lock, not the admission-set insertion.
Publishing Settling under the admission mutex refuses later requests
early, which is worth doing, but it cannot order a turn that is already
past that point: such a turn could mine after the claim and have its work
discarded by replace_tenant. What closes that is RFC 0052 §3.1’s own
span — an ingest turn holds the exclusion from the miner work through
pool.submit and the last_durable update — so a settler that takes the
exclusion and then the miner lock to claim the entry cannot interleave
with any mining span at all: no turn is mid-mine while it holds them, and
no turn can start one until it releases. A turn that appended before the
claim but had not yet mined waits at the exclusion and mines afterwards,
correctly, because the rebuild replays only to the entry’s own span and
that turn’s frame lies above it. So the mining span is excluded whole, the
admission-time refusal is an optimisation with no correctness weight, and
post-claim appends need no special handling. The commit sequence is not
reserved there, and that is deliberate: CommitCoordinator::append today
takes the journal lock, appends, and only then allocates seq from
FlushState, returning CommitOutcome { seq: None } when the append
failed — no sequence is consumed, so the gate never waits on one that was
never used and FlushState has no gap to tolerate. Reserving a sequence
before the append would introduce exactly that hole, needing a no-op
completion on every failure path to keep the gate advancing. So sequence
allocation stays where it is, after a successful append and under the
journal mutex; the gate itself is untouched, awaited outside both locks as
today. RFC0053.1 drives the concurrency: a settler claiming a tenant while
a turn for it is between its admission check and its append, asserting the
turn’s frame is appended, waits at the exclusion, and is mined against the
rebuilt tree afterwards — nothing mined into a tree about to be replaced,
and nothing replayed twice, since its frame lies above the rebuild’s
span.
One lock order covers all four locks, since this RFC adds the first of
them to the three RFC 0052 §3.1 fixes. Top to bottom: admission mutex →
barrier exclusion → miner lock → last_durable, with the journal
mutex taken below the admission mutex and never above it — an ingest
turn takes the admission mutex for the coordinator’s policy checks
(max-frame, tenant state, tenant slot, settling set) and releases
it, then takes the journal mutex alone for the terminal check, the
rotation decision, the byte and segment reservation and append_batch in
one hold — whose
success allocates the sequence, and inside which a rotation does its
directory work under that mutex only — then follows
RFC 0052’s order for the mining span; maintain takes the admission mutex
and then the journal mutex for its ledger halves, in that order, which
is why the forced rotation and the leave transition can be evaluated
together — and releases both across the file half, which is the point
of RFC 0052 §3.7’s prepare/commit split: the RECLAIM write, its fsync
and rename, the unlinks and the parent fsync all run with no admission
mutex and no journal mutex held, so a reservation never queues behind an
fsync. Holding the admission mutex there would put every ingest request
behind the pass’s disk I/O and reintroduce exactly the stall the split
exists to remove. The rule, stated as part of the order: **no lock in this
hierarchy above the journal mutex is held across store or directory
I/O — not the admission
mutex over the file half, not the barrier exclusion over a cut’s flush
(§3.2), not the miner lock over a snapshot write; and a
settler publishes its claim under the admission mutex — adding the
tenant to the settling set — before it takes the barrier exclusion, so
the claim is visible to admission from the instant it exists and no
request can be admitted for a tenant whose rebuild has begun. The miner
lock still carries the Unresolved/Settling transition on the entry
itself, which is what makes exactly one trigger the settler; the
admission-mutex insertion is what makes that decision visible to requests.
And every completion path removes it again, under the admission mutex
alone, in the same reacquisition pattern the tenant table uses: a rebuild
that succeeded removes the tenant from the settling set after it has
removed the entry, so the next request is admitted; a rebuild that found the tenant
unrecoverable removes it as well, because that tenant is refused by
its own terminal state from then on and two refusal reasons for one tenant
is one too many. A rebuild that unwound is the exception, and
deliberately so: the entry goes back to Unresolved but the tenant
stays refused, because the tree is half-mutated — the panic left it
between the widening and its audit event, which is the state the rebuild
exists to repair — and admitting a request into that window is exactly the
defect the claim prevents. So admission is refused for as long as the
tenant has an unmined entry at all, Unresolved or Settling alike,
and the set is really “tenants with an unresolved entry” rather than
“tenants a settler is holding”. The retry is the barrier tick: it
settles every unresolved entry at the start of each tick (§3.2), so the
next tick claims the entry afresh, and no ingest path has to coordinate a
retry it cannot perform while refused. So
ourios.wal.settling_tenants returns to zero when a rebuild succeeds or
the tenant goes terminal, and stays nonzero — visibly — while a panicked
rebuild awaits its next tick. RFC0053.2 asserts all three paths.
No path takes the admission mutex while holding any of the other three,
which is what makes the order a total one. The
refusal is its own error, ReceiveError::SettlementInProgress { tenant },
mapped by IngestFailure::classify to a Settling outcome: 503 /
UNAVAILABLE with the protobuf Status body naming the tenant, and —
unlike the tenant cap — with Retry-After on HTTP and a RetryInfo
detail on gRPC, because a settlement is short and self-clearing, carrying
the settlement’s expected remainder, the barrier cadence at most and one
second at least, as a non-binding hint; it is counted on the ingest
counter with error.type = wal_settling; the tenant’s live tree is
unreachable to ingest until the rebuild has installed it, turns for other
tenants take their sequences as usual, and no sequence is ever taken on
the tenant’s behalf. The losing trigger waits on the signal
and only then proceeds to its own work, so it never runs against a
half-rebuilt tenant. A failed rebuild returns the entry to Unresolved,
signals, and the next trigger claims it. RFC0053.1 asserts the bound-crossed
case settles on the timer with no append and no restart, and RFC0053.2
races the two triggers on one entry and asserts exactly one rebuild. The fallback reuses RFC 0052’s reclaimed-through
check, so an unrecoverable tenant stays blocked in-process as it would at
startup. Before rebuilding, settlement compares the tenant’s restorable
horizon — its installed snapshot’s mark, or none — with
ReclaimState::reclaimed_through — the map RFC 0052 §3.2 exposes after
open has reconciled planned against the directory, the only proof of
loss — per entry, by the retention mode that entry was written under,
since RFC 0052 §3.2 records it: an entry written under
SnapshotHorizons::Known was unlinked because every tenant’s snapshot
covered it, so a tenant whose horizon is below it — including one with an
entry and no restorable snapshot — has state the surviving frames cannot
rebuild, and the full replay would silently produce a tree missing what
was reclaimed; an entry written under NoConsumer is checkpoint-covered,
which is sound because RFC 0052 §3.2 makes that mode a precondition
— it means the caller held no miner state at all — so no snapshot was ever
expected and that tenant rebuilds from empty and pins at its oldest
surviving frame, exactly as recovery treats it at startup. This RFC adds
no path that passes NoConsumer: the receiver always holds a
MinerCluster, so it always passes Known and expresses an unsnapshotted
tenant as the Pinned floor, and the WAL refuses a NoConsumer pass on a
root holding any Known entry. A NoConsumer entry can therefore only
predate a miner on that root, which is why reading the mode classifies
nothing a running node did as loss.
That tenant is marked unrecoverable, and isolated rather than left to
pin the node: its appends are refused under the server-terminal,
client-retryable class naming the tenant — and a turn already admitted
re-tests that before it mines, since no replacement tree is installed in
this branch and a turn that passed admission before the settler ran would
otherwise mine and publish against the poisoned tree once the exclusion is
released. The check sits at the top of the mining span, immediately after
the turn takes the miner lock and before its first ingest_mined — and
it reads an atomic, not the admission table, since the lock order
forbids reaching for the admission mutex while the miner lock is held.
The coordinator publishes each tenant’s admission state as a per-tenant
AtomicU8 the settler stores with Release under the admission mutex
before it releases the barrier exclusion, and the turn loads with
Acquire after taking the miner lock. That linearises without a second
lock: a turn holds the exclusion across its mining span, so the settler’s
store precedes its release of the exclusion, which precedes the turn’s
acquisition of it, which precedes the load — a turn either ran wholly
before the settler claimed the tenant or sees Refused. Finding it, the
turn abandons the batch with the same terminal error rather than mining
it. The frame is
already durable and unacknowledged, so abandoning loses nothing a restart
will not replay — and a restart is what clears the state. Its entry leaves
the clamp set for a per-tenant Refused state, so last_durable and the node-wide
checkpoint advance for everyone else; its frames are retained instead by
RFC 0052’s own pin — the receiver drops the tenant from the
SnapshotHorizons it hands to maintain, so the WAL pins the tenant at
its oldest surviving frame (RetainFloor::Pinned, strict) and reclaims
only segments holding no frame of it. Retaining the frames is not
enough on its own, and this is the half an earlier draft missed: the
pin governs which WAL segments survive, while what a restart replays is
governed by the Parquet-side suppression horizon, and letting the
node-wide checkpoint advance past this tenant’s unpublished frames would
make a restart suppress records that never reached Parquet — acknowledged
data lost, which no isolation is worth. So the suppression horizon is
per tenant, and the PUBLISHED sidecar is what makes that cheap: a
tenant’s replay gate is max(S, records watermark) from its own entry,
falling back to the checkpoint X only for a tenant with no entry, so a
refused tenant whose records never published has a watermark that stays
behind and its frames are replayed in full. The node-wide checkpoint may
then advance for everyone else without loss — which is the point of the
isolation, and holding X back for every tenant instead would
reintroduce exactly the node-wide stall this paragraph exists to
prevent; no snapshot is installed for it;
the state is exported and alerted (ourios.wal.tenant_unrecoverable,
§3.3, with the pin visible in the floor); and the operator path is a
restart, which halts naming the tenant per RFC 0052 and is where the
tenant’s state is decided. Every other tenant continues, checkpoints and
reclaims.
restore_tenant refuses a tenant that already has live state
(TenantAlreadyLive), by design, so the rebuild goes through a new
MinerCluster::replace_tenant(tenant_id, Option<&SnapshotState>): under
the miner lock it drops the tenant’s live TenantState and installs the
restored (or empty) one as one operation, so no caller can observe the
tenant absent. The cluster-wide template_id allocator is not rewound —
ids are never reused — so a leaf the replay re-derives that had been
allocated after the snapshot gets a fresh id; rows already published under
the old id still render, since its audit events are durable, and the two
ids carry the same tokens: a duplicate template, which is the posture a
restart’s replay already has. Per-tenant derived state — the RFC 0023
ceiling accounting, template_count, the per-tenant gauges — is recomputed
from the installed tree, and no other tenant’s state is touched. Every
widening between the mark and the panic is re-derived with its event —
but not every record is re-emitted, because the rebuild separates
state-only replay from publication. The coordinator holds a per-tenant
publication watermark, published_through. It is frame-granular
and advances only on a fully settled frame: each mined record carries
its frame’s offset and its index within the frame from the turn that
mined it; a publish that settles durable marks those records settled in
the coordinator’s per-frame ledger (under the sink lock), and the
watermark advances to a frame only when every record of that frame and of
every frame below it is settled — and only a durably published record
counts: a record the sink quarantined under RFC 0025 §3.3 is settled for
ownership but is not in Parquet, so counting it would let recovery
suppress a frame whose row never landed. Quarantine has its own
disposition instead: such a frame advances the watermark once the
quarantined record’s record_quarantined event is durable in the audit
store, because that event is the durable record of the row’s fate and
replaying the frame would only re-quarantine it — at most a duplicate
quarantine event, never a lost row — and until then the watermark stops
below that frame — a frame holds several records, the
no-pool path publishes them one at a time, and a frame with some records
durable and a later one lost must not be counted as published. Progress
inside a partially published frame is kept in the unmined entry instead:
at settlement the entry records emit_from, the index of the first record
of its frame not yet settled, read from the same ledger. Replay into the
miner mutates the tree for every frame in the range but suppresses
emission for frames at or below the watermark — their records are all
durable — emits every record of the frames above it, the ambiguous
span, whose records were buffered, in flight, or never mined, and within
the entry’s own frame emits only records at or after emit_from. So the
submitted prefix and a record the salvage forwarded are re-emitted only if
they lie in the ambiguous span, and what the tenant published since its
snapshot is not re-emitted at all. The at-most-twice claim is therefore
narrowed: a record is present at most twice, never absent; a record at or
below the watermark, or below emit_from in its frame, is present exactly
once; and only a record in the ambiguous span — a frame above the
watermark, or a record at or after emit_from — can be present twice,
which is the same at-most-duplicate posture every crash between a publish
and its stamp already has.
The watermark is persisted, or a crash would republish what a panic
already retried. With the watermark in memory only, a partition object
O1 accepted, a panic, a retry O2, and a crash before the next
checkpoint replays from X and yields O3 — three objects. So the
watermark is written durably on the checkpoint’s own path, as an
amendment to RFC 0052’s sidecar layout: Journal::checkpoint gains
the per-tenant map, fn checkpoint(&mut self, durable_to: WalOffset, published: &HashMap<TenantId, PublishedMarks>) where PublishedMarks { records: WalOffset, audit: WalOffset } names the pair the rules need, and
the WAL writes it to a PUBLISHED sidecar beside CHECKPOINT, after
CHECKPOINT’s own write in the same call — the order every statement of
it in this RFC now gives, including the amendment §8 stages for RFC 0052,
which an earlier draft left saying the opposite. The order is the other way
round from an earlier draft, and the reason is a crash window that draft
bricked: writing PUBLISHED first leaves, on a power loss before the
rename, a PUBLISHED beside a still-version-1 CHECKPOINT — an ordinary
crash that the matrix would have read as a lost checkpoint and failed
closed on. Writing it second makes PUBLISHED-without-a-version-2-
CHECKPOINT reachable only by deleting the checkpoint, which is the
fault the matrix should catch, while the new window — a version-2
CHECKPOINT with PUBLISHED absent or stale — is exactly what
published_seeded already governs, and the recovery is conditional on
that witness rather than universal: with the flag armed but
unconfirmed the sidecar was never durably written, so seeding from X
is correct; with it confirmed a sidecar that is now missing is a lost
file and fails closed, as the matrix says. Only the first branch seeds
from X, and a stale sidecar under either flag is not a fault at all:
its entries still govern for the tenants they name, per the
authoritative-entry rule below, which replays a little more rather than
suppressing what was never published.
Neither order is unsafe for the seed itself — a present, valid entry
governs its tenant either way, and the node-wide horizon reaches only the
tenants without one — so the order is chosen for which crash window it
leaves, not for which file wins. The sidecar takes RFC 0052 §3.2’s
RECLAIM shape rather than a temp-and-rename: a fixed byte layout with
its own magic, two slots written alternately under a generation counter
with a CRC32-C per slot, preallocated at open and rewritten in place,
so a torn write is caught by the slot’s checksum and the older slot still
reads. The layout is RECLAIM’s shape, not its bytes: the two files hold
different things, so claiming identity would be false. What they share is
stated, and then PUBLISHED’s own layout is pinned in full. Shared:
the two-slot protocol with a u64 generation and the higher live, a
CRC32-C per slot over its own preceding bytes, preallocation and in-place
rewrite with a .new only for a resize, pinned offsets, little-endian
integers, RFC 4122 byte order for UUIDs, and every reserved byte zero and
checked on read. Different: PUBLISHED has no planned area, so its
slot_len derives from max_tenants alone and its trailer sits at an
offset a reader computes from that; its entry carries two frontiers and
two validity bits where RECLAIM’s carries one occupied bit; and its
file header records only the capacity that sizes it.
| Structure | Offset | Size | Field |
|---|---|---|---|
| File header | 0 | 4 | magic b"OWPB" |
| 4 | 2 | u16 version (= 1) | |
| 6 | 2 | reserved, zero | |
| 8 | 8 | u64 slot_len | |
| 16 | 4 | u32 max_tenants | |
| 20 | 4 | reserved, zero | |
| 24 | 4 | u32 CRC32-C over bytes [0..24) | |
| 28 | 4 | reserved, zero | |
| Slot header | 0 | 8 | u64 generation |
| 8 | 4 | u32 entry_count (reported, not a length) | |
| 12 | 4 | u32 next_slot_id — the id high-water mark (below) | |
| 16 | 2 | u16 header_flags, reserved and zero (the published_seeded witness lives in RECLAIM) | |
| 18 | 2 | reserved, zero | |
| 20 | 4 | reserved, zero | |
Dictionary record (array of max_tenants) | 0 | 2 | u16 len |
| 2 | 128 | key bytes, len significant, remainder zero | |
| 130 | 2 | u16 flags: bit 0 tombstoned, bits 1–15 zero | |
Entry (array of max_tenants, entry i = dictionary record i) | 0 | 2 | u16 flags, per the table below |
| 2 | 2 | reserved, zero | |
| 4 | 4 | u32 generation of the write that last changed these marks | |
| 8 | 24 | records WalOffset: 16 B segment UUID then u64 byte | |
| 32 | 24 | audit WalOffset | |
| Slot trailer | 0 | 4 | u32 CRC32-C over [0 .. slot_len - 8) of the slot |
| 4 | 4 | reserved, zero |
A slot is therefore 24 + 132 × max_tenants + 56 × max_tenants + 8 bytes
and the file 32 + 2 × slot_len. The entry carries no slot-id field,
since its position is its id. A 32-byte file header, then two slots; each slot a
24-byte header, a fixed entry area of exactly max_tenants entries,
and an 8-byte trailer whose checksum is a u32 followed by four reserved
bytes and covers only the bytes preceding it. The entry area is fixed,
not entry_count long: a variable area would put the trailer at a
variable offset, so a reader would have to trust entry_count — a field
inside the bytes the checksum protects — to find the checksum that
validates it, which is not self-delimiting and is the shape RECLAIM
avoids for the same reason. So entry_count is a count, not a
length, and not an index either: it reports how many entries are live
and no reader walks by it, since an entry’s position is its slot id and
its occupancy is its own flags (below). Unused positions are zeroed,
and the trailer sits at a fixed offset a reader computes from the
header’s max_tenants alone (Castagnoli, as everywhere in the WAL). The
encoding rules are RFC 0052 §3.2’s, adopted verbatim so one reader serves
both files: byte offsets are pinned for every structure rather than
implied by field order, integers are little-endian (matching the
existing checkpoint and segment encoders), UUIDs are in RFC 4122 byte
order, and every reserved byte is zero and is checked on read — a
non-zero reserved byte is a format error and Wal::open refuses the file
rather than ignoring the field, which is what keeps a later version from
colliding with sloppy writers. Tenants are referenced through a per-slot dictionary, not inline, the
shape RFC 0052 §3.2 settled on and for the same reason: TenantId is a
validated string, not a UUID, so any fixed-width digest of it either
truncates or collides, and a collision would silently merge two tenants’
marks. So each slot opens with a dictionary of max_tenants fixed
records, [u16 len][128 B key bytes][u16 flags] (132 B, the layout RFC
0052 §3.2 pins at those offsets), len naming how many of the key bytes
are live and an unused record carrying len = 0. The trailing field is
flags, not reserved space: bit 0 tombstoned, bits 1–15
reserved, zero and checked on read like every other reserved field — the
tombstone below needs an encoded home, and this is it, typed identically
to RECLAIM’s record so one reader serves both files. Every entry then belongs to the dictionary record at the same index — its position is its slot id, and it carries no id field.
The mapping is injective by construction and round-trips exactly — the
key is the tenant id itself, not a derivation.
The two files share one id space, and that needs saying rather than
assuming. Each carries its own copy of the dictionary — both must be
readable alone — but a copy is not agreement: if each compacted its own
tenant list on write, the same tenant would take different ids in the two
files and every cross-reading of them would be wrong. So the id space has
an owner and a lifetime. RECLAIM owns it: the WAL assigns a tenant
its slot id when that tenant is first recorded in either sidecar, in
RECLAIM’s dictionary. The table open seeds from is the union of both
dictionaries, not RECLAIM‘s alone, and that matters rather than being
tidy: publish_marks writes PUBLISHED without touching RECLAIM, so a
tenant can be assigned an id and recorded only there. Seeding from
RECLAIM alone would lose that id at the next open and hand it to another
tenant, crossing two tenants’ marks in the file that still names the
first. So the table is the union, and a record in either file — live or
tombstoned — reserves its id. A high-water mark backs it: each slot
header carries u32 next_slot_id, the lowest id never yet assigned,
written by whichever file is written and taken at open as the maximum
of the two, so allocation never goes backwards even if one file lags the
other by a write. That is what makes the ordinary one-file-alone case
safe rather than merely usually safe. Ids are never renumbered and never reused while the tenant is
recorded in either file — neither file may compact on its own, and a
tenant that leaves one but remains in the other keeps its id. PUBLISHED
is written from that same in-memory table, so its dictionary is that
dictionary, at the same ids, by construction rather than by convention.
No operation reassigns an id, not even a rebuild: a resize copies each
dictionary record into the wider stride at the same index, so ids survive
it unchanged. That is what makes a crash between the two files’ renames
recoverable (§3.2), and it is why a tombstoned id is retired rather than
reclaimed — renumbering across two files cannot be made crash-safe
without a third witness, and this design would rather spend ids than
invent one.
A tenant introduced by one file alone is the ordinary case, not an
error, and the rule says which disagreement is which. The two files are
written at different moments — publish_marks touches PUBLISHED without
RECLAIM, a pass touches RECLAIM without PUBLISHED — so at any
instant one may name an id the other does not. That is benign: the file
lacking it simply has no entry for that tenant, reads as “no frontier” or
“nothing reclaimed” accordingly, and the next write of that file adds it.
What is fail-closed is narrower and unambiguous: an id that both
dictionaries name with different keys, or one key the two dictionaries
place at different ids — either crosses two tenants’ marks if read
past, the first by resolving one id to two tenants and the second by
resolving one tenant to two frontier entries, and no correct writer can
produce either — OpenError::Corrupt, naming both files and the id. A crash between the
two writes therefore leaves a readable pair, never a halt.
The key is 128 bytes, which is what the governing spec says. RFC 0046
§3.1 caps a normalised tenant id at 256 bytes, but it is not the last word:
RFC 0048 §3.1 — accepted, and titled “Tenant id grammar (amends RFC 0046
§3.1)” — narrows the grammar to 1–128 bytes of ASCII graphic characters,
which is why ourios-core’s MAX_TENANT_BYTES = 128 matches the spec
rather than being stricter than it. Sizing the record at 128 is therefore
sizing it to the accepted bound, not to an implementation detail. (An
earlier round of this RFC read RFC 0046 alone and sized the record at
260 B; reading an amended section without following its amendment is the
mistake, and the rfc-check skill’s status routing exists to catch it.)
The frame codec has to be brought to the same bound, and this RFC
requires it. ourios-wal’s TenantBatch::MAX_TENANT_BYTES is 256
and rejects only above that, so a frame can carry a 129–256-byte tenant
that the 132-byte dictionary cannot represent — a real exposure, not a
theoretical one, since the constant predates RFC 0048 and its doc comment
still cites RFC 0046 §3.1 as its authority. The fix is to bound the
source rather than widen the layout, because widening it would double
both sidecars to carry ids no accepted grammar admits: the frame codec
(RFC 0046’s TenantOtlpBatch payload, whose constant RFC 0048 §3.1
silently superseded) is lowered to 128 on encode and decode by
RFC 0052 §8’s amendment, which this RFC cites and depends on, matching
the grammar and ourios-core’s own MAX_TENANT_BYTES. That amendment
reaches RFC 0046’s own text, not only the constant: its replay clause
and its criterion RFC0046.11 still reject a length above 256, which
contradicts that RFC’s own resolved-questions note recording that RFC
0048 §3.1 pinned the grammar at 1–128 bytes as the one tenant grammar
every boundary applies at. RFC 0052 §8 amends RFC 0046’s replay
validation and RFC0046.11 from 256 to 128, which makes that document
consistent with a decision it already records rather than making a new
one; this RFC makes no amendment of its own. A root whose replay yields a tenant longer than that
fails closed at open, naming the offending frame’s offset and the
length it carried, rather than being truncated into a dictionary that
cannot hold it. That is the pre-production posture — no migration tooling
— and it is only reachable on a root written by a build whose codec
predated this change. RFC0053.4 asserts both halves: the codec refuses a
129-byte tenant on encode, and a fixture root carrying one fails open
naming the offset.
An entry is then [u16 flags][2 B reserved][u32 generation][24 B records WalOffset][24 B audit WalOffset] — 56 B, matching the layout table, and
it names no tenant at all: entry i belongs to dictionary record i,
its position being its slot id — where a WalOffset is its
16 B segment UUID plus its 8 B byte offset, the pinned 24 B, and flags
carries four bits, assigned once here so no other paragraph has to
enumerate them:
| Bit | Name | Meaning |
|---|---|---|
| 0 | records_valid | the records frontier in this entry is present |
| 1 | audit_valid | the audit frontier in this entry is present |
| 2 | publishing | an intent is outstanding for this tenant (§3.2) |
| 3 | terminal | the tenant is in the server-terminal class (§3.2) |
Bits 4–15 are reserved, zero, and checked on read, a non-zero one
failing the slot’s parse as every other reserved field does. The
validity bits exist because the two frontiers advance independently and a
frame can produce records with no audit event at all, so “no mark yet” is
a real state that an unconditional offset cannot express — a zeroed
WalOffset is a legitimate position, not an absence. A frontier whose
bit is clear is absent, and recovery treats that side of the entry as
though the tenant had no entry at all, falling back to the node-wide
horizon for it while still honouring the other side — except for a
tenant the entry marks terminal, where the fallback would be unsafe.
The flags’ terminal bit (bit 3 above) is set
when §3.2 puts a tenant in the server-terminal class, and a restart
clears it rather than honouring it forever — persisting a refusal with
no exit would be a durable outage. The transition is stated so it stays
safe: at open the bit is read for the horizon and not for admission.
It is what tells recovery that a clear validity bit means “nothing of
that side was ever published” rather than “consult X”, so the tenant’s
frames are replayed in full; and then the tenant is admitted normally,
because the condition that made it terminal — an audit store rejecting
writes, a clock that would not resolve — is re-evaluated by the first
publish after the restart rather than assumed to persist. If it does
persist, that publish re-enters the terminal class and re-sets the bit,
visibly, on the same alert. The persisted bit is cleared on the first
publish_marks write that follows a successful publish for that tenant,
so the flag on disk tracks the last known state rather than the last
bad one. Publication stays safe throughout, because the horizon reading
happens before any admission and does not depend on the clear. That bit
is set and a terminal
tenant with a clear validity bit means nothing of that side was ever
published, not “consult the node-wide horizon”: its events were never
written, so X — advanced by every healthy tenant’s progress — would
suppress exactly what must be replayed. Its horizon is its oldest
surviving frame, the same position RFC 0052’s pin holds it at, so a
restart re-mines everything that tenant has. Both bits sit inside
the entry area, so the slot’s trailer checksum covers them like every
other byte before it: a flipped validity bit fails the slot’s CRC and the
other slot is read, which is what keeps “absent” from being forgeable by
a torn write. The u32 generation is §3.2’s staleness witness, below. Recovery maps
through the dictionary, not through a live tenant set: a slot id reads
its key, the key is a tenant id verbatim, and that tenant takes its marks;
an id naming a dictionary record with len = 0 is a corrupt slot and the
other slot is read. A tenant with no entry seeds from X, as one with no
record always has. An entry’s position is its slot id, which is what lets ids be
stable: entry i belongs to slot id i, occupancy is the entry’s own
records_valid / audit_valid bits rather than its position, and
entry_count is a count for reporting only, never a length to walk.
An earlier draft made the live entries a dense prefix [0, entry_count),
which cannot survive a stable id: removing a tenant would renumber every
id above it. A tombstone covers removal instead — the dictionary
record keeps its key with its tombstoned bit set (bit 0 of the record’s
flags field, at offset 130 within the record), the entry is zeroed, and
the id is retired, not freed: no later tenant ever takes it, since
nothing in this design renumbers — a resize preserves ids and there is no
compaction pass. A tombstone therefore holds its id for the life of the
root, and an operator who exhausts the space raises max_tenants or
starts a fresh root. A write rewrites the whole slot — no free list, no position to leak, which
is what keeps the geometry fixed. The file’s size is therefore decided at
open by max_tenants alone: a slot is 24 + (132 + 56) × max_tenants + 8
bytes and the file 32 + 2 × slot, which at the default 1024 tenants is
192,544 B a slot and 385,120 B — about 376 KiB — for the file, small
beside RECLAIM and a 128 MiB segment. Changing max_tenants therefore changes
a persisted layout, and two rules follow, both mirroring RECLAIM’s.
The file describes its own geometry: the 32-byte file header carries a
u32 max_tenants (the value it was preallocated for) beside the magic and
version, so a reader never infers the shape from configuration, and
Wal::open reads that value rather than assuming the configured one, and
raising the knob is supported by a copy at open, not by a refusal: a
refusal would strand every existing root the day an operator needs more
tenants, and the copy is crash-safe with the same primitives the WAL
already uses. Wal::open compares both dimensions, not one: max_tenants, which
sizes this sidecar and RECLAIM alike, and max_unlinks_per_pass,
which sizes RECLAIM’s planned list. Checking only the first was this
RFC’s gap rather than a format one — RFC 0052’s header already records
both — and it mattered: raising the unlink cap on an existing root would
let a pass plan more entries than the fixed file can hold, with nothing
detecting it. So the recorded pair is compared against the configured
pair, a configured value above either recorded one triggers the
rebuild and a value below the recorded entry_count is refused as
before. Finding either dimension raised, open rebuilds both sidecars as
one operation, because max_tenants sizes them both and resizing one
alone would let admission accept tenants reclamation cannot persist — a
root that starts and then cannot record what it reclaimed. So the two rebuilds run
together, each writing its new geometry under the temp name its own RFC
reserves — RFC 0052’s RECLAIM.new and, in parallel, PUBLISHED.new, a
name the sweep selector already covers — with the old dictionary and
entries copied in, fsyncing, renaming over the old name and fsyncing the
parent, and the node refuses to start if either rebuild fails, naming
the file that failed.
A crash between the two renames is the state that needs a rule, and
it has one, because each file’s header records the geometry it was built
for. A crash there leaves the pair at different geometries — one
rebuilt, one not — which open detects by comparing the two recorded
capacities rather than by trusting a temp to still exist. The reconciling
rule is complete, never roll back: open rebuilds whichever file is at
the smaller geometry, from its own contents, at the larger one, then
renames and fsyncs as the first rebuild did; the pair is then consistent
and the configured capacity is in force. Completion is always safe
because a resize never renumbers: it copies each dictionary record
into the wider stride at the same index, so slot ids are identical before
and after and the half-rebuilt pair still agrees on every id it names.
That is also why ids are never reassigned at all in this design —
reclaiming a tombstoned id would mean renumbering, which cannot be made
crash-safe across two files without a third witness, so a tombstone holds
its id for the life of the root and an operator who exhausts the space
raises max_tenants or starts a fresh root. A leftover .new from
either file is truncated by the next rebuild and swept. RFC0053.4
asserts the mixed-geometry restart: a pair crashed between renames comes
up at the larger geometry with every id unchanged. This is the one
supported procedure; the file still never grows in place, which is what
the two-slot protocol depends on. And max_tenants has a format ceiling, because a
slot id is a u16: validate_config rejects max_tenants > 65_536 with
that reason. The bound is 65,536 rather than 65,535 because slot id 0 is
usable — a position is marked unused by its dictionary record’s len = 0, not by a reserved id — so every id in 0..=65_535 names a real
position. The recovered tenant set is validated too, not just the
recorded count: recovery seeds Held from every tenant restored from
snapshots and replay, which can exceed a lowered max_tenants while
entry_count alone passes — a sidecar records only the tenants that have
a frontier, and a root can hold more tenants than that. So before the
coordinator is constructed, open counts the tenants recovery restored and
refuses to start when that count exceeds the configured cap, naming
the recovered count and the cap, on the same fail-closed posture as every
other geometry mismatch; the operator raises the knob back or starts a
fresh root. Lowering is checked against the id space, not the live count. A
tombstoned id persists, and ids are never reused, so entry_count — live
entries only — can sit far below the highest id in use: a root whose only
assigned slot is id 900 has an entry_count of one and would pass a cap
of two, then address entry 900 in a file sized for two. So the check is
against the highest assigned or tombstoned slot plus one, taken across
both files — which is exactly next_slot_id, the high-water mark each
slot header carries — and a configured max_tenants below that is
refused at Wal::open, naming both numbers. Lowering the knob below the recorded entry_count is
refused at Wal::open too, as the same error, naming both numbers, since the marks of the
tenants that no longer fit cannot be dropped without losing their
frontiers; raising it needs a new file, written at open before the first
pass, carrying the old entries into the larger geometry — never an
extension of the live file, whose second slot would move under a crash — which also makes the settlement write below cheap, one in-place
write and an fsync rather than a file creation. The file is outside the byte bound like the other sidecars, and that
exclusion has a stated limit: preallocation can fail. A volume full at
open, or full when tenant growth needs the file grown, blocks the write —
and with it reclamation, since RFC 0052’s pass writes its record before it
unlinks. So the byte limit promises bounded growth, not recovery from a
full volume: with no room for a sidecar write, a crossed bound stays
crossed and the refusal stands until an operator frees space, which §3.3
makes visible through the floor and the unreclaimed figure rather than
leaving it as an unexplained stall. Settlement needs to write it without
a checkpoint, so the surface gains one more call, another amendment to
RFC 0052 §3.7’s list: fn publish_marks(&mut self, published: &HashMap<TenantId, PublishedMarks>) -> Result<(), ReclaimError>, which
writes and fsyncs the sidecar alone and advances no checkpoint. Its
crash-ordering contract is what the marks need and nothing more: the write
is durable before it returns, the marks are monotonic per tenant, and a
crash mid-write leaves the previous slot readable, so a start reads either
the old marks or the new and never a mixture. The migration start below
uses the same call. Audit has its own watermark in the same record, because the record one
does not cover it: an audit group can be durable in the audit store while
the record publish of the same batch panics, and a crash before the next
checkpoint would replay the frame and regenerate an event the store
already holds. Both marks are frame offsets, which is what RFC 0052’s replay model
requires: audit replay there is regeneration-only — the miner
regenerates a frame’s events as it re-mines it, and stored AuditEvent
frames are never a source — so an event’s identity comes from the frame
that produced it, and a gate expressed in audit-stream positions would
have nothing to compare a regenerated event against. RFC 0052 §3.1’s
audit_durable_through is the sink’s own in-flight cursor over events,
so the coordinator maps it to frames rather than storing it: emission
records the source frame beside each event in the same per-frame ledger
the records use (in-process only — no change to the audit row schema), and
the audit mark advances to a frame when every event that frame
produced is covered by audit_durable_through. A partially settled group
therefore advances nothing, exactly as a partially settled frame does on
the record side. So the audit sink’s settlement raises a per-tenant
audit offset the way the record sink raises records — highest fully
settled frame, monotonic, under the sink lock, advanced only from
audit_durable_through, never from an emptied buffer, since a concurrent
drain can hold exactly those events in an unfinished write — both are
written in the one PUBLISHED write, and audit replay is gated the same
per-tenant way the record side is: a tenant with a PUBLISHED entry is
gated on max(S, audit watermark) from its own entry — the entry
governing, never maxed against X — and only a tenant without one, or a
frontier whose validity bit is clear, falls back to the node-wide X. The node-wide form alone
would carry the defect §3.2 fixed for records one round earlier — once
X advances for everyone else, a lagging or unrecoverable tenant’s
regeneration would be suppressed for events that were never written, and
a template event lost that way is CLAUDE.md §3.1’s silent merge. The
record gate is max(S, records watermark) per tenant on the same rule. The §3.2 claim that a settled audit group is never requeued
therefore holds across a restart too, not only in-process. A present entry is authoritative for its tenant, and is not maxed
against the node-wide horizon. Taking max(X, PUBLISHED) would let a
checkpoint advanced for everyone else override a lagging tenant’s stale
entry — precisely the suppression this sidecar exists to prevent, and
reachable by an ordinary crash, since CHECKPOINT is written first and a
power loss before the PUBLISHED write leaves exactly that state. The
rule is therefore the other way: where the entry is present and its
validity bit set, it governs, and the node-wide horizon applies only to
a tenant with no entry, or per frontier to a side whose validity bit is
clear. The direction is the safe one — a stale entry replays frames that
may already be published, the at-most-twice case the rest of §3.2 bounds,
where an over-advanced horizon would suppress frames that never published,
which is loss. Staleness is legible rather than guessed at: each entry
carries a u32 generation, set to the slot’s generation at the write
that last changed that entry’s marks, so recovery can tell an entry never
written (generation zero, validity bits clear) from one written and
unchanged since, and can report how far behind a tenant’s frontier is
instead of silently papering over it. With that, recovery seeds both
in-memory watermarks per tenant from the entry where it governs, and from
what the Parquet-side suppression horizon X implies only where it does
not — the read
sitting behind RFC 0052’s startup fsync of the WAL root, which precedes
any sidecar read, so a sidecar whose rename or creation was not durable
is not read as present — and a missing
PUBLISHED beside a version-2 CHECKPOINT is fail-closed like a missing
RECLAIM — RFC 0052 §3.2 names PUBLISHED in that matrix on the same
footing — with one stated exception, which is this RFC’s own migration
case: a node that ran RFC 0052 alone has a version-2 CHECKPOINT and a
RECLAIM carrying checkpoint_seen, and no PUBLISHED, because nothing
wrote one. Failing closed there would refuse to start every node that
upgrades. So PUBLISHED absent beside a version-2 CHECKPOINT and a
RECLAIM whose checkpoint_seen is set is accepted: the watermarks
seed from X, the checkpoint horizon, which is what RFC 0052 already
guarantees replay suppresses below, and the sidecar is created by the next
checkpoint. “Once” needs a witness of its own, though, because the
CHECKPOINT/RECLAIM pair cannot supply it: a crash before that next
checkpoint leaves exactly the state the accepting start read, so every
restart would take the same coarse-X branch and repeat its window. So
the record carries one more flag, on the same two-state pattern its
checkpoint witness already uses and as an amendment to RFC 0052 §3.2’s
record header: published_seeded, written durably on the accepting
start before any batch is admitted, and confirmed — a second durable
write, at the next record write after the first PUBLISHED write
succeeded — exactly as checkpoint_armed and checkpoint_seen are. And the
window is closed by a write, not by the next checkpoint: waiting for
one would leave the flag armed-but-unconfirmed across every crash in
between, and an armed flag proves nothing about what reached Parquet above
X, so repeated crashes could each add a copy. So the accepting start
writes PUBLISHED itself, carrying the seeded marks, before it admits
the first batch — one extra sidecar write at startup, on the same
durable path a checkpoint uses — and confirms the flag at the next record
write. A crash before that write leaves the flag armed with PUBLISHED
absent and nothing published under a finer horizon, so the next start
seeds from X again, correctly. A crash after the write but before the
flag is confirmed — armed and present — is the row the state machine
owed and now states: the sidecar is authoritative, the seed has already
happened and is durable, so recovery confirms the flag and proceeds
from the written marks rather than re-seeding from X, which would
discard a finer horizon the file already holds. A crash after the confirm
is an ordinary start. A
root whose flag is confirmed with PUBLISHED absent is a lost sidecar
and fails closed, exactly as a lost RECLAIM does. The coarse window is
therefore bounded by that single write rather than by a checkpoint that
may never come, and the consequence is stated rather than hidden: within
it the watermarks are as coarse as X, so records published between the
last pre-upgrade checkpoint and the seeding write are re-emitted if a
panic or crash makes the rebuild replay them — the at-most-twice bound
holds, the exactly-once refinement does not, once per upgrade. Per the pre-production layout policy there is no migration
tooling; the read path is the whole migration, and the implementing PR
carries the ! marker. A version-1 root carries neither sidecar: RFC 0052 opens it
without creating a RECLAIM, and PUBLISHED follows the same rule, both
being created on the path that rewrites the sidecar at version 2 — the
first checkpoint, including the case RFC 0052 §3.2 calls out where that
checkpoint’s mark equals the one on disk, since an idle node must still
be able to upgrade — so a restart inside that migration window stays on the
legacy branch, with no watermark to seed and nothing published under a
horizon anything reads. The converse is reachable, since PUBLISHED is written first,
and takes RFC 0052 §3.2’s two-state witness rather than a rule of its own:
PUBLISHED present with no version-2 CHECKPOINT is read against the
current write order, checkpoint first, which changes what it can mean.
Under that order the sidecar is never written ahead of the checkpoint by
Journal::checkpoint, so a present sidecar beside no version-2 checkpoint
has exactly two sources: a publish_marks write, which §3.2’s
settlement and the accepting start both make without touching the
checkpoint, or a deleted checkpoint. The first is ordinary and the
second is a fault, and checkpoint_seen is what separates them: with the
flag set the root has checkpointed and lost it, so open fails closed
naming both files; with it unset no checkpoint has ever succeeded
there, so the sidecar can only have come from a publish_marks write on a
root that has not yet checkpointed, and it is kept and seeded from rather
than discarded — an earlier draft discarded it, which was right only under
the superseded sidecar-first order, where such a file was always a torn
first checkpoint. It reads against seen, never
armed, which is what keeps RFC 0052 §3.2’s two non-fault armed rows
non-faults here as well: armed without seen beside a version-1
CHECKPOINT is that RFC’s migration retry state — nothing can have been
reclaimed, since housekeeping is a no-op until the witness exists — and
armed without seen with CHECKPOINT absent is its crash window
before the rename. A PUBLISHED written moments earlier in that same
first checkpoint call is what either looks like on a root with no
segments — genuinely fresh, nothing published under a horizon anything
reads — so there the record is discarded and the next attempt rewrites it,
as when neither flag is set. With segments present it is likewise retained:
RFC 0052 §3.2 reads armed-and-unseen beside segments as a retained
migration state, so the marks are kept and seeded from — discarding them
would re-open the coarse window on a root that already has frames to
replay. Under checkpoint-first ordering the two readings agree, which is
why this matrix has one rule rather than a fresh-root exception:
checkpoint_seen decides the fault, and everything short of it keeps the
marks. Only seen makes its absence a fault. One
witness governs both sidecars, which is why this RFC adds no second flag
beyond the migration marker above. Beside these rows sits RFC 0052 §3.2’s
own, with one scoping this RFC states because PUBLISHED would otherwise
inherit it too widely: segments with neither sidecar is a fault only
on a root that carries a post-RFC witness — a version-2 CHECKPOINT,
or a RECLAIM that is armed or seen — and has lost its companion, where
frames exist that no surviving witness accounts for. A root with segments
and no post-RFC witness at all is the ordinary live pre-RFC node —
Wal::checkpoint is unreachable from the pipeline today (#793), so a
running root has segments and no checkpoint — and failing it closed would
block exactly the upgrade this RFC’s migration case exists to allow. It
stays on the legacy branch until its first version-2 checkpoint creates
both sidecars. PUBLISHED adds nothing to that judgement and inherits
whichever verdict the witness gives, because a root that cannot say what
it reclaimed certainly cannot say what it published. RFC 0052’s own
criterion needs the same scoping, and this is stated as an amendment
to it rather than a difference between the two documents. The bound between checkpoints is then stated honestly, and made a bound
rather than a per-crash cost. A panic costs at most one duplicate per
record in the ambiguous span. A crash before the watermark is durable
costs at most one more for the records published since the last
durable watermark — but “one more per crash” is not a bound if the window
stays open, so the settlement forces the write: after a rebuild
settles, the coordinator writes PUBLISHED with the raised marks on the
same durable path a checkpoint uses, before it removes the entry. Each
window therefore closes with a durable frontier at the moment the
ambiguity is resolved, not at whatever checkpoint happens next, and
repeated crashes cannot accumulate copies of the same records: each
replays only what lies above the last written frontier. The write is in two steps, because a crash between the publish and the
write would otherwise lose the frontier. A rebuild that has already
handed its batch to the store and then dies before publish_marks leaves
nothing on disk saying so, and the next start replays the same span again
— and again on the next crash, with no ceiling. So the marks are written
before the publish as an intent and confirmed after it: the entry
format’s u16 flags carries a publishing bit, publish_marks writes
the intended marks with that bit set, the rebuild publishes, and a second
publish_marks clears the bit. Recovery reads a set bit as “this span was
attempted”: it seeds the watermark from the previous durable marks, so
nothing that may not have landed is ever suppressed, and it re-publishes
only the intent span, which is fixed and recorded rather than growing
with each attempt. Repeated crashes replay the same bounded span, and the copies are
bounded too, because the republish is idempotent: a normal publish names
its object with a fresh UUIDv7, so a replay would add a copy each time,
but the intent span’s republish derives its key deterministically instead
— the same derivation §3.2 makes normative below — so every attempt at
the same span writes the same object name. Each partition of the span
has its own such key, so a first attempt that published some partitions
and died reuses those names rather than inventing new ones. The claim is
the keys-not-objects one §3.2 states: repeated crashes produce one
distinct key per partition of the span, however many times they replay
it, and never a different set of rows under a name already used — whether
the store leaves one object or one per attempt is the backend’s business,
which this RFC does not require. The first confirm closes the window, and
the guarantee is claimed only for the span under an intent, since a
publish outside one keeps its UUIDv7. A failed write is a fail-closed park: the entry stays,
the tenant is refused under the server-terminal, client-retryable class
until the write succeeds, and the coordinator retries the same marks,
which is idempotent because they are already recorded and publish_marks
is monotonic per tenant, so a retry writes the same frontier and a crash
in between leaves the previous slot readable. RFC0053.2 covers both the
intent-set restart and the failed write. The write is one
sidecar write per settlement, which is a rare path by construction — it
follows a panic — and the checkpoint’s own write is unchanged. It follows that the salvage in ingest_mined is kept as
belt-and-braces and its count is never read by this path: an earlier
draft resumed at index + salvaged, which was unsound twice over — against
the unknown tree, and because the count increments before the salvage’s
own emit returns, so a panic inside that emit would report a record as
forwarded that no buffer holds. The frame’s bytes are read through an owned cursor, not a borrowed
iterator: the coordinator’s journal is Mutex<Box<dyn Journal>>, so any
Box<dyn Iterator + '_> borrowing the journal would hold that guard for
the whole replay — the very thing a bounded read exists to avoid. Journal
gains fn read_frames_from(&self, cursor: FrameCursor, to: FrameSpan, tenant: &TenantId, limit: usize) -> Result<(Vec<(WalOffset, Bytes)>, Option<FrameCursor>), ReceiveError>, where FrameCursor { segment, byte }
is an owned position — the start of the next frame to read — and the
returned cursor is None once the to frame has been yielded. The
coordinator takes the guard for one call, releases it, and re-takes it for
the next, so appends and rotations interleave between calls; a call
returns at most limit frames and never crosses a segment boundary,
whichever comes first, so the window is bounded by one segment or limit
frames; segments below the cursor are skipped from the ledger, never
scanned. The tenant filter is applied at the frame’s TenantOtlpBatch
prefix, the same field the ledger reads, so other tenants’ frames are
neither decoded nor returned; every returned frame’s header and checksum
are verified. The range is (from, to]: the first cursor is
FrameCursor::after(from) for a post-frame offset — the snapshot’s mark,
the horizon frame itself already folded — so the first frame returned is
the one after it, or Journal::oldest_frame(tenant) for the full-replay
arm; to is the panicking frame’s span, returned last. A span that does
not end on a frame boundary is an error that leaves the entry in place,
since it means the entry, not the WAL, is wrong. RFC0053.2 tests both ends
of the range and the per-call bound.
While an entry is unresolved the marks are clamped, by one rule at one
site. The entry also records clamp: the value of last_durable at the
moment the frame was appended, which is the previous turn’s offset — the
frame with the entry never advanced it, since the panic unwinds before
the write that would. From then until the entry settles, every write to
last_durable is min(offset, clamp) (with None staying None), so a
later successful append cannot carry the mark past the frame. Entries form
a set, not a slot: with several unresolved, the effective clamp is the
minimum over every remaining entry’s clamp, settling one entry
re-evaluates that minimum over those that remain, and the latest successful
offset — kept separately throughout — becomes the mark again only when the
set is empty. Every barrier, rotation, shutdown and
flush_then_snapshot mark reads last_durable, so they inherit the clamp
without a rule of their own. The clamp is necessary and not sufficient:
the snapshot serialises the live miner, so a barrier taken while an
entry is unresolved would capture the unknown tree under the clamped mark,
and a restart would restore the widened leaf and replay the frame as a
clean match with no event. So while an entry is unresolved no snapshot
is installed for the entry’s tenants — the barrier skips them and
snapshots every other tenant as usual, since trees are scoped per tenant
(§3.7) and the panic touched only the frame’s — and a restart in that
window restores those tenants from their previous snapshot and replays the
frame from the pre-frame state, which is the same rebuild the in-process
settlement performs. An unmined entry is removed only once the rebuild
has replayed through the span and its records have been submitted or
buffered — retained across a failure or a second panic — so an entry that
never settles holds the checkpoint, and those tenants’ snapshots, visibly
rather than silently.
The replay is authoritative, and the tree it replaces takes its
in-memory products with it. replace_tenant discards the tree the
panic left, but records mined from that tree can still be sitting in the
sink — forwarded by ingest_mined’s salvage, or requeued by an unwind —
and they carry that tree’s template_ids while the replay allocates
fresh ones from the never-rewound allocator. Publishing them later would
put a row in Parquet whose template has no audit history to fold (RFC
0017’s versioned rendering), or a stale row beside the regenerated one
for the same line. So the settlement has a handoff, stated in order:
before replace_tenant runs, it quiesces the publisher for that
tenant — waiting on the partitions already detached for it, which settle
durable or requeue into the buffers — and then, under the sink lock and
in the same section as the tree swap, drops every buffered record for
that tenant from both sinks, including anything the quiesce just
requeued. Only then is the tree replaced and the entry removed. Nothing
is un-published, and nothing needs to be: a partition that settled
durable is at or below the tenant’s publication frontier by
construction — publication settlement requires its audit events first —
which is exactly the range the rebuild suppresses re-emission for. So
after a rebuild the replay is the single source for everything above that
frontier, and the discarded tree contributes nothing. RFC0053.2 asserts
it: a salvaged record buffered before the rebuild is not published under
its old template_id, and the regenerated row appears exactly once. RFC0053.2 covers a swallowed submit followed by a
successful append, a mining panic followed by a successful append, a clean
barrier taken while an entry is unresolved, a panic after ingest_mined
returned, and a panic between a widening and its audit event.
9.5 Draft acceptance legs (source for §5)
- And in open mode a batch for a tenant id the miner does not hold, with
max_tenantsheld, is refused naming the tenant, asTenantCapacity—503/UNAVAILABLEwith a protobufStatusbody and noRetry-Afterand noRetryInfo— while a batch for a held tenant is admitted; the refusal is counted witherror.type = wal_tenant_cap, sets no refusal latch, andourios.wal.tenants.usageover its states sums toourios.wal.tenants.limitwithfreeat zero; two concurrent first writes for one new id share a slot and a burst of new ids never overshoots the guard- And when the frame that crossed the bound is the one whose mining panicked, the barrier task settles its entry on the next tick with no append admitted, the checkpoint then advances past it, and the bound clears by the timer alone — no restart
- And a node upgrading from RFC 0052 — version-2
CHECKPOINT,RECLAIMwithcheckpoint_seen, noPUBLISHED— starts, armspublished_seeded, seeds its watermarks fromXand writesPUBLISHEDwith those marks before admitting the first batch, confirming the flag at the next record write; a crash before that write seeds fromXagain on the next start, a crash after it proceeds from the written marks, and a start with the flag confirmed and the sidecar missing fails closed — so repeated crashes add no further copies- And a tenant whose
reclaimed_throughentry was written underNoConsumeris not marked unrecoverable when its snapshot is missing: it rebuilds from empty and pins, while the same shape underKnownis refused; no ingest path passesNoConsumer, the receiver always passingKnownwith unsnapshotted tenants expressed asPinned- And a first write that appends, panics in mining and unwinds keeps its tenant reservation: another new id cannot take that slot, and the entry’s settlement converts the reservation to
Heldwhen it installs the tenant- And a barrier taken while an
unminedentry is unresolved, with a healthy store, stamps no mark past the entry’s clamp and installs no snapshot for the entry’s tenants while installing the others’; a restart from that state restores those tenants from their previous snapshot and replays the frame; once the rebuild settles the next barrier stamps the latest successful offset and snapshots them again- And with two entries unresolved the mark is the lower clamp; settling the lower entry alone moves the mark up to the remaining entry’s clamp and no further, and only settling both restores the latest successful offset
- And an entry whose tenant’s restorable horizon is below its
reclaimed_throughis not rebuilt: the tenant’s appends are refused throughtenant_state, after the terminal-rotation check and before the reservation — an oversize payload for it is stillTooLargeand a terminal WAL still reports the WAL’s state — under the server-terminal, client-retryable class naming it, its entry moves toRefusedand out of the clamp set, its frames are held by the floor’s pin rather than the clamp, the node-wide checkpoint advances past other tenants’ frames, no snapshot is installed for it, the state is exported, and other tenants ingest, snapshot and reclaim normally- And
read_frames_fromstarted atFrameCursor::after(from)returns the frame afterfromfirst and thetoframe last, only that tenant’s frames; started atoldest_frame(tenant)it begins at the tenant’s oldest surviving frame; each call returns at mostlimitframes within one segment and the guard is released between calls — an append issued mid-replay lands between two of its calls- And a panic inside the settlement — in the bounded replay or in
replace_tenant— returns the entry toUnresolved(or marks the tenant unrecoverable) and signals the waiters before the tick resumes, so the next trigger settles it and a waiting request is refused with a fresh hint rather than held forever- And the barrier tick and an admitted ingest turn racing on one
unminedentry produce exactly one rebuild; the loser observes the entrySettlingand runs only after the rebuild has installed the tenant and the entry is gone; a request for that tenant issued mid-rebuild is refused at admission asSettlementInProgress—503/UNAVAILABLE, protobufStatusnaming the tenant,Retry-AfterandRetryInfocarrying the settlement remainder,error.type = wal_settling— with the settling set read and the sequence reserved under one hold of the admission mutex, so a settler cannot interleave between them, and admitted after settlement; a turn for another tenant takes its sequence and is not held; no settlement path takes the gate- And settlement rebuilds from the
SnapshotLedger’s retained state, not the directory: a.snaprenamed but not yet parent-fsynced, or left by a failed write, is never read- And the rebuild drops the discarded tree’s buffered records: the publisher is quiesced for that tenant, its detached partitions settle or requeue, every buffered record for it is dropped under the sink lock in the same section as
replace_tenant, and no row is published under atemplate_idthe replay did not allocate- And a panic raised inside
ingestafter a leaf was widened and before its audit event was emitted is settled by a rebuild from the tenant’s last installed snapshot: the widening is re-derived with its event, the version’s audit history is complete, the records at or below the tenant’s publication watermark — and, in the entry’s own frame, belowemit_from— are present exactly once, the gate being that tenant’s ownmax(S, watermark)for records and for audit alike and never the node-wideX, so a tenant whose frontier lags has its events regenerated rather than suppressed — a crash afterCHECKPOINTand before thePUBLISHEDwrite leaving a stale entry that still governs, its generation saying how far behind it is, while a cleared validity bit falls back toXfor that frontier alone — and those in the ambiguous span are present at most twice, never absent; a frame with one record durable and a later one lost does not advance the watermark; a crash before the next checkpoint after a retried panic yields at most one more copy of the records published since the last durable watermark, and none once the settlement’s forcedPUBLISHEDwrite has landed — repeated crashes replay only above the last written frontier and add no further copies; the same panic on a tenant with no installed snapshot is settled by a full replay from its oldest surviving frame, and a template defined before the span is present in the rebuilt tree;replace_tenantleaves every other tenant’s tree and the allocator’s next id unchanged- And the same holds for a panic raised after
ingest_minedreturned — inside the no-pool branch’s inlineemit, or the pool branch’ssubmit— and for a panic inside the salvage’s ownemit: the record is present at most twice, never absent, and the salvage count is not consulted;read_frames_fromon a span that does not end on a frame boundary is an error that leaves the entry in place- And the frame codec refuses a tenant longer than 128 bytes on encode and on decode, and a fixture root whose replay yields one fails closed at open naming the frame’s offset and the length it carried
- And a restart seeds the tenant admission table from the tenants recovery restored, before any listener is constructed: a held tenant is admitted at once and a new id at the guard is refused, with no first-write race window at startup
9.6 Draft open questions (source for §7)
-
Whether the publish should be made idempotent — a drain-time object key that a requeued batch reuses — so the §3.2 duplicate becomes a byte-identical overwrite instead of a second object. The cost is that a requeued batch would have to stay a unit through the next drain rather than merge with what arrived since.
-
An admin verb that releases a
Heldtenant slot. This stage never evicts an idle tenant and the only operator path at themax_tenantsguard is to raise it; in open mode, where ids are client-chosen, that is also the only answer to a client that filled the guard (§3.1 states why no fairness mechanism belongs there). An admin release verb is the follow-up. A release verb would need a definition of “idle” the WAL, the snapshot ledger and theRECLAIMrecord all agree on, and under RFC 0047/0048 tenants are an out-of-band decision, so the guard is the wrong place to make it; deferred.
9.7 The RFC 0046 amendment (draft §8)
Draft wording, kept for the record: the amendment below is RFC 0052 §8’s, not this RFC’s.
- RFC 0046 §3.1 (tenant selector) and criterion RFC0046.11 — amended by RFC 0052 §8, which this RFC cites (not by this RFC), from a 256-byte replay bound to 128, the grammar RFC 0048 §3.1 pinned and which RFC 0046’s own resolved-questions note records as “the one tenant grammar every boundary applies at”. The amendment is a consistency fix rather than a new decision: the replay clause and RFC0046.11 were left at the superseded number, and the frame codec was left with them.
RFC 0056 — Audit-sink durability on permanent write failure
rfc: 0056 title: Audit-sink durability on permanent write failure status: drafted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-09-15 supersedes: — superseded-by: —
RFC 0056 — Audit-sink durability on permanent write failure
Status note.
drafted. Split out of RFC 0053’s status note so an amendment to an accepted RFC is not hidden. Source wording: #802 @30a21f80, status note + the three-waywrite_ownedresult in §3.2. Blocked fromvalidatedby #809 — RFC 0026’s denial-audit durability, which §7 explains this RFC cannot close.
1. Summary
RFC 0005 §3.7 says the writer guarantees no audit event is lost across
crashes. The current audit sink reports a permanent write failure as
success and write_ordered then publishes the dependent records.
This RFC amends that clause: a permanent audit-write failure is a third outcome. Dependent records for that tenant stay unpublished and requeued. The tenant becomes server-terminal until repaired.
2. Motivation
A record in Parquet without the template event that describes it
breaks one of two contracts, and which one depends on where the event
went missing. CLAUDE.md §3.1 governs emission: it forbids a silent
template merge and requires an audit event on every template change. RFC
0005 §3.7 governs durability: an event the miner did emit survives a
crash, because the writer routes audit events through the same WAL path
as data records. A missing event does not by itself say which was broken
— it may never have been emitted — so the sink’s behaviour here is judged
against §3.7 only once the event is known to have been emitted, which is
the case this RFC is about: the sink held the event and dropped it on a
permanent write failure. The 0053 draft changed that behaviour in a status
note. An accepted RFC needs its own amendment and criterion.
3. Proposed design
write_ownedreturns a three-way result: durable / transient / permanent.write_orderedrefuses the dependent record publish for that tenant on permanent.- That tenant is marked terminal; other tenants continue.
- Derive failure on the audit side takes the same path.
- Authorization-denial events (
IngestDenied, RFC 0026) are emitted before any frame exists; replay cannot regenerate them. That gap stays RFC 0026’s, named here so 0056 does not pretend to cover it.
4. Alternatives considered
- Keep the silent drop. Rejected against RFC 0005 §3.7 and §3.1.
- Fold into RFC 0053. Rejected: wrong document.
5. Acceptance criteria
RFC0056.1
- Given a permanent audit-write failure for one tenant
- When
write_orderedreturns- Then that tenant’s records are unpublished and requeued, the tenant is terminal, other tenants publish, and the RFC 0005 §3.7 clause is the amended one
RFC0056.2
- Given an audit event whose
derive_audit_partitionfails permanently — a pre-epoch or nanosecond-overflow timestamp, so no partition key exists to write under- When
write_orderedreturns- Then that tenant takes the same path as a permanent write failure: its records are unpublished and requeued, the tenant is terminal, and other tenants publish
6. Testing strategy
One fault-injected permanent audit failure beside a healthy second tenant, and a second injection on the derive path — a record timestamped outside the representable range — asserting RFC0056.2 rather than the write path. Implementing PR.
7. Open questions
- RFC 0026
IngestDenieddurability — #809, and owned by RFC 0026. The path emits before any frame exists, so replay cannot regenerate it and §3’s mechanism does not reach it; closing it takes an amendment to RFC 0026’s denial-audit durability, not a widening of this RFC. It is not a deferral but a standing contradiction with RFC 0005 §3.7 and RFC 0026’s own accepted criterion, so this RFC cannot reachvalidateduntil #809 lands.
Not open, recorded so it is not reopened: the operator repair path for a terminal tenant is a restart, which §3 already decides — the dropped events are gone and only recovery regenerates them, so an in-process clear would release requeued records under events that were never written. A future operator repair still ends in a restart.
8. References
- RFC 0005 §3.7, RFC 0026, RFC 0053 draft status note on #802
9. Extracted wording (RFC 0053 draft, 30a21f80)
Moved here unedited from the RFC 0053 quarry so the reviewed sentences
survive the split rather than being rewritten from memory. §3 above is
the shape of this RFC and §5 above governs acceptance; the section
numbers inside these paragraphs (“§3.1”, “§3.2”) and the RFC0053.n
ids are the draft’s own.
9.1 The amendment, as the draft states it (status note)
It amends accepted RFC 0005’s audit-sink durability clause (§7, “The writer guarantees no audit event is lost across crashes…”): a permanent audit write failure stops being a silent drop that reports success and becomes a third outcome that refuses the dependent record publish and makes the tenant terminal.
9.2 A permanent audit failure is not a settled outcome (draft §3.2)
“Not drained” has to include a permanent drop, and today it does not.
AuditSink::write_owned reports fully_durable as retained.is_empty(),
and route_partition_result puts a permanent write error on neither
path: it counts permanent_errors, logs the batch, and retains nothing —
so write_owned returns true, write_ordered proceeds, and the records
whose template events were just dropped are published anyway. That breaks
the ordering the barrier exists to give and, worse, the CLAUDE.md §3.1
invariant behind it: a merge with no audit event is exactly the silent
merge the project forbids, and RFC 0005’s audit contract says no audit
event is lost. So this RFC amends the audit sink’s permanent-failure
policy (RFC 0005’s audit-sink contract, the flush routing in
audit_sink.rs): a permanent audit write failure is not a settled
outcome for the dependent records. write_owned returns a three-way
result — fully durable, retained-for-retry, or permanently failed —
and write_ordered refuses the record publish on the third exactly as it
does on the second, requeueing the records rather than publishing them
under a missing event. The outcome is per tenant, not per batch: a
drained batch spans tenants, and one tenant’s unwritable audit partition
must not stop every other tenant’s records from publishing — that would
turn a single bad partition into a node-wide stall. So the result carries
a failed-tenant set, the outcome is evaluated per tenant (per audit
partition, which is keyed by tenant and day), and write_ordered
publishes the record partitions of every tenant whose audit events are
fully durable while refusing and requeueing only those of the tenants in
that set. Retained-for-retry is already per partition on the existing
path and stays so. Refusing forever is not a contract, so the third
outcome is terminal for that tenant, not a retry loop: RFC 0025 §3.3’s
quarantine is for permanent BatchErrors on data records, and it works
by writing a record_quarantined event — into the very audit sink that is
failing — so it cannot be the escape hatch here. Instead a permanently
failed audit write puts the tenant in the server-terminal,
client-retryable class §3.1 already defines: its records stay in the
WAL, unpublished and unacknowledged-past, its appends are refused with
the tenant named, the state is exported and alerted beside
ourios.wal.tenant_unrecoverable, and a restart is the only thing that
clears it. Not an in-process operator action: the events are gone from
the sink’s buffer, so clearing the flag while the process runs would let
the tenant’s requeued records publish under events that were dropped —
precisely the ordering the flag exists to protect. A restart is different
in kind, because recovery re-mines the frames from the WAL and RFC 0052’s
regeneration-only replay produces those template events again, so the
records publish under events that exist. The rule is therefore stated
flatly: a permanent audit failure is cleared by restart alone, after the
operator has fixed whatever made the store reject writes.
That argument covers the record-dependent audit stream and nothing
else, which this RFC states rather than over-claims. The events it
reasons about are the ones the miner regenerates from frames — template
merges and widenings, which accompany published records. RFC 0026’s
binding denials do not fit it: Pipeline::enforce_binding emits an
IngestDenied event to the denial sink and returns the error before any
frame is appended, so there is nothing in the WAL for replay to
regenerate and the restart argument simply does not apply. A permanent
drop there is a real gap against RFC 0005 §3.7’s no-loss contract, and it
is out of this RFC’s scope — its subject is the ordering between
records and the events that describe them, not the denial stream, which
has no records to order against. The gap is named here rather than papered
over, and §7 carries it as a follow-up: denial events need either their
own durable path or an explicit exemption in RFC 0005’s contract, and
that is RFC 0026’s ground to settle.
The per-partition settlement below reads that third outcome as not
settled. RFC0053.2 asserts a permanent audit failure leaving the records
unpublished and requeued and the tenant terminal.
9.3 Derive failure, set-aside buffers, and what a restart clears
The audit sink’s derivation failure is not a quarantine, and this RFC
gives it the same terminal treatment as a permanent write.
derive_audit_partition can fail before a PartitionKey exists — a
timestamp that will not resolve to a partition — so the event never
reaches route_partition_result and the sink counts it and drops it.
Ownership-settled, certainly; but treating that as the end of the story
would let the dependent records publish with no template event behind
them, which is the failure §3.2 exists to close and CLAUDE.md §3.1
forbids. RFC 0025 §3.3’s quarantine cannot serve here either, for the
reason it cannot serve a permanent write failure: it works by writing an
event into the same audit sink. So a derive failure marks that event’s
tenant permanently failed, exactly as a permanent write does —
write_owned reports it in the failed-tenant set, write_ordered
refuses that tenant’s dependent records and requeues them, and the tenant
enters the server-terminal, client-retryable class.
A requeue alone would spin, so a terminal tenant’s buffers are set aside. Requeued records go back into the sink, the next drain picks them up, the same audit write fails the same way, and the node burns store calls on a failure nothing in-process can fix. So while a tenant is terminal its partitions are excluded from every drain — the age sweep’s, the barrier’s and the publisher’s alike — rather than retried: they stay in the buffers, under the sink’s byte accounting, doing nothing until a restart re-mines the frames behind them. Two consequences follow and are stated rather than left implicit. Its WAL frames stay unsuppressed: the tenant’s publication frontier does not advance, so recovery replays them, which is what makes the set-aside lossless rather than a quiet drop. And the barrier does not advance past them: the tenant’s own frontier holds where it is, so no checkpoint or snapshot mark claims its records are durable — the node-wide checkpoint still advances for every healthy tenant, per §3.2’s per-tenant horizon. RFC0053.2 asserts it: a terminal tenant’s partitions are not re-drained, its records are still in the WAL after a restart, and its frontier has not moved.
What a restart clears, stated precisely, because “a restart clears it”
is too strong on its own. A restart is the mechanism — recovery
re-mines the frames and regeneration produces the events again — but it
cures nothing by itself: it clears the state only once the underlying
condition is repaired, and the two conditions differ. A permanent write
failure needs the audit store to accept writes again. A derive failure
needs the clock, because the event’s timestamp is not carried in the
frame: MinerCluster stamps every audit event from its own wall-clock
source (self.clock.now()), so the failing timestamp is a property of
when the event was emitted, not of the record being mined. A restart
therefore re-mines the same frame and stamps a fresh timestamp, which
derives normally on a node whose clock has recovered — and fails again,
identically, on one whose clock is still wrong, which is the same shape as
a store that is still rejecting. So the rule is one sentence: the terminal
state clears on the first restart after the condition behind it is
repaired, and a restart into an unrepaired condition simply re-enters it,
visibly, on the same alert. Nothing is lost either way, because the
records stay in the WAL unpublished for as long as the state holds.
RFC0053.2 covers the derive failure beside the write failure.
9.4 Draft open question (source for §7)
- Where RFC 0026’s binding-denial events go when the audit store rejects writes permanently. §3.2’s three-way outcome and its terminal rule cover the record-dependent stream only, because a denial is emitted before any frame exists and replay cannot regenerate it — so a permanent drop there is an open gap against RFC 0005 §3.7. It needs either a durable path of its own or an explicit exemption in that contract; either is RFC 0026’s to settle, not this RFC’s.
9.5 Draft references (§8)
- RFC 0005 §3.7 (audit files and their durability clause) — amended by §3.2
of this RFC: the audit sink’s permanent-failure path reports a third
outcome rather than success,
write_orderedrefuses the dependent record publish on it, and the tenant becomes terminal until a restart clears it — restart alone, per §3.2, because the dropped events are out of the sink’s buffer and only recovery’s re-mining regenerates them. Without the amendment a dropped event is reported as fully durable and its records publish anyway, which breaks both that clause andCLAUDE.md§3.1. - RFC 0025 §3.3 (permanent-encode quarantine) — the disposition for a poisoned data record; §3.2 states why it cannot serve an audit-write failure, and §3.2’s watermark rule states how a quarantined record is covered.
RFC 0057 — meta: move CLAUDE.md to AGENTS.md
rfc: 0057 title: “meta: move CLAUDE.md to AGENTS.md so every AI coding agent gets the same project context” status: drafted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-09-27 supersedes: — superseded-by: —
RFC 0057 — meta: move CLAUDE.md to AGENTS.md
This is a
meta:RFC. It proposes a change toCLAUDE.md, whose footer declares it load-bearing: “further changes require ameta:RFC and majority maintainer approval.” PerCLAUDE.md§8.5 the edit is not made in the drafting session: this RFC specifies the exact change, and the move lands as a separate PR after approval. Precedent: RFC 0012 (the §2 pillar-#2 rewording), drafted and enacted the same way.
1. Summary
The project context every contributor’s agent must read lives in
CLAUDE.md, a file name that is Claude Code’s default and only an
optional fallback elsewhere (GitHub Copilot accepts it; Codex and
Gemini CLI do not read it). This RFC moves it, with git mv, to
AGENTS.md, the open convention that Codex and GitHub Copilot read
natively and that Cursor and Jules are reported to read (§3.6).
Every section number stays the same. CLAUDE.md becomes a one-line stub that imports
AGENTS.md, so Claude Code sessions load the same text as today and the
575 existing CLAUDE.md citations (507 of them CLAUDE.md §N) keep
resolving without being rewritten. The handful of Claude-specific lines
are reworded to be agent-neutral. One rule deliberately widens: the
§9 attribution requirement now covers every AI assistant, not only
Claude (§3.4.7).
2. Motivation
2.1 The context is Claude-first by file name, not by content
Ourios is public OSS and states that AI-assisted contributions follow
the same review, CI and RFC process as human ones (CLAUDE.md §9). The
invariants in §3 (no silent merges, WAL-before-ack, tenancy) are exactly
what an agent must know before touching the hot path. Today a
contributor using Codex or Gemini CLI gets none of it unless they notice
the file and paste it in by hand; Copilot’s documentation offers a root
CLAUDE.md only as an alternative to AGENTS.md [GH-instr]. The content is
already almost entirely agent-neutral (§3.4 below counts the exceptions),
so the barrier is the file name.
2.2 AGENTS.md is the shared convention
AGENTS.md is a plain-markdown file at the repository root that
multiple agents read natively (§3.6 lists which, with citations). One
canonical file, read by all of them, keeps a single source of truth
instead of CLAUDE.md + .github/copilot-instructions.md +
GEMINI.md + .cursor/rules/* drifting apart.
2.3 Why move rather than copy
A copy creates two load-bearing documents with one approval rule, and
they drift. A move keeps one text, keeps git log --follow history on
it, and keeps blame useful for the footer’s changelog.
3. Proposed design
3.1 The move
In the enacting PR, and as its first commit so rename detection is unambiguous:
git mv CLAUDE.md AGENTS.md
That commit changes no content. The rewording in §3.4 lands in a second
commit of the same PR, so git log --follow AGENTS.md walks through
the rename into the full CLAUDE.md history, and the diff reviewers
approve is the wording diff alone.
Section numbering is frozen. Every heading ## 1. … ## 10. and
every ### N.M keeps its number and its position. Headings may be
reworded (§3.4) but not renumbered, merged, split or reordered. New
material, if any, is appended as a new last subsection of an existing
section (e.g. a future §8.6), never inserted.
3.2 The CLAUDE.md stub
After the move, a new CLAUDE.md is created containing exactly:
@AGENTS.md
Claude Code’s memory documentation specifies that “CLAUDE.md files
can import additional files using @path/to/import syntax”, that
“relative paths resolve relative to the file containing the import”,
with “a maximum depth of four hops”, and that import parsing skips
code spans and fenced code blocks (so the backticked @path mentions
elsewhere in the file are inert) [CC-mem]. The same page documents the
bridge this RFC uses: “A CLAUDE.md containing @AGENTS.md: you can
leave it. Keeping the import never makes Claude read AGENTS.md
twice.”
The stub is required, not a courtesy. Claude Code reads
AGENTS.md natively, but by default “only when you have no
CLAUDE.md in your working directory or above it” [CC-mem]. Without
the stub, a contributor with a Claude Code version that predates
native AGENTS.md support, or one whose checkout sits below a
directory holding its own CLAUDE.md, would load nothing from this
repo. With it, every Claude Code version that
supports imports loads the full text.
The approval dialog Claude Code shows on first use applies only to
external imports, those that “resolve outside your working
directory” [CC-mem]. A session started at the repository root sees
AGENTS.md inside its working directory, so no dialog appears. A
session started in a subdirectory (e.g. crates/ourios-wal/) still
loads the root stub, but AGENTS.md then resolves outside the working
directory, so Claude Code asks once per project before loading it;
declining disables the import. Today such a session loads the root
CLAUDE.md with no prompt, so this is a small new cost: one approval
per contributor per project. It is accepted (§7 records it), and
RFC0057.3 checks the root-started case.
The stub is one line on purpose: Claude-only prose below it would
reintroduce the split this RFC removes. Changes to the stub fall under
the same meta: rule as AGENTS.md (§3.4.9).
3.3 Citations are not rewritten
Measured on main at d8c8208 (2026-09-27):
| Measure | Command | Count |
|---|---|---|
Occurrences of CLAUDE.md | git grep -o 'CLAUDE\.md' | wc -l | 575 |
| Lines containing it | git grep -c 'CLAUDE\.md' summed | 566 |
| Files containing it | git grep -l 'CLAUDE\.md' | wc -l | 159 |
Section citations CLAUDE.md §N[.M] | git grep -oE 'CLAUDE\.md.? ?§ ?[0-9]+(\.[0-9]+)*' | wc -l | 507 |
By area (occurrences / files; a markdown link
[`CLAUDE.md`](CLAUDE.md) counts twice):
| Area | Occurrences | Files |
|---|---|---|
docs/rfcs/NNNN-*.md (historical decision records) | 307 | 52 |
crates/ (code comments, test docs) | 147 | 79 |
docs/ other than RFC files (incl. docs/rfcs/README.md) | 78 | 9 |
.claude/skills/rfc-check/SKILL.md | 9 | 1 |
Everything else (README, CONTRIBUTING, CI, Helm, justfile, …) | 34 | 18 |
Section numbers cited, with counts: §1 (32), §2 (41), §2.1 (1), §2.2
(1), §3 (19), §3.1 (25), §3.2 (17), §3.3 (37), §3.4 (70), §3.5 (18),
§3.6 (29), §3.7 (55), §4 (26), §4.6 (13), §5 (1), §5.1 (5), §5.4 (1),
§6 (1), §6.1 (6), §6.2 (64), §6.3 (20), §6.4 (1), §6.6 (3), §6.7 (1),
§7 (15), §8.3 (1), §8.5 (1), §9 (1), §10 (2). Every one exists in the current
CLAUDE.md: §2.1, §2.2 and §4.6 cite numbered list items (pillar #1,
pillar #2, hazard #6), not headings, and those lists keep their order
under §3.1’s freeze.
Rule. Because the stub remains and section numbers are frozen, a
citation CLAUDE.md §N resolves to the same text after the move as
before it (open CLAUDE.md, follow the import, find §N). So:
- RFCs are not touched. They are historical records; an accepted
RFC’s text changes only through the routes
docs/rfcs/README.md§Lifecycle allows, and a file rename is not one of them. - Code comments, CI, Helm and tooling comments are not touched. They resolve via the stub; they may be updated opportunistically when the surrounding line is edited for another reason.
- New writing cites
AGENTS.md §Nfrom the enacting PR onward, including new RFCs. The RFC template’s “CLAUDE.mdsections constrained” wording indocs/rfcs/README.md§Required sections is updated in the follow-up (§3.7).
3.4 Agent-neutral wording (exact replacements)
grep -niE 'claude|anthropic|\.claude|sub-agent|Explore|model|prefix' CLAUDE.md finds every Claude-specific line. Each is listed with its
replacement. Nothing else in the file changes except the footer (§3.4.9).
3.4.1 Title (line 1).
# CLAUDE.md — Ourios
becomes
# AGENTS.md — Ourios
3.4.2 §5.2 (line 194). “This is for humans and for Claude equally” becomes “This is for humans and for AI agents equally”.
3.4.3 §7 layout. The tree gains AGENTS.md, the CLAUDE.md
stub and the existing .agents/skills/ directory, and the skills
comment is neutralised; the exact tree is in §3.5. Neither skills
directory moves.
3.4.4 §8 heading. ## 8. Context management (agent-specific) is
kept verbatim: “agent-specific” already means “for agents”, and the
section number is frozen. A one-sentence lead is added under it:
These rules apply to any AI coding agent. Tool names in parentheses are examples, not requirements.
3.4.5 §8.1–§8.4. General; they describe context decay, sub-agents, scratch files and grep, which every current agent has. Only §8.2 names a Claude Code feature. Its last sentence becomes:
Use a read-only research agent (e.g. Claude Code’s
Explore) for research, and a fresh agent in an isolated worktree for write tasks.
3.4.6 §8.5 “Cache discipline”. The rules are general (prompt prefix caching and context forking exist across vendors) but the first line names “this file”. Replacement body:
The system prompt, tools, and this file (loaded directly or through a tool’s import, e.g.
CLAUDE.md) are cached as a prefix by most agents.
- Do not suggest mid-session model switches.
- Do not edit this file in the same session where its rules will be applied downstream — that invalidates the prefix for the rest of the session.
- For long sessions, compact into
context-log.mdand fork rather than grow.
The Claude-Code-specific parts of §8 (the Explore name, the @
import) therefore stay as parenthetical examples in AGENTS.md rather
than moving to CLAUDE.md, keeping the stub a single line (§3.2).
3.4.7 §9 heading and body. ## 9. Collaboration with non-Claude contributors becomes ## 9. AI-assisted contributions. Body:
Ourios is public OSS. AI-assisted code, whichever assistant produced it, goes through the same review, CI, and RFC process as any human contribution. Do not self-merge. Do not label a PR as ready for review until CI is green and the PR description addresses any invariant listed in §3 or hazard in §4 that the change touches.
Attribution: AI-assisted commits carry a
Co-Authored-By:trailer naming the assistant that helped (e.g.Co-Authored-By: Claude <noreply@anthropic.com>, or the equivalent trailer the tool emits). RFCs authored with an AI assistant list the human driver as the author and name the assistant in thedrafting-assistance:header field (e.g.drafting-assistance: Claude,drafting-assistance: Codex).
This is a scope change, not only a rewording. Today §9 requires a
trailer and a drafting-assistance: entry for Claude-assisted work
only; the replacement requires them for work assisted by any AI
tool. That follows from the project’s stance that every agent gets the
same context and the same rules, and it is approved or rejected with
this RFC’s majority maintainer vote, not as an editorial change.
Contributions already merged are not affected.
3.4.8 Intro paragraph. “This document is your project context” is already neutral and stays.
3.4.9 Footer. Add a dated changelog sentence in the existing style
(“2026-MM-DD revision moves the file to AGENTS.md (CLAUDE.md is now
a stub importing it) and makes §§5.2, 7, 8.2, 8.5 and 9 agent-neutral,
per RFC 0057 (maintainer-approved meta: RFC).”) and bump Last
updated. The closing rule becomes “This document is load-bearing;
further changes to it (or to the CLAUDE.md stub) require a meta:
RFC and majority maintainer approval.”
3.5 Skills: .claude/skills/ stays where it is
The repository already has two skill locations:
.agents/skills/openfga/— the vendored OpenFGA skill, the real tracked files.skills-lock.jsonpins it fromopenfga/agent-skills, and the skills installer writes it to this agent-neutral path..claude/skills/— Claude Code’s discovery path. It holdsrfc-check/SKILL.md(tracked here, nowhere else) andopenfga, a tracked symlink to../../.agents/skills/openfga.
Every skill is plain markdown with YAML front matter, so any agent can read one. The directories do not move, because:
- Claude Code discovers project skills under
.claude/skills/, so that path must keep resolving for the one agent that loads skills automatically. - The installer already keeps the real files in the neutral
.agents/skills/path, with a symlink at.claude/skills/<name>pointing to../../.agents/skills/<name>. That is the pattern to follow, not something to replace. - Whether
rfc-checkshould follow the same pattern (move to.agents/skills/rfc-check/, with a.claude/skills/rfc-checksymlink) is a separate, reversible choice that touches no rule, so it is left as an open question (§7) rather than bundled into ameta:change.
AGENTS.md names both paths in §7’s tree; no other section gains
text. The tree for §3.4.3 is:
├── AGENTS.md # project context for humans and AI agents
├── CLAUDE.md # one-line stub importing AGENTS.md
…
├── .agents/
│ └── skills/ # vendored skills (skills-lock.json)
└── .claude/
└── skills/ # project skills (plain markdown, any agent);
# vendored ones are symlinks to .agents/skills/
Note: a nested AGENTS.md already exists at
.agents/skills/openfga/AGENTS.md (vendored upstream content, also
reachable through the .claude/skills/openfga symlink). Tools treat
nested files differently: Copilot applies the nearest one, so inside
that directory it would see the OpenFGA guide instead of the root
rules [GH-instr]; Codex concatenates root and nested files, so it sees
both, subject to its byte budget [Codex-src]. Work inside a vendored
skill directory is rare and never touches the hot path, so this is
accepted and the file is not edited.
3.6 Per-tool support
Checked 2026-09-27. Where the vendor’s documentation site was unreachable from the drafting environment, the vendor’s own documentation or source on GitHub was read instead; rows marked unverified rest on secondary sources and must be re-checked by the enacting PR’s author.
| Tool | Reads root AGENTS.md | Reads CLAUDE.md | Own file needed? | Caveats |
|---|---|---|---|---|
| Claude Code | Yes, but only when no CLAUDE.md exists in or above the working directory [CC-mem] | Yes (primary) | No: the stub (§3.2) is the bridge | Import depth 4 hops; skips imports in code spans |
| OpenAI Codex CLI | Yes, natively; AGENTS.override.md takes precedence; files concatenated from the git root down to the working directory [Codex-src] | No | No | project_doc_max_bytes default 32 768; excess is truncated [Codex-cfg] |
| GitHub Copilot (coding agent, code review) | Yes, anywhere in the tree, nearest file wins [GH-instr] | Yes, “alternatively”, a single root CLAUDE.md or GEMINI.md [GH-instr] | No; .github/copilot-instructions.md is optional | Code review lists AGENTS.md as a shared-rules source [GH-review] |
| Gemini CLI | Only if configured via context.fileName [Gemini-md] | No | Yes, a setting (below) | Default context file is GEMINI.md |
| Cursor | Yes, root and nested (unverified, secondary sources) | Unconfirmed | No | cursor.com/docs unreachable when drafted |
| Jules | Yes, root (unverified, secondary sources) | Not stated | No | jules.google unreachable when drafted |
Consequences for Ourios.
- No
.github/copilot-instructions.md, no.cursor/rules/, noGEMINI.md. Copilot and Codex readAGENTS.mddirectly (verified). Cursor and Jules are reported to (unverified); if the enacting PR’s re-check finds either does not, this RFC is amended before it reachesspecifiedto name that tool’s pointer file and add it to RFC0057.5 and RFC0057.6; the enacting PR adds no file those scenarios do not list. - Gemini CLI is the one genuine gap. It reads
AGENTS.mdonly whencontext.fileNamelists it. The documented example is{"context":{"fileName":["AGENTS.md","CONTEXT.md","GEMINI.md"]}}[Gemini-md]. The enacting PR adds a project-level.gemini/settings.jsoncontaining only{"context":{"fileName":["AGENTS.md"]}}: a pointer, not a copy of any rule, so it cannot drift. If the maintainer prefers no Gemini file in the repo, the enacting PR must instead add one sentence toCONTRIBUTING.mdgiving Gemini CLI users that exact setting for their user-level~/.gemini/settings.json(§7). One of the two is mandatory: without either, Gemini CLI loads onlyGEMINI.mdand gets none of the project rules. - Codex’s 32 KiB budget.
AGENTS.mdis 19 021 bytes today, well inside it. The vendored.agents/skills/openfga/AGENTS.mdis 104 117 bytes; Codex concatenates it only when working inside that directory, and it is truncated there. That is upstream content and a pre-existing condition; noted, not changed. - Tools that read
CLAUDE.mdbut notAGENTS.md, and do not expand@imports, would see the single line@AGENTS.mdinstead of the rules they see today. Of the tools checked, Copilot readsCLAUDE.mdonly as an alternative toAGENTS.md, so it is not affected. For any other such tool the stub is still a readable pointer to the right file, but it is a regression for that tool; RFC0057.3 covers Claude Code only, and a contributor report is the signal to revisit. - Line-count warning. Claude Code warns on memory files over 200
lines [CC-mem];
CLAUDE.mdis 424 lines today, so this is unchanged by the move.
3.7 Follow-up: living documents that switch to AGENTS.md
A small PR after the enacting one (not blocked on it being the same
PR; no meta: RFC needed because it changes no rule) rewrites the
citations in living documents only:
| File | Occurrences |
|---|---|
README.md | 4 |
CONTRIBUTING.md | 1 |
CODE_OF_CONDUCT.md | 2 |
docs/introduction.md | 1 |
docs/hazards.md | 13 |
docs/verification.md | 16 |
docs/benchmarks.md | 19 |
docs/glossary.md | 9 |
docs/roadmap.md | 6 |
docs/rfcs/README.md (process doc, not an RFC) | 9 |
.claude/skills/rfc-check/SKILL.md | 9 |
.github/pull_request_template.md | 0 (see below) |
.github/pull_request_template.md cites neither file today; the
follow-up adds one checklist line, “PR description addresses any
AGENTS.md §3 invariant or §4 hazard this change touches”, since that
is the rule §9 states.
Explicitly not in the follow-up: docs/rfcs/NNNN-*.md (historical),
docs/talks/ (dated lectures), CHANGELOG.md (history), crates/,
.github/workflows/, .github/scripts/, deploy/, justfile,
codecov.yml, rust-toolchain.toml, .gitignore, .bestpractices.json
and docs/SUMMARY.md’s RFC 0012 entry (its title names the file it
changed at the time). All of these resolve through the stub.
4. Alternatives considered
Keep CLAUDE.md, add AGENTS.md as a copy. Two load-bearing files
under one approval rule; a meta: change must land in both or they
drift, and nothing enforces it. Rejected for the reason §2.3 gives.
Keep CLAUDE.md canonical, make AGENTS.md the stub. Other agents
do not implement @path imports (§3.6), so they would read a single
line and nothing else. Only the reverse direction works.
Symlink CLAUDE.md → AGENTS.md. Works for tools that follow
symlinks, but symlinks are fragile on Windows checkouts
(core.symlinks=false materialises a text file containing the path),
render as a one-line file on GitHub, and some tools do not follow them.
The documented import is explicit and portable. Rejected.
Rewrite all 575 citations to AGENTS.md. A 159-file diff, 52 of
them RFCs whose text is historical and, when accepted, terminal. It
buys nothing the stub does not already give. Rejected; living docs only
(§3.7).
Per-tool files (.github/copilot-instructions.md, GEMINI.md,
.cursor/rules/). Each is another copy to keep in sync. §3.6 finds
only Gemini CLI unable to read AGENTS.md by default, and that gap is
closed by a one-key setting that points at AGENTS.md, not by a copy.
Move .claude/skills/ wholesale to .agents/skills/. Without
a symlink it breaks Claude Code’s skill discovery; with one it is the
pattern the vendored OpenFGA skill already uses, which §3.5 keeps and §7
leaves open for rfc-check. Not bundled into this meta: RFC.
5. Acceptance criteria
These are doc-state assertions checked on the enacting PR, like RFC 0012’s. Each carries a runnable check.
Scenario RFC0057.1 — history follows the move.
- Given the enacting PR’s first commit
- When
git show --stat --summary --find-renames=100% <commit>is run- Then it reports exactly one change,
CLAUDE.md => AGENTS.mdwith 100% similarity- And
git log --follow --oneline AGENTS.mdlists the commits that previously touchedCLAUDE.md(e.g.b50067d)
Scenario RFC0057.2 — section numbers are unchanged.
- Given
CLAUDE.mdonmainimmediately before the enacting PR andAGENTS.mdafter it- When both are reduced to their heading numbers with
grep -oE '^#{2,3} [0-9]+(\.[0-9]+)?' FILE- Then the two lists are identical, in the same order
- And the numbered pillar list in §2 and hazard list in §4 have the same item count as before (so §2.1, §2.2 and §4.6 still resolve)
Scenario RFC0057.3 — the stub imports AGENTS.md and Claude Code loads it.
- Given the enacted repository
- When
cat CLAUDE.mdis run- Then its entire content is the single line
@AGENTS.md- And in a fresh Claude Code session at the repo root,
/memory(or asking the agent to quote §3.4’s first rule) showsAGENTS.md’s content loaded
Scenario RFC0057.4 — no broken
CLAUDE.md §Ncitation.
- Given the enacted repository
- When every section number cited as
CLAUDE.md §N[.M]orAGENTS.md §N[.M]is extracted withgit grep -hoE '(CLAUDE|AGENTS)\.md.? ?§ ?[0-9]+(\.[0-9]+)*'- Then each number resolves to a heading in
AGENTS.md, or to item M of the numbered list in section N (§2, §4)- And the set of cited numbers is a subset of those that resolved before the move (no citation newly points at nothing)
Scenario RFC0057.5 — the only wording changes are §3.4’s.
- Given the enacting PR’s second commit
- When its diff against the first is reviewed
- Then every hunk in
AGENTS.mdcorresponds to an item in §3.4.1–§3.4.9, and the only other change is the newCLAUDE.mdstub of §3.2, plus exactly one Gemini CLI route from §3.6: a new.gemini/settings.json, or the one-sentence setting added toCONTRIBUTING.md- And
grep -niE 'claude|anthropic' AGENTS.mdmatches only the footer’s history, §7’s.claude/andCLAUDE.mdstub lines, §8’s parenthetical examples, and §9’s example trailer / header
Scenario RFC0057.6 — no per-tool copies.
- Given the enacted repository
- When
git ls-filesis searched for.github/copilot-instructions.md,GEMINI.md,.cursorrulesand.cursor/rules/- Then none exists
- And exactly one Gemini CLI route from §3.6 exists: either
.gemini/settings.jsonparses as JSON whosecontext.fileNamenamesAGENTS.mdand holds no rule text, orCONTRIBUTING.mdcontains thatcontext.fileNamesetting for the user-level file
Scenario RFC0057.7 — the book still builds.
- Given the enacted repository
- When
mdbook buildruns- Then it succeeds with no new warnings
6. Testing strategy
No code changes, so no proptest, corpus or criterion work
(CLAUDE.md §6.2 techniques do not apply). RFC0057.1, .2, .4 and .6 are
shell one-liners the enacting PR description runs and pastes, as is
RFC0057.3’s first clause (cat CLAUDE.md); RFC0057.3’s second clause
and RFC0057.5 are reviewer checks; RFC0057.7 is the existing CI
mdbook build job. As with
RFC 0012, the load-bearing gate is the footer’s majority maintainer
approval on the enacting PR.
A CI check for RFC0057.4 (fail when a cited section number stops existing) would protect the frozen numbering permanently; it is listed as an open question rather than required, since it is new CI surface.
7. Open questions
- Maintainer approval (majority), per
CLAUDE.md’s footer. - CI guard for citations. Add a
just/CI step running RFC0057.4’s check so a future renumbering fails the build? It would make §3.1’s freeze enforceable rather than a convention. - Trailer wording. §3.4.7 lets each tool emit its own
Co-Authored-By:trailer. Should the project instead require one fixed form (e.g. the tool’s product name and a no-reply address), so the history is greppable across tools? -
drafting-assistance:values. Free text naming the tool, or a short fixed list?docs/rfcs/README.md’s front-matter example (drafting-assistance: Claude # omit if no LLM drafted) is updated in the follow-up either way. - Gemini CLI setting. Commit
.gemini/settings.json(recommended, §3.6) or document the user-level setting inCONTRIBUTING.mdinstead? - Unverified rows. Cursor and Jules support rests on secondary
sources (§3.6); the enacting PR’s author re-checks
cursor.com/docsandjules.google/docs. -
rfc-checklocation. Move it to.agents/skills/rfc-check/with a.claude/skills/rfc-checksymlink, matching the vendored OpenFGA skill (§3.5), or leave it in.claude/skills/? - Subdirectory sessions. Claude Code sessions started below the
repository root will prompt once to load the imported
AGENTS.md(§3.2). Acceptable, or document it inCONTRIBUTING.md? - Stub-only rule. Is “the stub stays one line; Claude-specific
text needs a
meta:RFC” (§3.2) the right bar, or should small Claude Code notes be allowed below the import without one?
8. References
CLAUDE.mdfooter (themeta:RFC + majority-approval rule), §7 (layout), §8 (agent context management), §8.5 (why the edit is not made in this session), §9 (attribution).- RFC 0012 — precedent
meta:RFC amendingCLAUDE.md. docs/rfcs/README.md— front matter (drafting-assistance:), §Lifecycle (why accepted RFCs are not rewritten)..claude/skills/rfc-check/SKILL.md— classifiesCLAUDE.mdchanges as RFC-required; updated in the §3.7 follow-up.- [CC-mem] Claude Code, Manage Claude’s memory:
https://code.claude.com/docs/en/memory (imports,
AGENTS.mdhandling, external-import approval). - [Codex-src]
openai/codex,codex-rs/core/src/agents_md.rs: https://github.com/openai/codex/blob/main/codex-rs/core/src/agents_md.rs. - [Codex-cfg]
openai/codex,codex-rs/config/defaults.toml(project_doc_max_bytes,project_doc_fallback_filenames): https://github.com/openai/codex/blob/main/codex-rs/config/defaults.toml. - [GH-instr] GitHub Docs, Adding repository custom instructions
for GitHub Copilot (source:
github/docs,content/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions.md). - [GH-review] GitHub Docs, About Copilot code review (source:
github/docs,content/copilot/concepts/agents/code-review.md). - [Gemini-md] Gemini CLI, GEMINI.md files: https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/gemini-md.md.
- AGENTS.md convention: https://agents.md. Released by OpenAI in 2025 and since donated to the Linux Foundation’s Agentic AI Foundation (secondary sources; the site was unreachable when this RFC was drafted).
RFC 0058 — Query resource limits
rfc: 0058 title: Query resource limits status: drafted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-09-28 supersedes: — superseded-by: —
RFC 0058 — Query resource limits
Status note.
drafted. The motivation is issue #853. Its two main causes are already fixed: #857 stops the object-store bridge spawning an OS thread per call, and #858 makes the data listing walk only the query window’s partitions. This RFC is the defence in depth, so that the next unbounded query is rejected instead of taking the process down. It amends RFC 0016 §3.5 (error model) and §7 (the row cap becomes configuration), RFC 0027’s tool-error mapping, and RFC 0020 §3.2 (the env-only key set gainsOURIOS_QUERIER_LIMITS_*) and §3.4 (the file schema), all additively. It also narrows result contracts that today return every row: the RFC 0027 §3.2list_templatesandtemplate_drifttools (RFC0027.4, RFC0027.5), the RFC 0016 drift response, and RFC 0010’s engine-level drift contract (RFC0010.1 returns exactly the affected rows). Each may now be refused withquery_result_limitwhen its rendered rows exceedmax_result_bytes(§3.4.3). RFC0010.5 (an empty result is not an error) is unaffected, since a refusal is never an empty result. It touchesCLAUDE.md§3.6 (object storage is the source of truth, via the spill decision in §3.5), §3.7 (multi-tenancy: the limits are per process, not per tenant, see §7), hazard #6 (no DataFusion text in a rejection) and §6.3 (observability). It leaves RFC 0033’s audit-listing fork on #853 and RFC 0038’s phase spans to their own amendments (§3.9).
1. Summary
The querier gets a process-wide memory pool shared by every query, built
on DataFusion’s RuntimeEnv pool (a GreedyMemoryPool, with spilling
disabled). Its default size is derived from the process’s real memory limit
(cgroup v2 memory.max, then physical RAM): a share of about 50% when the
querier shares the process with another role, and about 75% when it runs
alone. A concurrency limit admits at most max_concurrent_queries
queries at once. Further queries wait in a bounded FIFO queue with a timeout.
The pool cannot see most of what #853 measured, so the querier adds its own
bounds: a cap on objects listed and files resolved per query, and a
server-side ceiling on returned rows and bytes. Crossing any limit rejects
that one query with a typed QueryError, a specific HTTP status and MCP
error, and an error.type value on the existing ourios.query.duration
histogram.
2. Motivation
2.1 The incident
On 0.10.0, one /v1/query grew the server’s RSS by more than 1 GB and did
not give it back, even for a query that read zero bytes of Parquet (#853).
The node ran both roles in one process under a 1.5 GB memory limit
(MemoryMax=1536M). One dashboard panel polling once a minute was enough to
get the process OOM-killed every few minutes. Because the receiver lives in
the same process, every query OOM also killed ingest. A read took down
writes.
The two causes #853 found are fixed:
- Listing linear in tenant age.
resolve_live_keyslisted the tenant’s wholedata/prefix before discarding everything outside the window. #858 walks the Hiveyear=/month=/day=/hour=levels and lists only the window’s partitions, on the blocking pool. - An OS thread per bridged call.
block_on_off_runtimespawned a thread for every sync-to-async object-store call. Thousands of short-lived threads each touched a glibc arena, and the freed memory was never returned. #857 polls bridged futures on the bridge runtime instead.
2.2 Why that is not enough
Both fixes remove a specific cost. Neither states a limit. Today nothing in the query path bounds memory at all:
exec::session()(crates/ourios-querier/src/exec.rs) builds aSessionContextfrom a bareSessionConfig. TheRuntimeEnvbehind it uses DataFusion’s defaultUnboundedMemoryPool, so no reservation ever fails.- There is no concurrency limit. Ten dashboard panels are ten concurrent scans.
- The only bound on a result is
MAX_LIMIT = 10_000rows incrates/ourios-server/src/querier.rs(RFC 0016 §7), a hard-coded clamp. There is no bound on bytes, on files resolved, or on objects listed.
So the next query shape that is expensive in a way nobody predicted (a
high-cardinality count by, a wide window over an uncompacted backlog (#807),
a result with very large retained bodies) meets no limit short of the kernel.
The kernel’s answer is to kill the whole process, including the receiver
and whatever it had not yet published. CLAUDE.md §3.4 keeps acknowledged
data safe across that kill because the WAL replays it, but ingest is down
until the restart, and a restart loop is an outage.
2.3 Why at this layer
The protection that matters is the total across concurrent queries,
because the OOM killer acts on the process total. A per-query cap alone does
not bound that: four queries each under a generous per-query cap can still
exhaust the process together. The bound therefore lives where all queries
meet: one pool per process, one admission gate per process. Both sit in
ourios-querier, beneath both serving surfaces (the RFC 0016 HTTP API and
the RFC 0027 MCP tools), so neither surface can bypass them.
3. Proposed design
flowchart TD
R[query arrives: HTTP or MCP] --> A{permit free?}
A -- yes --> X[execute]
A -- no --> Q{queue full?}
Q -- yes --> RF[reject: query_queue_full, 503]
Q -- no --> W[wait FIFO, up to queue_timeout_ms]
W -- permit --> X
W -- timeout --> RT[reject: query_queue_timeout, 503]
X --> L{listing and file caps}
L -- over --> RL[reject: query_file_limit, 422]
L -- within --> S[scan and operators reserve on the shared pool]
S -- refused --> M{held plus requested exceeds the whole pool?}
M -- yes --> RM[reject: query_memory_limit, 422]
M -- no --> RC[reject: query_memory_contended, 503]
S -- ok --> C[materialize rows, reserving on the pool]
C -- over max_result_bytes --> RR[reject: query_result_limit, 422]
C -- ok --> OK[200: result]
3.1 The process-wide memory pool
One pool per process. At querier construction, the server resolves a
pool size (§3.2) and builds one
Arc<dyn MemoryPool>:
#![allow(unused)]
fn main() {
TrackConsumersPool::new(GreedyMemoryPool::new(pool_bytes), NonZeroUsize::MIN.saturating_add(4))
}
Querier holds it. Every query builds its SessionContext from a
RuntimeEnv whose memory pool is a thin per-query wrapper,
QueryMemoryPool, that forwards every MemoryPool trait method to the
shared pool: register, unregister, grow, shrink, try_grow,
reserved and memory_limit. It additionally keeps this query’s own
figures, updated on the same calls: the bytes it holds now (decreased on
shrink and on unregister, which releases whatever the dropped consumer
still held) and the size of the request a refused try_grow asked for.
A consumer dropped mid-query therefore leaves neither the shared pool nor the
per-query count stale. When the query ends, every reservation has been
dropped, and a debug assertion checks the per-query count is zero.
Concurrency. DataFusion calls MemoryPool methods concurrently from a
query’s partition tasks, so the wrapper’s figures are synchronized. held
is an AtomicUsize, changed with fetch_add on a successful grow or
try_grow and fetch_sub on shrink and unregister, in the same call
that forwards to the shared pool, after the shared pool accepts. At a
refusal, the refused try_grow computes needed = held.load() + additional, where additional is that call’s own argument. The two reads
are not one atomic snapshot of the whole query, and they need not be: any
concurrent growth of the same query can only raise held, and §3.6 already
treats needed as a lower bound. The refusal records needed in the
wrapper for the error to report, and a later refusal on the same query
overwrites it with its own figure. The shared
pool is the only place a limit is enforced. The wrapper adds no limit of its
own. It exists so that a refusal can be classified (§3.6). exec::session() becomes
exec::session(&QueryRuntime) and keeps its collect_statistics = false
override and its test. The same seam serves run_query_with, run and
run_drift (drift.rs calls exec::session() too). collect_records
rebuilds its session state with SessionStateBuilder::new_from_existing,
which keeps the runtime, so the materialize pass reserves on the same pool.
GreedyMemoryPool, not FairSpillPool. FairSpillPool caps each
spillable consumer at (pool − unspillable) / num_spillable. That
division only makes sense when spilling is the relief valve: the operator at
its share spills and carries on. With spilling disabled (§3.5), the fair
share becomes an early rejection while the rest of the pool sits free. A
single count by query runs one GroupedHashAggregateStream per partition
(target_partitions defaults to the CPU count), each a spillable consumer.
On an 8-core node, FairSpillPool would refuse that query at one eighth of
the pool even with nothing else running. GreedyMemoryPool lets a query
use the whole pool when it is alone and refuses on the process total, which
is the quantity the OOM killer acts on. Fairness between queries comes
from the admission gate (§3.3), not from the pool. TrackConsumersPool
records the top five consumers for the server-side log of a refusal. It
never reaches the response (§3.6).
What the pool sees, stated plainly. A DataFusion pool accounts only explicit operator reservations: hash aggregates, sorts, joins, and repartition buffers. It does not see:
- the listing and manifest reads in
file_set.rs, including the key vectors a listing builds; - the sync-bridge threads and whatever glibc arena fragmentation they leave behind (#857 removes the thread-per-call pattern, not the allocator’s retention);
- Parquet decode buffers in
DataSourceExec(the Parquet opener does not reserve); - the batches
execute_plancollects, and row decode and rendering incollect_records; - the RFC 0033 template-map acquisition.
Of #853’s measured growth, the pool would have caught almost none: that query read zero bytes and ran no large operator. §3.4 adds the querier’s own bounds for these, and wherever the querier can size a buffer, it reserves that buffer on the same pool rather than tracking it separately (§3.4.3). What remains unaccounted is the headroom the §3.2 shares must leave.
3.2 The derived default size
The pool size resolves in this order:
querier.limits.memory_pool_bytes, if set (§3.7). Explicit bytes always win.- Otherwise
share × effective_limit, whereshareisquerier.limits.memory_pool_fractionif set, else the default share below. The product is computed in integer arithmetic and rounded down: the share is converted once to parts per million, and the pool iseffective_limit × ppm / 1_000_000inu128, so it never overflows and never exceedsshare × effective_limit.
effective_limit is the smaller of the cgroup memory limit and physical
RAM (MemTotal in /proc/meminfo). A cgroup limit above physical RAM is
meaningless, and a missing cgroup limit falls back to RAM.
cgroup v2 (the design target). The server reads /proc/self/cgroup. On
a unified hierarchy that holds a single 0::<path> line. For the process’s
cgroup directory <mount>/<path> and each ancestor up to the mount root,
the server reads memory.max and memory.high, ignoring the literal max
and absent files, and takes the minimum. Ancestors matter because a limit on
a parent slice applies to every child. memory.high counts because the
kernel throttles and reclaims hard above it, and a pool sized past it would
run the process in that regime. The walk covers both deployment shapes:
- systemd unit, as in #853:
MemoryMax=1536Msetsmemory.maxon/system.slice/ourios.service, andMemoryHigh=setsmemory.high. - container with a cgroup namespace (the default for containerd on v2):
/proc/self/cgroupreads0::/, and the container’s own/sys/fs/cgroup/memory.maxis the limit Kubernetes set fromresources.limits.memory.
cgroup v1 matters only as a fallback. Kubernetes has held v1 in
maintenance mode since 1.31, systemd 256 no longer boots on it by default,
and the current releases of the major distributions default to v2. v1 is
still worth one read, because the failure it prevents is the dangerous one:
without it, a v1 container limit is invisible, the size falls through to the
node’s physical RAM, and the pool is sized for memory the process cannot
have. So when /proc/self/cgroup has no 0:: line but has a memory
controller line, the server reads that cgroup’s memory.limit_in_bytes
under the v1 memory mount. It treats the page-rounded i64::MAX sentinel as
unlimited and does not walk ancestors: a v1 container sees its own limit
file, and v1 host hierarchies are out of scope.
Injectable paths. All three sources (/proc/self/cgroup, the cgroup
mount root, /proc/meminfo) are fields of a MemorySource struct with a
MemorySource::system() constructor, so tests point it at a fixture tree
(RFC0058.4).
Non-Linux builds (macOS development) have no derived default. The pool falls back to a fixed 1 GiB, and startup logs that it did so.
The default share. The querier “shares the process” when the resolved configuration enables the receiver or compaction next to it:
| Process | Default share | At a 1.5 GiB limit |
|---|---|---|
| querier with the receiver or compactor | 0.50 | 768 MiB |
| querier only (the Helm chart’s querier Deployment) | 0.75 | 1152 MiB |
What the share guarantees. The pool bounds tracked query memory, not
the process. The process stays under its limit only if the remainder (the
limit minus the pool) holds everything else: the baseline, the untracked
query costs §3.1 lists, and, on a shared process, the receiver and
compactor. The receiver’s sink ceiling (RFC 0014 §3.4) is a fixed 1 GiB
today, not derived from this limit. So on a small shared node, 0.50 protects
ingest only while the sinks run well below their ceiling, as they do at
#853’s ingest rate. It is not a guarantee. Deriving that ceiling from the same
MemorySource would be an RFC 0014 amendment and is outside this RFC. §7
keeps both shares open, with the reasoning.
Validation. A resolved pool below 32 MiB is a startup configuration
error: it would reject nearly every non-trivial query, and a hard failure at
boot is kinder than a silent reject-everything server.
memory_pool_fraction must lie in (0, 1]. An explicit
memory_pool_bytes above the detected effective_limit is allowed (the
operator may know better, e.g. about a limit the walk cannot see) but logs a
warning naming both figures. Startup logs the resolved size, its source
(explicit, cgroup_v2, cgroup_v1, meminfo or fallback) and the
share, once.
3.3 Admission: the concurrency limit
Querier holds a tokio::sync::Semaphore with max_concurrent_queries
permits (default 4) and an AtomicU32 count of occupied queue slots. Every Querier entry
point that does storage IO acquires a permit first: run,
run_query_with, run_drift, and template_registry (the RFC 0027
list_templates fold). Both serving surfaces therefore share one gate.
- A free permit → the query runs immediately.
- No free permit, a queue slot free (
max_queued_queries, default 16) → the query takes a slot and waits. Taking a slot is one atomicfetch_updatethat increments the count only while it is below the limit, so concurrent arrivals cannot all pass a stale check. The slot is an RAII guard, decremented on drop, whether the wait ends in a permit, a timeout or cancellation, so a disconnect never leaks a slot. Tokio’s semaphore is FIFO-fair among registered waiters, and a waiter registers when its acquire future is first polled. Taking the slot and registering are not one atomic step, so two arrivals within that scheduling gap may be admitted in either order, and a permit released inside the gap can go to a latertry_acquire. The guarantee is FIFO among waiters already queued, not strict arrival order. Strict order would need a mutex-held queue in front of the semaphore, which is not worth its cost for a dashboard’s panels. - No free permit, no queue slot → rejected at once with
QueryError::Overloaded { cause: QueueFull }, without waiting. - Waited
queue_timeout_ms(default 10 000) without a permit → rejected withQueryError::Overloaded { cause: QueueTimeout }.
The permit is an RAII guard. It is not released when the engine call
returns. The engine returns it inside the result, together with the
ourios_materialize reservation (§3.4.3), as one ResultGuard. The server
serializes the response, grows that reservation by the serialized body’s
length, and moves the guard into the response body. The guard drops when the
body has been sent or the connection closes. So the permit count bounds the
responses in flight, and the pool budget covers their bytes, including JSON
expansion. A client that disconnects mid-wait or mid-query frees its slot
and its memory through the same drops. A serialized body that does not fit
the pool, or that exceeds max_result_bytes, is rejected under §3.6 like a
materialization overflow.
The ourios.query.duration histogram keeps measuring the full request, so
queue wait is inside it. A rejected query’s duration is its time in the
queue.
No Retry-After. An admission rejection is a 503 without a
Retry-After header. The server cannot predict when a permit frees, and a
fixed value would be a guess every client synchronizes on. Clients back off
under their own policy, as RFC 9110 permits for a 503 without the header.
3.4 The querier’s own bounds
These cover what §3.1’s list says the pool cannot see. Checked against the
code as of this draft: apart from the 10 000-row clamp (§3.4.3), no such
bound exists today. The auth.openfga.visibility.max_objects key is an
unrelated RFC 0048 enumeration bound on authorization objects, which is why
the names below avoid max_objects.
3.4.1 Objects listed per query
max_listed_objects (default 100 000) bounds the keys a single query’s
file-set resolution may enumerate, counted incrementally during the walk:
window_listing’s delimited levels and recursive sub-listings on S3, and
the std::fs walk on the local backend. It counts every key returned
(Parquet files, *.parquet.tmp, manifests, superseded files), because the
cost is the enumeration, whatever the key turns out to be. This is what bounds
the “cost grows with tenant age” shape of #853, even under a backlog #858’s
window walk still has to descend into.
How early the walk can stop depends on the store call, and the guarantee is stated per call:
- Recursive listings (
list_recursiveat a fully covered span or anhour=leaf, and thestd::fswalk). TodayStore::listcollects theObjectStore::liststream into aVecbefore returning. The store gains a bounded variant,list_bounded_blocking(prefix, remaining), that consumes the stream and returnsExceededas soon as it has yieldedremaining + 1keys. Thestd::fswalk counts entries as it reads them. A recursive listing therefore never holds more than the cap. - Delimited listings (one per visited Hive level).
list_with_delimiteris one logical request that the object-store client collects internally, so it cannot be cut off mid-way through this seam. Its size is the level’s fan-out (at most 24hour=, 31day=or 12month=prefixes, and oneyear=per year of history), plus any objects stored directly at that level, which the RFC 0005 writer never produces. The cap is checked after each delimited listing. The residual exposure is foreign objects placed at a Hive level by something other than Ourios, and it is accepted and stated as such.
3.4.2 Files resolved per query
max_files (default 10 000) bounds the live file set after manifest
resolution: the URLs handed to register_listing_table.
Manifests are resolved before this count exists, so they are bounded on
their own. A partition’s manifest.json is read only if its size, known
from the listing’s object metadata, is at most 1 MiB. That is roughly
ten times the size of a manifest naming max_files UUIDv7 file names, and
the figure is fixed rather than configurable. A larger manifest rejects the
query with LimitExceeded { Manifest } before it is fetched, and a parsed
manifest’s entries count toward max_files as they are validated.
Manifest::read and read_with_etag keep their signatures for the
compactor. The querier uses a bounded read that takes the size limit. Each resolved file
costs a footer read for per-file schema inference (SchemaMode::Union), plus
at least one more footer read in the scan. A query over more files than the
cap is rejected before registration. Both caps reject with
QueryError::LimitExceeded (§3.6), naming the cap and telling the user to
narrow the time range.
3.4.3 Rows and bytes returned
- Rows. The existing clamp stays a clamp: RFC 0016 §7 caps a query’s own
limitatMAX_LIMITand injectsDEFAULT_LIMITwhen it has none. This RFC moves both constants into configuration asmax_rows(default 10 000) anddefault_rows(default 1 000), unchanged in behaviour. A clamped query is not an error. The response’scountalready tells the client what it did not see (RFC 0017). - Bytes.
max_result_bytes(default 64 MiB) bounds one query’s materialized result: the rendered size of itsLogRows (body, attributes, params), or the group keys of an aggregation result. It is checked as batches are decoded and rows rendered, and the query fails as soon as the running total crosses the ceiling, never after the whole result is built. Exceeding it rejects withQueryError::LimitExceedednaming the ceiling and suggesting a smallerlimitor a narrower window. Truncating instead would need a “truncated” field in the RFC 0016 response, which is a contract change this RFC does not make (§4). - Reserve before retain. Today
execute_plancollects the wholeVec<RecordBatch>beforecollect_recordslooks at it, so a check applied afterwards cannot bound the peak. The materialize pass instead executes the plan as a stream (execute_stream). For each batch, it callstry_growon aMemoryConsumer("ourios_materialize")for the batch’sget_array_memory_size()and adds the batch to the runningmax_result_bytestotal, before retaining the batch. A refused or over-ceiling batch fails the query while it holds at most one unretained batch. Decoding and rendering grow the same reservation by the rendered size before each row is kept, and the pass shrinks it as the Arrow batches are released. Result materialization is thereby inside the process-wide budget: four concurrent 64 MiB results cost 256 MiB of the pool, not 256 MiB on top of it. The reservation lives in theResultGuarduntil the response has been sent (§3.3). - Aggregations follow the same rule. The grouped-count scan’s output
is one row per group and, for a high-cardinality
count by, is not small. It is streamed, reserved and counted againstmax_result_bytesin the same way. The bare count scan’s single row is not reserved. - Drift and template listings.
run_drift’s output rows and thelist_templatesregistry rows are results too. Both are reserved and counted againstmax_result_bytesas they are built. The drift scan runs on the pooled session. The registry rows are rendered one entry at a time from the derived registry.
What stays unbounded, stated plainly. The audit fold behind the
template-map acquisition (RFC 0033), run_drift’s audit listing, and the
registry derivation for list_templates are neither pool-accounted nor
capped by this RFC. Their cost is a listing of the tenant’s audit/ prefix,
which is the open RFC 0033 fork in §3.9, plus the fold’s in-memory maps. The
admission gate bounds how many run at once, and §3.2’s headroom has to
absorb one per permit. Bounding them belongs with that fork.
3.5 Spilling to local disk: off
Recommendation: disabled. The runtime is built with
DiskManagerBuilder mode DiskManagerMode::Disabled. This has to be
explicit, because DataFusion’s default is OsTmpDirectory: bounding
the pool without disabling the disk manager would make spill-capable
operators start writing to $TMPDIR silently, the first time this RFC’s
pool refuses them.
The trade-off:
- For spilling: a grouped aggregation that outgrows the pool would finish, slowly, instead of failing.
- Against, on principle:
CLAUDE.md§3.6 makes local disk cache and WAL, nothing else. A spill file is scratch, not state, so it does not break the letter of §3.6. But it adds a third local-disk consumer to size and monitor. - Against, in practice: on a node running both roles, spill competes
with the WAL for the same volume, which RFC 0053’s backpressure bound
assumes it owns. In Kubernetes, spill counts against
ephemeral-storageand can get the pod evicted. Where$TMPDIRis a tmpfs, spill is charged to the memory cgroup, which makes the OOM this RFC exists to prevent more likely, not less. - Against, on the workload: Ourios’s query shapes rarely spill. The
count scan and a bare
countaggregate hold a few bytes;limitdoes not sort; the DSL has no join and no executablesortstage. The only spill-capable operator in play is a high-cardinalitycount by, and the right answer there is a clear rejection telling the user to reduce the group cardinality.
A later RFC can add querier.limits.spill_dir with its own byte budget if a
real workload needs it (§7).
3.6 Failure behaviour
QueryError (#[non_exhaustive], crates/ourios-querier/src/api.rs) gains
two variants:
#![allow(unused)]
fn main() {
/// A per-query limit refused the query; retrying unchanged will fail again.
LimitExceeded { limit: QueryLimit },
/// The querier is at capacity; the same query may succeed later.
Overloaded { cause: OverloadCause },
pub enum QueryLimit {
Memory { needed: u64, pool: u64 },
ListedObjects { limit: u64 },
Manifest { bytes: u64, limit: u64 },
Files { count: u64, limit: u64 },
ResultBytes { limit: u64 },
}
pub enum OverloadCause {
QueueFull { max_queued: u32 },
QueueTimeout { waited_ms: u64 },
MemoryContended { needed: u64, pool: u64 },
}
}
Classifying a pool refusal. A reservation refusal surfaces as
DataFusionError::ResourcesExhausted, often wrapped (Context, Shared,
External). The executor classifies on DataFusionError::find_root()
before the generic storage_err mapping, so a refusal is never reported as
Storage. At the refusal, the query’s QueryMemoryPool wrapper knows
what this query held and what the refused try_grow asked for. Their sum,
needed, is the least this query required at that moment. The question
that decides retryability is whether needed could ever fit:
needed > pool→LimitExceeded { Memory }. The query cannot fit even with the pool to itself, so retrying unchanged will fail again. This covers a lone query’s first allocation too: with nothing else reserved, a refusal means the request alone exceeds the pool.needed ≤ pool→Overloaded { MemoryContended }. The query would have fitted had other queries not held the rest, so it may succeed once they finish.
needed is a lower bound: the query might have asked for more later. So a
503 can precede a 422 for the same query once it runs alone. That order
errs toward retrying, which is the cheap mistake. The reverse (telling a
client that a query which would fit can never run) cannot happen under this
rule. Comparing the held bytes with a per-query fair share instead would
misclassify both ways, since a query can hold little and still need
more than the pool, or hold a lot and fit fine alone.
Messages are Ourios-owned (hazard #6). DataFusion’s ResourcesExhausted
text names internal operators (GroupedHashAggregateStream[3]) and must not
reach a user. Display renders only the Ourios message; the engine text and
the top-consumer list go to the server log. Each message names the limit and
the remedy:
| Variant | Display (user-facing) |
|---|---|
Memory | the query needs at least {needed} bytes, more than the querier’s {pool} memory budget; narrow the time range, add filters, or group by fewer distinct values |
Manifest | a partition manifest in the query’s time range is {bytes} bytes, over the {limit} read limit; narrow the time range |
ListedObjects | the query’s time range covers more than {limit} stored objects; narrow the time range |
Files | the query’s time range covers {count} files, over the limit of {limit}; narrow the time range |
ResultBytes | the result exceeds {limit} bytes; lower the limit or narrow the time range |
QueueFull | the querier is at capacity; retry later |
QueueTimeout | the query waited {waited_ms} ms for capacity; retry later |
MemoryContended | the querier’s memory is in use by other queries; retry later |
HTTP mapping (amends RFC 0016 §3.5). The body is the existing
{ "error": { "kind", "message" } }, with message the Display text
above:
| Variant | Status | kind |
|---|---|---|
LimitExceeded { Memory } | 422 | query_memory_limit |
LimitExceeded { ListedObjects | Manifest | Files } | 422 | query_file_limit |
LimitExceeded { ResultBytes } | 422 | query_result_limit |
Overloaded { QueueFull } | 503 | query_queue_full |
Overloaded { QueueTimeout } | 503 | query_queue_timeout |
Overloaded { MemoryContended } | 503 | query_memory_contended |
422 Unprocessable Content, not 400: the statement is valid, and the
server declines to execute it at this size. 400 stays reserved for
statements that do not parse or validate, so a client can tell “fix the
syntax” from “ask for less”. 503 for capacity, because the condition is
the server’s and temporary. 413 is wrong for both, since it describes the
request body.
MCP mapping (amends RFC 0027’s tool-error mapping, RFC0027.3, which
today maps everything but Forbidden to internal_error). The two classes are split
the same way: LimitExceeded → ErrorData::invalid_request (the caller
should ask for less), Overloaded → ErrorData::internal_error. Both carry
the Display text as the message and data: { "kind": "<kind above>" }, so
an agent can branch on the same token an HTTP client sees. It stays a tool
error, never a transport failure (RFC0027.3).
Telemetry. No new error metric. A rejected query is recorded on the
existing ourios.query.duration histogram through record_err, with
error.type set by query_error_type, exactly like invalid_query and
storage today. Its error.type value is the kind token in the table
above, so logs, responses and metrics share one vocabulary. An admission
rejection is recorded under the query’s own ourios.query.kind (logs,
drift or templates), since the request had already been parsed. The six error.type
values are proposals for the external ourios-semconv registry, going
through that repository’s review with the pin bump. None is hand-written as
a constant in this repository first.
Beyond error.type, the pool and the gate need to be visible when nothing
has failed yet (CLAUDE.md §6.3). The following are also proposed for the
registry, following RFC 0053 §3.3’s usage/limit pattern:
| Signal | Instrument | Unit | Attributes |
|---|---|---|---|
ourios.query.memory.usage | gauge | By | — (bytes reserved on the pool) |
ourios.query.memory.limit | gauge | By | — (the resolved pool size) |
ourios.query.admission.usage | gauge (int) | {query} | ourios.query.admission.state ∈ {running, queued} |
ourios.query.admission.limit | gauge (int) | {query} | ourios.query.admission.state ∈ {running, queued} (the two configured limits) |
These are state gauges, not per-error counters. Whether they ship with this RFC or a follow-up is open (§7).
3.7 Configuration
New keys under querier.limits.* in the RFC 0020 file, each a scalar
accepting ${env:NAME} / ${env:NAME:-default} substitution
(crates/ourios-server/src/config/file.rs), with deny_unknown_fields:
querier:
enabled: true
http_addr: 0.0.0.0:4319
default_window_secs: 3600
limits:
# Unset → derived: share × min(cgroup memory limit, physical RAM) (§3.2).
memory_pool_bytes: ${env:OURIOS_QUERY_POOL_BYTES:-}
# Unset → 0.5 when the process also runs the receiver or compactor, else 0.75.
memory_pool_fraction:
max_concurrent_queries: 4
max_queued_queries: 16
queue_timeout_ms: 10000
max_listed_objects: 100000
max_files: 10000
default_rows: 1000 # was DEFAULT_LIMIT (RFC 0016 §7)
max_rows: 10000 # was MAX_LIMIT (RFC 0016 §7)
max_result_bytes: 67108864 # 64 MiB
An empty value (including an unset ${env:…:-}) means “not set”: the
derived or built-in default applies, as it does for the existing optional
querier keys. Validation happens once, after resolution:
max_concurrent_queries ≥ 1; max_queued_queries ≥ 0 (0 disables
queueing: at capacity, reject at once); queue_timeout_ms ≥ 1; every
count and byte cap ≥ 1; default_rows ≤ max_rows; plus the §3.2 pool
rules. Without --config, the same keys resolve from
OURIOS_QUERIER_LIMITS_<KEY> environment variables (RFC 0020 §3.2’s
env-only mode), e.g. OURIOS_QUERIER_LIMITS_MAX_CONCURRENT_QUERIES.
Helm chart.
querier.limitsinvalues.yamluses the chart’s camelCase (maxConcurrentQueries,memoryPoolBytes, and so on). The helper renders them into the config file under the file’s snake_case keys (max_concurrent_queries,memory_pool_bytes), as it does for every other key (RFC 0020 §3.4). A camelCase key in the file would faildeny_unknown_fieldsat startup.querier.resources.limits.memorysets the cgroupmemory.maxthe default derives from. The chart’s querier Deployment runs the querier alone, so its default pool is 75% of that limit.- The chart ships
resources: {}. With no memory limit, the container has nomemory.max, and the pool derives from the node’s RAM. That is too large for a pod that shares the node. The chart does not impose a default limit, since sizing is the operator’s decision. Instead,NOTES.txtwarns when the querier is enabled withoutresources.limits.memoryor an explicitlimits.memoryPoolBytes. resources.requests.memorydoes not setmemory.maxand plays no part in the derivation.
3.8 What this RFC does not change
- The query planner, pruning, and the RFC 0021
collect_statistics = falseoverride. target_partitions. A query’s peak memory scales with it, and the pool bounds the peak regardless.- Any per-query execution timeout. A long query is bounded here by memory and admission, not wall-clock (§7).
- The receiver’s own memory, including RFC 0014’s 1 GiB sink ceiling
(
SINK_CEILING_BYTES), which is not derived from the process limit (§7).
3.9 Relation to other open work on #853
- RFC 0033 (template-map audit listing). #853’s first comment records an
open fork: on a row-returning query, the §3.3 freshness check lists the
tenant’s whole
audit/prefix, and the fix is either (a) exact set-equality over a frontier-bounded listing or (b) a bounded staleness window. Either is an RFC 0033 amendment. Out of scope here. This RFC neither caps nor accounts that listing; §3.4.1’s cap is on the data listing only. - RFC 0038 (self-tracing). The phase spans #853 asks for (file-set resolution, template-map acquisition) conflict with RFC0038.1’s exactly-one-span contract for a served query and need their own RFC 0038 amendment. Out of scope here. This RFC adds no span. Whatever RFC 0038 records for a failed query applies unchanged to the new variants.
4. Alternatives considered
A per-query memory cap only. Each query gets its own
GreedyMemoryPool(n). This bounds one query, not the process: with no
concurrency limit, N queries each under the cap reach N × cap, and the OOM
killer acts on the sum. It also idles memory: a query running alone cannot
use what the others would have. The maintainer rejected this as the primary
mechanism in favour of one shared pool (§3.1). A per-query ceiling layered
on the pool remains open (§7).
FairSpillPool. Rejected in §3.1. Its fairness divides spillable
consumers’ memory on the assumption they can spill. With spilling off, it
refuses a lone multi-partition aggregate at 1/target_partitions of the
pool. If spilling is ever enabled (§3.5), this should be revisited.
Keep the unbounded pool, rely on the cgroup limit. The status quo: the kernel is the limit, and its enforcement is killing the process, receiver included. #853 is what that looks like.
Run the querier in its own process. This is the right deployment for isolation, and the Helm chart already does it: one Deployment per role. But the single-binary, both-roles shape is supported (RFC 0020, and #853’s node), and the querier-only process still needs a bound, because OOM-killing every in-flight query to stop one is the same failure one level down. Process separation complements this RFC; it does not replace it.
An RSS watchdog. Poll the process RSS and cancel queries above a threshold. It sees everything the pool cannot, but it acts after the fact, races the allocator, and must guess which query to cancel. #853 also shows that RSS does not come down after memory is freed (allocator retention), so a watchdog could keep cancelling after the cause has gone. It may be a useful complement later. It is not a limit.
Switch allocator or set MALLOC_ARENA_MAX. This addresses
fragmentation, the “never returns it” half of #853, not the bound. It is
orthogonal and worth measuring separately. Whatever overhead remains
belongs in the §3.2 headroom.
Enable spilling. Rejected for now in §3.5.
Truncate an over-size result instead of rejecting it. Friendlier for a
dashboard, but the RFC 0016 response has no field that says “truncated by
bytes”, and adding one is a contract change. The row clamp already covers
the common case, and count exposes it. A byte overflow is rare enough
that a clear rejection is the honest answer.
Streaming results (NDJSON). RFC 0016 §7 leaves this open. Streaming bounds the serialization buffer, not the scan, the aggregation, or the listing, so it does not remove the need for this RFC.
5. Acceptance criteria
The ids are referenced from test code. Every “rejected” below means the HTTP
status, body kind, MCP code and error.type per §3.6’s tables.
Scenario RFC0058.1 — A query that outgrows the pool is rejected, and the server keeps serving
- Given a server running both roles over a seeded tenant, with
memory_pool_bytesset small enough that acount byover a high-cardinalityparam(n)needs more than the pool- When that query runs alone
- Then it is rejected
422query_memory_limit, and the message names the bytes the query needed and the pool size, and contains no DataFusion operator name or engine text- And a small query issued immediately afterwards succeeds
- And the pool’s reserved bytes return to zero after the rejection
- And an OTLP export sent to the same process after the rejection is acknowledged
Scenario RFC0058.2 — The pool is shared, and contention is told apart from size
- Given
max_concurrent_queries = 2and a pool that fits query A alone and query B alone, but not both together- When A holds its reservation (a test hook pauses it) and B then requests a reservation that exceeds the remainder but not the pool
- Then B is rejected
503query_memory_contended, notquery_memory_limit- And a lone query whose first reservation exceeds the pool is rejected
422query_memory_limit, not503- And after a run that registers and drops operators mid-query (a consumer unregistered while holding bytes), the shared pool and the per-query count both read zero
- And the shared pool’s reserved bytes never exceed its size at any point of the run
- And B run again after A finishes succeeds
Scenario RFC0058.3 — Spilling is disabled
- Given
$TMPDIRpointed at an empty directory and RFC0058.1’s pool- When RFC0058.1’s query runs
- Then it is rejected as in RFC0058.1, and the directory is still empty afterwards
Scenario RFC0058.4 — The default pool derives from the real memory limit
- Given a
MemorySourcepointed at fixture trees- When the pool size resolves
- Then each case gives exactly the stated result:
- v2,
memory.max = 1610612736at the leaf, querier with receiver → 805306368- the same, querier only → 1207959552
- v2, a tighter
memory.maxon an ancestor than on the leaf → the ancestor’s governs- v2,
memory.highbelowmemory.max→memory.highgoverns- v2,
maxeverywhere →MemTotal× share- v2, a limit above
MemTotal→MemTotal× share- v1,
memory.limit_in_bytes = 1073741824→ 1 GiB × share- v1, the unlimited sentinel →
MemTotal× share- an explicit
memory_pool_bytes→ exactly that value, whatever the fixtures say- an explicit
memory_pool_fraction = 0.6→ 0.6 × the derived limitmemory.max = 1000000007at share 0.5 → 500000003, rounded down- And a resolved size below 32 MiB, and a fraction outside
(0, 1], are startup configuration errors- And startup logs the size, its source and the share
Scenario RFC0058.5 — Admission waits, then rejects
- Given
max_concurrent_queries = 1,max_queued_queries = 1,queue_timeout_ms = 200, and query A holding the only permit (a gated test store blocks its listing)- When B arrives, then C arrives while B waits
- Then C is rejected at once
503query_queue_full, and B is rejected503query_queue_timeoutafter at least 200 ms- And neither response carries
Retry-After- And with a long timeout instead, B and a later D queued behind it are admitted in the order they registered as waiters (D arriving only after B is observed waiting) when A finishes
- And a waiting query whose client disconnects leaves the queue, and A’s permit is released when A’s future is dropped mid-execution
- And the same gate applies to a
query_logsMCP call, which is refused with the §3.6 MCP code anddata.kind- And with
max_queued_queries = 4, 32 concurrent arrivals against one held permit yield exactly 4 waiters and 28query_queue_full, and the slot count returns to zero once every future has completed or been dropped
Scenario RFC0058.6 — The listing and file caps reject over-wide queries early
- Given the request-counting S3 test store and a tenant with more in-window keys than
max_listed_objects- When a query covers them
- Then it is rejected
422query_file_limit, and the walk issued no listing after the one that crossed the cap- And with
max_listed_objectsabove the key count butmax_filesbelow the live file count, the query is rejected422query_file_limitbefore any footer is read- And a recursive listing under a covered span holding more keys than the cap stops consuming the listing stream at cap + 1 keys (asserted on the stream the bounded list polls)
- And a partition whose
manifest.jsonis listed at more than 1 MiB is rejected422query_file_limitwith noGETfor that manifest- And the local backend enforces the listing and file caps identically
Scenario RFC0058.7 — The result ceiling
- Given a query whose
limitexceedsmax_rows- Then it is clamped to
max_rowsand succeeds, withcountthe full total (the existing RFC 0016 §7 behaviour, now driven by configuration)- And given rows whose rendered size exceeds
max_result_byteswithin the row limit- Then the query is rejected
422query_result_limit- And the same rows under a larger
max_result_bytesare served- And during materialization the pool’s reserved bytes include the collected batches: a pool smaller than the result but larger than the scan rejects the query as a memory limit instead of serving it
- And the materialize pass never retains a batch it has not reserved: a test pool that refuses the Nth reservation fails the query with at most N − 1 batches retained
- And a
count bywhose group rows exceedmax_result_bytes, a drift query whose rows do, and alist_templatescall over a registry whose rendered rows do, are each rejected422query_result_limit- And the permit and the reservation are held until the response body has been sent: with
max_concurrent_queries = 1, a second query waits while the first response is still being written, and the pool includes the serialized body’s length during that time
Scenario RFC0058.8 — Configuration
- Given each
querier.limits.*key set in a config file, once literally and once through${env:VAR}- Then the resolved querier configuration carries the value
- And an unknown key under
querier.limitsis a startup error- And each §3.7 validation rule rejects its invalid value at startup
- And the Helm chart renders
querier.limitsvalues into the querier’s config file, andNOTES.txtcarries the warning whenquerier.resources.limits.memoryandlimits.memoryPoolBytesare both unset
Scenario RFC0058.9 — Telemetry is
error.typeon the existing histogram
- Given the in-memory metric exporter
- When each of the six rejections in §3.6 is triggered once, over HTTP and over MCP
- Then each is one
ourios.query.durationdata point carrying that rejection’serror.typevalue- And no other metric name is emitted for the rejection
- And the attribute values pass
weaver registry live-checkagainst the pinned registry
6. Testing strategy
Per CLAUDE.md §6.2, mapped to the §5 ids.
- RFC0058.1, .2, .3, .7 (pool). Integration tests in
ourios-querierover the local fixture store with an injected small pool. The high-cardinalitycount byfixture comes fromourios-testgen. RFC0058.2 needs a deterministic interleaving: a test-only pause hook inside the first query’s scan, released after the second query’s refusal. A timing-based race would pass by luck. RFC0058.1’s “OTLP still acked” leg is anourios-servertest with both roles in one process. Aproptestover arbitrary sequences of reserve/release on the shared pool from concurrent wrappers asserts that the pool total equals the sum of the wrappers’ own counts, and never exceeds the size. That invariant is what makes the contended-versus-limit split honest. - RFC0058.4 (derived default). Pure unit tests over fixture directories
in a
tempdir(proc/self/cgroup, a cgroup tree,proc/meminfo), one per case. No test reads the host’s real/sys. - RFC0058.5 (admission).
tokio::testwith paused time for the timeout legs, and the gated store to hold a permit. The disconnect leg drops the request future and asserts the slot count. The burst leg runs the arrivals on a multi-threaded runtime behind a barrier, so a check-then-increment race would overfill the queue and fail it. - RFC0058.6 (caps). The existing
Call::List/Call::ListDelimitedrequest-counting store (file_set.rstests) asserts where the walk stopped. The bounded recursive listing is tested against an in-memoryObjectStorewhoseliststream counts the items polled. The manifest leg lists an oversizedmanifest.jsonand asserts noGETfor it. The local leg uses a fixture directory tree. - RFC0058.7, .8. The existing
apply_limittests keep passing unchanged against the constants’ new configured defaults. New config-resolution tests mirror thequerier.default_window_secsones. The chart legs extenddeploy/helm/render-tests.sh, the chart render assertions CI already runs. - RFC0058.9. The in-memory exporter pattern the querier metrics already
use, plus
weaver registry live-check. - Benchmarks. The pool wrapper and the permit are on the hot path of
every query. The
ourios-benchquery benchmarks run before and after, and a regression beyond noise blocks, perCLAUDE.md§6.2.
Maturity, per docs/rfcs/README.md: green is RFC0058.1–.9 passing in CI.
This RFC touches no thesis gate in docs/benchmarks.md §7. validated
additionally requires a replay of #853’s shape on a 1.5 GiB node running
both roles: the dashboard poll plus a deliberately over-wide query must
produce rejections and no OOM kill over at least an hour, with RSS staying
under the limit.
7. Open questions
- The two default shares (0.50 shared, 0.75 querier-only). The first
real test is a 1.5 GiB node running both roles, where 0.50 gives a
768 MiB pool. The reasoning, and where it is weak:
- The pool bounds tracked memory only. The remainder must hold the process baseline (#853 measured 110–138 MB idle with ingest running), the receiver’s sinks, allocator overhead, and everything §3.1 lists as unaccounted. At 0.50 that remainder is 768 MiB. At 0.75 on a querier-only process it is 384 MiB, which is enough because there is no receiver.
- The receiver’s sink ceiling is not derived. RFC 0014’s hard ceiling
is a fixed 1 GiB (
SINK_CEILING_BYTES). On a 1.5 GiB node, a sink near its ceiling plus a full 768 MiB pool exceeds the limit. At #853’s ~1 record/s the sinks flush by age long before that, so 0.50 is safe in practice, but it is not a guarantee. Deriving the sink ceiling from the sameMemorySourceis the natural follow-up and would be an RFC 0014 amendment. - Is 0.50 too generous for a both-roles node, given that the thing being protected is ingest? 0.40 leaves more room for the unaccounted part and costs only aggregation headroom.
- Should the share scale with the limit (a fixed baseline reserve plus a fraction), since the baseline is roughly constant and dominates on small nodes?
-
max_concurrent_queries = 4. Enough for a dashboard’s panels to mostly run rather than queue. An even split ispool / 4(192 MiB at 768 MiB). Should the default scale with CPUs, or stay fixed? - The cap values.
max_listed_objects = 100 000,max_files = 10 000,max_result_bytes = 64 MiB,max_queued_queries = 16,queue_timeout_ms = 10 000. None is measured yet. The file cap interacts with #807: until compaction reduces file counts, a wide window over an uncompacted backlog may hit it legitimately, and the message then points at a window the user did not think was wide. - Per-tenant limits. The pool and the gate are per process, so one
tenant’s dashboards can starve another’s (
CLAUDE.md§3.7). A per-tenant share of the permits, or a per-tenant pool slice, is a natural second step. Out of scope until the per-process limits exist. - A per-query ceiling on the pool. §3.1 lets a lone query use the
whole pool. A per-query cap (for instance, a fixed fraction) would
stop one large query from turning every concurrent query into
query_memory_contended, at the cost of refusing it while memory sits free. - The state gauges in §3.6. Ship them with this RFC, or leave only
error.typenow and add the gauges with a follow-up registry bump? - A wall-clock query timeout. Not proposed here. A query that is cheap in memory but slow still holds a permit indefinitely.
- Spilling. Revisit only if a real
count byworkload keeps hittingquery_memory_limitwith a pool that cannot grow (§3.5).
8. References
- Issue #853: the incident and its first comment (the RFC 0033 fork, the RFC 0038 conflict).
- #857 (bridged futures polled on the bridge runtime), #858 (window-scoped listing): the fixes this RFC backs up.
- #807: compaction not reducing file counts, which is what makes the file caps bite.
- RFC 0014 §3.4: the receiver’s sink ceiling (§7).
- RFC 0016 §3.5, §3.6, §7: the error model, the query metrics, and the row default and cap this RFC amends.
- RFC 0017:
countalongside capped rows. - RFC 0020 §3.2–§3.4: the configuration file and substitution.
- RFC 0021 §3.2a: the
collect_statisticsoverrideexec::sessionkeeps. - RFC 0027, RFC0027.3: MCP tool errors.
- RFC 0033 §3.3: the template-map freshness listing (out of scope, §3.9).
- RFC 0038, RFC0038.1: one span per served query (out of scope, §3.9).
- RFC 0048:
auth.openfga.visibility.max_objects, the unrelated key the names here avoid. - RFC 0053 §3.3: the
usage/limitgauge pattern. - DataFusion 55:
MemoryPool,GreedyMemoryPool,FairSpillPool,TrackConsumersPool,RuntimeEnvBuilder::with_memory_pool,DiskManagerMode::Disabled,DataFusionError::ResourcesExhausted. - Linux cgroup v2 (
memory.max,memory.high,/proc/self/cgroup), and cgroup v1memory.limit_in_bytes. - RFC 9110 §15.5.21 (
422), §15.6.4 (503), §10.2.3 (Retry-After). CLAUDE.md§3.4, §3.6, §3.7, §4 hazard #6, §6.2, §6.3.
RFC 0059 — Durable template-id allocation
rfc: 0059 title: Durable template-id allocation (RFC 0001 amendment) status: green author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-10-05 supersedes: — superseded-by: —
RFC 0059 — Durable template-id allocation (RFC 0001 amendment)
Status:
green(2026-10-06). Every §5 scenario, RFC0059.1 to RFC0059.19, passes. The implementation (#911) replaced eachred-stage stub with its test, and no RFC0059 stub stays#[ignore]d.(
red: an#[ignore]dtodo!stub per scenario, #912.) This RFC amends RFC 0001 §6.1 (template identity), §6.9 (persistence and recovery) and scenario §3.5.3, and RFC 0023 §3.4 (the parse-failure reason enum). It coordinates with RFC 0052 §3.7 (the audit gate, for re-minted ids): RFC 0052 isgreen, notaccepted, so the overlap is recorded on both sides rather than as an amendment (§3.8). RFC 0001 keeps the old text with a dated pointer here, the way RFC 0023 amended it.
1. Summary
template_id is allocated by one in-memory counter per process. A
restart rebuilds that counter from what recovery restores and replays,
so a discarded snapshot, reclaimed WAL frames, or a replaced local root
can restart it below ids that Parquet rows and audit events already
carry, and the next new template takes one of them (#898). This RFC makes
uniqueness durable:
- one small object in the store records the highest id reserved since it was created;
- the allocator draws only from blocks it has reserved there, before using them;
- every start allocates above it.
The first start of an existing deployment computes the object from every data and audit file footer, so the floor is provable and needs no margin.
Invariants and hazards touched (docs/verification.md), each with
the §5 scenarios that cover it:
| Touched | How | Covered by |
|---|---|---|
CLAUDE.md §3.1, no silent template merges | a re-issued id binds existing rows to another template | RFC0059.1, .2, .9, .12, .14, .16, .17, .18, .19 |
CLAUDE.md §3.3, bit-identical reconstruction | the registry’s last-wins fold would render old rows against the wrong text; an exhausted range must keep the body | RFC0059.1, .4 |
CLAUDE.md §3.6, object storage is the truth | the high-water lives in the store; local snapshots are trusted only under a valid seated marker | RFC0059.5, .6, .11, .12, .14, .15, .16, .17, .18 |
CLAUDE.md §3.7, multi-tenancy | ids stay unique across tenants; the scenarios mint for two tenants | RFC0059.1, .8 |
| Hazard #1, template miner correctness | allocation, restore and replay order inside the miner | RFC0059.3, .4, .10, .13 |
| Hazard #5, template schema evolution | re-minted old shapes stay drift on fresh ids, never collisions; restore equivalence holds up to renaming | RFC0059.1, .9 |
CLAUDE.md §3.4 (WAL-before-ack) and hazard #4 (small files) are not
changed: the WAL format and acknowledgement path are untouched, and the
high-water is one small object per store.
2. Motivation
The bug. #898’s scenario tests, run against main at 09e493ed,
show both outcomes:
- a new shape after a discarded snapshot took id 1, which the audit
stream already binds to
user alice logged in; - re-minting that old shape took id 2, which belongs to another template.
The querier’s registry fold is last-wins per (template_id, version),
so existing rows then render against the wrong text. That breaks
CLAUDE.md §3.1 (no silent merges) and §3.3 (bit-identical
reconstruction).
Why the audit stream cannot be the floor. #908 first seeded the counter from the audit stream’s highest id. Review showed three holes:
- structured templates emit no audit event (RFC 0001 §6.2 step 0);
- an audit write that fails permanently is dropped while its records still publish (RFC 0056);
- a replaced local root looks like a fresh node, so nothing triggered the read at all.
Why it is reachable today. The 0.10.0 → 0.11.0 → 0.11.1 upgrade discarded every version-1 and version-2 snapshot (RFC 0052 §3.1, RFC 0001 §6.9’s 2026-09-30 amendment). It did so on a node whose WAL reclamation had already removed the frames below those snapshots.
Why at this layer. Object storage is the source of truth (CLAUDE.md
§3.6). A counter that must outlive any local state belongs there, not in
a local snapshot that is, by RFC 0001 §6.9’s own definition, a
rebuildable cache.
3. Proposed design
3.1 The high-water object
-
Key. One object per store, at
miner/template_ids.v1.json.miner/is a new top-level prefix besidedata/,audit/,erasure/andbackfill/.- No audit or data scan lists it, since both scope their listings to their own prefixes.
-
Format. The version is in the filename, following RFC 0033’s
template_map.v2.json.zst. The body is JSON, following the RFC 0009manifest.jsonand the RFC 0047 erasure markers:{"reserved_through": 4021000}reserved_through(N) is au64written as a JSON integer, at mosti64::MAX(the id domain, §3.7). It is the highest id any allocator has been allowed to issue. Readers ignore unknown fields within version 1. -
Read failures fail closed. A reader fails when:
- the key exists but cannot be fetched or parsed;
reserved_throughis absent, is not au64, or is abovei64::MAX;miner/holds anytemplate_ids.v<k>.jsonwithkabove the version the reader knows, whether or not its own version’s key also exists.
-
Why every later version is refused. RFC 0033’s map is a cache: a reader that skips an unknown version just rebuilds. This object is the reverse, the one record that keeps ids unique. A later version may have moved the authoritative high-water to its own key and left the v1 object stale, so an older binary that trusted v1 could re-issue ids the newer one already handed out. The only safe reading of an unknown version is to refuse to start (RFC0059.11).
-
Evolving the format. A future change takes one of two paths:
- it evolves version 1 backward-compatibly, adding fields v1 readers
ignore and keeping
reserved_through’s meaning; - it stops every binary that reads only version 1 before any later-version key is published.
Writing v1 alongside a later version is not an option: a v1-only reader refuses to start when it sees the later key, whatever v1 holds.
- it evolves version 1 backward-compatibly, adding fields v1 readers
ignore and keeping
-
Absent means no
template_ids.v<k>.jsonof any version exists. It triggers the bootstrap (§3.5) only on a root without a seated marker, and over a store that already holds data only with explicit authorisation (§3.5). -
A seated root that finds the object absent fails closed. The object was deleted. A bootstrap then would scan only published ids and could reseat
Nbelow a block another receiver still holds, so the start stops with an error naming the object instead (RFC0059.14). -
The object must never be deleted. No documented Ourios policy grants a delete that reaches
miner/: the only role holdings3:DeleteObject, the compactor, has it ondata/*,erasure/*andbackfill/*alone. Every documented policy also carries an explicitDenyofs3:DeleteObjectonminer/*, so a later widening of a delete grant still cannot reach the object (§3.6, RFC0059.17). Object lock or retention, where the store offers it, adds a second control. -
Restoring an older copy is unsupported. A stale
reserved_throughwould let every allocator reserve blocks it, or another replica, already issued from.- Each root guards against it: its seated marker records the highest
reservation the root has made usable (
max_reserved_seen, §3.5). - A start that reads
reserved_throughbelow that value fails closed, with an error naming a rollback of the high-water object (RFC0059.18). - Every replica that saw a later value therefore refuses the stale copy at its next start.
- The only recovery is a quiesced, authorised re-bootstrap (§3.5),
the same for a rolled-back and for a deleted object (RFC0059.14):
- stop every receiver;
- remove the object and every root’s seated marker. A root that
keeps its marker refuses to bootstrap and fails closed with
HighWaterDeleted; - start one replica with
receiver.template_ids_allow_bootstrap(chart valuereceiver.templateIdsAllowBootstrap); - remove the setting, and scale out as in the upgrade.
- The replica in step 3 recomputes the floor from every data and audit
footer, plus its own restored snapshots. That is exactly the
upgrade’s path, and the markers’
max_reserved_seenis safe to drop:- every id a root issued and published lies in the footers, so the floor covers it;
- every root that later starts markerless discards its snapshots as
predates_high_waterand re-mints its unpublished frames above the new high-water (§3.5); - an id a root reserved but never published binds no row, so it may be re-issued safely.
- This needs no new configuration, and is tested (RFC0059.18).
- §3.6 bounds what this check covers.
- Each root guards against it: its seated marker records the highest
reservation the root has made usable (
3.2 Reservation: write before allocate
An allocator holds a current block (a, b] and allocates ids from
it in increasing order. It never allocates an id above b.
Reserving a block, given f, the highest id this allocator has
restored or issued:
- Read
Nand itsETag. - Compute
a = max(N, f)andb = min(a + BLOCK, i64::MAX): the final block below the top of the id domain (§3.7) is shortened, so no id up toi64::MAXis stranded andi64::MAXitself is issuable. Onlya = i64::MAX, nothing left, fails, with the same controlled exhaustion error as everywhere else. Filling the ready blocks stops there without retrying. Fresh mints fail once the held ids run out. - Write
N' = bas a compare-and-swap against theETag(§3.6). On a precondition failure, re-read and retry from step 1. - Only once the write is durable does
(a, b]become usable. On the local backend, whose put renames a file into place without fsync, that means fsyncing the object and then every directory from its own up to the store root, so a newly createdminer/entry is durable too.
A reservation never creates the object. Only the startup bootstrap
(§3.5) creates it. If step 1 finds it absent, the reservation fails with
HighWaterDeleted (§3.1). A compare-and-swap against an object deleted
after step 1 fails its precondition rather than re-creating it. A store
that offers compare-and-swap but returned no ETag in step 1 fails the
reservation closed instead of overwriting.
Crash semantics. A crash between the write and the use leaves ids
in (a, b] unissued, and they are skipped. Ids are u64, so these
gaps cost nothing. Nothing in Ourios reads id density (§3.7).
BLOCK = 1000, a constant. New templates are rare next to lines:
- RFC 0023 bounds a tenant at 20,000 tree templates;
- the C2 gate corpora plateau in the tens of templates (RFC 0001
benchmarks.md§9.5).
So a block lasts hours to days, and reservation runs at a negligible rate. A smaller block raises the refill rate and the chance of an empty range under a burst (§3.3). A larger one only wastes more ids per crash, which is free.
3.3 No object-store I/O under the miner lock
The miner allocates under its lock (RFC 0035 §3.1’s ordered phase), so no reservation ever runs there.
- Holding blocks ahead. The miner holds its current block, and two ready blocks wait beside it. When the current block is used up, the oldest ready block becomes current. That is an in-memory pop.
- Why two. Refill is asynchronous: when a ready block becomes
current, the refiller is asked for a replacement, and that reservation
takes a store round trip, or longer under backoff. With a single ready
block, a burst that spends the new current block inside that window
would find nothing ready. The second ready block keeps one whole block
(
BLOCKfresh templates) of headroom while a refill is in flight. - Background refill. Each time a block becomes current, the
ingester’s background refiller reserves blocks off the lock until two
are ready again. The refiller starts only once replay ends (§3.4), so
it never reserves at the same time as replay’s synchronous fill. On
the local backend’s unconditional overwrite, two concurrent
reservations could hand out the same block.
- Requests to refill coalesce: one pending request covers every later one, and asking never blocks the miner.
- If the store fails, the refiller retries with capped exponential backoff (100 ms doubling to 30 s), and waits out each backoff in full whatever requests arrive meanwhile. Failed mints therefore cannot drive one store call each.
- Running out of ids is not a refill failure: it surfaces only as the parse failures below.
- A receiver stops its refiller and waits for any reservation in
flight before it releases its roots, at shutdown and on a failed
start, so no stale write can land under the next receiver over
them. After the stop no block is recorded in the marker or made
ready. The wait is unbounded on a backend without compare-and-swap,
where a stale write could lower the high-water. Elsewhere it is
bounded, since such a write fails its
If-Match.
- A high-water deleted while live. If a refill finds the object
absent (
HighWaterDeleted), the refiller stops for good and logs an error. The object counts as absent whether the refill’s read finds it gone or its write does: a compare-and-swap answered not-found, or, on a backend without one, an overwrite that first finds the object gone and so never re-creates it. It does not retry: an object that reappears, for example restored from an old copy, may sit below blocks other receivers hold.- The blocks already held are spent, with no reservation overlapping them.
- Then fresh mints fail as below, while existing templates keep attaching.
- Only a restart gets past it, and a seated root’s restart fails closed while the object is absent (RFC0059.14).
- Exhausted range. Once the listeners are open, if the current
block and every ready block are used up before a refill lands, every
fresh allocation fails immediately: a new tree leaf, an
adoption-interned template, or a first-seen structured key. (Startup replay is different; see §3.4.)
- Each such line is emitted as a parse failure:
template_id = 0, its body retained (CLAUDE.md§3.3 holds through the body), andlossy_flag = truefor string bodies. - It is counted on the existing
ourios.miner.parse_failurescounter underourios.miner.parse_failure.reason = id_reservation_failed. - Lines that match an existing template keep attaching to it.
- An adoption that would intern is not attempted. The line is mined instead, and either attaches or counts as above.
- Allocation resumes as soon as the refiller hands over a block, unless the refiller stopped on a deleted high-water.
- Each such line is emitted as a parse failure:
- Precedence. RFC 0023 §3.1’s per-tenant ceiling is checked before
id availability. A line at the ceiling reports
template_ceiling, notid_reservation_failed.
3.4 Every start
Recovery decides whether the snapshots may be restored before it
restores any of them. restore_tenant puts a snapshot’s leaves into the
live miner, so an untrusted snapshot must never reach it: deleting the
file afterwards would leave its leaves live. The order is:
- Check the seated marker and the object’s presence, before any restore.
- Object present, no marker (§3.5): discard every artefact with
reason
predates_high_water, without restoring any, then seat aboveN. Only after replay and RFC 0052 §3.2’s legacy stale-gap check pass are the files removed and the marker written. The object must still be there at the seat; if it is gone, startup fails closed (HighWaterDeleted). - Object present, marker present and valid: restore the snapshots
normally, then seat above
max(N, highest restored id). An invalid marker, or a valid one whose object is absent, fails startup closed (§3.1, §3.5). - Object absent, no marker, the bootstrap (§3.5): restore first,
because the floor needs
restored_max. Then bootstrap the object, provided the store holds no data yet or this start is authorised; otherwise startup fails closed. The marker is written after replay and the legacy check, as in case 2. This start must create the object itself: finding it already present at the seat, because another replica created it after the trust read, or losing the create, fails startup (BootstrapRaceLost) before any marker or replay. Its restart takes case 2.
A seated root (case 3) records its marker at the seat and keeps it current while replay reserves.
In every case the allocator then reserves three blocks synchronously, before any listener opens: the miner takes the first as its current block, and the other two are its ready blocks. Startup thus leaves exactly the steady state of §3.3, a current block plus two ready, and replay mints from those blocks.
- Replay reserves on demand. Replay can mint far more than two
blocks of templates, for example a discarded snapshot’s tenant
full-replaying its WAL. While it runs, the recovery driver owns the
miner exclusively, before any listener opens. There is no pipeline
lock yet and no ingest to stall, so a replay that drains the ready
blocks reserves the next block synchronously, on demand, rather
than failing a template a healthy store could have given an id.
- The exhausted-range rule of §3.3 applies only once recovery has handed the miner to the pipeline. The background refiller starts at that point too, so during replay this synchronous fill is the only reserver.
- A store failure during replay fails the reservation, and with it startup, never a single template. The failure is latched where the miner would otherwise count a parse failure. Recovery checks it after mining each frame, and fails before that frame is folded or published.
- Read fails. Startup fails closed. This is the same trade-off as RFC 0052’s fail-closed checks and #791 (recovery during an object-store outage): a restart then needs the store reachable, but a guessed floor could silently bind published rows to another template.
The guarantee. Within one high-water generation (between
re-bootstraps, §3.1), write-before-allocate makes every id issued at
most N, and every start allocates only above N. So nothing replay or
later ingest mints can equal an id issued before the restart, whatever
recovery restored, discarded, or never found. A replaced local root is
covered too: it has no snapshots and no WAL, but N is in the store.
Across a re-bootstrap the guarantee covers every id still bound by
stored data or audit (§3.7, RFC0059.19).
Restore equivalence (amends RFC 0001 scenario §3.5.3, approved by the maintainer on 2026-10-05):
- A template first allocated during tail replay now takes an id from the fresh block, not the one the uninterrupted process would have issued.
- Restore plus tail replay therefore equals a full rebuild only up to an injective renaming of the ids first minted in tail replay.
- Restored ids, versions, slot types, routes, the structured map, and every other field remain exactly equal.
- No renamed id equals an id issued before the restart.
When the renaming is observable. In the steady state a tenant’s
frames above its horizon S also lie above the checkpoint X (S ≥ X,
RFC 0001 §6.9’s 2026-06-12 amendment). They were never published, so the
renaming is invisible. Only a lagging snapshot (S < X) re-mints
templates whose rows in (S, X] were already published. That is
drift, surfaced by RFC 0010, never a collision.
A re-minted id publishes its mapping.
- A frame at or below
Xreplays for the miner’s state alone, because its rows and audit events were published before the crash (RFC 0052 §3.7’s gates). - A template such a frame mints afresh takes an id above the
high-water that no published event binds. That happens when the
snapshot is discarded, or when
S < X. - Rows ingested after startup that match the template carry that id. Without its events, the querier could not render them.
- Replay therefore forwards every audit event of a template it minted
itself: one whose
template_idlies above the highest id seated before replay. It withholds every other event at or belowXas before, and every row.- Nothing is duplicated. The forwarded events bind a new id, while the published originals bind the old one.
- The events reach the audit sink during recovery, before any listener opens. The sink’s audit barrier publishes them no later than the first row that carries the id.
3.5 Bootstrap: a provable floor
Trigger. The object is absent and the root has no seated marker. A markerless root is not proof of a first bootstrap: if the object were deleted while receiver A held an unused block, a replica on a replaced root would bootstrap from the published ids alone, below A’s block. So:
| High-water | Store holds data or audit objects | Authorised | Seated marker | Outcome |
|---|---|---|---|---|
| absent | no (a genuinely new store) | either | none | bootstrap |
| absent | yes | no | none | fail closed, with an error explaining the upgrade step |
| absent | yes | yes | none | bootstrap |
| absent | either | either | valid | fail closed (RFC0059.14) |
- “Holds data” means one delimited
LISTof each ofdata/andaudit/returns anything at its first level. The check is conservative: a tenant directory with no file still counts. - Authorisation is the receiver setting
receiver.template_ids_allow_bootstrap(config file), the chart valuereceiver.templateIdsAllowBootstrapthat renders it, orOURIOS_TEMPLATE_IDS_ALLOW_BOOTSTRAP(environment, read only when no config file is given), default off. It is meant for the one upgrade start only, and is removed once that receiver has seated.
The bootstrapping start computes:
floor = max(data_max, audit_max, restored_max)
A source with no id counts as 0, the NO_TEMPLATE sentinel (RFC 0001
§6.1): a data file whose rows are all parse failures, an empty audit
stream, or no restored snapshot. On a genuinely new store every maximum
is absent, so floor = 0 and the first allocatable id is 1, as
before this RFC.
data_max. The highest template_id in the statistics of every
data file under data/.
- This covers every published row, structured or not, whatever the
audit stream holds. The data writer enables page-level statistics on
every column (RFC 0005 §3.6,
writer.rs), and those include chunk min/max fortemplate_id. - The read goes footer only, up to 16 files at a time (a fixed pool fed by the listing walk; the floor is a maximum, so completion order does not matter, and any failed read still fails the scan). One suffix-ranged GET of the last 64 KiB returns the footer and the object’s size, and a second, larger one follows only when the footer is bigger than that.
- The footer answers a file only when every id column it holds has
usable statistics in every row group. That is
template_idfor a data file, and all three id columns for an audit file (below). - If any id column lacks them in any row group, the file comes down whole and its id columns are decoded. The writers record the statistics, so a file that needs this is a pre-statistics or foreign file.
- No row is ever materialised.
Which keys are read. Only keys ending in lowercase .parquet. That
is the rule the compactor applies (compaction/keys.rs,
is_committed_parquet) and the one RFC 0033’s map key relies on to stay
out of every audit walk (template_map.rs). It skips *.parquet.tmp
staging objects, manifest.json, and template_map.v2.json.zst.
audit_max. The same footer read over every audit/ file. It
covers the template_id column, plus alias_representative_id and the
alias_member_ids leaf, since alias events carry ids too. Statistics on
template_id alone do not answer an audit file: if either alias column
lacks usable statistics, that column’s data is read.
restored_max. The highest id in the snapshots this start restored.
Those can hold ids whose rows were not yet published.
Why no margin is needed.
- An id that no surviving data row, audit event or restored snapshot carries binds nothing durable. If it is re-issued, no existing artefact mis-renders. Quarantined records (RFC 0025) persist no id.
- Rows whose ids were erased (RFC 0047) or aged out are gone, so their ids bind nothing either.
reserved_through = floor, and the allocator starts atfloor + 1by construction.
Listing cost. The store’s listing seam returns one /-delimited
level per call (Store::list_delimited_blocking: the objects directly
under a prefix plus its immediate child prefixes). Data partitions nest
data/tenant_id=…/year=…/month=…/day=…/hour=…/, so the walk issues one
delimited LIST at every directory of every level:
LISTs = 1 (data/) + tenants + tenant-years + tenant-months
+ tenant-days + tenant-hours
That is dominated by the hour partitions. A backend pages a large
directory, at 1,000 keys per page on S3, and each page is one more
request. audit/ adds the same walk over its
tenant_id=…/year=…/month=…/day=…/ levels.
On top of the listings, every file costs one suffix GET (two when its
footer exceeds 64 KiB). On the reporter’s node that is about 620,000
footer reads and, for two tenants with a year of hourly partitions,
about 17,500 hour-level LISTs.
This is the whole-prefix walk #853 warns about. It runs at a store’s first start under this RFC and again at each authorised re-bootstrap (§3.1). Ordinary starts between re-bootstraps never scan.
Memory.
- Listings.
list_delimited_blockingmaterialises one directory’s listing into memory, every page of it. The walk is depth-first, so it holds that one listing plus the prefixes still to visit, which are the unvisited siblings at each level of the current path. - Files. Each reader drops a file’s footer, or a whole pre-statistics file, before it fetches its next one.
- The bound. The largest single directory listing, plus the current
path’s pending siblings, plus one file per concurrent read. It does not grow with the
number of directories or files in the store as a whole. It does grow
with the widest directory. That is an hour partition’s file list,
which compaction keeps to a handful of files (RFC 0009), or a level
such as
tenant_id=…, with one name per tenant. - Why not a streaming seam. A paginated listing would bound memory
at one page, but only on S3. Its lexicographic
start-afterpaging is a guarantee there, whileobject_store’s local backend lists in an unspecified order. A page-by-offset walk there would skip keys, or have to sort the whole prefix first. The directory-at-a-time walk is correct on both backends, and its bound is the honest one stated above.
Progress. Every 10,000 files, and at least every 30 seconds, the
bootstrap logs ourios.receiver.template_ids.bootstrap.progress (§3.9),
and prints its start, progress and end on stderr. A shutdown signal
stops the scan between reads with nothing written, as a crash would.
A failed scan fails closed. Every step of the scan can fail: a listing, a ranged footer read, a footer parse, the full-file fallback, or an id column’s decode.
- Any such failure aborts startup with
error.type = scan. - The receiver writes neither the high-water object nor the seated marker. A floor over a partial scan proves nothing.
- The next start redoes the scan (RFC0059.6).
Crash mid-bootstrap. Nothing is written until the scan completes. A
crash or restart during the scan leaves no object, so the next start
redoes the scan from the beginning. The write is create-if-absent
(put_if_absent), and this bootstrap is the only writer that ever
creates the object (§3.2). A start that loses that race to a concurrent
replica (§3.6) fails startup with BootstrapRaceLost. It never
proceeds against the winner’s object: it has already restored its own
snapshots, whose ids may lie above the winner’s floor. Its restart finds
the object present and no marker, and takes the discard path below
(RFC0059.12).
Snapshots written before the high-water existed. Before this RFC, two receivers sharing a store each started counting at 1, so a multi-replica deployment already has colliding ids. #908’s read-only DuckDB procedure finds them.
-
The danger. A replica’s local snapshots hold ids from its own pre-RFC counter. Once another replica has bootstrapped the high-water and allocated from it, restoring those snapshots would bring back leaves whose ids that replica has since issued for other templates. Scaling to one replica for the upgrade only delays this until scale-out.
-
The seated marker. Each root records that its snapshots were written under reservations: a file
<snapshots root>/TEMPLATE_IDS_SEATEDwhose body is JSON{"version": 1, "seated_above": N, "max_reserved_seen": M}:Nis the high-water the root first seated above;Mis the highest block end the root has made usable, withN <= M.- It is written through the snapshot store’s temp-file, fsync and rename discipline.
- Every start rewrites it with the blocks the start reserved.
- Every later reservation raises
Mdurably before its block can be taken, soMcovers every id the root can issue. That is one local fsync per block of 1,000 ids. - The snapshot loader ignores it, since it is not a
*.snapartefact. The snapshot and WAL formats are unchanged.
-
The marker must be valid to count.
- It must parse, and carry version 1, a
u64seated_aboveand au64max_reserved_seen, withseated_above <= max_reserved_seen. - A marker that is zero-length, truncated, malformed, of another
version, unreadable, missing either field, or with
seated_above > max_reserved_seenfails startup closed. It never trusts the snapshots, and it is never read as “absent”. - The format is new in this RFC and unpublished, so no marker without
max_reserved_seenexists in the field.
- It must parse, and carry version 1, a
-
Marker present and valid, object present.
- If the object’s
reserved_throughis belowmax_reserved_seen, the object was rolled back to an older copy, and startup fails closed (§3.1, RFC0059.18). A marker claiming more than the object holds is the same case. Seating re-reads the object, and holds that read tomax_reserved_seenas well, before it reserves any block: a rollback between the trust decision and seating fails closed the same way. - Otherwise the root’s snapshots are trusted and restore normally.
- If the object’s
-
Marker present and valid, object absent. Startup fails closed (§3.1, RFC0059.14).
-
Marker absent, high-water object absent. This start is the bootstrap. It restores its snapshots, folds their highest id into the floor (
restored_max), creates the object, then writes the marker. -
Marker absent, high-water object present. Another replica, or this one before a crash, already seated the store. Every artefact here predates this root’s first seat and is untrusted.
- Recovery discards them all with reason
predates_high_water, so each tenant full-replays its surviving frames. - It seats above
Nand replays. Only once replay and RFC 0052 §3.2’s legacy stale-gap check pass does it remove the artefact files, fsync the directory, and write the marker. A refusal or crash before then keeps the artefacts, whose version-1 marks are that check’s evidence. - A discarded snapshot only costs drift. The WAL replays, and the high-water makes every re-minted id fresh.
- Recovery discards them all with reason
-
Losing the bootstrap race. If two replicas without markers both find the object absent, both restore their own snapshots, and only one create lands. The loser has already restored ids the winner’s floor may not cover. So the loser fails startup, and its restart takes the “object present” path. The same holds when the winner’s object appears between this start’s trust read and its seat: a start that saw no object must create it itself (
BootstrapRaceLost). Conversely, a start that saw the object never bootstraps if it then vanishes (HighWaterDeleted). -
Crash safety.
- Every step before the marker write leaves the marker absent, so the next start reruns the same decision.
- After the marker write, no untrusted artefact remains: they were removed just before it, and every later snapshot is written under reservations.
-
A lost or replaced root has no marker and no snapshots, so it just writes the marker.
-
Live pre-RFC receivers are a different case. The marker covers offline replicas’ snapshots. It cannot cover a pre-RFC receiver that is still running.
- A Kubernetes StatefulSet rolling update keeps old pods serving while the first upgraded pod bootstraps. An old binary can publish an id after the scan has passed its file and before the create lands.
- That id may then lie inside the bootstrapper’s first block.
-
The upgrade rule. For the one upgrade to this RFC:
- grant the permissions of §3.6, including the bootstrap-only listing and reads;
- stop every pre-RFC receiver (scale
receiver.replicasto 0); - start one upgraded replica with the bootstrap authorised
(chart value
receiver.templateIdsAllowBootstrap=true; config keyreceiver.template_ids_allow_bootstrap), and let it bootstrap and seat; - remove the setting, revoke the bootstrap-only listing and reads (§3.6), then scale out.
The Helm chart states the rule beside
receiver.replicasand in itsNOTES.txt, and the release notes carry it. A store that already has the high-water needs no step. -
No downgrade. Once the high-water object exists, a receiver built before this RFC must not run against the store again. It ignores the high-water, so it can re-issue ids an upgraded replica already reserved or issued.
- Direct downgrade below RFC 0059 is prohibited.
- A safe rollback would mean restoring the object store and every receiver’s WAL to their state from before the upgrade. That is not a supported operation.
- The chart’s upgrade note and the release notes say so.
-
Detection is a stated limitation. Spotting a still-running pre-RFC writer would mean finding data files whose ids exceed the floor yet were not allocated from a reservation. That needs re-listing every data partition after the bootstrap, the whole-prefix walk (#853) that runs only at a bootstrap. It is not cheap enough to run, so the receiver does not warn. The DuckDB procedure from #908 finds any collision such an upgrade left.
-
Relation to RFC 0052. The discard runs before RFC 0052 §3.2’s legacy stale-gap belt. That belt still reads the discarded version-1 artefacts’ marks, so a pre-RFC 0052 root is checked exactly as before.
3.6 Several receivers on one store
What the chart allows.
- The Helm chart runs the receiver as a StatefulSet whose
receiver.replicasan operator may raise (values.yaml, default 1). - Each replica has its own WAL PVC, and all of them write one shared store.
- The receiver Service load-balances, and nothing routes a tenant to one replica.
S3-compatible stores. Reservation is therefore a compare-and-swap on
the one object. This is RFC 0013’s manifest-swap primitive,
Store::put_if_match, including its unquoted-ETag retry:
- it writes with
If-Match: <etag>; - only the startup bootstrap creates, with
If-None-Match: *(put_if_absent); a reservation that finds the object absent fails (§3.2); - on a precondition failure it re-reads and retries, up to 16 attempts per reservation;
- a reservation that still loses is a failed reservation (§3.3).
The store linearises conditional writes, so every replica’s blocks are disjoint, and no per-node key is needed.
The local backend.
- It has no
If-Match(RFC0019.7: it commits by atomic overwrite, last writer wins). The reservation writesN'by overwrite there. An overwrite cannot be made conditional on the file still existing, so a deletion that lands between a reservation’s read and its write can re-create the object.- That is the local backend’s accepted limit, for single-node development only. The re-created value is still above every block this one receiver reserved, so no id is re-issued.
- RFC0059.18’s “no reservation re-creates the object” is therefore scoped to stores with conditional writes.
- A deletion that does not race a write is still caught on the local
backend: the next reservation fails with
HighWaterDeletedand stops the refiller, and the next restart fails closed (RFC0059.14, whose test runs on the local backend).
- One receiver per local store is a stated constraint. It is the
same constraint under which the chart’s shared local PVC is coherent
at all (
data-pvc.yaml: “coherent only on one node or with a ReadWriteMany class”).
Permissions. Every key sits under one top-level prefix (behind the
configured storage.s3.prefix, written PREFIX/ below; with no prefix it
is dropped). Each is written by these roles, as the code’s writers show:
| Prefix | Receiver | Compactor | Querier |
|---|---|---|---|
data/ | puts record files | puts compacted files and manifests; deletes inputs and orphans | reads |
audit/ | puts audit events | puts erasure events | reads; template-map cache key |
miner/ | gets and puts the high-water | none | none |
erasure/ | none | gets, puts and deletes erasure markers | none |
backfill/ | none | gets, puts and deletes backfill locks | none |
The receiver’s object-store role therefore gains, beyond its existing
s3:PutObject on PREFIX/data/* and PREFIX/audit/*:
- always:
s3:GetObjectands3:PutObjecton the objectsarn:aws:s3:::BUCKET/PREFIX/miner/*, ands3:ListBucketon the bucketarn:aws:s3:::BUCKETwith ans3:prefixcondition ofPREFIX/miner/*. Conditional writes (If-Match,If-None-Match) need nothing beyonds3:PutObject. No delete is granted. - for a bootstrap only (the first start against a store, and a
quiesced re-bootstrap, §3.1):
PREFIX/data/*andPREFIX/audit/*added to thes3:prefixcondition. Every first bootstrap lists both prefixes, an empty store included: that listing is how it tells a new store from one already holding data (§3.5).s3:GetObjecton the objectsBUCKET/PREFIX/data/*andBUCKET/PREFIX/audit/*, which the scan needs to read footers when the store holds files: the upgrade, or a re-bootstrap.- Once the high-water exists, revoking both is a required step.
No ordinary start lists or reads
data/oraudit/, so the receiver’s steady-state policy carries neither.
s3:ListBucket is a bucket action. AWS evaluates it against the bucket
ARN, so a grant on an object ARN such as BUCKET/miner/* never matches
and the listing is denied. The StringLike condition on s3:prefix is
what scopes it: the receiver may list under those prefixes only, not the
whole bucket. Object actions go on one object ARN per prefix the role
writes, never BUCKET/*. The receiver’s minimal policy during a
bootstrap:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": [
"arn:aws:s3:::BUCKET/PREFIX/data/*",
"arn:aws:s3:::BUCKET/PREFIX/audit/*",
"arn:aws:s3:::BUCKET/PREFIX/miner/*"
]
},
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": [
"arn:aws:s3:::BUCKET/PREFIX/miner/*",
"arn:aws:s3:::BUCKET/PREFIX/data/*",
"arn:aws:s3:::BUCKET/PREFIX/audit/*"
]
},
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::BUCKET",
"Condition": {
"StringLike": {
"s3:prefix": ["PREFIX/miner/*", "PREFIX/data/*", "PREFIX/audit/*"]
}
}
},
{
"Effect": "Deny",
"Action": "s3:DeleteObject",
"Resource": "arn:aws:s3:::BUCKET/PREFIX/miner/*"
}
]
}
And its steady-state policy, once the high-water exists:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": [
"arn:aws:s3:::BUCKET/PREFIX/data/*",
"arn:aws:s3:::BUCKET/PREFIX/audit/*",
"arn:aws:s3:::BUCKET/PREFIX/miner/*"
]
},
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::BUCKET/PREFIX/miner/*"
},
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::BUCKET",
"Condition": {
"StringLike": { "s3:prefix": ["PREFIX/miner/*"] }
}
},
{
"Effect": "Deny",
"Action": "s3:DeleteObject",
"Resource": "arn:aws:s3:::BUCKET/PREFIX/miner/*"
}
]
}
No documented policy can delete the high-water. The compactor’s
documented s3:DeleteObject is scoped to PREFIX/data/*,
PREFIX/erasure/* and PREFIX/backfill/*, where it reclaims; it was
previously documented on PREFIX/*, which reached miner/. The querier’s
optional cache grant deletes only its audit/tenant_id=*/template_map*
key. Every documented policy (receiver, compactor, querier) also ends with
the explicit Deny above, as belt and braces. The chart README carries
all three policies, and a test enforces the rule on them (RFC0059.17).
Before this RFC the chart documented the receiver as PutObject only.
These grants must be in place before the scale-to-one upgrade step,
and the bootstrap-only listing and reads must be revoked once the
high-water exists (re-granted only for a quiesced re-bootstrap).
A denied call fails startup with an error naming the action it lacked
(s3:GetObject, s3:ListBucket or s3:PutObject, RFC0059.15). The
chart’s README and values.yaml document every role’s set, and the
release notes carry the receiver’s.
Out of scope. The same template reaching two replicas gets two ids. That is drift across replicas (hazard #5), not a collision.
Threat model (maintainer decision, 2026-10-06). The high-water is
protected against accident and misconfiguration, not against an
adversary who holds delete or overwrite rights on miner/.
- Supported:
- the object is never deleted (§3.1, enforced by every documented policy);
- a deletion fails closed at every seated start (RFC0059.14) and stops live reservation (RFC0059.18).
- Unsupported, but caught:
- Restoring an older copy of the object. The per-root check refuses it at every replica whose marker saw a later value (§3.1).
- Recovery is the quiesced, authorised re-bootstrap described there: remove the object and every seated marker, then bootstrap one replica from the provable footer floor.
- Out of scope:
- An operator with delete rights who restores stale state while some replica never observed the later value. That replica’s marker cannot tell the stale copy from the truth.
- Deleting a root’s marker along with its snapshots. The root then takes the markerless path.
- Neither is detected, and preventing them is left to the store’s access control, versioning and object lock.
3.7 Monotonicity
RFC 0001 §6.1 said template_id is “a cluster-wide unique monotonic
u64”. That is amended as follows:
- ids lie in
1..=i64::MAX. They stayu64in memory and in the Parquettemplate_idcolumns, but the domain stops ati64::MAX, so every id fits the signed 64-bitintthat OTLP and the semconv registry use for attribute values. Roughly 9.2 × 10^18 ids is effectively unbounded, and the registry needs no change.- Allocating past
i64::MAXis exhaustion: the controlledIdSpaceExhaustederror, which fails fresh mints asid_reservation_failed(§3.3). - A
reserved_throughabovei64::MAXis malformed and fails startup closed (§3.1). - So is a bootstrap file whose footer or decoded id column holds an id above it: no allocator issued that id, so no floor is built on it (§3.5).
- Parquet stores the column as unsigned 64-bit, and its footer statistics and decode both return such an id exactly, so the scan sees it rather than a truncated or negative value.
- Allocating past
- ids are unique cluster-wide against every id still bound by stored
data or audit. This is narrowed by the maintainer’s 2026-10-06
decision:
- the upgrade bootstrap and the quiesced re-bootstrap (§3.1) seat above the footers of the data and audit that exist at the time;
- an id whose data and audit retention or erasure has already removed may be issued again after such a re-bootstrap. That is harmless, because nothing stored binds it (RFC0059.19);
- between re-bootstraps, no id is ever issued twice;
- ids are strictly increasing per allocator, across that allocator’s restarts, because every block lies above every earlier one. A re-bootstrap may restart the sequence lower, but never at or below an id stored data or audit still binds;
- ids are not dense;
- ids are not monotonic across replicas: two replicas interleave disjoint blocks.
Nothing relies on cross-replica order. Each place that orders or compares ids:
| Where | What it does with id order | Effect |
|---|---|---|
Querier registry fold template_registry.rs:110, RegistryFold::push | Keys by (template_id, version) and orders by (timestamp, file path, row) (RFC 0005 §3.7.1) | No id order involved |
Template map template_map.rs:605 | Sorts entries by (template_id, version) | Serialisation determinism only |
Drift query drift.rs:155–161 | ORDER BY widening_count DESC, template_id ASC | Display tiebreak |
Alias store alias.rs:14 and :350 | Canonical representative is min(members) | Documented there as a display convenience that is not part of the contract |
Compaction writer.rs:744–790 | Sorts by time and promoted service columns | Never template_id |
Miner tree.rs:281 | Convergence ties go to the lowest id | Within one tenant tree, which only one allocator ever mints into |
Miner persist.rs:156, :418 | Snapshots sort records by id, and restore re-inserts leaves in id order | Within one tenant tree, which only one allocator ever mints into |
C2 gate counter ourios-bench/src/c2.rs:156 | Counts a template as created when its id exceeds the running maximum | Correct per allocator: the bench drives one in-process miner |
DSL comparisons on template_id (plan/predicate.rs:354) | Answer correctly | “Newer templates have higher ids” holds only within one allocator; the query cookbook will say so when the RFC reaches green |
The miner rows follow from the per-allocator guarantee: within one allocator, ids increase with creation order across restarts, which is all the restore-order equivalence of RFC 0001’s 2026-09-30 amendment needs.
3.8 Amendments to earlier RFCs, and coordination
- RFC 0001 §6.1 (Template identity; RFC 0001 is
accepted, so this is an amendment). Uniqueness is a durable guarantee (§3.4), monotonicity is per allocator (§3.7), and uniqueness holds against every id still bound by stored data or audit. After the upgrade bootstrap or a quiesced re-bootstrap, an id whose data and audit retention or erasure removed may be reissued. This narrowing was approved by the maintainer on 2026-10-06. - RFC 0001 §6.9: every start reads the high-water and allocates above it (§3.4), and the first start bootstraps it (§3.5).
- RFC 0001 scenario §3.5.3: restore equivalence holds up to the injective renaming of tail-minted ids (§3.4), and RFC 0001 §8’s restore-equivalence test asserts exactly that.
- RFC 0023 §3.4:
ourios.miner.parse_failure.reasongains the memberid_reservation_failed, checked aftertemplate_ceiling(§3.3). - Coordination with RFC 0052 §3.7 (not an amendment: RFC 0052 is
green, notaccepted). The audit gate atXforwards the events of templates replay minted afresh (§3.4); the record gate is unchanged.- RFC 0052’s own RFC0052.10 carries the matching dated And-clause, so the two green gates agree.
- The maintainer approved the change on 2026-10-06.
- RFC 0001 gets only a dated pointer here, in §6.1 and §6.9. The
§6.1 pointer states the reuse boundary:
- an id still bound by stored data or audit is never reissued;
- an id whose data and audit retention or erasure removed may be reissued after an authorised re-bootstrap (§3.7, RFC0059.19).
3.9 Telemetry
All names go through the shared semconv registry (ourios-semconv), where they are final as of v0.3.8:
| Name | Kind | Attributes / members |
|---|---|---|
ourios.receiver.template_ids.bootstrapped | event, at most once per successful creation of the high-water: the upgrade bootstrap and each authorised re-bootstrap (a crash after the create but before the event leaves none) | ourios.receiver.template_ids.floor (int, required); ourios.receiver.template_ids.data_max (int, conditionally required when any data file carries an id); ourios.receiver.template_ids.audit_max (int, conditionally required when any audit file carries an id); ourios.receiver.template_ids.files_scanned (int, required) |
ourios.receiver.template_ids.bootstrap.progress | event, every 10,000 files and at least every 30 s | ourios.receiver.template_ids.files_scanned (int, required); the progress-event shape of ourios.graph.backfill.progress |
ourios.miner.parse_failure.reason | existing enum attribute | new member id_reservation_failed |
ourios.receiver.snapshot.discarded | existing event | new error.type value predates_high_water (§3.5) |
The registry’s brief and note for ourios.receiver.template_ids.bootstrapped
say the same, “at most once per successful creation of the high-water”
(ourios-semconv#9). That contract was first released in semconv v0.3.7
(ead0337). The code pins v0.3.8 (bb19221), which adds the two
refill events below.
Reservation failures in the background refiller log the event
ourios.receiver.template_ids.refill.failed (WARN) with error.type
set to the store error class. A deleted high-water, which stops the
refiller, logs ourios.receiver.template_ids.refill.stopped (ERROR) with
error.type = deleted, the value startup already uses for RFC0059.14.
Both are in the registry from semconv v0.3.8 (bb19221). A start refused over a rolled-back high-water fails with
error.type = rolled_back (§3.1). No new metric is added: a run of
failures shows on the parse-failure counter as soon as it costs a
template.
3.10 What this RFC does not change
- The snapshot payload (format 3, including
wildcard_routed). - The WAL format.
- The Parquet and audit schemas.
- The RFC 0052 recovery horizons.
Restore still seats the allocator past the restored ids; the high-water only adds a floor above that.
4. Alternatives considered
Floor from the audit stream only (#908). It cannot see structured templates, dropped audit writes, or a lost root (§2). That makes it unsound, not just imprecise.
Bootstrap with a fixed margin instead of the data scan. Cheaper at the one-time bootstrap, but the margin is a guess: nothing bounds structured keys or dropped audit batches above the audit maximum. The maintainer chose the provable floor (2026-10-05).
Journal reservations as WAL frames. This would keep exact id continuity across restarts, so §3.5.3 would need no renaming. But it is an RFC 0008 format change, and it still needs the store object for a lost root. The renaming it avoids is unobservable in the steady state (§3.4).
A per-node key with static disjoint ranges. For example,
node k issues ids ≡ k (mod R). It needs a stable node identity and a
fixed R, and it breaks on scale-out. The compare-and-swap needs
neither.
Synchronous reservation under the miner lock as a fallback. It puts store latency, and store outages, on the ingest path for every line in the batch. Failing only fresh allocations (§3.3) bounds the damage to lines that need a new template.
A content hash as the id. RFC 0001 §6.1 already rejects this: it leaks identity across tenants and makes versioning collapse into aliasing.
5. Acceptance criteria
The ids are referenced from test code.
Scenario RFC0059.1 — A discarded snapshot never re-issues a published id
- Given a receiver that minted string and structured templates for two tenants, published them through a barrier cut, and reclaimed their WAL frames
- When one tenant’s snapshot is discarded (each of
unknown_version,corrupt,empty,no_horizon,restore_failed) and the receiver restarts and mints new shapes and the old ones- Then no newly allocated
template_idequals anytemplate_ida data row or audit event in the store already carries- And no
(template_id, version)in the audit stream is bound to two template texts- And an old shape first seen in a reclaimed frame re-mints under a fresh id (hazard #5 drift), never another template’s
Scenario RFC0059.2 — A replaced local root never re-issues a published id
- Given RFC0059.1’s published store
- When the receiver restarts on an empty WAL root (no snapshots, no frames) over the same store
- Then its first allocation is above the high-water
N- And no newly allocated id equals any id the store carries
Scenario RFC0059.3 — Ids are reserved before they are used; a crash only skips
- Given a receiver whose high-water reads
N- When it allocates its first id
- Then the object already reads at least that id when the allocation happens
- And after a SIGKILL that follows a reservation but precedes any use of its block, the restarted receiver’s first allocation is above that block, and every id below it stays unissued
Scenario RFC0059.4 — An exhausted range fails fresh mints without blocking ingest
- Given a receiver whose startup left exactly a current block and two ready blocks, three reservations in all
- And all three used up, and a reserver that is down
- When lines arrive, some needing a fresh template and some matching an existing one
- Then each fresh-needing line is emitted with
template_id = 0, its body retained, and counted onourios.miner.parse_failureswithourios.miner.parse_failure.reason = id_reservation_failed- And each matching line attaches to its existing template as before
- And no store call is made while the miner lock is held
- And once the reserver recovers, the next fresh line is allocated from the new block
Scenario RFC0059.5 — An unreadable high-water fails startup closed
- Given a store whose
miner/template_ids.v1.jsondoes not parse, lacksreserved_through, holds a non-u64value, or holds a value abovei64::MAX- When the receiver starts
- Then startup fails with an error naming the object, before any listener opens
- And the object is not rewritten
Scenario RFC0059.6 — The bootstrap floor is provable and written once
- Given a store with published data and audit files but no high-water object, including a structured template that has no audit event and a data row whose audit event was dropped
- When the receiver starts
- Then the object is created with
reserved_through = max(data_max, audit_max, restored_max), with no margin- And every id allocated afterwards is above it
- And
ourios.receiver.template_ids.bootstrappedis logged at most once per successful creation of the high-water, by the start whose create landed, with the floor and the maxima (a crash between the create and the event leaves none)- And a restart killed mid-scan leaves no object, and the next start redoes the scan and writes once
- And when any scan step fails (a listing, a ranged read, a footer parse, the full-file fallback, or an id column’s decode), startup aborts with
error.type = scanand writes neither the object nor the seated marker
Scenario RFC0059.7 — The bootstrap reads footers in bounded memory
- Given histories of 40 and 160 data and audit files with large bodies and templates
- When the bootstrap scan runs
- Then it decodes no row and reads no data page of a file whose id columns all have usable statistics
- And its peak heap is below one eighth of the history’s body and template bytes
- And when the history grows 4× by adding directories, with the widest directory and the largest file held fixed, the peak of a scan reading one footer at a time grows less than 1.5×: it is bounded by the largest directory listing plus one file, not by the number of files or directories
- And at the default concurrency of 16 the peak stays below that one-at-a-time peak plus 15 more reads, each of at most three times the largest file, and below one eighth of the history’s bytes
Scenario RFC0059.8 — Concurrent reservers on one store get disjoint blocks
- Given a store with
If-Matchsupport and two reservers on it- When both reserve blocks concurrently, many times
- Then every block either reserver received is disjoint from every other
- And the high-water reads the highest block end
Scenario RFC0059.9 — Restore equivalence holds up to renaming tail-minted ids
- Given a tenant snapshotted at
S, frames aboveSthat mint new templates, and a high-water above every id issued- When recovery restores the snapshot and replays the tail
- Then the recovered state equals a from-scratch control under an injective renaming that touches only ids first minted in the tail
- And every restored id and every other field is exactly equal
- And no renamed id equals an id issued before the restart
- And when the snapshot is discarded, or lags the checkpoint (
S < X), every template replay mints afresh from frames at or belowXhas its audit events published before any listener opens, while those frames’ rows stay withheld and no event of an older id is re-published- And a row ingested after startup that matches such a template carries an id a published event binds
Scenario RFC0059.10 — Ids increase per allocator across restarts
- Given an allocator that issued ids, restarted, and issued more, with no re-bootstrap in between
- When its ids are listed in issue order
- Then they strictly increase
- And no id above
i64::MAXis ever issued:i64::MAXitself is the last id, and seating past it or restoring an id above it is the controlled exhaustion error- And from a high-water whose distance to
i64::MAXis not a multiple ofBLOCK, a shortened final block ending ati64::MAXis reserved, every id throughi64::MAXis issued, and only the next fresh mint fails- And a bootstrap that finds a published id above
i64::MAXfails startup closed, naming the file
Scenario RFC0059.11 — Any later-version high-water fails startup closed, even beside a v1
- Given a store holding
miner/template_ids.v2.json, both alone and beside a readableminer/template_ids.v1.json- When a binary that knows only version 1 starts
- Then startup fails with an error naming the later-version key, before any listener opens
- And it neither bootstraps nor writes any high-water object
Scenario RFC0059.12 — Snapshots written before a root’s first seat are never restored
- Given two receivers on one store, each with local snapshots from its pre-RFC counter that hold ids the other also used, and receiver A bootstrapped the high-water and allocated from it
- When receiver B starts with no seated marker
- Then every one of its artefacts is discarded with reason
predates_high_waterwithout being restored, removed, and each tenant full-replays- And the miner holds none of the discarded snapshots’ leaves: a shape one of them held mints a fresh id above the high-water
- And a marker that is zero-length, truncated, malformed, of another version, missing
seated_aboveormax_reserved_seen, or withseated_aboveabovemax_reserved_seen, fails startup closed and never trusts the snapshots- And no id B restores or allocates equals one A issued since the bootstrap
- And B writes its seated marker only after the artefacts are gone, so a kill at any point before it leads the next start to the same decision
- And when two markerless receivers race the bootstrap, the loser fails startup and its restart takes the discard path
Scenario RFC0059.13 — Replay past the ready blocks reserves on demand
- Given a seated root whose surviving WAL holds more first-seen templates than the three blocks startup reserves (a current block and two ready), and a healthy store
- When the receiver restarts and replays every frame
- Then no template fails with
id_reservation_failedandourios.miner.parse_failuresstays at zero- And the recovered state equals a from-scratch rebuild up to the renaming of RFC0059.9
- And once replay has ended, a drained reserver fails instead of calling the store
Scenario RFC0059.14 — A seated root that finds the high-water gone fails closed
- Given a root whose seated marker is valid, and a store whose high-water object was then deleted
- When the receiver starts
- Then startup fails with an error naming the object
- And it neither bootstraps nor writes any high-water object
Scenario RFC0059.15 — A denied store call names the missing permission
- Given a store that refuses the receiver’s calls with an access-denied error
- When the receiver starts
- Then startup fails with an error naming the S3 action the receiver lacks
Scenario RFC0059.16 — Bootstrapping over existing data needs authorisation
- Given a markerless root and a store with no high-water object
- When the receiver starts:
- over a store with no data or audit object, unauthorised;
- over a store that holds data, unauthorised;
- over a store that holds data, authorised
- Then, respectively:
- it bootstraps, and the first allocatable id is 1;
- startup fails closed with an error naming
OURIOS_TEMPLATE_IDS_ALLOW_BOOTSTRAPand the upgrade step, and writes neither the object nor the marker;- it bootstraps with a floor at or above every published id, and writes its marker
- And the setting is off unless the configuration sets it
Scenario RFC0059.17 — No documented policy can delete the high-water
- Given every JSON IAM policy the chart README documents
- When each is evaluated for
s3:DeleteObjectonminer/template_ids.v1.json, with and without astorage.s3.prefix- Then no
Allowstatement’s actions and resources reach it (wildcards such ass3:*andBUCKET/*included)- And each policy holds an explicit
Denyofs3:DeleteObjectthat covers it
Scenario RFC0059.18 — A high-water deleted while live is never re-created
- Given a running, seated receiver that holds its current and ready blocks
- When the high-water object is deleted, and fresh templates keep arriving
- Then the held ids are each issued once and none lies past the last reservation
- And on a store with conditional writes, no reservation re-creates the object (on the local backend a deletion racing a write may re-create it, §3.6, and one that does not race is caught at the next reservation and fails the next restart closed, RFC0059.14)
- And once the held blocks are spent, fresh mints fail as
id_reservation_failedwhile known templates attach- And if the object then reappears, the refiller does not reserve from it
- And when the object reappears as an older copy below the receiver’s
max_reserved_seen, a restart fails closed with an error naming a rollback of the high-water object, and writes nothing- And after the documented recovery (every receiver stopped, the object and every seated marker removed, one replica started with
receiver.template_ids_allow_bootstrap), that replica seats at or above every published id, and its next mint is fresh
Scenario RFC0059.19 — Retention or erasure followed by re-bootstrap never reissues a bound id
- Given a seated store from which retention or erasure removed one tenant’s data and audit, including its highest ids
- When the documented re-bootstrap runs (every receiver stopped, the object and every seated marker removed, one replica started with
receiver.template_ids_allow_bootstrap)- Then the new floor is at or above every id still bound by stored data or audit
- And no id minted afterwards equals an id still bound by stored data or audit; ids only the removed files bound may be reissued
6. Testing strategy
Integration tests (ourios-ingester tests/it). These run on #898’s
scenario harness: the production barrier, housekeeping and recovery
path, with the store’s data rows and audit events compared against
every newly minted id.
- RFC0059.1, .2, .3 (the SIGKILL arm reuses the RFC 0052 crash fixture), .5, .6, .9, .11, .12, .13, .14, .15 and .16.
- RFC0059.18 deletes the object under a running node and restores it later, asserting no re-creation and no reservation from the restored copy. Its deletion does not race a write, so it holds on the local backend too.
- RFC0059.19 removes one tenant’s data and audit directories, as retention would, runs the documented re-bootstrap, and asserts no new id equals one still bound.
- RFC0059.17 parses the chart README’s JSON policies (
include_str!) and evaluates their action and resource wildcards against the high-water key. - RFC0052.10’s transition. Its audit-gate test
(
rfc0052_10_audit_events_above_the_mark_are_forwarded_exactly_once_in_frame_order) changes its contract with this RFC. The maintainer approved this on 2026-10-06.- Before: the forwarded events equal the reference’s
(X, tail]events. - After: the forwarded events equal the reference’s
(X, tail]events plus the(S, X]events whose id lies above the seated high-water, in frame order.audit_events_suppressedcounts every other(S, X]event. - The assertions stay exact. A recovery unit test keeps the withheld branch covered.
- RFC0059.9’s
rfc0059_9_a_reminted_replay_id_publishes_its_mappingcovers the discarded-snapshot andS < Xcases end to end.
- Before: the forwarded events equal the reference’s
Miner unit tests, with a scripted IdReserver:
- RFC0059.4: the reserver records every call; the test asserts none happens while the miner holds its lock;
- RFC0059.10.
Property test (proptest). Over any reachable miner state, any
subset of discarded tenants, surviving replay and new traffic: a miner
seated past the issued ids only hands out an issued id for a template a
kept snapshot restored under that id. This covers RFC0059.1 and .9.
Concurrency test (RFC0059.8). Two reservers on an in-memory
If-Match store, plus an #[ignore]d LocalStack arm run by the
s3-integration job.
Heap test (RFC0059.7). dhat in its own binary, in the style of
#896’s rfc0033_bounded_fold.rs.
Red gate. One #[ignore]d todo! stub per scenario, registered in
tests/it/main.rs (#819’s convention), flips this RFC to red.
7. Open questions
- Semconv names. The bootstrapped event’s contract was first
released in the shared registry’s v0.3.7 (
ead0337). The code pins v0.3.8 (bb19221), which addsrefill.failedandrefill.stopped; §3.9’s names are all final there. - Template-map artefacts. RFC 0033’s map is derived from the audit stream, so it inherits the audit fold’s last-wins on historical collisions. Repairing already-collided history is out of scope; the DuckDB procedure from #908 detects it.
8. References
- Issue #898 and PR #908 (the investigation, the scenario tests, the DuckDB collision check); #909 (the review this RFC answers).
- RFC 0001 §6.1, §6.2 step 0, §6.9, scenario §3.5.3.
- RFC 0005 §3.6 (data-file statistics) and §3.7.1 (audit fold order).
- RFC 0010 (drift).
- RFC 0013 (
put_if_matchand the manifest compare-and-swap). - RFC 0019 (RFC0019.7, the local backend).
- RFC 0023 §3.1 and §3.4.
- RFC 0025 (quarantine).
- RFC 0033 (the map artefact precedent).
- RFC 0035 §3.1 (the ordered phase).
- RFC 0047 (erasure).
- RFC 0052 (reclamation and recovery horizons).
- RFC 0056 (audit durability).
- #791 (object-store outage recovery); #853 (listing cost); #896 (the bounded audit fold).
CLAUDE.md§3.1, §3.3, §3.6, §3.7; hazards #1 and #5.
RFC 0060 — Background template-map refresh
rfc: 0060 title: Background template-map refresh status: red author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-10-13 supersedes: — superseded-by: —
RFC 0060 — Background template-map refresh
Status:
red. RFC0060.1–.7, .10 and .11 pass: inourios-querier(tests/it/rfc0060_refresh.rs,tests/rfc0060_refresh_observability.rs) andourios-server(tests/it/rfc0060_refresh_config.rs). RFC0060.8’s span shape is asserted with .1; its weaver live-check ofacquire.triggeris still an#[ignore]dtodo!stub, the one open criterion beforegreen. RFC0060.9 is a Dash0 measurement after release, not a CI test (§6). Specified 2026-10-13 (#990, maintainer-approved: the RFC 0033 §4 reversal, the two telemetry names and the §7 decisions as recorded below; checked against the OpenTelemetry docs). This RFC adds a querier background task on top of RFC 0033’s acquisition path. It changes no artifact format, fold rule or publish rule of RFC 0033 (accepted), so it is a new RFC rather than an amendment. It revisits an alternative RFC 0033 §4 rejected, “a background refresher”, on new evidence (§2.1). If this RFC is accepted, RFC 0033 §4 gets a dated pointer here.
Amendment (2026-10-16, #1008, maintainer-approved) — a pass skips the artifact read its listing proves unnecessary. Adds §3.7: a pass remembers, per tenant, the frontier and artifact
ETagof its last verified hit. When its next listing shows the same frontier and the sameETag, it stops there instead of reading the artifact again. The skipped read could only have been a hit, so the freshness argument of §2.1 holds for every change that completed before the listing. A change that lands between the listing and the skipped read is repaired one pass late (§3.7). Queries are untouched. It does not shrink the per-tenant listing itself, which is most of a pass’s requests on a tenant with a long audit history. §7 records the maintainer’s decisions on that and on telemetry. In-place notes in §3.3, §3.5 and §6, and an amended RFC0060.10 (a hit or publish no longer forces an acquisition on the next pass, only a listing). New scenarios RFC0060.12–.16 (drafted); §9 records the history. The frontmatter status and RFC0060.1–.9 and .11 are unchanged.
1. Summary
The querier refreshes each tenant’s template map in the background:
- once at start;
- then on a fixed interval;
- one tenant at a time;
- through the same per-tenant single-flight a query acquires through
(
map_flight).
The full folds that RFC 0033 still runs are paid by the background task
in most cases, not by a user query. These are the no_fold_state
bootstrap, the depth cap, and the files_removed / out_of_order
fallbacks. A query that arrives while the task is folding its tenant
joins that fold instead of starting a second one. The task never fails
the process, is bounded to one acquisition at a time, and is off when
configured off.
2. Motivation
RFC 0033’s guarded incremental fold (§3.3 amendment 2026-10-12, #897) made a stale refresh cheap. On the production node, over the first 24 h of v0.13.0:
- 24 stale derives were incremental, p95 0.45 s;
- 2 derives were full folds: the one-time
no_fold_statebootstrap, one per tenant; - one of those took 49 s and 5,726 GETs, inside a user’s
POST /v1/query.
A querier restart is no longer cold: the artifact lives in the store, so the first query after a restart is a hit or an incremental refresh.
Full folds still happen, and each lands on whichever query asks first:
- Bootstrap. The first acquisition after an artifact-format change
(
no_fold_state,unknown_version) or after a torn artifact. - Depth cap. Every
INCREMENTAL_DEPTH_CAP(256) published incremental steps (RFC 0033 §3.3). An abstained, failed or lost publish leaves the stored depth unchanged. If every one of the 24 incremental derives a day observed above published, that is one cap fold per tenant per ten to eleven days. That is an upper bound on the frequency: each unpublished step makes the interval longer. - Fallbacks.
files_removed(audit objects removed, e.g. by retention) andout_of_order.
For a short-timeout client a 50 s fold is an outage: the client gives
up, the fold completes for the next query (map_flight), and the user
saw an error. This was the failure mode in #853. Moving that cost to a
task nobody waits on is cheaper than making every client patient.
2.1 Revisiting RFC 0033 §4
RFC 0033 §4 rejected a background refresher. Its reasoning was that the refresher adds a scheduling component and a staleness-window policy “for something the write-through gets for free at the moment of demand”. Two things have changed since:
- The moment of demand is not free. A full fold costs a user up to 50 s, and since #897 every remaining full fold is one that some query pays for (above).
- No staleness window is introduced. The task only pays early. It calls the unchanged acquisition, whose frontier check still decides freshness for every query, so the RFC 0033 correctness argument is untouched. A query never reads a map older than its own listing.
What remains is the scheduling component, the cost this RFC proposes to pay.
Why this layer. The querier already owns acquisition, single-flight and publish. A separate process or the ingester would need its own copy of all three and would compete with the querier’s CAS publish. RFC 0033 §4 rejected ingester-side write-through for the same reasons (fold code on the ingest path, publishing for tenants nobody queries).
3. Proposed design
3.1 The task
When the querier role starts, it spawns one tokio task, template-map refresh. Each pass:
- lists the tenants that have an audit stream, one delimited
LISTofaudit/taking thetenant_id=common prefixes, decoded with the partition key’s tenant encoding (the inverse ofpercent_encode_tenant); - for each tenant, in listing order, calls the same
TemplateMapFlights::acquire_fetcheda query uses, and waits for it to finish before starting the next tenant.
The first pass starts immediately at role start. Each later pass starts
interval after the previous pass finished, so passes never
overlap. Nothing new is decided: a background acquisition takes the
usual path (a hit, an incremental fold, or a full fold), publishes
through the usual write-through, and is subject to every RFC 0033 rule.
3.2 Interaction with queries
The background acquisition is a flight like any other:
- a query for the same tenant and frontier joins it and records
joined; - a query at a different frontier waits for it and lists again
(
map_flightsemantics).
So a query never runs a fold concurrently with the background task for its tenant. A query that arrives during a 50 s background fold still waits for the rest of it. The task narrows that window rather than closing it: it only removes the case where the query is the first to ask. §7 lists pre-emptive full folds as an open question.
3.3 Bounds
-
Concurrency. One background acquisition at a time, for the whole process. Its peak memory is one fold’s (the RFC 0058 / #853 bound), never a sum over tenants.
-
Shutdown. The task stops at the next tenant boundary once shutdown begins. An acquisition already on the blocking pool runs to completion, as a dropped query’s does (
map_flight); shutdown does not wait for it. -
Failure. A failed acquisition is recorded (§3.5), and the pass moves on to the next tenant. A failed tenant listing ends the pass; the next pass retries. Neither failure stops the task or the process.
-
Cost on an idle tenant. A tenant with a current artifact costs one audit
LISTplus one artifactGETper pass (a hit). With the default interval that is negligible beside the ingest path’s own requests. It is still paid per tenant, hence §7’s question about large tenant counts.Amendment (2026-10-16, #1008, maintainer-approved). Measured on the production node (one pass, 2026-10-16, five idle tenants): one delimited
LISTofaudit/, then per tenant oneLISTper 1,000 audit objects (8 and 6 pages for the two oldest tenants, 1 for the rest) and one artifactGET. That is 23 requests a pass, about 6,600 a day at the default interval, and theLISTpages grow with each tenant’s audit history. §3.7 removes the artifactGETfrom a pass whose listing proves the tenant unchanged (5 of the 23). TheLISTpages stay; see §7. -
Back-off when nothing persists. An acquisition that ends without a publish and without a hit persists nothing. That covers a write-through abstention (RFC 0033 §3.2 size gate or ceiling, which may recur indefinitely), a failed publish, and a failed acquisition. Re-running it each pass would repeat the fold and its
GETs everyintervalfor no later query’s benefit. So afterkconsecutive such acquisitions, the task skips that tenant for the nextmin(2^k − 1, 11)passes. The cap counts passes, not wall-clock time: at the default interval that is 11 ×intervalplus the skipped passes’ own run time. A hit, a publish of its own, or a lost CAS race (someone else published) resetskto 0. The counter lives in process memory and resets on restart. A successful tenant listing drops the counters of tenants it no longer names, so the state follows the current tenants, not their history; a failed listing keeps them, since it names no current set. A query is never skipped: it acquires exactly as it does without the task.
3.4 Configuration
These keys sit under querier.template_map.refresh.* (RFC 0020;
${env:…} substitution as usual):
| key | type | default | meaning |
|---|---|---|---|
enabled | bool | true | spawn the task |
interval | duration | 5m | idle time between passes; must be ≥ 10s |
enabled: false restores today’s behaviour exactly. As with
compaction.enabled / interval, interval is parsed and validated
only when the task is enabled, so an unused bad value never blocks
startup. When enabled, an interval that is not a duration, or is
below the floor, is a startup configuration error naming the key. It
is not silently clamped, as RFC 0020 validates every other resolved
value.
3.5 Observability (CLAUDE.md §6.3)
- Span. Each pass is one root
INTERNALspan,refresh template_map. It has no remote parent or child, soINTERNALis the kind, and the name follows OTel’s{action} {target}pattern besideload template_map. Each tenant’s acquisition is the existingload template_mapphase span, carryingourios.tenant, with its CLIENT spans beneath it, nested under the pass as it nests underPOST /v1/querytoday (RFC 0038 §3.7.2). Under the defaultparentbased_always_onsampler these roots are always sampled. - Errors (OTel Recording errors). A tenant’s failed acquisition
sets error status and
error.typeon that tenant’sload template_mapspan. The pass handled it and continued, so the pass span and the pass sample carry no error for it. Only a pass that ends early (a failed tenant listing) setserror.type, on both the pass span and itsrefresh.durationsample, with the same value. So the span and its duration metric describe the same operation, as OTel recommends. - Metrics. The RFC 0033 lookup and derive instruments gain one
attribute,
ourios.template_map.acquire.trigger=query|refresh. This splits the existing series so a dashboard can tell a fold a user paid for from one the task paid for. The criterion of this RFC is thatquery-triggered full folds approach zero. - Pass. One histogram,
ourios.template_map.refresh.duration(s), one sample per pass, named per OTel’s{operation}.durationrule. Tenants and tenant counts are not attributes: OTel’s cardinality guidance keeps ID-like values off metrics, and they are on the spans.
ourios.template_map.acquire.trigger follows the precedent of the
upstream faas.trigger (“type of the trigger which caused this
invocation”). Its own values (query, refresh) are more precise here
than faas.trigger’s http / timer: a query can arrive over HTTP or
MCP, and the start pass is not a timer. With two values it at most
doubles the existing series.
ourios.template_map.acquire.trigger and
ourios.template_map.refresh.duration are new names, registered in
ourios-semconv v0.3.17 under the purpose-check and
maintainer-approval rule, after an OTel naming-guidance check.
Amendment (2026-10-16, #1008, maintainer-approved). A tenant that §3.7 skips still gets its
load template_mapspan, with the listing’sCLIENTspans beneath it and noS3.GetObject. It carries noourios.template_map.lookup.outcomeand records nolookupssample: no artifact was looked up. The effect is measured by the existingourios.storage.operation.duration{ourios.storage.caller=template_map, ourios.storage.operation=get}count and theS3.GetObjectspans. No new name; §7 asks whether a registeredunchangedoutcome is wanted.
3.6 Multi-tenancy and replicas (CLAUDE.md §3.7)
Each acquisition is per tenant, as today. Several querier replicas each run their own task. Their publishes are CAS writes, so the worst case is duplicated fold work, never a torn or regressed artifact (RFC 0033 §3.4). Coordinating replicas is out of scope (§7).
sequenceDiagram
participant T as refresh task
participant F as map_flight
participant Q as user query
T->>F: acquire(tenant A)
Note over F: full fold (bootstrap / depth cap)
Q->>F: acquire(tenant A), same frontier
F-->>Q: joined (waits for the fold)
F-->>T: map, published
T->>T: next tenant
3.7 Skipping a read the listing proves unnecessary (amendment 2026-10-16, #1008, maintainer-approved)
Every acquisition starts with one recursive listing of the tenant’s
audit prefix (RFC 0033 §3.3). The artifact lives under that prefix, so
the listing already returns it, with its ETag, next to the audit
objects. Today the listing keeps only the *.parquet keys. A pass now
also keeps the artifact’s listed ETag, and uses it like this.
What a pass remembers. Per tenant, at most one verified listing: a
frontier F and an artifact tag T. It is recorded only when the
pass’s own acquisition was a hit (RFC 0033 §3.3: the artifact
decoded Valid with folded_files == F, the listed frontier), and
the artifact GET returned the same tag the listing showed. Nothing
else records one: a publish (the new object’s tag is not known
without a read), a lost CAS race, an abstention, a failure, or a
joined flight. Any of those, or an error, drops the tenant’s verified
listing.
What a pass skips. Before the acquisition it would start, a pass
compares its listing against the tenant’s verified listing. If the
listed frontier equals F and the listed artifact tag equals T, the
pass stops: no artifact GET, no flight, no derive, no publish. Any
difference (a new audit object, a removed one, a replaced, removed or
unlisted artifact) runs the acquisition exactly as §3.1 describes,
against the same listing.
Why the skip changes nothing a query sees. The skipped acquisition
would have read the artifact the listing shows. That object has tag
T, and an object store’s ETag names one object content, the
identity RFC 0033 §3.4’s CAS publish already relies on. So the read
would have returned the bytes the verified hit read, which decode to a
Valid map at F, which is the listed frontier: a hit. A hit derives
nothing and publishes nothing. So the store after a skip is exactly the
store after the acquisition it replaces, and §2.1’s argument holds
unchanged: the task still never serves anything, and every query still
lists and reads for itself. S3’s listings are strongly consistent, so
the listing reflects every write and delete that completed before it,
by any process: another querier replica, an ingester elsewhere, or a
bucket lifecycle rule. That is why the proof needs no in-process
signal and holds in multi-process deployments.
The one interleaving it does not cover. The proof is about the
store as the listing saw it. The artifact GET is not pinned to the
listed version: it reads whatever is at the key when it runs. If
another process replaces or deletes the artifact after the listing and
before that GET, the acquisition the skip replaces would have read the
new state. On a deletion it would have folded and published. The skip
does neither. So “exactly the store after the acquisition it replaces”
holds for every change that completed before the listing, and not for
one that lands between the listing and the would-be GET. That change
is in the next pass’s listing (a different tag, or no artifact) and is
repaired there. The repair is late by at most one pass, interval plus
the pass’s run time. This RFC accepts that delay. Query correctness
does not depend on it: a query in that window lists and reads for
itself, and pays the fold that the old pass might have paid. The same
window already exists without §3.7 for a change that lands just after a
pass’s GET. §3.7 moves the window’s start from the GET to the
listing, a few milliseconds earlier. RFC0060.16 states the behaviour.
Tags. On the S3 backend the tag is the object’s ETag, compared
after stripping one pair of surrounding double quotes, since some
S3-compatible stores spell it quoted in one response and unquoted in
the other. A weak (W/) tag never equals a strong one. A store that
lists no ETag never skips. On the local backend the tag is the
file’s size, modification time (nanoseconds) and, on Unix, inode. The
listing takes it with a stat. The read takes it from the open file
handle it reads through, so it names the bytes read. A local publish
installs a fresh temp file by rename (RFC 0033 §3.4), which changes
the inode.
Bounds. One verified listing per tenant, the size of one frontier.
That is the same size as the listing a pass already holds for that
tenant. It lives in process memory and resets on restart, so a new
binary (and a new artifact format) always reads. A successful tenant
listing drops the verified listings of tenants it no longer names, as
§3.3 does for the back-off counters. Queries never consult it: a query
needs the map itself, and map_flight keeps no map once a flight ends.
What it saves, and what it does not. On the production node
(2026-10-16) the skip removes the artifact GET from about 1,150 of
the 1,255 daily refresh acquisitions (the hits), together with their
bytes. One tenant listing is still paid per tenant per pass, at one
LIST request per 1,000 audit objects. On a long-lived tenant those
pages are most of the cost (§3.3 note). Cutting them needs a signal
weaker than a listing, or a smaller listing. §7 lists the options; each
needs a maintainer decision.
4. Alternatives considered
Warm only at start. Same task, one pass, no interval. Simpler, but since #897 a restart is not the cold case: the artifact survives in the store. The depth-cap and fallback full folds still land on queries. Rejected as covering only the case that has already been fixed.
Pre-emptive full fold before the depth cap. The task would fold in full when a tenant’s depth nears the cap, so the cap is never reached on the query path. This needs a new rule in RFC 0033’s derive policy (fold in full below the cap, but only off the query path). Deferred to §7 so the task lands first and Dash0 can show whether cap folds still reach queries.
Serve stale while revalidating. Answer a query from the stale artifact and fold in the background. This breaks RFC 0033’s guarantee that a query renders against a map at its own frontier: rows written after the artifact would render against missing or old bindings (CLAUDE.md §3.3). Rejected.
Raise client timeouts. Not ours to control, and it hides a 50 s fold instead of removing it from the request.
Fold in the ingester. See §2: the ingester does not own the read path, and a second publisher contends for the CAS.
5. Acceptance criteria
Scenario RFC0060.1 — A start pass absorbs the bootstrap
- Given a store whose tenant has an artifact without
fold_state, and a folded map the write-through publishes (not abstained, no IO error)- When the querier starts with refresh enabled, and a query arrives after the first pass has finished
- Then the full fold ran with
acquire.trigger = refresh- And the query’s lookup is a
hitwithacquire.trigger = query, and runs no fold.
Scenario RFC0060.2 — A query during a background fold joins it
- Given the task holding a tenant’s flight on a gated fold
- When a query for that tenant at the same frontier arrives
- Then exactly one fold runs, and the query records
joinedand receives the task’s map- And the query’s
joinedlookup carriesacquire.trigger = refresh: the attribute names who initiated the shared acquisition, not who recorded the sample.
Scenario RFC0060.3 — One background acquisition at a time
- Given three tenants, each with a gated fold
- When a pass runs
- Then at most one background acquisition is in flight at any instant, and the tenants are visited in listing order.
Scenario RFC0060.4 — Failure is contained
- Given two tenants, the first with an unreadable audit object
- When a pass runs
- Then the first tenant’s
load template_mapspan carries error status anderror.type- And the second tenant is still acquired, under the same pass span
- And the pass span and its
refresh.durationsample carry noerror.type, and the task runs its next pass- And a pass whose tenant listing fails ends early with the same
error.typeon its span and its sample.
Scenario RFC0060.5 — Disabled means today’s behaviour
- Given
querier.template_map.refresh.enabled: false- When the querier starts and idles past
interval- Then no acquisition runs, and no
refresh template_mapspan or refresh sample is emitted.
Scenario RFC0060.6 — Configuration is validated
- Given refresh enabled with
interval: 1s, or a non-duration value- When the configuration is resolved
- Then startup fails with an error naming the key
- And with
enabled: falsethe same values resolve without error.
Scenario RFC0060.7 — Shutdown is prompt
- Given a pass between tenants, or waiting on its interval
- When the querier shuts down
- Then the task exits without starting another acquisition
- And given a pass whose acquisition is on the blocking pool, gated, when the querier shuts down, then the task exits without waiting for the gate, and once the gate is released the flight still completes and publishes (it is not cancelled).
Scenario RFC0060.8 — Telemetry is registry-conformant
- Given RFC0060.1’s run
- When its spans and metrics are exported
- Then the pass is one root
INTERNALspan namedrefresh template_map, with each tenant’sload template_mapspan beneath it, asserted through the in-memory span exporter (span names are not registry entries)- And
acquire.triggerandrefresh.durationpass weaver live-check against the ourios-semconv registry.
Scenario RFC0060.9 — Query-paid full folds drop (Dash0, after deploy)
- Given the node on the release that ships this RFC, and the previous release, each over a 7-day window
- When full folds are counted, normalised per tenant-day
- Then
derive.duration{mode=full, acquire.trigger=query}on the new release is below the previous release’smode=fullcount. A query that joins a background fold records no derive of its own, so the remainingqueryfull folds are those where a query became the acquirer before the pass reached its tenant.
Scenario RFC0060.10 — A tenant that persists nothing is backed off
- Given a tenant whose write-through abstains on every acquisition
- When consecutive passes run
- Then the task acquires it on passes 1, 3 and 7 (skipping 1, then 3), and on no more than one pass in 12 thereafter
- And a query for that tenant still acquires on every request
- And after a hit or a publish the tenant is acquired on the next pass again
- And once a successful listing no longer names the tenant, its counter is dropped; a failed listing drops nothing.
Amendment (2026-10-16, #1008, maintainer-approved). The second And becomes: after a hit or a publish, back-off no longer skips the tenant. The next pass lists it, and acquires it unless §3.7’s skip applies; RFC0060.12 says when it does. After a publish the skip never applies on the next pass, since a publish leaves no verified listing (RFC0060.14), so the existing test’s “a hit, on the next pass” after a publish still holds.
Scenario RFC0060.11 — Passes are spaced from their end, never overlap
- Given paused time,
interval: 5m, and a pass whose fold is gated for longer thaninterval- When the gate is released
- Then no second pass starts while the first is running, and the next pass starts
intervalafter the first pass finished, with no catch-up pass in between.
The scenarios below are added by the 2026-10-16 amendment (§3.7, #1008)
and are drafted.
Scenario RFC0060.12 — An unchanged listing skips the artifact read
- Given a tenant whose last pass acquired a hit, with the artifact read’s tag equal to the listed one
- When the next pass lists the same frontier and the same artifact tag
- Then that pass reads no artifact and starts no flight, and records no
lookupsorderive.durationsample for the tenant- And a query for the tenant afterwards still lists and reads the artifact, and records a
hitwithacquire.trigger = query.
Scenario RFC0060.13 — Any change in the listing reads again
- Given a tenant with a verified listing
- When before the next pass (a) a new audit object appears, (b) an audit object is removed, (c) another writer replaces the artifact with a valid one at the same frontier whose tag differs from the verified one, or (d) the artifact is removed
- Then that pass runs the acquisition: (a) and (b) are
stalelookups and fold, (c) is ahitthat records a new verified listing, and (d) is amissthat folds in full and publishes- And in every case the store afterwards equals what the acquisition would leave without §3.7
- And a replacement with the same bytes that keeps the same
ETag(a single-part S3PUTof identical content) is not a change: the pass skips it, correctly, since a read would return the same bytes.
Scenario RFC0060.14 — Only a verified hit is remembered
- Given a tenant whose pass acquisition publishes (a stale or missing artifact), abstains, fails, or reads an artifact whose tag differs from the listed one
- When the next pass that §3.3’s back-off does not skip for that tenant lists the same frontier (the very next pass after a publish or a mismatched read; a later one after an abstention or failure)
- Then it reads the artifact (no skip), and only after that read is a hit with matching tags does a later pass skip
- And the back-off is unchanged by §3.7: an abstention or failure still skips the passes §3.3 says, and drops the verified listing
- And once a successful tenant listing no longer names a tenant, its verified listing is dropped; a failed listing drops nothing.
Scenario RFC0060.15 — The skip on S3, and its spans
- Given the S3 backend and a tenant with a verified listing
- When a pass skips it
- Then its
load template_mapspan has itsS3.ListObjectsV2children and noS3.GetObjectchild, and carries noourios.template_map.lookup.outcome- And after another writer
PUTs an artifact with different bytes (so a differentETag), the next pass has anS3.GetObjectchild again.
Scenario RFC0060.16 — A deletion after the listing is repaired one pass late
- Given a tenant with a verified listing
- When another process deletes the artifact after a pass’s tenant listing and before the pass decides
- Then that pass skips: it reads nothing and publishes nothing
- And a query between the two passes answers correctly. Its lookup is a
miss, it pays the fold itself, and its usual write-through (RFC 0033 §3.5) may republish the artifact- And if the artifact is still absent at the next pass’s listing, that pass acquires a
miss, folds and publishes- And if a query republished it instead, the next pass reads the artifact the listing shows. Should that artifact restore the verified frontier and tag, the pass may skip under §3.7; a hit is not required.
6. Testing strategy
- RFC0060.1–.4, .7, .10 and .11 are integration tests (RFC0060.11
under tokio paused time) in
ourios-querierover a local store, using themap_flighthooks (before_acquiregating, acquisition and join counters) through thetestingfeature. - RFC0060.5–.6 are
ourios-serverconfig tests (RFC 0020 style) plus one served-role test. - RFC0060.8 asserts span names and nesting through the in-memory span
exporter, and the registry names through the weaver live-check
harness (
live_check). - RFC0060.9 is a Dash0 before/after measurement recorded on the release (the RFC0033.14 precedent), not a CI test.
No proptest or corpus test applies: the task adds scheduling, not
fold semantics, and the fold’s own property tests (RFC0033.10–.13)
already cover what it calls.
Amendment (2026-10-16, #1008, maintainer-approved). Test map for §3.7:
Scenario Test Where RFC0060.12 rfc0060_12_an_unchanged_listing_skips_the_artifact_readourios-queriertests/it/rfc0060_refresh.rs, local backend, acquisition counter + lookup metricsRFC0060.13 rfc0060_13_any_change_in_the_listing_reads_againsame file; (a)–(d) as four cases over one store RFC0060.14 rfc0060_14_only_a_verified_hit_is_rememberedsame file, plus unit tests of the tag comparison in map_refresh/audit_scanRFC0060.15 rfc0060_15_the_skip_on_s3_reads_no_artifactsame file, #[ignore]d, run by thes3-integrationCI job (LocalStack)RFC0060.16 rfc0060_16_a_deletion_after_the_listing_is_repaired_one_pass_latesame file; a test hook deletes the artifact right after the tenant listing The local backend’s tag is exercised by .12–.14; the S3
ETagpath, including the listing that carries it, by .15.
7. Open questions
Decided for this RFC (maintainer, 2026-10-13); each is revisited only on the Dash0 evidence named:
- Pre-emptive full fold near
INCREMENTAL_DEPTH_CAP: deferred. Cap folds come at most once per 256 published incremental steps per tenant (about ten days at the observed rate if every step published, rarer otherwise), so a 5-minute pass almost always reaches them first. Reopen, as an RFC 0033 derive-policy amendment, ifderive.duration{mode=full, acquire.trigger=query}stays above zero after RFC0060.9. - Large tenant counts: not in v1. Every tenant is visited each
pass. The preferred later shape is to refresh only tenants queried
within a window. Reopen when
refresh.durationapproachesinterval. - Replicas: accept duplicated folds. CAS publish keeps them correct (§3.6). A CAS lease object for a single refresher is the later shape, when replicas are deployed.
- Default
interval:5m. Revisit with publish and fold rates after release.
Decided for the 2026-10-16 amendment (#1008, maintainer, 2026-10-16):
- The listing pages (most of a pass’s cost): compact the audit
stream, in a follow-up RFC. Option 1 below, not part of this
amendment. Options 2–5 are not taken. §3.7 is the only skip a
listing can prove. The options considered for fewer
LISTpages: 1. Shrink the listing: compact the audit stream (chosen). Pages grow with audit objects, and the stream writes many small objects (one per audit flush and one per compaction event, RFC 0005 §3.7). Merging a tenant’s closed audit days into a few objects shrinks every listing, the refresher’s and every query’s, and the full folds too. It changes no freshness rule. It is an RFC 0005 §3.7 and RFC 0033 change (merged objects change the frontier, so it must not cost a full fold each time), and so a separate RFC. 2. Probe recent days, list in full everykpasses. A pass lists only the current and previous UTC day of each tenant, and lists in full everyk-th pass or when the probe shows a change. Most passes cost one page per tenant. But it misses a backdated audit object (a WAL replay) or a removed one (a lifecycle rule) for up tok × interval. In that window a query pays theout_of_orderorfiles_removedfull fold that the refresher exists to absorb. That weakens §2.1’s guarantee to “withink × interval” for those two causes. 3. Back off unchanged tenants. Lengthen a tenant’s interval while its listing stays unchanged, as §3.3 does for tenants that persist nothing. This weakens freshness the same way as 2, for every cause. It also saves little where it matters: the tenants with many pages are the active ones, which move every few passes. 4. A per-tenant change marker. Every audit writer rewrites a small marker object after each audit write, and a passHEADs it instead of listing. This is a new object in the store layout (CLAUDE.md §3.5), adds aPUTto each audit write, and cannot see removals by a lifecycle rule or writes by older binaries, so it still needs a periodic full listing. Same weakening as 2. 5. An in-process signal from the local ingester (combined role). It sees only this process’s writes: not another ingester, a lifecycle rule, or a separate-process deployment. Not a proof, so it is rejected as the basis of a skip. - An
unchangedlookup outcome: not now. §3.5’s note records a skip only through the storage counters and the span shape. Registeringunchangedas a value ofourios.template_map.lookup.outcomewould let a dashboard count skips directly. That is a new registry value under the purpose-check rule. Recommended: not now; theGetObjectcount is the quantity #1008 is about.
8. References
- RFC 0033 (cached template map): §3.3 freshness and the incremental fold, §3.4 CAS publish, §3.5 write-through, §3.7 instruments, §4 (the background refresher and ingester write-through alternatives).
- RFC 0038 §3.7 (query-phase spans), RFC 0020 (configuration), RFC 0005 (partition layout), RFC 0058 (query resource limits).
- #853 (cold map / RSS), #897 (incremental fold), #934 (resumable bootstrap), #990 (this RFC’s issue).
- CLAUDE.md §3.3, §3.7, §6.3.
9. Amendment history
- 2026-10-16 — a pass skips the artifact read its listing proves
unnecessary (#1008, maintainer-approved 2026-10-16). Adds
§3.7: a per-tenant verified listing (frontier and artifact tag),
recorded only on a hit whose read tag equals the listed one; a pass
whose listing matches it reads nothing. In-place notes in §3.3 (the
measured per-pass request count), §3.5 (no new name; the skip’s span
shape) and §6 (test map). Amends RFC0060.10: after a hit or a
publish the next pass lists the tenant and acquires it unless §3.7’s
skip applies. States the one interleaving §3.7 does not cover, a
change between the listing and the skipped read, and accepts its
one-pass repair delay. New scenarios RFC0060.12–.16. Two §7
decisions: the listing pages are cut by compacting the audit stream
(a follow-up RFC), and there is no
unchangedlookup outcome for now. RFC0060.1–.9, .11 and the frontmatter status are unchanged. Review (Copilot, #1028): the race qualification and RFC0060.16, the RFC0060.10 amendment, a different tag in RFC0060.13 (c), and the back-off-eligible pass in RFC0060.14.
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:
- Draw the Drain parse tree from memory and walk a log line through it.
- Name the six gaps between the published algorithm and a production log backend, and state the Ourios invariant that fills each gap.
- Explain why bit-identical body reconstruction is a property test and not a unit test.
- Defend the thesis above against a critic who says “just use zstd.”
Outline
| § | Topic | Minutes |
|---|---|---|
| 1 | Motivation: where the compression comes from | 5 |
| 2 | The paper: Drain as published | 10 |
| 3 | Worked example: a line walks the tree | 5 |
| 4 | What the paper does not say | 8 |
| 5 | The Ourios extensions | 8 |
| 6 | The honesty contract: reconstruction | 5 |
| 7 | What is still open | 2 |
| — | Questions | 2 |
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
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 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 say | Ourios 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
Every field on that diagram is a commitment:
tenant_idis 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_idis 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_versionlets a template’s representation change over time while the logical identity persists.paramsare 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.”confidenceis the scalar the three-zone model was defending.lossy_flagis 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.
- Why not use an LLM-based parser instead of Drain?
- 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?
- How does the merge audit scale when a single deployment produces a high merge rate — does the audit stream itself need to be templated?
- If a tenant configures a threshold below 0.7, how is that audited as a policy event?
- 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