Frame Delimiting: Final Design Decisions¶
Companion to frame-delimiting.md. Records the forks, their state and the amendments the implementation forced, and replaces the Open Questions section of that document on the listed points.
Concept Hierarchy¶
flowchart LR
Frame --> self_delimited
Frame --> buffer_delimited["buffer_delimited<br/>O(1)"]
self_delimited --> explicitly_delimited
self_delimited --> implicitly_delimited
self_delimited --> declared_root["(the bare root, declared)<br/>finds its own end, by none<br/>of the mechanisms below"]
explicitly_delimited --> length_delimited["length_delimited<br/>O(1)"]
explicitly_delimited --> count_delimited["count_delimited<br/>O(1) or O(n)"]
implicitly_delimited --> static_delimited["static_delimited<br/>O(1), compile-time constant"]
implicitly_delimited --> no_leaf["(an id, an extended header)<br/>O(1), no leaf of its own"]
count_delimited -.->|cost from element stride| many_box
subgraph many_box["many<U> — a separate concept"]
direction LR
static_stride["static_stride<br/>O(1)"]
dynamic_stride["dynamic_stride<br/>O(n)"]
end
subgraph Legend
direction LR
o1["O(1)"] --> constant_length["constant_length<br/>carries length()"]
on["O(n)"] --> not_constant_length["not constant_length<br/>frames::distance only"]
end
classDef constant stroke:#2e7d32,stroke-width:2px
classDef nonConstant stroke:#e65100,stroke-width:2px
classDef mixed stroke:#1565c0,stroke-width:2px,stroke-dasharray:4 3
class buffer_delimited,length_delimited,static_delimited,no_leaf,static_stride,declared_root,o1 constant
class dynamic_stride,on nonConstant
class count_delimited mixed
Two orthogonal axes classify a frame — a mechanism axis and a cost axis. They compose; neither
is a subset of the other (an earlier draft made self_delimited a subset of the cost axis — that
is the bug this revision fixes).
- Axis A —
self_delimited(mechanism): can the frame find its own end from its own bytes (header + body), or must the caller's buffer bound it? Cost is irrelevant here.buffer_delimitedis the only category that is notself_delimited: it is bounded by the span handed in, so it knows its size but cannot self-identify its end within a stream. There is no third root: a frame that resolves its own length in code is the first answer, and it says so by declaringself_delimited_tag, the bare root (see A8). - Axis B —
constant_length(cost): can the frame's extent be had in O(1)? The answer is the frame's own, and it gives it by carrying alength()or not: the member exists exactly where it is O(1), so the concept detects rather than believes, asstd::ranges::sized_rangedoes. A frame without one still has a well-defined extent, it just costs a walk, andrbe::frames::distance(f)is the spelling that says so. The_delimitedsuffix is reserved for the mechanism axis;constant_lengthnames the cost axis so the two never read as alternatives in one list. See A13.
The axes are independent:
constant_length (O(1)) |
not constant_length (O(n)) |
|
|---|---|---|
self_delimited |
length_delimited, implicitly_delimited/static_delimited, count_delimited over a static-stride many, a declared bare root |
count_delimited over a dynamic-stride many |
| buffer-bounded | buffer_delimited |
— (impossible) |
The bottom-right cell is empty: a buffer-bounded frame's length is the span size, so it is always
O(1). Cost therefore only varies within self_delimited, and there is exactly one
not-constant_length case: a count_delimited frame whose many<U> has a variable element stride. It is
reachable and inhabited — aquis::packet is one — and such a frame has no length() at all
(A13). A frame that resolves
itself in code is constant_length when it carries the member: cboe::top::line_view does, because its
LF scan happens in make() and length() returns the stored result.
static_stride / dynamic_stride are many<U> concepts, not frame concepts — they describe the
element stride (whether wire_size_of<U> is a compile-time constant). A count_delimited frame
(frame<H, many<U>>) inherits its cost from them: constant_length iff its many is static_stride.
| Concept | self_delimited |
constant_length |
nestable in many<T> |
|---|---|---|---|
length_delimited |
yes | yes | yes |
implicitly_delimited / static_delimited |
yes | yes | yes |
count_delimited (frame<H, many<U>>) |
yes | iff many<U> is static_stride, and only that case is deduced |
yes¹ |
buffer_delimited |
no | yes | no |
bare self_delimited (declared) |
yes | presumed yes | yes |
¹ Over a dynamic_stride many, a count_delimited frame is still self_delimited, so it may nest
in an outer many — at O(n) per element.
many<T> requires self_delimited<T> and constant_length<T>: the element has to find its own end,
and the step that advances the run is length(), so it has to find it in O(1). A buffer_delimited
frame is excluded for the first reason (it consumes the whole remaining buffer, so it cannot be one
element among many) and a dynamic-stride count_delimited frame for the second — it has no length(), so
a many of it would have had to walk per element, which is an O(n²) run nobody asked for. An earlier
revision of this document admitted it ("an element only needs to find its own end, it need not do so in
O(1)"), which was written while every frame had a length() to advance by; see
A13.
Both axes are tag types, not predicates: the mechanism hierarchy above is a derivation hierarchy of
*_delimited_tag types, every frame carries the one it belongs to as a delimiting_concept member, and
every concept in this document is one std::derived_from over it. The cost axis is not a tag: it is a
walk down the nesting chain, because a tag speaks only for the frame that carries it. See
A5.
An id (an rbe::frames::any payload selecting among alternatives) and an extended header are
both implicitly_delimited and constant_length without being static_delimited: O(1), settled at
runtime, no leaf of their own. A sentinel scan (terminated_by, Fork 1) declares the bare
self_delimited_tag: it finds its own end by none of the mechanisms the refinements name. The tag says
nothing about cost because no tag does — the scan is make()'s to pay, and length() returns what
make() stored.
Forks¶
| Fork | Decision | State |
|---|---|---|
| 1 — extent customization point | revised: 1c is the seam, 1a/1e deferred | 1c implemented |
2 — length() / distance() |
resolved 2026-10-13: length exists only where it is O(1), distance always |
implemented |
| 3 — the counted case | 3b landed 2026-10-13, both strides; 3c is the many category |
implemented |
4 — std::expected in step 2 |
decided | not started |
5 — many's stop reason |
decided | not started |
| 6 — how the classifier learns a custom category | resolved, not as 6b | implemented |
| 7 — extent validation | decided | half: reports failure, not which (needs 4) |
Fork 1 — Extent customization point¶
Decision (revised 2026-10-12): 1c is the seam. 1a and 1e deferred to future work.
- 1c — Hand-written view on
base_frame(implemented, and the only seam today): a frame that resolves its own length derives fromrbe::frames::dsrl::base_frame, supplies the two primitives (make()andlength()) and declares its category:
class line_view : public rbe::frames::dsrl::base_frame<Header, messages::dsrl_type> {
public:
using delimiting_concept = rbe::frames::self_delimited_tag; // the LF scan: finds its own end
static constexpr auto make(buffer_type) -> std::optional<line_view>;
constexpr auto length() const -> size_type;
};
The declaration is not optional: rbe::frames::is_frame requires a delimiting_concept, so a
custom frame cannot be silently classified by its shape — which is what closes bug #4
(see A6). It replaces
the static constexpr bool self_delimiting opt-in this document originally specified: saying which
category is strictly more than saying yes/no, and it is the same channel the library's own frames use.
base_frame deliberately declares no default tag, so a derived frame has nothing to inherit and
has to state its category, exactly as it has to supply its own make() and length().
What it declares when no leaf fits. The tags are a derivation hierarchy, so the interior nodes are usable answers — that is the whole reason they are tags and not predicates (A5). A custom frame picks the most precise node it can honestly claim, on the mechanism axis only: a tag answers who knows where the frame ends and by what, never what the answer costs (A11).
| Declares | Means |
|---|---|
static_delimited_tag |
every byte fixed by the types |
length_delimited_tag |
a length field on the wire, wherever it sits (a body length, not only a header one) |
count_delimited_tag |
a count on the wire bounds a run |
implicitly_delimited_tag |
the types settle it, with no wire field and no compile-time constant — out-of-band state, a session parameter, an id |
explicitly_delimited_tag |
a wire field settles it, but in neither leaf shape |
self_delimited_tag |
finds its own end, by none of the mechanisms above — a sentinel scan |
buffer_delimited_tag |
the buffer bounds it |
The bare root is the honest answer whenever none of the refinements describes the mechanism, and the
only one a frame should declare when it is not sure. What it costs to resolve is not asked: the frame
signs the same length() contract as every other — resolve once in make(), keep the result — and the
library presumes it honoured.
The one case the hierarchy cannot express today is "a compile-time constant whose value the library
cannot compute" — a fixed-width frame over an opaque payload. static_delimited_tag static-asserts
that detail::static_frame_size<T>() can add up the parts, so such a frame has to settle for
implicitly_delimited_tag and lose static_length<T> (and with it static_stride for a many of it).
If a real protocol needs it, the fix is the same declared-over-deduced pattern one level down: let the
frame declare the constant (static constexpr size_type static_length) and have static_frame_size
prefer it over the computation. Not implemented — no case for it yet.
-
1a — Extent policy as third template parameter (deferred):
dsrl::frame<H, P, terminated_by<'\n'>>would turn the ~40 hand-written lines ofline_viewinto one declaration, with the policy declaring the category (E::delimiting) instead of the frame. It is the main thing still missing for custom frames, and it is additive: it does not change how 1c frames are classified, it only removes the boilerplate. Not scheduled. -
1e — Declarative annotation (
[[=rbe::terminated_by('\n')]]): deferred. When implemented it lowers to a 1a policy, so it is blocked on 1a.
Fork 2 — length() semantics and axis-B expression¶
Decision (2026-10-13): length exists exactly where the extent is O(1), because the member does.
distance answers for every frame. Implemented in rbe/frames/length.hpp.
namespace rbe::frames {
inline constexpr length_fn length {}; // O(1). Exists only for a frame that carries length()
inline constexpr distance_fn distance {}; // every frame. Walks when that is the only way
template<typename F> concept constant_length = /* does it carry length()? */;
}
Two earlier drafts of this fork are worth keeping, because the third one is what their failure modes add up to:
lengthgated onconstant_length, the member left mandatory. The gate was the point, the waystd::ranges::sizerefuses what.size()would have answered. But a gate the member can bypass protects nothing, and.length()was one.away.- Neither function gated, the O(1)-ness of
length()a documented contract. Honest, unverifiable, and it left a real bug standing: a counted run of variable-size elements is not bounded by the buffer it arrived in, andlength()was returning the buffer size for it.
So: length() is simply absent where it cannot be O(1), and everything derived from it goes with it
(as_span(), payload_span(), being an element of a many; flatten() is not derived from it — it
walks header() and payload() — so it stays). Nothing to gate, nothing to
promise, nothing to bypass — the member either is there or is not, and constant_length detects which.
That is std::ranges::sized_range exactly, and it is the shape
A13 explains, including the
one thing this document argued against twice before accepting it.
Rules:
buffer_delimitedframes areconstant_length:length()returns the size of the span handed in. The semantic is "I am bounded by the buffer you gave me; that boundary is known O(1)".- A
count_delimitedframe over adynamic_stridemanyis the only case that is notconstant_length. That is the whole informational content of the axis, and it is deduced. - The element requirement for
many<T>isself_delimited<T>andconstant_length<T>: the run advances by the element'slength(), so an element without one is not admissible. This is what keeps amanyfrom quietly becoming O(n²). length()is O(1) wherever it exists, andrbe::frames::dsrl::is_frameno longer requires it. For the library's own frames its existence is deduced (dsrl::frame::length()is constrained onrbe::detail::is_constant_length<frame>()); for a hand-written frame, writing one is the claim and not writing one is the opposite.base_framekeeps the payload view it resolved inmake(), which is what makes the claim true for everything the library builds.distanceis an algorithm and not a second customization point, the wayranges::distanceis not one:length()first, then adistance()member, then aframe_distance(f)found by ADL, then the library's own walk (the header plus the measured elements of the run). The ADL hook exists whereranges::distancehas none because a frame does not always carry something to walk the way a range carries++.
Still no disable_constant_length, and still no declared cost — see
A11 for the shapes that were weighed and
A13 for the one that landed:
the opt-out a frame has is to not write length(), which is a decision taken where it belongs (in the
class, next to the resolution it describes) and in a form nothing can contradict.
Sub-decision — the member .length(): kept. The alternative was renaming the primitive
(resolve_extent()) so the free functions were the only public spellings. It is moot now: the member is
the claim, so it has to keep the name the claim is about.
Fork 3 — The counted case¶
Decision: 3b, both strides (2026-10-13). 3c is what is left of it.
The frame concept is a single count_delimited = frame<H, many<U>>; the O(1)/O(n) split is a
property of the many<U>, not two frame types:
-
static-stride
many<U>(3b):wire_size_of<U>is a compile-time constant. Thecount_delimitedframe isself_delimitedandconstant_length:rbe::frames::length(f)=header().length() + hdr.count() × wire_size_of<U>. -
dynamic-stride
many<U>(variable-stride U): U has no static stride. The frame is stillself_delimited— the count is on the wire, so it finds its own end — but notconstant_length: the run has to be walked to know where it ends. This is the one case the cost axis exists for, and the only one in the whole classification: delimited, but O(n). Such a frame carries nolength(), andframes::distance(f)is the walk that answers for it. The limitation lives on themany(static_stridevsdynamic_stride), so no separately named frame concept is needed.
What it must not be is buffer_delimited, which is what it was until 2026-10-13: the count outranks
whatever the buffer happens to measure (the wire is the golden source), so the bytes after the last
element are not payload however well the buffer fits. aquis::packet is the instance — a UDP datagram
where the two usually coincide, which is exactly why the misclassification was invisible.
-
3c (what is left): a
dynamic_stridemanyopting in toconstant_lengthby resolving its extent once, and — the other half, from the std-views reading ofmany—many<U>computing its iterator category fromU, so a static-stride run is a random-access range (size(),operator[], jump to element i) instead of input-only. Not a new type either way; see A12. -
manycontinues to be handed the element count from the header in step 3 (no behaviour change to the existing test coverage).
Implementation state: both strides are in (2026-10-13; the static one landed first, see
A12). Three pieces, one per
layer, and the dynamic case adds a fourth by removing something — it has no length():
// the deduction: rbe::detail::frame_delimiting
else if constexpr (contains_annotation<H, rbe::payload_count> and frames::is_many<P> and has_static_stride<P>())
return ^^frames::static_stride_delimited_tag;
// step 2, the bytes: rbe::frames::dsrl::counted_extent -> count * static_length<many_element_t<P>>
// the runtime length: dsrl::frame::length() -> header().length() + counted_extent<payload_type>(header())
counted_extent is the only place in step 2 where the payload type takes part at all; everything else
there reads the header. many is untouched: it is never asked for an extent, because the count and the
stride settle it above.
The frames that moved are the HeaderWithCount fixtures in the tests. aquis::packet did not: its
elements carry rbe::frame_length and an any, so the stride is dynamic and the packet stays
buffer_delimited, exactly as before. The visible change for a frame that did move is that it now ends
where the run ends: trailing padding is outside length(), as_span() and payload_span(), and a
stream of them is walked by length() instead of by payload().remainder().
Fork 4 — Result type of the extent rule (step 2)¶
Decision: std::expected<size_type, rbe::error>.
The project already intends to replace all std::optional returns with std::expected.
The error type is the library-wide rbe::error (or whatever the unified error type
becomes), not a dedicated extent_error enum. The incomplete/malformed distinction is
carried as error codes within that type. Propagation scope: step 2 and frame::make;
propagation to header::make, any::make and the deserialize entry points is a
separate, subsequent change.
Fork 5 — many's stop reason¶
Decision: done() stays as bool; add status() returning an enum; retire has_seen_partial().
enum class many_status {
running, // more elements may follow
consumed, // buffer exhausted cleanly
count_reached, // payload_count satisfied; bytes may remain in buffer
incomplete, // buffer ends mid-frame — wait for more data
malformed, // no continuation can recover — resync required
};
bool done() const; // status() != running
many_status status() const; // replaces has_seen_partial()
has_seen_partial() is removed; callers migrate to status() == many_status::incomplete.
many also gains consumed() (bytes eaten so far), which is what a counted_extent
rule (3c, when implemented) would call, and which makes many::as_span() / data()
mean the same thing they mean on frame and proxy.
Fork 6 — How the classifier learns a custom rule's axis-A category¶
Decision: resolved (2026-10-12), but not as 6b — the frame declares it, not a policy.
There is no policy to carry the category, because 1a is deferred. The answer is the member every frame already has to expose:
template<is_frame T>
using delimiting_concept_t = typename T::delimiting_concept; // declared
template<header H, typename P>
using frame_delimiting_tag = …; // deduced, what the library's frames declare
rbe::frame and rbe::frames::dsrl::frame declare frame_delimiting_tag<H, P>, the deduction from
their shape; a 1c frame declares its own and that is the end of it — there is no precedence rule to get
wrong, because there is a single channel. is_frame requires the member, so the accidental-self-delimiting
bug (#4) cannot occur: a frame with no stated category is not a frame.
If 1a ever lands, 6b folds in without changing this: the policy would declare E::delimiting and the
frame would forward it as its delimiting_concept.
Independently, both bugs listed here are fixed: the deduction names every structural case explicitly
with a static_assert on the fallthrough (no != buffer_end, bug #5), and it tests is_frame / is_any
/ is_many before wirable_payload (bug #6).
Fork 7 — Extent validation¶
Decision: 7c — wide path reports, narrow path asserts.
This is the existing house style:
| Path | Validates how |
|---|---|
frame::make (wide) |
returns unexpected(rbe::error::…) if extent out of range or frame length zero |
frame::resolve / constructor (narrow) |
assert(n <= rest.size() && header.length() + n > 0) in hardened builds |
many::next() |
always guards against a non-advancing step (infinite loop) |
The Extent Rule Contract (unchanged from proposal)¶
Laws L1–L7 from the proposal document stand as written. Summary:
| Law | Requirement |
|---|---|
| L1 — Purity | result depends only on header bytes + rest bytes |
| L2 — Prefix stability | appending bytes never moves a found boundary |
| L3 — Range | 0 ≤ n ≤ rest.size(), frame length > 0 |
| L4 — Three states | incomplete / malformed / extent, with clear semantics |
| L5 — Extent is authority | payload_span() = full n; payload().as_span() may be narrower |
| L6 — Id independence | recommended; violating it inherits dispatch failure modes |
| L7 — Declared category matches | a rule consulting rest.size() is not self-delimiting |
Naming Resolutions¶
| Old name | Landed as | Reason |
|---|---|---|
enum payload_extent |
gone | the enum and its file were deleted; the deduction returns a tag directly, so the clash with the function of the same name (#8) is moot and delimiting_kind never existed |
function payload_extent |
unchanged (rbe::frames::dsrl::payload_extent) |
it is the runtime byte-bounds helper of step 2; nothing clashes with it any more |
has_seen_partial() |
not yet status() == many_status::incomplete |
Fork 5 is unimplemented |
sized_frame / measurable_frame |
constant_length |
one name for the cost axis; keeps _delimited for the mechanism axis |
self_delimiting_frame |
self_delimited |
mechanism axis only; participle, so the concept and its tag read as the same word |
explicitly_delimited_frame |
explicitly_delimited (node) + length_delimited (leaf) |
the node says "a wire field settles it", the leaf says "in bytes" |
implicitly_delimited_frame |
implicitly_delimited (node) + static_delimited (leaf) |
type_delimited was dropped: the node is the interior answer for an id or an extended header |
counted_delimited_frame / counted_frame |
count_delimited |
one frame concept; the static_stride / dynamic_stride split is a many<U> property |
length_field_delimited / count_field_delimited |
length_delimited / count_delimited |
_field was redundant with what each one documents |
custom_delimited |
gone | see A8 |
dispatch_delimited |
gone | see A7 |
All of the above live in rbe::frames (see A10).
Amendments from the implementation (2026-10-12)¶
Implementing the two recursive predicates (self_delimited, static_delimited) turned up four points
this document got wrong or left implicit. The code now reflects the amended version.
A1 — constant_length is a recursive walk, not the union of the O(1) leaves¶
Two frames are O(1) without matching any leaf named above:
- An extended header. A header annotated
rbe::header_lengthdeclares its own length on the wire and may be longer thanwire_size_of<header_type>().frame<WithHeaderLength, StaticPayload>is thereforeself_delimitedandimplicitly_delimited, resolves in O(1), and is not a compile-time constant — so it is notstatic_delimited, and it has no leaf of its own at all. - A nested frame. The cost of a frame is the cost of its whole nesting chain, not of its outermost mechanism: an outer frame with no length field over an inner count-delimited run is O(n) however the outer header reads.
Both break a union-of-leaves definition. The answer is rbe::frames::detail::is_constant_length<T>(), a
recursive walk: it reads the frame's tag, refuses the one non-O(1) tag, and otherwise recurses into a
nested payload, so one slow level settles the cost for everything above it. (This amendment originally
specified a walk over the payload_extent enumerators; the enum is gone, and the walk is over tags —
see A9.) The mechanism tags stay as
the structural vocabulary; the cost axis is computed, not enumerated.
It follows that constant_length holds for every frame in the tree today: every structural mechanism
— a wire field, an id lookup, a static size, the buffer size — is O(1), and a declared tag is taken at
its word (A11).
That stopped being true the next day, and the walk stopped being the definition with it: Fork 3b landed
for both strides, so a counted run of variable-size elements is the first deduced frame that is not O(1),
and the public axis became the detection of length()
(A13). The walk described here
is still what decides whether the library's own frames expose that member, which is the whole of its job
now.
A2 — dispatch_delimited is a leaf of its own (superseded)¶
Reverted the same day — see A7. Kept here because the observation it is built on still holds: an
anypayload resolves its length from the id, which is O(1) but a runtime value, so it is notstatic_delimited. What was wrong was giving that case a leaf of its own; it is the interior node,implicitly_delimited, together with the extended header.
A3 — The counted case is blocked on many's byte extent (superseded by A12)¶
Superseded the same day. The blocker was real but the fix was not needed: once only the static-stride half is deduced, nothing has to walk and nothing has to report a walk, so
manynever needs an extent of its own. Kept because the diagnosis below — that the original A3 blamed the wrong layer — still stands, and because the walk described here is what Fork 3c will need.
A3 said the order was Fork 2 first, then Fork 3b, on the grounds that flipping the constant sends
dsrl::frame::length() into header().length() + payload_length(payload()) and a dynamic_stride run
"has no O(1) answer to give". The diagnosis was off by one layer: an O(n) answer is a perfectly good
answer and the concept is what reports the cost (Fork 2, resolved). What is actually missing is that
dsrl::many cannot say how many bytes it occupies at all — it has no length() and no size(), and
its as_span() / data() return remainder(), the rest of the buffer, because it is a consuming cursor
rather than a view with a known extent. detail::payload_length(many) therefore does not compile.
So 3b is unblocked by Fork 2 and blocked on giving dsrl::many an extent. The walk belongs in step 2
(payload_extent), not in the many's constructor: a many is already built over whatever step 2
returns (construct_payload → view {bytes, payload_count(hdr)}), and payload_extent<P, H> ignores P
entirely today — it consults header::delimits_payload and otherwise hands back all of rest. Acting on
P there is the whole change, and it keeps the resolution to one pass:
step 2, for a many payload |
returns | cost |
|---|---|---|
has_count, static stride |
rest.first(count × static_length<U>) |
O(1), nothing walked |
has_count, dynamic stride |
walk the count elements once, then rest.first(total) |
O(n), once |
| no count | rest, as today |
O(1) |
many then needs three lines and no new state: length() returns data_.size_bytes() (the bytes step 2
bounded, so O(1) for every case), and as_span() / data() stop returning remainder() — the cursor —
so that the as_span() == {data(), length()} invariant every other view honours holds here too.
remainder() stays for the cursor's own users (has_seen_partial()). split_span already keeps both the
whole buffer and the cursor, so nothing is recomputed.
Walking in the constructor instead would pay twice: once to resolve, once to iterate. In step 2 it is
paid once, and it is also where the wide form gets a real precondition check for free — walking the run
reveals a truncated buffer, so try_payload_extent can return nullopt from make() instead of leaving
it to be discovered mid-iteration by has_seen_partial() (Fork 7).
What stays expensive is inherent: for a dynamic-stride counted run, make() walks and then the reader
walks again. Nothing can locate the end of a variable-stride run without traversing it, which is what
not constant_length is there to announce.
Both markers this amendment named are gone: counted_runs_are_delimited was replaced by the annotation
and stride conditions, and the static_assert(buffer_delimited<many_frame>) in
tests/runtime/test_dsrl_frame.cpp is now static_assert(static_stride_delimited<many_frame>). Every
node of the counted branch is inhabited as of 2026-10-13, the dynamic one by aquis::packet.
A4 — static_stride / dynamic_stride need an element alias¶
They are many<U> concepts as decided, which means reaching U from the many at any level. The draft
spelled it frame_payload_t<T>::value_type, which exists on neither rbe::many (it had no alias at all)
nor dsrl::many (value_type is on its iterator; the element is frame_type). Both now spell the
element frame_type, reached through rbe::frames::many_element_t<M>, next to header_t / payload_t
in frame_concepts.hpp.
A5 — The mechanism axis is a tag hierarchy, the cost axis is a walk¶
The concepts are no longer predicates computed from the structure on every query. The mechanism axis is a
hierarchy of tag types that derive from one another, the way std::forward_iterator_tag refines
std::input_iterator_tag:
self_delimited_tag
|-- explicitly_delimited_tag
| |-- length_delimited_tag
| `-- count_delimited_tag
| `-- static_stride_delimited_tag
`-- implicitly_delimited_tag
`-- static_delimited_tag
buffer_delimited_tag
Three things follow, and all three were problems this document had left open:
- Refinement is derivation, so every concept is one
std::derived_fromoverdelimiting_concept_t<T>and the hierarchy cannot drift from the concepts that read it. - An interior node is a usable answer. The extended-header case of
A1 had no leaf and could
not be named by a predicate; it is
implicitly_delimited_tagexactly. - A declaration made deep in a nesting chain reaches the outermost frame, because the deduction recurses through the nested frame's tag rather than re-deriving from its shape. A structural walk cannot do that, which is what made the 1c opt-in (Fork 1) useless where it mattered.
The cost axis is deliberately not a tag: a tag speaks for the frame that carries it, and the cost
belongs to the whole chain. It stays a walk (is_constant_length), as A1 said.
Deduced by default, declared where it is not deducible: this is the std::iterator_traits /
ITER_CONCEPT shape, and the same rule std follows for ranges — detect what is structurally detectable
(sized_range), declare only what is not (enable_borrowed_range).
A6 — is_frame requires the category, which replaces the self_delimiting opt-in¶
template<typename T>
concept is_frame = requires {
typename T::header_type;
typename T::payload_type;
requires header<typename T::header_type>;
requires declares_delimiting_concept<T>;
};
The category is part of the frame shape, so a frame with no stated category is not a frame. That closes
bug #4 by construction and supersedes the static constexpr bool self_delimiting opt-in specified under
Fork 1c: a declaration that says which category is strictly more than one that says yes/no, and it is
the same member the library's own frames carry.
It is required as a member, not through delimiting_concept_t, because the trait is constrained on
is_frame and asking it here would make the concept depend on itself.
Two costs, both accepted:
- A frame written from scratch, deriving from nothing, is not a frame until it declares one.
rbe::frameandrbe::frames::dsrl::framededuce it, so this only bites a hand-written one — where it is the point. - The diagnostic for a forgotten member is "does not satisfy
is_frame", not "you forgotdelimiting_concept". Astatic_assertinmany/frame::makewould fix that; not done.
L7 is unchanged and still a promise: nothing verifies that a declared self_delimited_tag does not peek
at rest.size(), exactly as nothing verifies enable_borrowed_range.
A7 — dispatch_delimited is gone (reverts A2)¶
An rbe::frames::any payload now deduces implicitly_delimited_tag, the interior node, which already
means what the dispatch leaf meant: O(1), settled at runtime, not a compile-time constant. The leaf bought
nothing — neither length(), nor the cost walk, nor many ever asked for it — and the tag went with the
concept, since a tag no concept queries can only be read by comparing delimiting_concept_t by hand.
The visible consequence is in the examples: cboe::top::message, nasdaq::message and opra::message
assert implicitly_delimited instead, and the id case is documented as one of the two node-only answers.
A8 — custom_delimited is gone: the bare root is the escape hatch¶
The category was a trap for its only client. cboe::top::line_view — the one custom frame in the tree —
declares self_delimited_tag, because its LF scan genuinely does find its own end; had it declared
custom_delimited_tag, which derived from nothing, it would have stopped being admissible as an element
of rbe::frames::many. "Custom" is provenance, not a category: the answer to who knows where the frame
ends is still "the frame itself".
So the bare self_delimited_tag is the escape hatch. It was also read, at first, as carrying a cost
claim — the root claims nothing about complexity, so is_constant_length returned false for it — and
that part is reverted in A11: it is a mechanism
answer and only that. The two clauses and not custom_delimited<T> on self_delimited /
buffer_delimited disappeared with the category — they had been redundant since the tags started
deriving.
A9 — payload_extent is gone: one authority for the classification¶
rbe/framing/detail/payload_extent.hpp (the payload_extent enum, payload_extent_of,
is_self_delimiting, is_dispatch_delimited, is_constant_length, static_frame_size) was deleted. The
deduction in rbe/frames/detail/frame_delimiting.hpp reads the frame directly — the length annotations of
its header, then the category of its payload — and is the single authority on the precedence between them:
the wire is the golden source, so a header length field outranks whatever the types would answer.
With it went the five rbe:: concepts that read it (self_delimiting_frame, buffer_delimited_frame,
explicitly_delimited_frame, implicitly_delimited_frame, dispatch_delimited_frame); dsrl::many,
dsrl::views and dsrl::frame::length() now ask rbe::frames::, which is how a declared category finally
reaches the runtime (the point of Fork 1c).
rbe::frames::dsrl::payload_extent — the function that returns the payload's bytes in step 2 — is a
different thing and stays.
A10 — Where everything lives now¶
src/rbe/frames/ (was src/rbe/framing/)
rbe::frames:: frame, many, any, blob, is_frame, header, payload, serder,
the tags, frame_delimiting_tag, delimiting_concept_t,
self_delimited … static_length
rbe::frames::dsrl:: frame, base_frame, many, any, header, payload_extent, …
rbe::frames::srl:: frame, many, any
rbe::frames::views:: many, with_ids, known_ids, unknown_ids (+ alias rbe::views)
rbe::frames::length / distance / constant_length (length.hpp)
rbe:: deserialize, try_deserialize
rbe::frames::detail:: the module's private machinery (see A14)
rbe::frames::dsrl::detail:: the lowering's own private machinery
The whole module is under rbe::frames except the two verbs. Two things could not follow and both are
hard constraints, not taste:
rbe::dsrlcould not be aliased.namespace rbe::dsrl = rbe::frames::dsrl;is ill-formed:rbe::dsrlis a real namespace with its own members insrc/rbe/dsrl/(proxy, the strategy tags,deserializefor wirables), and an alias cannot share a name with it. Ausing namespacepatch compiles but splits ADL.rbe::viewscan be aliased, becauserbe::frames::viewsis its only home — which is exactly howstd::viewsnamesstd::ranges::views.- Names inside
frames::lost theframe_prefix only where the namespace replaces it:header,payload,header_t,payload_t,serder_payload,wirable_payload.frame_serderkeeps it (it classifies a frame, likesized_rangekeepsrange), andis_frame/is_any/is_manykeep theis_because the bare names are the types.
A11 — The cost axis is deduced only, never declared¶
Partly superseded on 2026-10-13 by A13. Its conclusion stands — a tag never carries a cost claim — but its argument against detecting the cost from
length()rested onlength()being mandatory, and it no longer is. The axis is now a detection, which is the first bullet below, adopted rather than rejected.
The bare self_delimited_tag was doing two jobs: answering the mechanism question ("I find my own end,
by none of the mechanisms the refinements name") and, silently, the cost one ("and do not count me as
O(1)"), because is_constant_length returned false for it. That second job is removed. The branch is
gone from the walk, so a frame that declares the root is constant_length like any other, and the one
category the library can actually see to be O(n) — a counted run with no static stride — is the only
thing the axis reports.
Three shapes were weighed and two rejected:
- A
disable_constant_length<T>variable template, thestd::disable_sized_rangetransplant: pair it with a syntacticconstant_length<T> = requires { f.length() }and the axis becomessized_range. It does not transfer whilelength()is mandatory — which is the premise A13 removes, making the second half of this bullet the design and thedisable_half unnecessary.disable_sized_rangeearns its keep because.size()is optional in ranges, so its presence carries information;length()is required bydsrl::is_frame, so the detection is a tautology — true for every frame — and all the information would move into the opt-out. That inverts the default from "the library deduces what it can see" to "everything is O(1) until someone remembers otherwise", including the library's own counted runs, which it already knows statically. The specialization that fixed those would have to recurse down the nesting chain:is_constant_lengthagain, inverted, in a second channel that can contradict the first. - A
static constexpr bool constant_lengthmember on the frame, declared next to the tag, present meaning "this is the answer" and absent meaning "deduce it". This one is sound — it is the same declared-over-deduced shape as the tag itself, it reads exactly like the question the concept asks, and it offers the opt-in too, which would have retired the "implicitly_delimited_tagis the O(1) slot" puzzle. It was dropped because nothing needs it: the only case that motivated it is a custom frame whose resolution is O(n), and no decision in the library turns on that. The user's own frame paying for its own scan is the user's business, exactly as an O(n²)size()is in ranges. - Keeping the cost in the tag hierarchy, either as today's overloaded root or as a named leaf
(
scan_delimited_tagunderself_delimited, making the mechanism a legible trichotomy: a wire field / the types / computed in code). The named leaf fixes the legibility and keeps one channel, but it still requires a custom frame to know which node carries which cost — which is the thing that made the overloaded root unintuitive in the first place.
What landed is the third option minus the cost: a tag answers the mechanism, and nothing else. The
cost axis is computed from the chain of tags, with one documented, unverifiable contract standing in for
everything it cannot see — length() is O(1) on a constructed frame — in the same place and for the same
reason sized_range asks size() to be O(1) and cannot check it. A frame that resolves itself in code
honours the contract by resolving in make() and keeping the result, which is what cboe::top::line_view
and the line_view in tests/runtime/test_dsrl_frame.cpp do (the LF scan, then a stored line_length_).
The cost of this is stated plainly: constant_length is now true for every frame in the tree, so the axis
carries no information until Fork 3b lands (A3).
It is a classification waiting for its one real client, not a classification of nothing — and the
alternative was a declaration channel for a case the library never consults.
A12 — A count delimits by the cheapest means it has, and many is one type¶
The first half was superseded within the day. Fork 3b first landed for the static stride only, with the dynamic case left
buffer_delimitedon the grounds that nothing then had to walk. That was wrong for a reason the half-measure made visible:length()was still answering for such a frame, and the answer was the buffer size, which is not where the frame ends. The fix was not to walk inmake()but to stop answering — see A13. What stands from the original is everything aboutmanybelow, and the staging: the static stride arrived first, and it is still the only one that costs nothing.
A count delimits a frame, and the stride decides what that costs. count × static_length<U> is read
off the header and resolved without touching an element (dsrl::counted_extent, the only place in step 2
where the payload type takes part). Over a variable stride the count still says where the run ends, but
only measuring every element finds it — so the frame is count_delimited and not constant_length, has
no length(), and answers through frames::distance.
Two things fall out:
- No walk is ever paid up front. Not in
make(), not in a constructor. The static case is a multiplication; the dynamic case walks only when someone asks for the extent, which is what askingdistancemeans. manykeeps no new state and is never asked for an extent. The cursor-versus-view question aboutmany::as_span()/data()therefore stays open and harmless.
Why not a separate many type for it. The proposal was a second vocabulary type for counted or
static-stride runs. The precedent it was argued from says the opposite: std::ranges has one
transform_view, whose iterator_concept is computed from the base range — there is no
random_access_transform_view. many<U> is that shape already: one template whose properties come from
U, exposed as static_stride<M> / dynamic_stride<M>, and read by the deduction through
has_static_stride<P>(). A second type would duplicate the template to express something the first one
can compute.
What the views analogy does argue for is the part many is missing, and it reframes Fork 3c: the
iterator category. A many<U> over a static-stride U can be a random-access range — size(),
operator[], jump to element i at i × stride, no traversal — while a dynamic-stride one is input-only,
as all of them are today. That is one type computing its category from its element, exactly as
views::take is random-access over a vector and input over an istream. So 3c is not
random_access_many<U>; it is many<U> computing iterator_concept from U, plus the walk (and the
not constant_length it finally makes reachable) for the counted dynamic case.
A13 — length() exists only where it is O(1), which makes the axis a detection¶
The bug that forced this: a counted run of variable-size elements was classified buffer_delimited, so
dsrl::frame::length() answered with the buffer size. The frame does not end where the buffer ends — it
ends after element number count — so the answer was wrong whenever the two differed, and silently right
for aquis::packet, where a UDP datagram makes them coincide. Classifying it count_delimited (A12) only
moved the problem: now the category was honest and length() still had to answer something it could not
compute in O(1).
The resolution is that it answers nothing. rbe::frames::dsrl::frame::length() is constrained on
rbe::detail::is_constant_length<frame>(), so it does not exist for such a frame, and
rbe::frames::dsrl::is_frame no longer requires a length() at all. Everything derived from it is
conditional too — as_span(), payload_span() and admissibility as a many element — which is the
std::ranges::view_interface shape: view_interface::size() exists only for a sized range, and for
the same reason, that a view which cannot measure itself cheaply should not hand out a measurement.
Three consequences, all of them things this document previously decided the other way:
- The cost axis is now a detection, not a deduction.
constant_length<F>asks whetherFcarrieslength(). That isstd::ranges::sized_rangeexactly — the transplant A11 rejected, and it rejected it for a reason that was true at the time:length()was mandatory, so the detection was a tautology. Making the member conditional is what removes the premise, and nothing about A11's other conclusion changes — a tag still carries no cost claim, and there is still nodisable_constant_length, because a frame opts out by not writing the member. The deduced walk survives with one job: deciding whether the library's own frames exposelength(). Deduced for ours, detected for the user's —std::iterator_traitsagain. One wrinkle: a vocabularyrbe::frames::framedeclares types and no members, so there is nothing to detect on it. It delegates to itsdsrl_type, the view that has the API, and the answer is the same one by construction since lowering never changes the category. The first version of this fell back on the deduced walk instead, which gave two answers for one frame: a hand-written view that opts out by supplyingdistance()came backconstant_lengthat the vocabulary level, where there was no member to inspect and the bare-root tag was presumed O(1).
For the same reason the condition on dsrl::frame::length() is not the walk alone: a nested frame has
to be able to answer too, and a hand-written one may declare a category and define no length(), so the
constraint asks the nested frame's member. Both halves of the axis now recurse by one rule, and the
terminated_line / wrapped_line fixtures in tests/static/test_framing.cpp pin it: a declared tag
with no member answers nothing, and nothing above it can answer either.
2. A custom frame declares its cost by which member it writes, with no tag and no bool: length()
means O(1), a distance() of its own means "I can find my end, it costs what it costs". That is the
escape hatch A11 could not offer without a second
channel, and it is not a second channel — it is the operation itself.
3. many<T> now requires constant_length<T>. The run advances by the element's length(); an
element without one would have had to be walked per step, which is an O(n²) run. The earlier rule
("self_delimited is enough, O(1) is not required") was written when every frame had a length() to
advance by.
What it costs: aquis::packet loses length(), as_span() and payload_span(). Nothing used them — the
example reads the header's count and iterates — and frames::distance(packet) is there for a caller who
does want the extent and accepts the walk. That is the whole point of the pair of names.
A14 — The module's private names are rbe::frames::detail¶
Everything under src/rbe/frames/detail/, plus the tag base and the extent-detection concepts, moved from
rbe::detail to rbe::frames::detail (2026-10-13). The convention it buys is one line long:
unqualified
detail::is the module's own;rbe::detail::is a sibling module's.
An earlier amendment recorded the opposite — that all detail stays in rbe::detail, because an
attempt to introduce rbe::frames::detail had broken candidate_list.hpp. That attempt moved part of
the machinery, so the rest became siblings reached by a name that no longer resolved; moving all of it at
once leaves only genuine siblings to qualify, and there are ten of them
(invoke_concept, normalize_type, id_of, id_type_of, no_throw, split_span, unmatched,
specialization_of, member_alias_of, default_context).
What it fixes is not tidiness. rbe::frames code refers to its machinery as unqualified detail::, and
lookup of that name stops at the innermost namespace called detail — it does not continue to
rbe::detail. to_value_type.hpp happened to declare an empty namespace detail { } inside
rbe::frames, so whether detail::is_tag in a neighbouring header meant rbe::detail::is_tag or a
compile error depended on the order the two headers were included in. That placeholder is gone and the
ambiguity with it: there is exactly one detail reachable unqualified from inside the module, and it is
the module's own.
Names that moved, for anything outside the module that referred to them: any_tag, many_tag,
candidate_list, candidate_index, compatible_candidates, belongs_to, hashable, insert_unique,
seen_ids, diagnose_compatible_candidates, frame_delimiting, is_tag, has_static_stride,
static_frame_size, is_constant_length, to_dsrl / to_dsrl_t, to_srl / to_srl_t,
delimiting_tag, and the detection concepts of length.hpp. Only the tests referred to any of them.
rbe::frames::dsrl::detail already existed (any_dispatcher, payload_size) and is unaffected: it is one
level deeper, so inside rbe::frames::dsrl an unqualified detail:: is that one, and the module's own is
spelled frames::detail::.
Also noted, not acted on¶
An explicitly empty payload is now static_size (zero bytes) rather than buffer_end, so a header-only
frame is static_delimited instead of swallowing the rest of the buffer. It is only reachable at the
lowered level, though: rbe::frame_payload admits frame_wirable (so, explicitly_empty) while
rbe::frame_serder_payload requires wirable or serder_traits, which rejects it, so
rbe::frame<H, Empty> is ill-formed and dsrl::frame<H, Empty> is not. That inconsistency is §10
(header-only messages), not this document.
The compile cost of resolving a category once per frame type, rather than per concept query, was measured
and is not measurable in a representative TU (2.67 / 2.66 s with the delimiting_concept member
against 2.62 / 2.89 s without it, ccache disabled). The member is instantiated with the class, so it is
paid whether or not anything asks; it does not show up.
Open Items (not resolved here)¶
Decisions still open
- Fork 3c — what a
dynamic_striderun could still gain: resolving its extent once so it becomesconstant_length(and gets alength(), and becomes admissible in amany), andmany<U>computing its iterator category fromUso a static-stride run is random-access rather than input-only. Both are additive; neither is needed for the classification to be correct now (A12). - OQ-9 — Length units for
count_delimited:count × wire_size_of<U>is in bytes only ifwire_size_ofis; document the unit contract before shipping 3b.
Deferred, with no date
- Fork 1a — extent policy as a third template parameter: the only thing still missing for custom frames to stop being artisanal (~40 hand-written lines per frame today). Additive: it changes no classification, it removes boilerplate, and it is where 6b would fold in.
- Fork 1e — declarative terminator annotation: blocked on 1a.
many::as_span()/data()are cursor-shaped, returning the remainder rather than the run; nothing depends on it today, and it starts to matter if 3c gives amanyan extent of its own.- Fork 4 —
std::expectedin step 2: not started (std::optionaleverywhere), and its propagation needs the unifiedrbe::errortype first. Fork 7's wide path therefore reports that it failed, not why. - Fork 5 —
many_status/consumed(): not started;has_seen_partial()is still there. - A declared constant the library cannot compute: see the last paragraph of Fork 1c.
frames::distanceover two frames: the unary form is in (rbe/frames/length.hpp); the stream-walking form the name suggests (distance(first, last)) is not written, and nothing needs it yet.
Known soft spots
- L7 is unverifiable. A declared
self_delimited_tagon a rule that peeks atrest.size()is a lie the compiler cannot catch, exactly as withenable_borrowed_range. - Diagnostics for a forgotten
delimiting_concept: the error is "does not satisfyis_frame". Astatic_assertinmany/frame::makewould say what is actually missing. - OQ-8 —
same_extent_when_lowered: the invariant is now checked assame_tag_when_loweredoverframe_delimiting_tag<H, P>vsframe_delimiting_tag<H, to_dsrl_t<P>>intests/static/test_framing.cpp. It needs restating in prose here, and again if 1a lands (the policy would live at one level only). framing-design-notes.mdis stale on all of the above: it still describespayload_extent_ofas the authority and points at the deleted header.