P2.5 tool_binding Asset Type (Storage Half) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax.
Goal: Ship the tool_binding config-asset type's storage half — a Zod content schema, CONTENT_SCHEMAS registration, and an Ops editor — so operators can author/version/publish tool bindings through the existing config-asset base. NOT the execution half (chat-path tool exposure, slug-exists validation, credential resolution, write→Expert approval) — all of that is P4.7.
Architecture: tool_binding is already in the ConfigAssetType enum, the migration CHECK, and the DTOs. So this is a pure additive change: one schema file + one registry line + one frontend editor + one page branch. The existing publish state machine, CRUD controller, importer framework, and RLS all apply automatically. No migration, no controller change, no DTO change.
Tech Stack: Zod v4 (config-asset schemas), NestJS config-assets module, Next.js Ops editor (React 19).
Design source: docs/superpowers/specs/2026-07-07-unified-config-asset-model.md §3 (ToolBindingContent) — with 3 verified corrections (the design's placeholders diverge from real code, verified @ 490517fb):
sourceKind→kind— real field onAgentToolDescriptor(api/src/agent-api/tool-registry.ts:1) iskind.- enum drops
mcp— realAgentToolKind = 'bespoke' | 'nango'(nomcp;mcpis a P4.6 addition that does not exist in code yet — pre-seeding it would break the P4.7 service-layer registry cross-check). credentialRefscope — references anintegration_credentialsrow at(org_id, integration_type)granularity (@Unique(["orgId","integrationType"]), entities.ts:1156), NOT the ADR-030 OSA/Channel dimension the design comment claims.
Scope note: Long-lived branch feat/specialist-value-output-p2plus. Accumulates with P2.4 and later phases into one PR; NOT merged per-phase. Commit per task.
Pre-existing test baseline: verify failures against branch base 490517fb; local npm test has ~21 known pre-existing failures suite-wide. Only NEW failures are blocking.
Execution-half boundary (do NOT cross into P4.7): the Zod schema validates SHAPE only — regex/enum/optionality. It does NOT check that toolSlug exists in any registry, that credentialRef points to a real credential row, or enforce write→approval. Those are runtime concerns in P4.7. Keep the schema pure.
File Structure
Create:
api/src/config-assets/schemas/tool-binding.schema.ts—ToolBindingContentZod schema +ToolBindingContentTtype.api/src/config-assets/schemas/tool-binding.schema.spec.ts— schema unit tests (mirrorkb-domain-rule.schema.spec.ts).frontend/src/app/ops/config-assets/ToolBindingEditor.tsx— Ops editor (mirrorKbDomainRuleEditor.tsx).
Modify:
api/src/config-assets/schemas/index.ts— import + oneCONTENT_SCHEMASline + update the "only remaining unimplemented" comment.api/src/config-assets/schemas/index.spec.ts— the registry-keys assertion (4 → 5 keys).frontend/src/app/ops/config-assets/[id]/page.tsx— import ToolBindingEditor + addassetType === "tool_binding"branch + removetool_bindingfrom the "not available yet" fallback list.api/src/config-assets/CLAUDE.md— mark tool_binding storage-half shipped; add the schema to theschemas/inventory.docs/superpowers/specs/2026-07-07-unified-config-asset-model.md§3 — correct the 3 divergences inline (so the design doc stops teaching the wrong field names).
Task 1: ToolBindingContent Zod schema (storage half)
Files:
- Create:
api/src/config-assets/schemas/tool-binding.schema.ts - Test:
api/src/config-assets/schemas/tool-binding.schema.spec.ts
Decision on allowedOps: constraints.allowedOps is a list of operation identifiers (e.g. orders.read) consumed by the P4.7 ActionPolicy gate — they are constrained identifiers, not operator prose, so a bare z.string().max(64) is correct (NOT opStr). Only genuinely operator-visible free-text fields need opStr/NFR5; tool_binding has none (every field is a slug/enum/identifier/uuid/number). No budget superRefine needed — the content is tiny and bounded by the field maxes.
- Step 1: Read the template + real interfaces
Read api/src/config-assets/schemas/kb-domain-rule.schema.ts (the pattern), api/src/agent-api/tool-registry.ts:1 (AgentToolKind = 'bespoke' | 'nango' — confirm no mcp), and api/src/config-assets/schemas/skill.schema.ts:47 (tools: z.array(z.string().regex(/^[a-z0-9_]{1,64}$/)) — toolSlug must use the identical regex so skills ∩ bindings aligns).
- Step 2: Write the failing test
tool-binding.schema.spec.ts
import { ToolBindingContent } from "./tool-binding.schema";
describe("ToolBindingContent", () => {
const valid = {
toolSlug: "shopify_query",
kind: "nango",
accessClass: "read",
constraints: { allowedOps: ["orders.read"], rateLimitPerHour: 100 },
credentialRef: "00000000-0000-0000-0000-0000000000c1",
};
test("accepts a valid binding", () => {
expect(ToolBindingContent.parse(valid)).toMatchObject({ toolSlug: "shopify_query", kind: "nango" });
});
test("constraints defaults to {} and credentialRef is optional", () => {
const parsed = ToolBindingContent.parse({ toolSlug: "order_lookup", kind: "bespoke", accessClass: "write" });
expect(parsed.constraints).toEqual({});
expect(parsed.credentialRef).toBeUndefined();
});
test("rejects toolSlug with illegal chars", () => {
expect(() => ToolBindingContent.parse({ ...valid, toolSlug: "Bad Slug!" })).toThrow();
});
test("rejects an unknown kind (mcp is NOT a valid kind yet — P4.6)", () => {
expect(() => ToolBindingContent.parse({ ...valid, kind: "mcp" })).toThrow();
});
test("rejects an unknown accessClass", () => {
expect(() => ToolBindingContent.parse({ ...valid, accessClass: "admin" })).toThrow();
});
test("rejects a non-uuid credentialRef", () => {
expect(() => ToolBindingContent.parse({ ...valid, credentialRef: "not-a-uuid" })).toThrow();
});
test("rejects rateLimitPerHour over cap / non-positive", () => {
expect(() => ToolBindingContent.parse({ ...valid, constraints: { rateLimitPerHour: 0 } })).toThrow();
expect(() => ToolBindingContent.parse({ ...valid, constraints: { rateLimitPerHour: 100000 } })).toThrow();
});
});
Run: cd api && npx jest src/config-assets/schemas/tool-binding.schema.spec.ts -v → FAIL (module not found).
- Step 3: Implement
tool-binding.schema.ts
import { z } from "zod";
// R6.2 storage half. `kind` + enum align with the real AgentToolKind in
// api/src/agent-api/tool-registry.ts ('bespoke' | 'nango' — 'mcp' is a P4.6
// addition that does NOT exist in code yet, so it is intentionally absent
// here; adding it would desync the P4.7 registry cross-check). The runtime
// dispatch chain (tool_permissions × TOOL_CATALOG → AgentRun.toolsManifest)
// and the exposure/approval gate are P4.7 — this schema validates SHAPE only:
// it does NOT verify toolSlug exists in any registry or that credentialRef
// points to a real credential row.
export const ToolBindingContent = z.object({
// Same regex as skill.schema.ts `tools[]` so `skills ∩ bindings` (P3/P4.7)
// intersects by slug string.
toolSlug: z.string().regex(/^[a-z0-9_]{1,64}$/),
kind: z.enum(["bespoke", "nango"]),
accessClass: z.enum(["read", "write"]), // write ⇒ P4.7 routes each call to Expert approval (R6.4)
constraints: z
.object({
// Operation identifiers (e.g. "orders.read") — constrained tokens, not
// operator prose, so bare string is correct (no NFR5 opStr needed).
allowedOps: z.array(z.string().max(64)).max(64).optional(),
rateLimitPerHour: z.number().int().positive().max(10_000).optional(),
})
.default({}),
// References an integration_credentials row at (org_id, integration_type)
// granularity (entities.ts @Unique(["orgId","integrationType"])). The
// credential body NEVER enters content/prompt/trace (R6 AC3). Existence +
// resolution are validated in P4.7, not here.
credentialRef: z.string().uuid().optional(),
});
export type ToolBindingContentT = z.infer<typeof ToolBindingContent>;
-
Step 4: Run test → PASS (all 7).
npx tsc --noEmit→ clean. -
Step 5: Commit (from the worktree root)
git add api/src/config-assets/schemas/tool-binding.schema.ts api/src/config-assets/schemas/tool-binding.schema.spec.ts
git commit -m "feat(config-assets): tool_binding Zod content schema (storage half, P2.5)"
Task 2: register tool_binding in CONTENT_SCHEMAS
Files:
-
Modify:
api/src/config-assets/schemas/index.ts -
Test:
api/src/config-assets/schemas/index.spec.ts -
Step 1: Read
index.ts(theCONTENT_SCHEMASobject at ~:15-20 + the "only remaining unimplemented" comment ~:13) andindex.spec.ts(find the assertion onObject.keys(CONTENT_SCHEMAS)— currently expects 4 keys). -
Step 2: Update the failing test first
In index.spec.ts, change the registry-keys assertion to include tool_binding (5 keys). Also add an assertion that CONTENT_SCHEMAS.tool_binding is defined and parses a valid binding (mirror how the spec asserts other types are registered). Run → FAIL (tool_binding not registered yet).
- Step 3: Register
In index.ts: import { ToolBindingContent } from "./tool-binding.schema"; and add tool_binding: ToolBindingContent, to CONTENT_SCHEMAS. Update the ~:13 comment (no longer "the only remaining unimplemented type" — now all 5 types have schemas; note the execution half of tool_binding is P4.7).
-
Step 4: Run
npx jest src/config-assets/schemas/ -v→ all PASS.npx tsc --noEmit→ clean. -
Step 5: Commit
git add api/src/config-assets/schemas/index.ts api/src/config-assets/schemas/index.spec.ts
git commit -m "feat(config-assets): register tool_binding in CONTENT_SCHEMAS (P2.5)"
Task 3: ToolBindingEditor Ops editor
Files:
-
Create:
frontend/src/app/ops/config-assets/ToolBindingEditor.tsx -
Modify:
frontend/src/app/ops/config-assets/[id]/page.tsx -
Step 1: Read the template + integration point
Read frontend/src/app/ops/config-assets/KbDomainRuleEditor.tsx fully (props contract: { initialContent, saving?, issues?, onSave }; useIssueMapping for per-field + form-level errors; get/asStringArray/splitLines from ./editorUtils; <select> for enums; optional fields OMITTED in handleSave not sent as ""/[]). Read [id]/page.tsx around the editor branches (:238-273) and imports (:30-33), and the fallback "not available yet" block that currently lists tool_binding.
-
Step 2: Implement
ToolBindingEditor.tsx— mirrorKbDomainRuleEditorexactly for structure. Fields:toolSlug— text input (FormField+ input),errorFor("toolSlug").kind—<select>with optionsbespoke/nango(match the schema enum EXACTLY — nomcp).accessClass—<select>withread/write.constraints.allowedOps— textarea, one op per line, parsed viasplitLines; OMIT fromconstraintsif empty.constraints.rateLimitPerHour— number input; OMIT if blank/NaN.- Build
constraintsobject with only the present keys; ifconstraintsends up empty, still send{}(schema default) or omit (schema defaults it) — match how KbDomainRuleEditor handles its optional object. credentialRef— text input (uuid); OMIT from payload if blank (elsez.string().uuid()rejects"").onSave(content)with the assembled object;<Button loading={saving}>.- Wire
issuesviauseIssueMapping→errorForper field +formLevelErrors.
-
Step 3: Wire into
[id]/page.tsx- Add
import { ToolBindingEditor } from "../ToolBindingEditor";alongside the other editor imports. - Add a branch:
{detail.assetType === "tool_binding" && (<ToolBindingEditor key={...} initialContent={detail.draftContent ?? detail.publishedContent} saving={saving} issues={issues} onSave={(content) => void handleSave(content)} />)}— copy the exact prop shape from the sibling branches. - Remove
tool_bindingfrom the fallback "not available yet" condition/list so it no longer falls through.
- Add
-
Step 4: Verify the frontend builds
Run the frontend typecheck/lint per the frontend's real commands (read frontend/package.json scripts — likely npm run lint and/or npm run build / tsc). At minimum run the lint + typecheck; a full next build if fast enough. Do NOT raise the lint max-warnings budget — fix any new warning at source (frontend lint has a hard budget; see the CI gates). Confirm no new warnings/errors.
- Step 5: Commit
git add frontend/src/app/ops/config-assets/ToolBindingEditor.tsx frontend/src/app/ops/config-assets/[id]/page.tsx
git commit -m "feat(ops): tool_binding config-asset editor (P2.5)"
Task 4: documentation + design-doc correction
Files:
-
Modify:
api/src/config-assets/CLAUDE.md -
Modify:
docs/superpowers/specs/2026-07-07-unified-config-asset-model.md§3 -
Step 1: Update
config-assets/CLAUDE.md- The opening line lists
tool_bindingas "the only asset type still enumerated-but-unimplemented" — change to:tool_bindingstorage half shipped (Zod schema + registration + Ops editor); the execution half (chat-path exposure viatool_permissions × TOOL_CATALOG → AgentRun.toolsManifest, slug-exists validation, credential resolution, write→Expert approval) is P4.7. - In the
schemas/inventory line, addtool-binding.schema.ts(ToolBindingContent:toolSlugregex-aligned with skilltools[]/kind: bespoke|nango/accessClass: read|write/constraints{allowedOps[], rateLimitPerHour}/credentialRefuuid →integration_credentials(org,integration_type)).
- The opening line lists
-
Step 2: Correct the design doc
2026-07-07-unified-config-asset-model.md§3 (~:195-207) — fix the 3 divergences INLINE (leave a short note that they were corrected against real code @ 490517fb):sourceKind→kind; enum["bespoke","nango","mcp"]→["bespoke","nango"](mcp is P4.6, not present);credentialRefcomment "ADR-030 OSA/Channel" → "integration_credentialsat (org_id, integration_type)". Also fix the misleading "对齐 AGENT_TOOL_REGISTRY" note if present — the real runtime chain isTOOL_CATALOG/tool_permissions(AGENT_TOOL_REGISTRY is the legacy domain-mode static table). If §5 has a "five Zod schemas" checklist item, tick tool_binding. -
Step 3: Self-check no machine paths
Run: grep -rnE '/Users/|/home/|/private/tmp' api/src/config-assets/CLAUDE.md docs/superpowers/specs/2026-07-07-unified-config-asset-model.md; echo "exit=$?" → exit=1.
- Step 4: Commit
git add api/src/config-assets/CLAUDE.md docs/superpowers/specs/2026-07-07-unified-config-asset-model.md
git commit -m "docs: tool_binding storage half shipped + correct design-doc divergences (P2.5)"
Task 5: full verification
- Step 1:
cd api && npx tsc --noEmit && npm run lint→ clean (no new warnings). - Step 2:
npx jest src/config-assets/ --runInBand→ all pass (schema + registry + no regression). - Step 3: Frontend: run its lint + typecheck (real commands from
frontend/package.json) → clean. - Step 4: No commit — verification only. Proceed to Phase-level review.
Self-Review Checklist
Spec coverage (corrected):
- tool_binding Zod schema, kind (not sourceKind), enum without mcp → Task 1 ✅
- CONTENT_SCHEMAS registration → Task 2 ✅
- Ops editor mirroring the pattern → Task 3 ✅
- 3 design divergences corrected in the design doc → Task 4 ✅
- credentialRef = (org, integration_type) semantics → Task 1 comment + Task 4 doc ✅
- NO execution half (slug-exists / credential resolution / write-approval) → explicitly deferred to P4.7 ✅
Type consistency: ToolBindingContent/ToolBindingContentT defined Task 1, imported Task 2. toolSlug regex identical to skill tools[]. Editor enum options must match schema enums exactly (bespoke/nango, read/write).
Boundary discipline: schema validates shape only; no registry/credential lookups (those are P4.7). No migration, no controller change (tool_binding already in enum + CHECK + DTO).