# Two tables both called 'skill', and nothing knew which was which

> Vodou had 160 file skills and 15 console skills sharing one word. Every feature picked a table and called it the truth. Here is the seam and how to find yours.

- Author: Chad Priest
- Published: 2026-08-27
- Canonical URL: https://blog.vodou.ai/one-word-two-registries-skill-seam/
- Tags: ai-agents, mcp, architecture, llm, observability

---

Your agent has skills. Ask it how many, and you will get a number. The question worth asking is which query produced that number, and whether any other part of your system would produce a different one.

Mine did. Vodou has two things that both answer to the word "skill": a `SKILL.md` folder on disk, tracked in a `skills_registry` table, 160 rows; and a prompt template that runs on a cadence, tracked in `skills_meta`, 15 rows. They share the word and nothing else. Different storage, different lifecycle, different execution path, different scheduler spelling. Every feature that ever asked "what skills do we have?" picked one table and reported it as the whole truth.

That is a seam. It is the kind of bug that never shows up as an exception, because both answers are correct queries against real tables. It shows up as absence: a thing that should have been in a list and quietly was not.

## 160 on disk, 15 on a cadence, and one list that showed you either

The visible symptom came from the scheduler. A schedule row for a file skill is stored as the skill's `name` plus a `query` action. A schedule row for a console skill is stored as `skill:name` plus a `skill_run` action. Two spellings, invented at different times by different code, both correct in their own lane.

The catalog page joined schedules to skills using the console spelling. So it found console schedules. It would never have found a scheduled graph, because a graph skill lives on disk and gets scheduled with the other spelling. Earlier, a UI pass hid four standing agents the same way: it queried one table and rendered the result as "your agents."

The fix is boring and that is the point. One file, `MCP-servers/Vodou-Console/src/skill-kind.ts`, is now the only place allowed to spell the seam. It answers two questions and no others: which system owns this name, and what does a schedule row look like for that system. Then `listAllSkills()` returns every skill from both systems, each row labelled `file` or `console`, keyed on the registry's frontmatter name.

**Diagram (beforeafter)**

Before, each caller derived the seam locally from one of two tables; after, all callers go through one module that labels rows and reports collisions

```text
  BEFORE: Before: ten local derivations
    - catalog joins on the console spelling
    - agents page reads the on-disk table
    - scheduler writes name + query
    - scheduler writes skill:name + skill_run
    - a string literal duplicated verbatim in two files
    - each answer correct, none complete

  AFTER: After: one module owns the spelling
    - classify(name) -> file | console | error
    - scheduleShape(kind) -> the two spellings
    - listAllSkills() -> both systems, labelled
    - a name in both is a collision, listed in neither
    - a grep gate fails the build on a local re-derivation
```

A name that exists in both systems classifies as an error with a reason. Never a coin flip, never last-write-wins. Zero names collide today, so the rule costs nothing right now and holds the day one does.

## The plan said three files derived it locally. It was five, in ten lines

I wrote the plan before I went looking. The plan said three files derived the seam locally and would need rewiring.

It was five. Ten lines total across them, plus one string literal duplicated verbatim in two of them. That duplicate is the whole argument for the module: nobody had written it twice on purpose. Two people, months apart, both needed the same magic string and both typed it out. Neither knew about the other. That is what a seam looks like before it has a name.

I corrected the number in the plan rather than quietly shipping past it, because "the plan guessed three, it was five" is the measurement. If you go looking for a duplicated concept and find exactly what you predicted, you probably did not look hard enough.

## The duplicate walk found nothing in production, and that was `require()` in ESM

Then the part I am least proud of.

I wrote a check that walks the skill corpus looking for duplicates and aliases. It ran clean. Zero duplicates, zero disagreements. I read that as good news for about a day.

It was `require()` inside an ESM module. The walk threw on its first call, the throw was caught by a wrapper that treated any failure as "nothing to report", and the surface printed a clean result. Green because it never ran.

This is the same shape as a lesson already sitting in my own notes: absence-shaped metrics are satisfied by total failure. "Zero duplicates found" and "the duplicate finder is dead" produce byte-identical output. Any check whose passing condition is an empty result needs a second assertion that the check itself executed: a row count it walked, a timestamp it stamped, anything that is non-empty on success.

While fixing it, the walk started reporting for real, and the first thing it reported overturned an identity assumption. `skill-discovery.ts` keyed skills on DIRECTORY name and had been listing `templates/custom` and `templates/research-analysis` as "on disk but not registered." They were registered, as `custom-skill-template` and `research-analysis-template`. The directory is a short alias. Identity is the frontmatter name, which is what the sync keys on (`if (!meta.name) continue`), what intent mappings key on, what graph runs key on, what the skill loader keys on. Five consumers, one key, and the discovery tool was using a sixth thing.

**Diagram (timeline)**

Five stages from writing the module to the dead duplicate walk to shipping the union

```text
  Aug 23  ->  recipes compile to actions.json (fixed)
  Aug 24  ->  skills loaded with dead servers, silently (problem)
  Aug 26  ->  one module owns the seam; plan said 3 files, found 5 (fixed)
  Aug 27  ->  duplicate walk reports zero: require() in ESM, never ran (problem)
  Aug 27  ->  union ships, directory-vs-name alias found (fixed)
```

There was a second silent failure in the same week, worth one paragraph because it is the same disease in a different organ. A tool-reference verifier had existed for a long time and was reached from the promote gate and the MCP surface, but never at load. So a skill could load in chat with a required server absent or deactivated, run, and fail partway through with nothing persisted. It now checks at load. The reason it took this long is instructive: the obvious fix, pointing the existing verifier at the skill's `required_tools` field, would have warned on essentially every skill in the corpus, because that field is overwhelmingly a bare server name and the verifier expects `server/tool`. A warning on every skill is worse than the silence it replaces. The right fix was a second function that accepts all three shapes, not a reuse that produces noise.

And a third, same family. Since March, the skill-creation path in the engine had asked a language model to hand-write a complete `AGENT_ACTIONS` JSON block in one shot. Malformed JSON is loud and we already fell back on it. A *structurally valid* block describing a fan with no join, or `need: 4 of 3`, or an unguarded `slack_post_message` is not a JSON error and ships silently. Now the model writes a recipe and a compiler emits the actions, so the join is emitted whether or not the model thought about failure, every branch is counted, and the plan card gates side effects. Same lesson: the thing that hurts you is the structurally valid wrong answer, not the crash.

## The invariant: one concept, one module, and a gate written before the rewiring

State it as a property of your codebase, not as advice:

**If two subsystems share a word, exactly one module may spell the distinction, and a test must fail the build when a second one does.**

That is checkable. Go look. Pick a word your system overloads (skill, agent, job, run, session, workspace) and count how many files independently decide which kind they are holding. If the answer is more than one, every one of those files is a place where a future feature will pick a lane and report it as the whole.

The gate matters as much as the module. I wrote the grep gate before rewiring anything, which is how I learned it was five files and not three. A module without a gate decays: the next person under time pressure re-derives the string locally, and it is correct, and it passes review, and you are back where you started.

## Run this against your own store in five minutes

Nothing here is Vodou-specific. Substitute your own noun, your own two tables, your own magic string.

**1. Find the overloaded word.** If your schema has two tables whose names both contain the same noun, you likely have this.

```sql
-- SQLite. Adapt for Postgres via information_schema.tables.
SELECT name FROM sqlite_master
WHERE type='table' AND name LIKE '%<your-noun>%';
```

Try the nouns your product overloads: agent, job, run, tool, session. (Mine was `%skill%`.) Two hits is the signal. Now compare the populations of the two tables you found:

```sql
SELECT 'a' AS src, COUNT(*) FROM <table_a>
UNION ALL
SELECT 'b', COUNT(*) FROM <table_b>;
```

Passing looks like two numbers you can immediately explain, plus a name in your codebase for what distinguishes them. Failing looks like two bare numbers and a shrug: mine were `160` and `15`.

**2. Count how many files independently decide the kind.** This is the real test. Grep for the literals a caller would have to type in order to derive the distinction without asking anyone: the prefixes, the discriminator values, the two table names.

```bash
# Every place that derives the distinction locally, instead of calling one module.
grep -rn --include='*.ts' --include='*.js' --include='*.py' \
  -e '<your-magic-string>' -e '<table_a>' -e '<table_b>' \
  src/ | grep -v '<your-owning-module>' | grep -v '_test'
```

(For me that was the `skill:` prefix and the `skill_run` action name, with the one owning module filtered out.)

Passing output is empty, or only your one owning module. Failing output is what I got: ten lines across five files, two of them containing the identical string literal. Read those two lines side by side. If neither author knew about the other, that is your seam and it is currently load-bearing.

**3. Prove the collision case is handled.** You need one name that is present in both populations. If your data already has one, use it. If it does not, take a scratch copy of your database and put one there: insert the same name into each of the two tables you found in step 1, whatever their actual key columns are called.

Then call whatever function answers "what do we have", the list endpoint, the catalog query, the CLI subcommand your UI is built on, and read what comes back.

Passing means an explicit error that names both owners. Failing means you get one row, silently, and you have just learned your precedence rule is whichever table the code queried first. Also worth checking: whether the collided name appears twice, which is the other silent answer and no better.

**4. Prove your empty-result checks actually ran.** For any validator whose success condition is "found nothing," add one assertion that it executed:

```python
result = walk_corpus()
assert result.items_scanned > 0, "walk scanned zero items: it is dead, not clean"
assert result.problems == [], result.problems
```

The first assertion is the one that would have saved me a day.

## Still open: the gate greps, and the collision rule has never fired

Two honest limits.

The gate is a grep. It catches a re-derived string literal, which is the failure I actually had. It does not catch a caller that re-derives the distinction by *shape* rather than by string, say by checking whether a row has a `file_path`. That is a real hole and I know how it will be found: the same way this one was, by a list that is missing something.

And the collision rule has never fired against real data. Zero names collide today. The path that classifies a dual-owned name as an error is exercised only by tests. I believe it is right. I have not watched it be right in production, and those are different claims.

One note on the literature, since I went looking. The [current writing on agent skills](https://arxiv.org/html/2602.12430v4) is settled and good on the shape of a *single* skill, a folder, a trigger, a payload, frontmatter, progressive disclosure, and every guide assumes one registry, one loader, one lifecycle. None of it says who owns a name when you have two kinds, and [MCP explicitly does not either](https://gingerlabs.ai/blog/multi-agent-workflows-with-mcp); your identity keyspace is yours, and if you do not name it, five files will name it for you.

---

Source: [Two tables both called 'skill', and nothing knew which was which](https://blog.vodou.ai/one-word-two-registries-skill-seam/) by Chad Priest, from Building Vodou in Public.
