One of everything

Answer it once, Part 3. Capture got your answers through the right doors. Shape decides what one thing is in each home: one customer, one skill per job, one page per person, so a customer who asks twice is still one customer. Plus a paid shape kit with a tested merge you can undo.

One of everything

Part 2 built a front door for each of your three homes. Numbers go through a schema into the database. Procedures go through your harness's skill builder. History goes through a write contract into the brain. If you ran the capture kit, things now land where they belong.

This part is about what they land on.

In Part 2 I bragged about my lead pipeline. Every inquiry carries the original message's ID, "so the same lead can't land twice." That's true, and it's the problem. The key is the message, not the person. When somebody fills out a form one week and answers a different ad two weeks later, that's two messages, so it's two leads. And because each lead got its own page in my brain, it's two people.

I went looking on October 22 and found one in a few minutes. Same phone number, same email, two pages fifteen days apart, each one a tidy copy of a form. Neither page knows the other exists. When I ask my agent about that customer, it finds one of the two pages and answers confidently from half the history. Across my brain, about one person page in eleven is either a second copy of somebody or a page for someone the pipeline never got a name for.

Nothing broke. Every row passed its checks, and every page followed the write contract. The capture was clean. I just never decided what one customer is.

The problem

Capture answers "where does this go?" Shape answers "what is this, and is it one thing or a new thing?" Skip the second question and every home fills with copies. Each copy is correct on its own, and together they're wrong.

Data people call this grain: what one row, one file or one page stands for. An inquiry and a customer have different grains. So do a supplier and a price quote, or a procedure and the story of why you changed it. Mix them in one table and your agent counts the wrong thing, then can't tell which copy is current.

Each home sets its grain its own way. The database uses tables and keys. Skills use one job per file and a size limit. The brain uses one page per person or thing. Here they are one at a time.

Shaping numbers: decide what a row is

A database has two kinds of tables, and most small-business databases blur them. Entity tables hold things that last: customers, suppliers, products, jobs. Event tables hold things that happen to them: an inquiry, an order, a price quote, a payment. One customer, many inquiries. One supplier, many quotes.

My lead table is an event table that I was treating as a customer list. Each row is one inquiry, with a unique key from the message that started it, and that part works. What's missing is the table above it. There was nowhere for a second inquiry to say "this is the same person as that earlier row." So the brain, which makes one page per row, made one page per inquiry.

Shaping the database comes down to four decisions.

  1. Name the grain of every table in one sentence. "One row per inquiry." "One row per customer." If you can't finish the sentence, the table is doing two jobs. Split it.
  2. Give every entity table a match key you trust. For a customer that's usually a normalized phone number or email address: digits only for phones, lowercase for email. Names are a bad key. About a quarter of my inquiries arrive without one, and "Mike" from a phone call and "Michael Smith" from a web form look like strangers. Keep the keys in their own table, so one customer can have two phones and an email and a phone can belong to only one customer.
  3. Point events at entities with a real foreign key. The inquiry row carries the customer's ID, and the database refuses an inquiry for a customer that doesn't exist. Part 2 covered turning foreign keys on in SQLite, since they're off by default.
  4. Merge with a row, not a delete. When two customer records turn out to be one person, write down the merge (which two, what evidence, who approved it, when) and mark the loser as merged into the winner. Never delete. Your agent reads a view that follows the merge, so every inquiry shows up under the surviving customer, and an undo puts things back.

That fourth rule is where most business software lets you down. Check what your own tools do before you click merge:

None of that is wrong for a CRM. It does mean that if your agent is allowed to merge records in those tools, one bad match is permanent. Keep the matching and the merge log in a table you own, and let the agent propose while you approve.

The rule that follows: let code do the matching, not the model. A script that normalizes a phone number and looks it up gets the same answer every time. A model asked "are these the same person?" gets it right most of the time, which isn't good enough for something you can't undo.

Shaping procedures: one job per skill

A skill's grain is one job. "Price a land-clearing quote." "Receive a supplier shipment." "Close the month." Not a department, a customer, or a tool. Part 2 covered how to write one well. This is about what happens when you have a lot of them.

Your agent doesn't read every skill. It reads a list of names and descriptions and picks from that list, and the list has a budget. Claude Code's skill listing gets 1% of the model's context window, and when it overflows, Claude Code "drops descriptions starting with the skills you invoke least." Codex caps its list at 2% of the context window (8,000 characters when it can't tell the window size) and "shortens skill descriptions first." One Claude Code user with 36 project skills found "only 9 of 36 project skills carry description text; 27 are name-only" in a fresh session. A skill with no description is a skill your agent can't match to the job. So the shape rule is a size budget per skill (Anthropic's authoring guide says under 500 lines) and a cap on how many skills compete for the same list.

In Part 1 I said I was splitting the skill that runs this newsletter. It went from over 1,100 lines to about 140, plus 16 topic files it loads only when it needs them. That was one skill. About one in eight of the skills on my machine is still over 500 lines, and that count has gone up since I first measured it in October.

Shaping history: one page per thing

The brain's grain is one page per person, customer, supplier, job or decision. Not one page per conversation, form, or email. My duplicate customer is what happens when the pipeline picks the page grain for you.

GBrain's docs call this out directly. In a system fed by meetings, email and contacts, "entity identity is the first real failure mode," and without a fix you get "split-brain pages" for the same person from a transcript, an email, and a social handle. The fix is a page with two zones. On top is what's true now, rewritten whenever the evidence changes. Underneath is a dated timeline that only grows. The compiled-truth guide puts the alternative plainly: "To understand a person, you read 200 timeline entries. The answer is buried in entry #147."

The shape rules for the brain:

  • One page per thing, with a stable ID. The page name is the identity, and every link uses it.
  • Current summary on top, dated timeline below. A new inquiry, call or email adds a timeline entry and, if it changes the picture, a rewrite of the summary. Every claim in the summary should trace to an entry below it.
  • Aliases on the page, not new pages. Nicknames, misspellings from transcripts, old business names. Phone numbers and emails go in their own fields as exact keys. GBrain's schema doc says it plainly: "Phone numbers are not aliases."
  • Typed links between pages. "Customer of," "supplier to," "works at," "referred by." An untyped link says two pages are related. A typed link tells the agent how.
  • Merge into a survivor and keep both histories. Move the duplicate's timeline entries onto the survivor in date order, add its name as an alias, repoint links, and leave the old page as a pointer to the new one. The pointer is my addition. GBrain's own merge protocol ends with "Delete the duplicate," and a deleted page there can be restored for 72 hours before cleanup removes it for good. In a plain folder of notes there's no restore at all. Leave the pointer.

GBrain itself isn't immune. In October someone found my exact misfile inside its own email collector: pages are keyed by thread ID, and "two Gmail threads can carry the same delivered message under different thread IDs," so both become pages. The maintainers closed it on October 31 and moved it to their ideas board as an opt-in guard, so it's a proposal, not a fix. Whatever you pick as the key decides the grain, and that holds for the people who wrote the docs on grain too.

Where this goes for our members

  • Departments aren't pages. On October 20 a member asked how to build a brain for a business with three departments, procedures that live in the leads' heads, and written steps that are only about 80% right because the marketplace and the software keep changing. The shape answer: each department is a folder of skills, one per job. Each person is a page. When a procedure changes, the skill gets the edit and the brain gets the dated story of why.
  • Brands and stores. A member's profit database lets her click a brand and see every store she's sourced it from. That only works because brand and store are separate tables joined by the buys. Put the store name in a text field on each buy and that report can't exist.

What I run, and what else is out there

I run SQLite tables, Hermes skills and GBrain. Everything else here is described from each vendor's own docs, read in October 2026, and untested by me.

  • CRMs: the merge behavior above. HubSpot also deduplicates contacts by email automatically, and Attio's merge creates a new record with a new ID, which breaks any outside link that pointed at either original.
  • Notes tools with types: Notion relations, Obsidian properties and Bases, Tana supertags and Capacities object types all give a customer a type and fields. I didn't find a merge feature for duplicates on any of those pages.
  • Agent memory with entities: Mem0's graph memory stores "each distinct entity" once but "does not assign typed, labeled relationships between entities." Graphiti, the open-source engine under Zep, keeps dated relationships and invalidates old ones when a newer fact contradicts them.
  • Matching tools you can hand your agent: Splink, dedupe and OpenRefine find likely matches. Deciding and recording the merge is still your job.

If you have forty customers, plain search finds them and all of this is overkill. Shape starts paying the first time your agent answers from half a customer's history, and it pays more once several agents read and write the same records. A team that built three memory systems for its own agent wrote up what happened in October. Their freeform knowledge graph came out inconsistent because "edges used different metadata keys and values for the same attributes." Once each memory had a fixed place, a job that "had taken agents dozens of turns" now "usually took one tool call."

One ID, three homes

Shape gets you one of everything in each home. The homes still have to agree on which one.

The customer's row carries the address of their brain page. Their brain page carries their customer ID in its frontmatter. A skill never names a customer at all. It names the table and the page type ("look up the customer by phone, read their page before you quote"). When a merge happens, it happens in the database first, because that's where the match key lives, and the brain follows: the surviving row keeps its page, and the merged row's page becomes a pointer.

That's the same why_ref wiring from Part 1, pointed at the thing instead of the fact.

My duplicate customer, reshaped

I haven't run this on my own lead table yet. Here's what that customer looks like once I do:

  • Database: one customer row with two keys, a phone and an email. Two inquiry rows underneath it, fifteen days apart, each keeping the name and details exactly as they were given. A merge row recording which phone matched and that I approved it.
  • Brain: one page. The summary says it's one person who asked twice, from two different ads, and that the second inquiry added something the first didn't mention. The timeline has both inquiries as dated entries. The second page becomes a one-line pointer to the first.
  • Skill: my intake skill gets one line: before creating a customer, look them up by phone and email, and if you find a match, attach the inquiry and propose a merge instead.

Then when I ask my agent about that customer, it reads one page and sees both inquiries.

Next in the series is find: how your agent searches all three homes once there's one of everything in them.

The playbook: the shape kit

Operators: this kit gives every home a grain, a match key, and a merge you can undo. It builds on Part 1's sort and Part 2's capture doors, and it doesn't repeat them. Steps 1 through 7 work in any harness, and step 6 is the GBrain version. Nothing here merges, splits or deletes anything until you approve it.

Step 1: The grain audit

Paste this into your harness:

Audit the shape of my three homes. Read only; change nothing. Database: [path]. Skills: wherever you load them from. Brain or notes folder: [path]. For the database, list every table with one sentence saying what one row stands for, and flag any table whose unique key comes from a message, form or event but that I use as a list of people, customers, suppliers or products. For each of those, count rows that share a phone number or email after normalizing (digits only for phones, the last 10 digits; lowercase for email). For the brain, list pages that share a phone, email or exact name with another page, pages whose name contains a row number or message ID, and pages that are a copy of a form with no summary. For skills, list every skill over 500 lines, every skill whose description overlaps another's, and every skill that contains dates, incident stories or a specific customer's name. Count only what you can show me, name the file or query that proves each count, and say "no record" where you can't. Rank the problems by what each one costs when my agent answers from the wrong copy.

Start with the top database item. Fixing the database grain fixes the brain grain downstream.

Step 2: The database: an entity table above your events

This is a tested sample for customers and inquiries. Save the SQL as schema.sql, the matcher as match.py and the merge script as merge.py; step 7 uses those names. Rename it for suppliers and quotes, products and buys, or jobs and visits; the shape is the same. Have your agent adapt it to your existing tables, not replace them.

PRAGMA foreign_keys = ON;

-- One row per customer. The thing itself.
CREATE TABLE customers (
  id            INTEGER PRIMARY KEY,
  display_name  TEXT,
  status        TEXT NOT NULL DEFAULT 'active'
                CHECK (status IN ('active', 'merged')),
  merged_into   INTEGER REFERENCES customers(id),
  brain_page    TEXT UNIQUE,              -- the one brain page for this customer
  created_at    TEXT NOT NULL DEFAULT (datetime('now')),
  CHECK ((status = 'merged') = (merged_into IS NOT NULL))
) STRICT;

-- How you recognize a customer. A phone or email belongs to one customer.
CREATE TABLE customer_keys (
  customer_id   INTEGER NOT NULL REFERENCES customers(id),
  key_type      TEXT NOT NULL CHECK (key_type IN ('phone', 'email')),
  key_value     TEXT NOT NULL,            -- normalized: phone = last 10 digits, email = lowercase
  first_seen    TEXT NOT NULL DEFAULT (datetime('now')),
  UNIQUE (key_type, key_value)
) STRICT;

-- One row per inquiry. The event. Many per customer.
CREATE TABLE inquiries (
  id            INTEGER PRIMARY KEY,
  external_ref  TEXT NOT NULL UNIQUE,     -- message id from the source system
  customer_id   INTEGER NOT NULL REFERENCES customers(id),
  received_at   TEXT NOT NULL,
  source        TEXT NOT NULL,
  name_given    TEXT,                     -- what they typed, kept as said
  summary       TEXT
) STRICT;

-- Two customers that might be one person. Nothing merges without a yes.
CREATE TABLE merges (
  id            INTEGER PRIMARY KEY,
  keep_id       INTEGER NOT NULL REFERENCES customers(id),
  merge_id      INTEGER NOT NULL REFERENCES customers(id),
  evidence      TEXT NOT NULL,            -- which key matched, from which inquiry
  status        TEXT NOT NULL DEFAULT 'proposed'
                CHECK (status IN ('proposed', 'approved', 'rejected', 'undone')),
  decided_by    TEXT,
  decided_at    TEXT,
  repointed     TEXT,                     -- customers moved along with this merge, so undo can move them back
  CHECK (keep_id <> merge_id)
) STRICT;
CREATE UNIQUE INDEX one_open_merge_per_pair
  ON merges (min(keep_id, merge_id), max(keep_id, merge_id))
  WHERE status IN ('proposed', 'approved');

-- What your agent reads: every inquiry under the customer it belongs to today.
CREATE VIEW customer_inquiries AS
SELECT coalesce(c.merged_into, c.id) AS customer_id,
       i.id AS inquiry_id, i.received_at, i.source, i.name_given, i.summary
FROM inquiries i JOIN customers c ON c.id = i.customer_id;

Then the matcher. It's code, so the same inquiry gets the same answer every time. Know what it decides on its own: a new inquiry that matches one existing customer's phone or email attaches to that customer with no approval step. Your yes only guards joining two customers you already have. If your customers share phones (families, offices, a front desk), add single-key attachments to the weekly review in step 3.

It also keeps phone numbers by their last 10 digits, which suits US numbers. If you have customers outside the US, keep the country code instead.

"""Attach each new inquiry to one customer. Code decides, not the model.

Usage: python match.py customers.db inquiries.json
inquiries.json is a list of {external_ref, received_at, source, name, phone, email, summary}.
"""
import json, re, sqlite3, sys

def norm_phone(p):
    digits = re.sub(r"\D", "", p or "")
    return digits[-10:] if len(digits) >= 10 else None

def norm_email(e):
    e = (e or "").strip().lower()
    return e if "@" in e else None

def live(db, cid):
    # follow a merge to the customer that survived it
    row = db.execute("select merged_into from customers where id=?", (cid,)).fetchone()
    return row[0] if row and row[0] else cid

def attach(db, inq):
    if db.execute("select 1 from inquiries where external_ref=?",
                  (inq["external_ref"],)).fetchone():
        return "skipped (already have it)"
    keys = [(t, v) for t, v in (("phone", norm_phone(inq.get("phone"))),
                                ("email", norm_email(inq.get("email")))) if v]
    hits = {}  # customer it belongs to today -> (customer that owns the key, evidence)
    for t, v in keys:
        row = db.execute("select customer_id from customer_keys where key_type=? and key_value=?",
                         (t, v)).fetchone()
        if row:
            hits.setdefault(live(db, row[0]), (row[0], f"{t} match on {inq['external_ref']}"))
    if not hits:
        cid = db.execute("insert into customers(display_name) values (?)",
                         (inq.get("name"),)).lastrowid
        result = f"new customer {cid}"
    else:
        keep = min(hits)  # the oldest matching customer
        # attach to the record that owns the key; the view follows any merge,
        # so an undo sends this inquiry back to the right customer
        cid = hits[keep][0]
        result = f"existing customer {keep}"
        for other, (_, why) in hits.items():
            said_no = db.execute(
                "select 1 from merges where status='rejected' and min(keep_id, merge_id)=min(?,?)"
                " and max(keep_id, merge_id)=max(?,?)", (keep, other, keep, other)).fetchone()
            if other != keep and said_no:
                result += f", keys span rejected pair with {other}, check it"
            elif other != keep:
                db.execute("insert or ignore into merges(keep_id, merge_id, evidence) values (?,?,?)",
                           (keep, other, why))
                result += f", merge proposed with {other}"
    for t, v in keys:
        db.execute("insert or ignore into customer_keys(customer_id, key_type, key_value) values (?,?,?)",
                   (cid, t, v))
    db.execute("insert into inquiries(external_ref, customer_id, received_at, source, name_given, summary)"
               " values (?,?,?,?,?,?)",
               (inq["external_ref"], cid, inq["received_at"], inq["source"], inq.get("name"),
                inq.get("summary")))
    return result

def main(db_path, json_path):
    db = sqlite3.connect(db_path)
    db.execute("PRAGMA foreign_keys = ON")
    for inq in json.load(open(json_path)):
        with db:
            print(inq["external_ref"], "->", attach(db, inq))

if __name__ == "__main__":
    main(*sys.argv[1:3])

And the merge decision, with undo. Nothing is ever deleted:

"""Decide a proposed merge. Nothing is deleted, so every merge can be undone.

Usage:
  python merge.py customers.db list
  python merge.py customers.db approve <merge_id> <your name>
  python merge.py customers.db reject  <merge_id> <your name>
  python merge.py customers.db undo    <merge_id> <your name>
"""
import json, sqlite3, sys

def main(db_path, action, merge_id=None, who=None):
    db = sqlite3.connect(db_path)
    db.execute("PRAGMA foreign_keys = ON")
    if action == "list":
        for row in db.execute("select id, keep_id, merge_id, evidence from merges where status='proposed'"):
            print(row)
        return
    m = db.execute("select keep_id, merge_id, status, repointed from merges where id=?",
                   (merge_id,)).fetchone()
    if not m:
        sys.exit(f"no merge {merge_id}")
    keep, gone, status, repointed = m
    with db:
        if action == "approve":
            if status != "proposed":
                sys.exit(f"merge {merge_id} is {status}, not proposed")
            for cid in (keep, gone):
                if db.execute("select status from customers where id=?", (cid,)).fetchone()[0] != "active":
                    sys.exit(f"customer {cid} is already merged; decide that first")
            # anything already merged into the losing customer moves with it
            moved = [r[0] for r in db.execute("select id from customers where merged_into=?", (gone,))]
            db.execute("update customers set merged_into=? where merged_into=?", (keep, gone))
            db.execute("update customers set status='merged', merged_into=? where id=?", (keep, gone))
            db.execute("update merges set status='approved', decided_by=?, decided_at=datetime('now'),"
                       " repointed=? where id=?", (who, json.dumps(moved), merge_id))
        elif action == "reject":
            if status != "proposed":
                sys.exit(f"merge {merge_id} is {status}, not proposed")
            db.execute("update merges set status='rejected', decided_by=?, decided_at=datetime('now')"
                       " where id=?", (who, merge_id))
        elif action == "undo":
            if status != "approved":
                sys.exit(f"merge {merge_id} is {status}, not approved")
            if db.execute("select status from customers where id=?", (keep,)).fetchone()[0] != "active":
                sys.exit(f"customer {keep} was merged again later; undo that merge first")
            db.execute("update customers set status='active', merged_into=null where id=?", (gone,))
            for cid in json.loads(repointed or "[]"):
                db.execute("update customers set merged_into=? where id=?", (gone, cid))
            db.execute("update merges set status='undone', decided_by=?, decided_at=datetime('now')"
                       " where id=?", (who, merge_id))
        else:
            sys.exit(__doc__)
    print(action, merge_id, "done")

if __name__ == "__main__":
    main(*sys.argv[1:5])

To move an existing event table onto this shape, paste:

Migrate my [inquiries/orders/quotes] table onto the customer shape from this kit, in a copy of the database first. Create the customers, customer_keys, inquiries, merges tables and the customer_inquiries view exactly as given. Feed every existing row through match.py in date order, oldest first, so the oldest record becomes the customer. Don't approve any merge. Show me: customers created, rows attached to an existing customer, merges proposed with their evidence, and rows with no phone or email. Then run the shape test in step 7 against the copy. Only after I approve the counts, repeat on the real database and point my import script at match.py.

Step 3: The merge review

Once a week, paste:

Run merge.py list and show me each proposed merge as a pair: both customers' names as given, every key they share, their inquiries with dates, and the brain page each one has. Put any pair where the names differ a lot at the top. Then list this week's inquiries that attached to an existing customer on a single shared key, and any match.py line that says "keys span rejected pair." Don't approve anything. After I answer, run approve or reject for each one, then update the brain (step 5) for every approved merge.

Three rules for the review. Never merge on a name alone. A shared phone or email is evidence, not proof, since families and businesses share them. And a rejection is a decision: the matcher won't propose that pair again.

Step 4: The skill shape pass

Paste:

For every skill over 500 lines, or with a description that overlaps another skill's, propose a reshape. Read only until I approve. For each one: (1) name the one job it does, in a "use when" description of 200 characters or less; (2) mark each section as procedure (stays), reference (moves to a supporting file the skill loads on demand), number (moves to the database, and the skill names the table), or story and reasons (moves to the brain, and the skill links the page); (3) if two skills do the same job, propose which survives and what moves into it. Show me the before-and-after line counts. After I approve, make the changes, then open a fresh session and ask for each job the way I normally would, without naming the skill, to check that the right one loads.

Each harness has its own tools for this, and they don't all promise the same thing:

Harness Size guidance in its docs When you have too many skills Built-in help
Hermes skill_manage warns past about 24,000 characters; "lessons, not logs" No documented cap on count The curator archives unused agent-made skills (never deletes). hermes curator run --dry-run reports what it would archive without changing anything. Merging overlaps into umbrella skills (--consolidate) is off by default and makes real changes, so run it on a copy first
Claude Code Under 500 lines (Anthropic's guide) Listing gets 1% of context; least-used descriptions drop first /skill-doctor reports each skill's cost and flags skills never used. It reports; you decide
Codex "Keep each skill focused on one job" List capped at 2% of context (8,000 characters when the window is unknown); descriptions shorten first No audit tool on the page I read; use the prompt above
OpenClaw Advisory warning over 250 lines or 12 KB, or a description over 160 bytes Past skills.limits.maxSkillsPromptChars, descriptions shorten, then drop; omitted skills stay findable through skills_search Skill Workshop's /learn merges same-class learned skills into an umbrella and archives the rest. /learn applies right away and saves the previous version, so its safety net is undo. Set autonomous.mode to propose and background reviews save drafts for you to review instead

Step 5: The brain: one page per thing

Every person, customer, supplier or job page follows one template. This one works in a plain notes folder:

---
type: customer
customer_id: 42            # the row in your database
aliases: ["Pat", "P. Doe"]  # names only, never phone numbers
phones: ["+15555550142"]
emails: ["pat@example.com"]
merged_into:               # set only on a page that was merged away
---

# Pat Doe

## Now
What's true today, rewritten whenever it changes. Every claim here
traces to an entry below.

## Links
- customer_of: [[companies/your-business]]
- referred_by: [[people/lee-roe]]

## Timeline
- **2026-03-26** | Second inquiry, from a different ad. Asked about a
  second location. [Source: inquiry fb-1002]
- **2026-03-03** | First inquiry. [Source: inquiry fb-1001]

After every approved merge in step 3, paste:

Merge brain page [duplicate] into [survivor]. Move every timeline entry from the duplicate onto the survivor in date order, keeping each entry's original date and source. Add the duplicate's names to aliases and its phones and emails to their fields. Rewrite the Now section from the combined timeline. Repoint every link to the duplicate so it points at the survivor. Replace the duplicate's body with one line pointing at the survivor and set merged_into in its frontmatter. Don't delete it. Show me the diff before you save.

For a new page, the rule goes in your always-loaded file: "Before creating a page for a person or company, look them up by phone, email and alias. If you find one, add to it. Create a page only when nothing matches, and set its customer_id."

Step 6: The GBrain version

GBrain has most of this built in. Use its parts and know their limits:

  • Pages: the compiled-truth format is the template above, with <!-- timeline --> marking the line between summary and history.
  • Typed links: gbrain link <from> <to> with a link type your schema pack declares. The bundled packs ship works_at but no customer verb. Copy the pack to an editable name once (gbrain schema fork gbrain-base my-pack), then gbrain schema add-link-type customer_of --inverse has_customer --pack my-pack.
  • Merging: there's no gbrain merge command. Run the merge protocol from GBrain's schema doc as an agent procedure, using the step 5 prompt. gbrain revert restores a page to an earlier version and gbrain restore brings back a deleted one for 72 hours, so a merge can be undone piece by piece. Leaving the pointer page instead of deleting means there's no clock.
  • Cross-source identity: gbrain entity-identity-link groups the same person's pages from different sources. It's manual on purpose ("no auto-matching"), which fits the approve-every-merge rule here.
  • Finding problems: gbrain orphans lists pages with no links in or out, which is where duplicates tend to hide. There's no built-in duplicate finder, so the step 1 audit is still your duplicate check.

Step 7: The shape test

Run this once before you migrate, and again after any change to the matcher. Paste:

Run the shape test in a sandbox. Never touch my real database, skills or brain. 1) Make a scratch folder, save schema.sql, match.py and merge.py from this kit into it exactly as written, and create a fresh database from schema.sql. Show me the path. 2) Feed match.py five fake inquiries: two from "Pat Doe" 23 days apart with the same phone written two ways ("+1 (555) 555-0142" and "555-555-0142") and the same email in different case; one from "Lee Roe" sharing nothing; one call with no name from 555-555-0777; one email from "Sam Poe" at sam@example.com. 3) Run the checks below and give me a table of check, expected, actual, PASS or FAIL. 4) Delete only the scratch folder and confirm my real files haven't changed.

Run every database command with foreign keys on (PRAGMA foreign_keys = ON). Checks: (a) Both Pat inquiries belong to one customer; 4 customers total. (b) Feed the same five again: still 5 inquiries and 4 customers. (c) Feed a text from 555-555-0777 signed "Sam" with sam@example.com: exactly 1 merge is proposed, and the view still shows the caller and Sam as 2 customers. (d) Approve it: the view shows one customer with 3 inquiries, and the customers table still has 4 rows (nothing deleted). (e) Feed a new email from sam@example.com: it lands on the surviving customer, now 4 inquiries. (f) Undo the merge: the caller and Sam are 2 customers again, the email from step (e) is back with Sam, and the merge row says "undone." (g) Feed another text with both keys: 1 merge is proposed again. Reject it with merge.py, then feed one more text with both keys: 0 merges proposed. (h) Insert an inquiry for customer 999: refused. (i) Insert a customer with the text "abc" as its ID: refused.

If all nine pass, migrate. Paste the table into the brain as the test record.

What not to do

  • Don't merge on a name. Two Mike Smiths are two customers until a phone or email says otherwise.
  • Don't let a model decide a match it can't undo. Code proposes, you approve, the merge log remembers.
  • Don't merge inside a tool that can't unmerge until you've made the same decision in your own table first.
  • Don't make one page per conversation. Conversations are timeline entries on the page of the person they were with.
  • Don't fix a long skill by deleting the stories. Move them to the brain and link them. You'll want them later.

Steal this

Tonight, run the step 1 audit and look at one number: how many of your customers (or suppliers, or products) share a phone or email with another record. If it's more than zero, build the step 2 tables in a copy, run the step 7 test, and migrate the oldest table first. Then pick your longest skill and run step 4 on that one skill.

Hit reply and tell me how many duplicates the audit found. I read every one, and the hard ones go into this series.

Subscribe to Operators AI

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe