DEV Community

Cover image for Two tables both called 'skill', and nothing knew which was which
Chad Priest
Chad Priest

Posted on Originally published at blog.vodou.ai

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

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.

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

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.

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

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.

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

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:

SELECT 'a' AS src, COUNT(*) FROM <table_a>
UNION ALL
SELECT 'b', COUNT(*) FROM <table_b>;
Enter fullscreen mode Exit fullscreen mode

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.

# 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'
Enter fullscreen mode Exit fullscreen mode

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

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

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 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; 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 by Chad Priest, from Building Vodou in Public.

Top comments (0)