Catalog-Driven Resolver
Turn a declared policy table into the only code path that produces its output. Use when a catalog, registry or config map declares a per-type rule (who a message is from, who may call a tool, which posture or limit applies) but each call site computes the result itself; when a rule in the table is not what production does; or before adding a new input dimension to that rule (a tenant setting, a status, a region) that would otherwise mean editing every call site.
Install this skill
git submodule add https://github.com/modh-labs/playbook.git .playbookOne submodule installs all 17 skills. Reference .playbook/skills/catalog-driven-resolver/SKILL.md from your AGENTS.md.
Catalog-Driven Resolver
A policy table that no code reads is a comment. It feels like a control, it reviews like a control, and production does whatever each call site happened to write.
The fix has five parts, and they only work together:
- One resolver reads the catalog and produces the output for every type.
- A branded output type that only the resolver can create, so a hand-built value does not compile.
- A lint ban on building the output anywhere else.
- A characterization grid captured on the old code before anything moves.
- A matrix test generated from the catalog: every type x every state x every case, failing when a catalog entry has no rows.
Once those exist, a new dimension (a per-tenant setting, a verification status) is one new input to one function, and the matrix grows a column instead of N files growing a branch.
When This Skill Activates
- A catalog or registry lists a rule per type, and a grep shows call sites building the output themselves.
- A reviewer asks "does the table actually say what production does?" and nobody can answer by reading one file.
- A planned feature changes the rule for every type ("send from the tenant's domain", "scope every tool by role").
- A bug report shows one type ignoring a rule the table states.
The diagnostic question: if I change one row of this catalog, does behavior change, and if I add a new input, how many files do I edit? If the answers are "no" and "N", this pattern applies.
Decision Tree
Does a table declare the rule per type?
├─ No → write the table first; this pattern needs a single source of truth
└─ Yes → Does exactly one function read it to produce the output?
├─ Yes → Can a caller still build the output by hand?
│ ├─ Yes → add the branded type + lint ban (Rules 2, 3)
│ └─ No → is there a matrix test generated from the table?
│ ├─ No → add it (Rule 5); the pattern is unproven without it
│ └─ Yes → done; new dimensions go into the resolver's input
└─ No (call sites compute it) →
1. Characterization grid on the old code (Rule 4), green before any move
2. Resolver + branded type
3. Move callers to pass facts, not outputs
4. Lint ban on, shown failing on a planted violation
5. Matrix test from the catalog
6. Only then add the new dimension
Core Rules
1. Callers pass facts, the resolver decides
WRONG: each call site assembles the result from shared helpers. The table is consulted by nobody.
// notification-a.ts
const from = buildSender(branding, hostName);
const replyTo = buildReplyTo(branding, hostEmail);
await send({ from, ...(replyTo && { replyTo }), type: "reminder" });
CORRECT: the caller states what is true about this message; the resolver applies the catalog.
const identity = resolveMessageIdentity({
type: "reminder",
branding,
facts: { host, closer, guest },
});
await send({ identity, type: "reminder" });
2. Only the resolver can create the output
Brand the type so a literal or a hand-assembled object fails typecheck.
declare const brand: unique symbol;
export type MessageIdentity = { from: string; replyTo: string; subject: string } & {
readonly [brand]: true;
};
export function resolveMessageIdentity(input: IdentityInput): MessageIdentity {
const policy = CATALOG[input.type];
// ...apply policy...
return result as MessageIdentity; // the one sanctioned cast, inside the resolver
}
send() accepts MessageIdentity, never { from: string }.
3. A lint rule makes the bypass visible
Ban the raw client and the output's key outside the resolver's module. Show the rule failing on a planted violation before trusting it. A guard that has never gone red is a hypothesis.
4. Characterize before you move
Before touching any call site, record today's output for every type and a representative set of inputs. The grid must be green on the old code. Every later step re-runs it. Each cell that changes is either a named, deliberate change listed with the PR, or a defect. "It was inconsistent anyway" is not a third category.
5. The matrix is generated from the catalog
const TYPES = Object.keys(CATALOG) as MessageType[];
const STATES = ["none", "pending", "verified", "failed"] as const;
const CASES = [noSettings, nameOnly, replyToSet] as const;
describe.each(TYPES)("%s", (type) => {
it.each(cartesian(STATES, CASES))("%s / %s", (state, kase) => {
expect(resolveMessageIdentity(build(type, state, kase))).toEqual(EXPECTED[type][state][kase.name]);
});
});
it("every catalog type has expected rows", () => {
for (const type of TYPES) expect(EXPECTED[type]).toBeDefined();
});
A new catalog entry without expected rows fails the build. That is the point: the table and the test cannot drift apart.
6. New dimensions enter as resolver input
Adding "tenant sending domain status" means one new input field, one new matrix axis, and one change in the resolver. If the plan touches the call sites, it has missed the job.
Anti-Patterns
- The comment catalog. A table with a
replyPolicyper type that the code never reads. It passes review because it looks like policy. - Helpers mistaken for a resolver. Shared
buildX()helpers that every caller combines differently. N combinations of correct helpers still produce N behaviors. - The string type key.
type: stringon the send path. Any typo is a new, untested type. - The silent optional.
...(value && { value })at every call site, so a missing value drops the field instead of failing. Required outputs are required in the branded type. - Refactor and behavior change in one breath. Moving callers and changing rules without a characterization grid makes every diff unreviewable.
- A matrix written by hand. It covers the types someone remembered. Generate the type axis from the catalog.
Audit Checklist
- Every value in the policy table is read by exactly one function.
-
grepfor the output's key (e.g.from:) outside the resolver returns nothing, and a lint rule enforces it. - The send path accepts only the branded type and a typed key, not a string.
- A characterization grid exists, was green on the pre-refactor code, and lists every deliberate change by name.
- The matrix test derives its type axis from the catalog and fails on a type with no expected rows.
- Each guard (brand, lint, matrix) has been shown failing on a planted violation.
- The next planned dimension is described as a resolver input, not as call-site edits.
Related Skills
Code Review
Run educational code reviews against your project's quality standards. Use when reviewing PRs, checking current branch before pushing, or doing batch quality sweeps across all open PRs. Applies observability, testing, SOLID, type safety, security, business logic, and clean code checks with pass/fail verdicts. Produces actionable findings with real-world impact explanations, not just "best practice" citations.
UniversalCost to Serve Audit
Cost to serve: what one customer costs in vendor fees, measured from the vendor's own meter. Use when pricing a plan, a reseller or partner deal, or a usage add-on; when a vendor invoice does not match the usage your app records; when asked for gross margin or unit economics per customer; when deciding whether to stay with a vendor or move; or before a deal that multiplies volume on usage-billed vendors.
UniversalCross-Editor AI Setup
Ensure AI configuration works across Claude Code, Cursor, Copilot, Windsurf, and Codex. Use when creating AGENTS.md files, adding skills, setting up project rules, or discussing cross-editor compatibility. Enforces the @import convention and single-source-of-truth strategy.
Universal