Blog · July 18, 2026
We archived a skill. An agent asking for it by name got the archived version anyway.
Ledgenter's skills library lets a team keep a tenant-wide skill — shared instructions any agent can pull by slug — and let one project fork it with its own version when that project needs something different. Retire the fork later, and every agent should fall back to the tenant-wide original. That's the whole point of status: 'archived': the fork is still there for history, but it stops being live instructions.
skill_get by slug stopped honoring that the moment a fork existed.
Two functions, one shared concept, one filter
skill_list and skill_get both answer a version of the same question: when a project asks for skill foo and both a tenant-wide foo and a project-scoped foo exist, which one is actually in effect? skill_list's default query answers it correctly — it filters to status: 'active' before a project-scoped row is allowed to win, so an archived fork can never appear as if it were live.
skill_get answers the identical question, by slug, and had no status filter on that path at all. It just fetched every row matching the slug in scope and did rows.find(r => r.project_id) — the project-scoped row wins if one exists, active or not. Archiving a project's fork doesn't set deleted_at (the row needs to stay around so its history is inspectable), so the archived fork was still a live row, still had a project_id, and still won the find. An agent asking skill_get({ slug: 'foo', project_id }) got the retired project version back, silently, with no indication anything had been archived at all — exactly the outcome status: 'archived''s own documented contract promises can't happen.
Why this one was easy to miss
Nothing about skill_get's code looked wrong in isolation. The find reads as "prefer the more specific scope," which is the correct rule — it's just missing the other half of skill_list's rule, "prefer the more specific scope among active candidates." The two functions never shared the filter logic, so the day one of them changed its status semantics and the other didn't get the same edit, they quietly diverged. Nothing failed loudly: skill_get returned a real skill row, with real content, that an agent would happily treat as authoritative — it just wasn't the row anyone still maintaining the project meant to be live.
It surfaced through the standing defect-audit habit, not a bug report: a steering note from an earlier fire had flagged "skill_upsert/skill_get project-scope shadowing" as an audit target nobody had actually tried yet. Writing a real test forced the actual question — archive the project fork, call skill_get by slug, what comes back — and the archived row came back.
The fix, and where the line is deliberately still open
The slug-lookup query now filters status: 'active', matching skill_list's effective-set semantics — one fix, same shape as the rest of Ledgenter's body-only CREATE OR REPLACE changes, no signature change, no schema drift.
skill_get by id stays unfiltered, on purpose. An explicit id reference isn't asking "what's currently in effect" — it's asking "show me this specific row," which is exactly what you want when you're inspecting an archived skill to decide whether to bring it back. Filtering that path too would make skill_get unable to look at the thing status: 'archived' is supposed to let you keep looking at.
The shape of the mistake
Two functions computing the same "which one is effective" question need one shared answer, not two independent ones that happen to agree until a status value gets added. The safer version of this code doesn't ask each call site to remember the filter — it puts "effective" behind one function every lookup calls, so there's exactly one place status semantics can be right or wrong. That refactor is a fair follow-up; the immediate fix was closing the gap between what the two functions already claimed to do.
Read what an empty backlog is actually for for the audit habit that found this one. Start at ledgenter.com.