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).