Framework: FRAMEWORK.md (one reference set + honest frontier rule; Law I/II; resource → conflict → resolution; locality dividing line; stability requirement). Design lineage: Peritext (PRIOR_ART.md §5) — the anchoring and span model, with hash-committed ids and causal supersession in place of Lamport ids and timestamp LWW.
Rich-text formatting and span annotations (bold, links, comments) over a seq object: anchored to elements, not indices; concurrent mark/unmark converges; spans survive deletion of the underlying text. The substrate already provides everything the model requires — stable element ids, tombstones that keep resolving, and both-sided glued anchors.
struct MarkOp {
start: Anchor, // glued points — HASHSEQ_SPEC.md
end: Anchor,
kind: Id, // value commitment, e.g. value_id("bold")
value: Id, // value commitment: flag, payload, or an
// object link (comment thread);
// TOMBSTONE = unmark
overwrites: BTreeSet<Id>, // mark ops this op saw and supersedes
// within [start, end]
}
struct MarkNode { refs: BTreeSet<Id>, op: MarkOp }
// id = BLAKE3::derive_key(NODE_CONTEXT, canonical_encoding) — the family's
// single context; op kinds are tags in the encoding (HETEROGENEITY.md)One shape covers mark (value = value_id(true) or a payload,
overwrites = the visible same-kind marks it replaces), unmark including
partial unmark (value = TOMBSTONE over the sub-range; the overwritten
bold keeps applying outside it), and re-style (a link's new URL,
overwrites = {old}).
named(u) = { anchor_id(start), anchor_id(end) } ∪ overwrites
refs(u) = named(u) ∪ frontier pins // kind and value are values, not references
The pinned frontier is the mark layer's own: marks are downstream-only
(marks reference content; content never references marks), so mark ops never
enter the text object's tips or touch its runs — the frontier-granularity
choice per LAYERING.md. Anchor ids are refs, so a mark arriving before its
text parks as a normal orphan. kind and value are value commitments —
never buffered on, pending when unresolvable.
An anchor is a glued point (HASHSEQ_SPEC.md): Before(c) is crossed
immediately before emitting c, after all of c's before-descendants;
After(c) immediately after c, before any of its after-descendants.
Anything later inserted into the adjacent gap lands on the same side of the
point, so span membership is unambiguous. Marks are regional: the two
points are fixed for life at their anchors' base slots (origin ghosts —
a Move never relocates a point, so no placement op can reshape or drag a
span's region), and an element is in the span iff its rendered
crossing falls strictly between them. A moved-out element sheds the
region's marks; a moved-in element acquires them. Formatting that should
travel with moved content is editor policy — the move gesture authors a
re-mark — exactly as edge expansion is anchor choice.
Grow-at-edges behavior ("typing at the end of bold text continues bold; at
the end of a link does not") is anchor choice, not a flag. For a span
with first/last elements s/e, p preceding and n following:
| edge behavior | anchor | why |
|---|---|---|
| start, non-expanding | Before(s) |
gap inserts land before the point |
| start, expanding | After(p) |
gap inserts land after the point |
| end, non-expanding | After(e) |
gap inserts land after the point |
| end, expanding | Before(n) |
gap inserts land before the point |
Bold = Before(s) .. Before(n); link = Before(s) .. After(e). The policy
is chosen by the editor at op-creation time and committed in the artifact —
the CRDT layer needs no registry of mark kinds to converge.
Edge cases: expanding start at document start = After(origin) (a real
interned node). Expanding end at document end — deliberately no
sentinel (HASHSEQ_SPEC.md): the editor extends the span with an overwrite
mark as typing continues at the boundary, or the app maintains its own
terminal element to anchor Before of. Anchors to tombstoned elements keep
resolving — tombstones keep their slot; that is why they exist.
One register per (element, kind): the k-formatting of element x,
claimed by every k-mark covering x.
The live set at (x, k) is every k-mark covering x not named in the
overwrites of a same-kind op that also covers x. Suppression is
range-scoped: an overwrite erases its targets only where the
superseding op's span overlaps them — and kind-scoped: cross-kind
entries in overwrites are ignored by the definitional filter (mirroring
HashKv's same-key rule; never gated — HASHWEB_SPEC.md "Gate vs filter"). A conflict is a multi-head live set — non-supersession, per
FRAMEWORK; the honest-author lemma connects it to concurrency exactly as
everywhere else.
- MVR-first: expose the live set. No LWW — there is no timestamp input.
- Cosmetic ambiguity (which of two identical concurrent bolds to
attribute) →
max-Iddisplay tiebreak, never semantics. - Semantics-bearing values — the sharp case is a link's URL — freeze: render the conflict state (link disabled, all targets surfaced). Hash order is grindable; a ground id must not silently win a phishing target (locality dividing line).
- Comments never arbitrate: concurrent comments all survive. A comment
thread is
value = <the thread object's origin id>— replies are seq inserts in the child object (HASHWEB_SPEC.md).
The classic hard cases, as they converge here:
- Concurrent bold vs overlapping unbold: the unbold kills only what it names; a concurrent bold it never saw survives in the overlap — add wins, surfaced as a multi-head, never timestamp-flattened.
- Insert into a gap inside a concurrently-unbolded sub-span: the new element falls between the unmark's points → covered by it → not bold.
- Span text deleted, new text inserted between the tombstones: between the points → inherits the mark (documented, slightly surprising, correct).
- Adversarial inverted span: gated at apply — Validation below.
O(1) bookkeeping: intern; attach start/end events to the anchor elements
(anchor_events: element → mark events); update mark-layer tips.
Suppression is computed at read, never at apply.
One treap-order pass: anchor events toggle the active set as their ghost
(base) slots are crossed — tombstoned and moved-out slots included — and
each element samples the active set where it renders (its base slot, or
its destination fragment when moved), emitting coalesced
(text, FormatSet) spans. An unmark/overwrite op is itself an interval,
so suppression is interval-vs-interval inside the sweep. Cost
O(text + anchor events); marks attach by id, so text edits never
reposition marks. Point queries compare sweep positions (rendered element
vs base-fixed points) in O(log F). Wire: mark volume is orders of magnitude below element volume —
individual ops, dict + positional refs (ENCODING_SPEC.md); a "format
painter" session chain can run-compress later if profiles say so.
- Inverted span (end point before start point) — an apply-time gate:
- the check can always run at apply: anchor ids are refs, so both elements are present when the op leaves the orphan buffer;
- it is one
cmp_ordercomparison over the base order — O(log F), resolved through origin ghosts (HASHSEQ_SPEC.md), so a concurrent or laterMovecan never flip a verdict; - a failed check is permanent (base order is immutable) and convergent (all replicas agree on every verdict);
- no honest op ever depends on one: honest replicas gate before apply, so inverted marks never enter honest mark-layer tips. Defense in depth: the sweep stays activation-guarded — an end event for a never-started mark is inert rather than perturbing the active set — so a future relaxation of the gate cannot reintroduce the formatting-leaks-to-end-of-document failure.
- Anchor kind: anchors must name elements, the origin, or a move op's
splice point, in one
Seq— a row of the edge table (HASHWEB_SPEC.md). An op-anchored endpoint brackets wherever the op's target renders (the regional exception that expresses "cover that moved-in word"); its position is the op's id-rank in its anchor's fork order — permanent, so the inverted-span verdict stays stable — and ops that marks anchor at retain their fragment for life. kindandvalueare ids (BLAKE3 outputs), so in-memory keying by kind id is fast-hash-safe — no SipHash needed (the HASHKV_SPEC.md key rule; adversarial kind bytes cost their author indirection, never a table).- Amplification: one O(log F) comparison plus one quarantine entry per malicious op — linear in attacker effort. Mark spam over huge ranges costs the renderer O(anchor events), not O(range); MVR set growth is the application-surfaced symptom. Note the gate does not shrink the spam surface (valid empty spans are always authorable); its goals are bounded cost and no rendering leak.
- Overwrites hygiene. Should a new bold be required to name the bold it covers (keeping live sets minimal)? Leaning yes for honest editors — the HashKv discipline — but stacking must converge regardless, since a Byzantine author can always stack.
- Cross-object spans (a mark spanning multiple text objects): punt — marks are per-object; the editor splits the gesture into one op per object.
- History retention. Superseded marks: keep (time travel) vs ids only. Same question as the seq placement-register spine; likely the same answer.
Merge-law props (commutative/associative/idempotent) port directly; the
Peritext worked examples become a fixture suite (each a small op DAG with an
expected rendered span list); plus the quickcheck invariant: render is
identical across all delivery orders — including marks delivered before
their anchor text, and now including interleaved Moves of anchored
elements.