A tenant-scoped skill registry. (tenant_id, name, version) is the primary key,
versions are immutable, nothing is deleted (a bad version is yanked), and every
client-facing read is audited.
resolve() is the internal read and is deliberately unaudited. fetch() is the
client-facing one and writes an audit row. Keeping them separate is what stops
the audit log filling with the hub's own lookups until nobody reads it.
These are three tiers, not one catalogue that fell apart. They are recorded
because it is easy to mistake any one of them for "the" skill catalogue, and
because the counts differ enough that the mistake is not obvious.
| Population | Where | Count | Has evals | Reachable over MCP |
|---|---|---|---|---|
| Ingested SKILL.md packs | ~/.axe/hub/hub.db, tenant axe-internal | 523 | no | no |
| Action modules | skill_NN_*.py | 110 | no (all has_evals: false) | yes, via hub_list_skills |
| Eval-carrying skills | main branch | 73 | yes | no |
Nothing joins them. The 03:30 sync keeps hub.db current from ~/.claude/skills,
but hub.db is not what hub_list_skills returns.
The three populations are not a mistake to be merged away. Knowledge packs,
action executors and external federation have different owners, review standards
and blast radius, so they get different planes -- which is what enterprise
platforms in this space actually do. Knowledge packs are curated, versioned and
tenant-scoped. Action executors are engineering-owned, typed and gated. Only the
federation tier faces third parties, and it lands in community-quarantine
rather than any client tenant.
| Tier | Store | Count | Resolver |
|---|---|---|---|
| Knowledge | hub.db SKILL.md packs | 523 | skills_hub_bridge.py |
| Action | skill_NN_*.py executors | 105 matched, 110 on disk | axe-mcp run_skill |
| Federation | external catalogues | catalogue only, no payloads | hub/community.py |
What must NOT happen is two resolvers answering the same question. That is a
different failure from tiering, and the distinction is the whole point: tiering
means "which tier owns this?" has exactly one answer, whereas two resolvers means
the answer depends on which host the caller reached and on deploy timing. You
discover it during an incident, when the question is which version an agent is
actually running.
It matters here specifically because the promotion gate only means something if a
single resolver is authoritative. With two, a promotion can be live on one and not
the other, which quietly voids the eval gate while every test still passes.
hub_list_skills currently has exactly this hazard. axe-mcp is configured
type: http against https://mcp.axe.onl/mcp, so the implementation serving its
110 entries runs remotely and cannot read the local hub.db file. A local
skills_hub_bridge.py now exists and reads all 523. Both answer the same tool
name.
The resolution is that the local bridge becomes the knowledge-tier resolver and
the remote's overlapping hub_* responses are retired in the same change. Not
both. Shipping the bridge while the remote still answers is the failure described
above, not a step toward fixing it.
Note the action-tier count: the skill_[0-9]*.py glob matches 105 of the 110
files on disk, silently excluding non-numbered ones such as skill_spawn.py and
skill_ato_scan.py.
Every claim above was checked against the running system rather than the source
tree, because the source tree and the running system disagree here. A green test
suite and a plausible file read both asserted things that a direct call
contradicted.