Content-addressed join keys¶
When laterite lands a group in the relational (DuckDB) model, every row carries
two synthetic columns: _id and _parent_id. Both are UUIDv8 values derived
from the row's spec key-chain — its own AGS key fields, plus its parent's, walked
up the group tree. The id is a hash of what the row is, not of when or where it
was read.
That single property does the work:
- Joins are by construction. A child's
_parent_idis computed from the same key-chain its parent uses for_id, sochild._parent_id == parent._idalways — there is no foreign key to populate, reconcile, or get wrong. - No shared state across reads. Read
LOCAfrom one file andSAMPfrom another; if their key fields agree, the ids line up. Nothing has to be read together, or in order, for the graph to connect. - Identical content → identical id. The same row re-read (or re-emitted)
hashes to the same
_id, so de-duplication is aDISTINCTaway — no surrogate-key bookkeeping.
The join you'd otherwise write on AGS keys collapses to the id columns:
-- AGS-key join (what you write today across groups)
SELECT * FROM SAMP s JOIN LOCA l USING (LOCA_ID);
-- the same edge, content-addressed
SELECT * FROM SAMP s JOIN LOCA l ON s._parent_id = l._id;
Both return the same rows. The difference is that the second join holds for
every parent/child edge in the dictionary with one column pair — you don't need
to know that SAMP hangs off LOCA by LOCA_ID, or that GEOL hangs off SAMP
by (LOCA_ID, SAMP_TOP). The key-chain is baked into _parent_id.
Where the keys live¶
The keys are core on every surface — the Python wheel, the Node package, the
browser (wasm), and the DuckDB extension — and they are byte-identical across
them, because all four compute them through the one shared keychain. The same file
read anywhere yields the same _id/_parent_id.
Two rules govern where you see them:
- The relational layer always carries them.
ags.sql(...)/ags.at(...)expose_id/_parent_idon every group, so the content-addressed join above needs no opt-in. - Frames strip them by default.
ags["LOCA"]/ags.table("LOCA")return the AGS columns only — the synthetic keys don't clutter a data frame. Ask for them withkeys=True:
import laterite as L
ags = L.read("delivery.ags")
ags["LOCA"] # AGS columns only
ags.table("LOCA", keys=True) # + _id / _parent_id
L.read("delivery.ags", keys=True)["LOCA"] # handle-wide default
# joins work regardless of the flag — the engine always has the keys
ags.sql("SELECT * FROM SAMP s JOIN LOCA l ON s._parent_id = l._id")
In Node it is the same shape — ags.table("LOCA", { keys: true }) and
await ags.sql("… ON s._parent_id = l._id").
Because the ids live in the Arrow columns themselves (with keys=True), a join
needs no engine — hand the keyed frame to any Arrow tool (polars, arrow-js,
DuckDB, …) and match _parent_id to _id.
Emit never writes them. save() / build_ags4(...) stay byte-faithful to the
AGS data, so a keys=True frame round-tripped back to AGS4 drops the synthetic
columns — they live only in the read model.
Why it matters
Content-addressing turns the AGS parent/child tree into a stable graph that survives independent reads, partial files, and re-emission. Same content, same key — so merging two deliveries, or spotting a row that appears twice, is a set operation rather than a join you have to hand-author per group pair.
See also: SQL across groups · Born-typed reads