Place gives every object one containment register in its own op
DAG, deciding which link atom — anywhere in the store — places it.
Containers decide order; the placed object decides membership.
Concurrent relocation therefore cannot duplicate an object: contention
freezes instead. It is the fourth instance of the supersession pattern
(Remove, Put.overwrites, Mark.overwrites, Move.overwrites).
Framework: FRAMEWORK.md. Rationale: MOVE.md (same-container Move),
CYCLE_REVERT.md (cycle analysis), APP_NOTES.md #29 (the duplicate-link
incident under remove + insert relocation that motivated it).
Status. The register, Place op, wire encoding and read surface
(placement(), wasm placeAt / placementOf) are in the crate
(src/placement.rs). The membership read rule, freeze walk and D4 cycle
detachment are not: the crate exposes the fallback chain, and
web/kb.js implements an approximation of the read layer (QUEUE.md 58;
deviations noted below).
// valid in ANY object (Seq or Kv): tag 5 of the shared node grammar
// (GRAMMAR_SPEC.md "Op kinds")
Place {
placed_at: Id, // value commitment, never a ref: an Insert op id
// in some container (the link atom placing
// this object), or TOMBSTONE (detach/delete)
overwrites: BTreeSet<Id>, // the placement heads this op saw and replaces
// (refs, in THIS object's DAG)
}placed_atis a value commitment in a foreign DAG (HASHSEQ_SPEC.md "Payload"): not inrefs(u), never waited on, never refused, never dereferenced. Supersession, verification, frontiers, sync and orphaning stay strictly per-object.- There is no
parentfield: the parent ishome(placed_at), the object whose DAG holds that insert, derived at read time. A stored copy would be a second copy of a fact and a new mismatch class (GRAMMAR_SPEC.md meta-rules).
A cross-container move is two ops in two objects, one gesture:
Insert { at, payload: X.origin }in the destination — claims the order slot; its op id is the new link atoma₂;Place { placed_at: a₂, overwrites: heads }in X — claims membership.
Neither op needs the other to converge; the coupling is interpretive. A move does not remove the source atom: it goes dead by the membership rule and stays as a ghost (Retention).
named(u) = overwrites
refs(u) = named(u) ∪ frontier pins // placed_at is NOT a ref
The first Place of an object refs at least its origin. Place ops are
ordinary ops in the object's own tips (a separate placement frontier is
the LAYERING.md parameter shared with Move; open thread 3).
Membership applies to atoms whose payload is the placed object's origin (the instantiation capability). Atoms carrying an object id are references (links, transclusion embeds): unlimited in number, invisible to placement registers, and governed only by the render guard (CYCLE_REVERT.md finding 1). The origin / object-id asymmetry (APP_NOTES.md #1) is the type tag. An object has at most one containment placement and any number of references.
heads(X) = { p ∈ Places(X) : ∄ q ∈ Places(X). p.id ∈ overwrites(q) }
A conflict is |heads(X)| > 1. The register root — the implicit
placement every Place transitively overwrites — is the atom whose id is
X's origin when X was born by the composition convention (HASHWEB_SPEC.md
"Op: opening and delivery"); otherwise X starts unplaced.
A link atom a in container P whose payload is X's origin is live
iff a itself is live in P (not tombstoned) and:
Places(X) = ∅ : live by presence (legacy rule)
|heads(X)| = 1 : live iff heads(X).placed_at = a.id
|heads(X)| > 1 : FREEZE — only the last-agreed atom is live; the
contenders' atoms are dead; conflict surfaced
winning value = TOMBSTONE : X is unplaced (detached by intent; deletion
if nothing re-places it)
- Remove-wins absorption: a tombstoned atom confers no membership even when the register names it, so tombstoning a link still means deletion.
- Legacy rule: objects with no
Placeop (older data, never-moved objects) are members by presence; duplicates heal deterministically (first occurrence in a canonical walk wins). An object's firstPlaceswitches it to register membership permanently.
kb.js deviations: for blocks, winningAtomOf (web/kb.js:221) falls
back down the chain past a dead head atom to the next present atom
instead of leaving X unplaced, and treats a TOMBSTONE anywhere in the
chain as deleted; the chain() doc (src/placement.rs:111-116) and wasm
placementOf doc describe the same fallback. For pages, childrenOf
(web/kb.js:406) checks only chain[0], with no fallback.
|heads| > 1 renders X at the last agreed placement: the maximal ops
every head transitively overwrites, recursing down to the register root.
The walk skips an entry whose atom is tombstoned, whose home object this
replica does not hold, or whose value is TOMBSTONE with further history
below, landing on the first placement that renders; if none does, X
renders detached, flagged.
- Never max-id: placement of honest content must not be decided by
anything grindable or fabricatable (MOVE.md "Contested registers
freeze"). The next
Placenaming both heads resolves. - Skipping tombstoned atoms is an op-set function; skipping unheld objects
is ordinary eventual consistency.
placed_atids are raw 32-byte values; no artifact is ever dereferenced to render placement.
PlacementRegister::chain() returns this fallback chain: placed_at
values in descending agreement (single head: the head then its history;
conflict: starting below the heads at the last agreed op). It does not
append the register root; an empty chain means unplaced.
Containment edges parent(X) = home(p₀(X)), where p₀ is X's rendered
placement after the membership and freeze rules, form the p₀ graph.
Cycles need no conflicts (two clean single-head registers can jointly
cycle).
Compute the SCCs of the p₀ graph once. Every member of a cyclic SCC
renders detached, surfaced as a root-level flagged cluster (its
internal edges may be shown); everything else renders at p₀
unconditionally. Detaching only removes edges, so no iteration and no
schedule: confluence is structural. Any member's next Place re-places
it. A self-cycle (placed_at inside X's own subtree) is the SCC-of-one
case; no admission rule. Why D4 and not revert-downward:
CYCLE_REVERT.md.
Not implemented in the crate (QUEUE.md 58). kb.js approximates it: its tree walks guard against revisits, and registered pages the walk never reaches (cycles included) surface in an "unplaced" strip (web/kb.js:449); no SCCs are computed.
heads(X) = heads(X) − overwrites(u) ∪ {u}, no replay. Containment is a
read-time projection, so there is no index to relocate and a container's
own order is untouched. Late or adversarial delivery costs the same as
on-time apply.
Admission rows live in HASHWEB_SPEC.md "Admission": Place is admitted
in both Seq and Kv; placed_at is never checked; Place.overwrites
entries that are not Place ops of the same object are ignored. A
placed_at naming a non-insert, an atom whose payload is not X's origin,
or garbage never matches any atom — inert by the membership rule. Nothing
here is refused, because placed_at cannot be checked without foreign
state and admission must never depend on what a replica holds.
Pre-Place replicas are specified to carry tag 5 opaquely and render by
the legacy rule (GRAMMAR_SPEC.md); current decoders reject unknown kinds
instead (QUEUE.md 48).
- The register spine is retained: every
Placeop's id,overwritesedges andplaced_atvalue — the freeze walk reads them. - Ghost atoms superseded in a register's history SHOULD be retained (the freeze walk may land on them). They MAY be tombstoned for hygiene: the walk then falls further down the chain, deterministically.
- Atoms never named by any register are droppable per ordinary deletion.
| adversarial action | honest cost | bound |
|---|---|---|
| fork-spam on one register | head-set growth; object frozen at last-agreed, flagged | linear in attacker ops |
| cycle bomb (K registers forming loops) | SCC recompute over the component; members detach, flagged; no placement conferred | O(component) per op; the component is attacker-growable, so per-op cost is linear in the attacker's prior spend |
| placing honest content somewhere bad | one dominating Place |
the permissionless-write baseline; attributable, revertible |
placed_at garbage / mismatch spam |
none | never matches; no verdicts to grind |
| stale replicas | legacy membership (duplicates, healed) | no divergence in op state |
- Incremental SCC maintenance under single-edge p₀ changes, plus the
reverse index (atom id → home object) as a substrate cache — both Law
II obligations (cache pinned to the definitional recompute). Candidate:
an eager per-atom
live/homebit maintained onPlaceapply (flip the ≤ 2 affected atoms), a pure, reversible function of the op set — theMoveindex-relocation discipline (MOVE.md "Apply cost"). The decision may be cached eagerly but never stored as a tombstone in the container: aPlaceis never known uncontended at apply time, tombstones are append-only while freeze/detach are reversible, and X cannot author ops in P. Also:chain()is O(n³–n⁴) (closures recomputed insidemaximal; QUEUE.md 58). - Property harness per CYCLE_REVERT.md "Acceptance criteria": definitional recompute vs incremental maintenance over randomized op sets and delivery orders, biased toward contended registers, chained supersessions, cycle bombs, and register/atom arrival races.
- Placement frontier — shared LAYERING.md parameter with
Move(HASHSEQ_SPEC.md open thread 2). - Orphan-cluster UI — detached SCCs and frozen conflicts need product surface (flagged strip, one-drag resolution); APP_NOTES.md.
- Test vectors for tag 5 (GRAMMAR_SPEC.md open item 1).