Notes · For developers

When a node changes its type

Here is how Curated Thoughts catches node-type drift at write time, repairs it on a schedule, and flags the rest for a human.

By Tessera · · 4 min read

Days after we started minting entities in Curated Thoughts, our local-first second brain, a third of them carried knowledge-graph types the schema never declared. The schema said nodes have types and edges have typed endpoints. The machine writing the nodes had other ideas, and the answer we ended up shipping has three parts: a gate at write time, a repair pass on a schedule, and a human queue for everything the machine refuses to decide. This note walks through all three, because the interesting part is less the validation and more the rule about who is allowed to fix what.

Infographic: the drift, the three-rung write gate, the nightly heal and the human review queue for Curated Thoughts node types

The drift was real

Curated Thoughts stores curated facts as rows in SQLite: entities in curated_entities, typed edges in llm_wiki_edges. An ontology manifest declares the vocabulary, a shipped seed of 17 node types and 40 edge types for our software-organization schema: person, organization, project, service, and so on. Brains extend the seed. Ours had grown to 18 by our October census, after an emergent merge added concept, and the gate's setup step adds the two legalized types below on top.

On a strict manifest, every edge write re-validates against the declared vocabulary at commit, with a loud disarm if the manifest cannot be read. Node types were not constrained. The librarian's synthesis prompt offers an open set, its type hint literally ending in person|project|concept|..., and the commit path stores whatever comes back. By our October 2026 recount, 53 of 152 live entities carried one of six types the manifest never declared: document (30), agent (10), software (7), component (3), process (2), job (1). None of them were leftovers from an older schema. The manifest already existed when every one of those rows was minted. That is the shape of the problem: not one bad node, but a prompt that quietly invited vocabulary the schema never declared.

Two of the six turned out to be types we wanted. A human ruling added document and process to the manifest rather than retyping 32 rows, and software maps onto document through the alias table, agent onto role, component onto service. The stragglers without a ruling, a single job row later joined by one repository, sit in the review queue today.

The gate: no invented type ever lands

Since v3.10.0 (PR #269), on a brain running a strict manifest, every entity write goes through one shared helper. If the incoming type is not in the declared set, a fixed ladder runs:

  1. Canonicalize casing (Person becomes person).
  2. Look up a signed alias table of human-ruled mappings (agent maps to role, component to service).
  3. Fall back to the manifest's declared fallback type.

Whatever label the model proposed, the row that lands is always a declared type, or, if the manifest or ontology config is misconfigured, the proposal is held and its facts kept. The original label is not thrown away; the type-origin ledger keeps it: a fallback degrade is logged as a degraded ledger entry, an alias remap as an alias_retype entry, each carrying the original label. Provenance survives the correction, which matters more than the correction itself. If a ruling changes later, you can find every row it touched.

Brains that opt out of strict mode are the exception, and the opt-out is covered below.

The heal: repair on a schedule, authority first

The gate only covers new writes. Existing drift is the heal pass's job. ct heal reports first: how many live rows carry undeclared types, and which ones, by entity id. Add --yes and it retypes: casing fixes are mechanical, everything else only along paths a human has already ruled on, and if the ontology config changed since the last run, it wants an explicit confirmation before it moves anything. The alias table is not something the code invented; every entry in it is a recorded decision.

Rows with no ruling, a type nobody has mapped, go to the review queue. The system flags them and waits. That line matters to us: the machine retypes what was ruled and queues what was not. Motion without authority is forbidden, and in practice that is what keeps the graph from being "corrected" into something worse.

On our own machine this runs nightly at 02:10, unattended, and most nights it changes nothing, which is the point.

What is still open

Retyping a node does not cascade to its edges. If a node moves from component to service, edges that were valid under the old type can now violate their endpoint signatures, and the heal does not fix those yet. We have not re-counted endpoint conformance on the live graph since the gate shipped. It is the next piece of work, and we would rather say so than imply the graph is clean.

The other honest gap: any host, folder, or entity can opt out of strict mode entirely, by design: an explicit opt-out means no enforcement. Opt-outs are recorded, and the heal pass never silently skips a row it meets under an opt-out: it reports or queues instead of retyping.

If you are building agent memory of your own, the part worth stealing is not the ladder. It is the split: deterministic fixes run alone, judgment calls accumulate in a queue with a human's name on them. The open-source engine behind this design is our LLM Wiki memory engine, and the gate described here ships in Curated Thoughts since v3.10.0.