rfc: 0004 title: Configuration policy — tunables vs invariants status: green author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-05-18 supersedes: — superseded-by: —
RFC 0004 — Configuration policy: tunables vs invariants
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).