Projection: mappings, ownership, and merges
The same human appears in a dozen systems. The substrate never fuses records by value. Instead each source keeps its own entity, points at the one subject it describes, and the subject's shared properties are projected from all those sources at once, under a per-property ownership rule that protects your hand edits. This page is that projection: entity mappings, managed properties and tiers, and the merges that join two subjects that turn out to be one.
Entity mappings
What a source holds stays its own entity, pointing at the one subject it
describes through an ordinary edge. An entitymapping names that edge and
declares how the record's properties reach the subject.
The GitHub integration ships a user type
(GitHub's record of an account, in GitHub's own shape, named bare because the
group already says GitHub) and this mapping onto the built-in person:
group: core.teild.dev
type: entitymapping
metadata:
id: userperson.github.bundles.teild.dev
data:
group: github.bundles.teild.dev
from: user.github.bundles.teild.dev
to: person.people.teild.dev
edge: person # the ordinary edge the mapping rides
match: # first-link probes: how a new record
- from: email # finds an existing person
to: emails
map: # assignment paths, nothing else
name: name
emails:
path: email
merge: union
map is assignment-only: paths like name, name.displayName, or
emails[].value, no expressions and no conditionals. Computation is the
extension's job before the write, and the loader type-checks every path
against both declared types, so a disagreement fails on the manifest that
caused it, never on the first sync that hits it.
Three behaviors fall out of this one document:
- Match, or shell birth. A
userarriving without itspersonedge is resolved in the same transaction: exactly one live person carrying that email links; zero, or several, mint a fresh person instead of guessing. Nothing ever auto-merges; joining two existing people is the owner's manualmerge, and it is reversible. - Recompute, with yield. The person's mapped properties are recomputed from all live source records whenever one changes:
namefrom the latest writer,emailsas the union of what every source asserts. But a value you wrote is never touched (the next section is the whole rule). - Ids that never lie. After a merge, the losing id resolves to the winner forever, and any read by it says so.
Managed properties
The mechanism behind "hand edits survive syncs" is per-property ownership. Every accepted write records its actor as the property's manager: the substrate always knows, per entity per property, who holds the current value. Beside the actor it records a tier, the manager's standing against recompute. There are three:
- machine: the sync machinery. Group-declared connector actors and the engine itself write here, and everything recompute writes is machine-held whatever actor it credits. Machine-held values are recompute's to replace.
- extension: installed code. Every write a function or agent makes through its dispatch holds here.
- owner: you. The literal
owneractor, and any minted token actor not declared otherwise. The moment you type a name into the console, you are the manager ofname.
The tier is an explicit attribute, not a naming convention. A declared
actor may carry tier: owner|extension|machine on its
actor document (machine is the default
for group-declared actors), function and agent dispatch stamps the extension
tier on its own writes, and an actor no declaration knows holds at the owner
tier. The tier is read from the live declaration on every write, never frozen
at mint. Renaming an actor never changes write semantics.
Mapping recompute runs whenever a source record changes, and per mapped property it follows three rules:
- Yield. If the manager's tier is above machine, the recompute leaves the value alone and records what it would have written as an alternative beside it. Your edit survives the sync, and so does a function's: an extension write is a visible pin, never a silent freeze.
- Select. Otherwise the latest-updated live source wins (
atomic) or the union of every live source's items lands (union), and the manager becomes the winning source's actor at the machine tier, so the changelog says a name came from GitHub, not from "the system". - Delete. A property no live source carries, and no outside manager holds, is deleted. When the provider stops asserting a phone number, it goes; what another source still asserts, stays.
The ledger on the wire
Single-entity reads surface the whole ledger as propertyMeta: per property,
its manager, its tier, when it changed, and the alternatives, every live
source value that differs from the stored one:
"propertyMeta": {
"name": {
"manager": "owner",
"tier": "owner",
"alternatives": [
{"actor": "function.sync.github.bundles.teild.dev", "value": "ada"}
]
}
}
The owner typed "Ada Lovelace" by hand, GitHub still says "ada", and both facts are on the wire. Adopting an alternative is just writing it. Releasing a hand edit is patching the property to null: the delete clears the value and its manager, and the same transaction recomputes from live sources, so the property refills on the spot, back to following the sources. One deliberate cost comes with this: a held value is immune to fresher truth until released, which is exactly why every read shows the alternatives beside it. One release works for every tier: an extension pin lets go exactly like an owner hold.
Contributing a value
There is exactly one way for an integration to contribute a value without pinning it: ship a source type and an entitymapping, and write your own records. Your records become live sources, your values compete in the same selection as every provider's, and they release by omission when your records go. A minimal contribution:
group: core.teild.dev
type: entitytype
metadata:
id: enrichment.enrich.example.com
data:
group: enrich.example.com
names:
singular: enrichment
plural: enrichments
properties:
name:
type: string
email:
type: email
edges:
person:
to: person.people.teild.dev
required: true
---
group: core.teild.dev
type: entitymapping
metadata:
id: enrichmentperson.enrich.example.com
data:
group: enrich.example.com
from: enrichment.enrich.example.com
to: person.people.teild.dev
edge: person
match:
- from: email
to: emails
map:
name: name
A function that puts an enrichment record now contributes name to the
linked person: freshest source wins, the value shows as an alternative when
someone holds the property, and deleting the record withdraws it. (An earlier
draft had a separate offer write on the patch effect; it is not part of v1,
and shipping a source type plus an entitymapping is its replacement.) Writing
the person's property directly remains possible, and is a pin.
State properties are never recomputed: a state moves through its declared transitions or not at all, so no amount of syncing can quietly complete a task.
Merges
Nothing in the substrate fuses by value: two people holding the same email address are two entities until somebody merges them. Merging is always a deliberate act, one of the seven mutations, and the engine never performs one on its own. What it does instead is suggest.
merge(type, winner, loser) takes two live entities of the same entity
type, addressed by full identity, the type beside the two ids (an edition is
not a bad copy of a work, so a merge across that line is a category error the
engine refuses), and joins them so the winner absorbs the loser's place in
the graph:
- Every edge re-points at the winner, incoming and outgoing, and every source record's subject edge moves with them. Collisions with edges the winner already has dedupe.
- Labels fill gaps: the winner's stand, the loser's land where the winner has none. Annotations move too, colliding keys resolving newest-wins.
- Properties do not migrate. The winner now has more sources pointing at it, so its mapped properties are recomputed, through the same yield rules as any sync, which is how a hand edit on the winner survives its own merge. Copying values across would freeze a stale answer into the winner.
- States never move. A merge is not a transition, and nothing in it may demote a person or complete a task.
- The loser is tombstoned, not erased: a finalizer holds garbage collection off it, so the merge stays reversible.
Every merge writes an entitymerge record: an ordinary entity carrying
winner and loser edges and a moved property recording everything the
merge moved. That record is what makes the undo possible.
Ids that never lie
Merging means ids move, and a client that cached one must not silently read stale data. Three guarantees:
- Any read by a former id returns the canonical entity and says so. The response carries
canonicalId, present only when the id you used was not the canonical one, so a stale id self-corrects on its next read instead of 404ing. The trail is the type's own: a former id resolves within its type, and another type wearing the same id is untouched. - Trails stay flat. After A merges into B and B into C, both A and B are former ids of C directly: resolution is one lookup, bounded forever.
- Ids are never reused within a type, and never re-derived. A tombstoned loser's id stays a former id of its winner forever, so notes, annotations, and an agent's memory can hold a full identity without a validity window.
Split, the undo
split reverses one merge, addressed by the entitymerge record's own id:
POST /api/v1/core.teild.dev/entitysplits
{"merge": "kq3v9x2m41pf"}
The loser comes back to life at its own id, the moved edges, subject edges,
labels, and annotations go back where the record says they came from, and both
sides recompute from the source sets they now have. A split reverts the merge, not everything that happened after it:
a label or annotation rewritten since the merge keeps its newer value. The
split writes its own entitysplit record, pointing at the merge it undid.
Merge requests
An entitymergerequest is the envelope a suggested merge travels in before
anyone has agreed to it. A function or an app writes one, the owner decides,
and accepting it is what performs the merge. Here is one as the shipped
duplicate detector writes it, proposing that a second record of Ada is the
same person:
group: core.teild.dev
type: entitymergerequest
metadata:
id: dupe-9f2k-x41c # deterministic: the pair, sorted
data:
properties:
rationale: '"Ada Lovelace" and "ada" look like the same person'
evidence: # the signals that matched
signals:
- signal: email
value: ada@example.com
decision: proposed # a state: proposed, accepted, rejected
edges:
- rel: winner # the entity that survives the merge
to:
group: people.teild.dev
type: person
id: 9f2k
- rel: loser # the entity merged away into the winner
to:
group: people.teild.dev
type: person
id: x41c
The decision state is the whole lifecycle: proposed until somebody
decides, then accepted or rejected, each transition stamping decidedAt.
Accepting is an ordinary
state transition, a patch:
PATCH /api/v1/core.teild.dev/entitymergerequests/dupe-9f2k-x41c
{"properties": {"decision": "accepted"}}
The transition's onEnter: applyMerge performs the merge in the same
transaction, re-running the merge's own guards, so a stale request (an entity
already merged away, deleted, or of the wrong type) fails the transition
whole: the request stays proposed and gains a conflict annotation saying
why. A reviewer's note rides the same atomic write, as an owner/note
annotation on the request: one patch carries properties, labels, and
annotations together.
The deterministic id does double duty. It dedupes suggestions, and it is the rejection memory: a request for the pair in any state suppresses re-suggesting it, so a rejected pair stays rejected instead of coming back after every sync.
The suggestions come from a shipped function: on every person
created, it probes for likely duplicates (an exactly shared email address is
near-certain; strongly overlapping names score by similarity) and emits one
request per strong candidate, preferring the established record as the winner.
The owner reviews the queue in the console and accepts or
rejects, with the matcher's evidence beside a field-by-field comparison of the
two entities. Merging without a request is the same mutation driven directly:
merge over GraphQL, or a REST post naming the type
and the two ids:
POST /api/v1/core.teild.dev/entitymerges
{"type": "person.people.teild.dev", "winner": "9f2k", "loser": "x41c"}
The patch request sibling
An entitypatchrequest is entitymergerequest's sibling: an app or agent
proposes a change, the owner decides, and accepting applies it atomically,
re-validated against the version it was computed on. It carries an op,
create, patch (the default), or delete, so a reviewed write can mint a
new entity, edit an existing one, or tombstone one. The agent
propose tool emits exactly this request rather than writing the
target directly, which is how a semi-trusted agent contributes a dangerous
verb through review instead of on its own authority.
Next: the API, the surface every one of these operations rides.