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:

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:

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:

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 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:

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.