Spec-Driven Development
Spec-Driven Development (SDD) is a structured planning mode for complex or multi-part Infrahub builds. Instead of generating files immediately, the AI reasons through requirements with you first — capturing what needs to be built, validating the approach against Infrahub conventions, breaking the work into discrete tasks, and only generating once the plan is approved.
The key benefit: structural mistakes — wrong relationship cardinality, missing allow_upsert, incorrect generic usage — are caught before any file is produced.
When to use SDD vs. direct mode
| Direct Mode | SDD |
|---|---|
| Adding an attribute to an existing node | Designing a new schema node with relationships |
| Writing a single validation check | Building a generator chain |
| Creating a menu section | Standing up a complete new domain (schema + objects + checks + generators) |
| Populating a batch of objects | Refactoring relationships across multiple schema files |
| Any well-scoped, single-skill task | Anything that involves design decisions or spans multiple skills |
The SDD workflow
1. Specify
Describe the feature or requirement in plain language. The AI captures requirements, asks clarifying questions about scope, Infrahub version, existing schema context, and dependencies. The goal is a complete picture of what needs to be built before any planning starts.
2. Plan
The AI produces an implementation plan. For each component to be built (schema nodes, generators, checks, transforms), it identifies which skill to use, what the inputs and outputs are, and what dependencies exist between steps. The plan is validated against Infrahub skill rules before it's presented.
3. Review
You review the plan. This is the key checkpoint — adjust the approach, correct assumptions, or request changes before any file is generated. The AI explains its reasoning for each decision so you can evaluate whether the approach is correct.
4. Implement
Once approved, the AI executes each task using the correct Infrahub skill. Tasks run sequentially where dependencies exist, or in parallel where they don't — the AI manages the dependency tree.
Working with the plan
- Interrogate — ask why a specific decision was made, request alternatives, or ask what would change if a requirement changed
- Adjust — describe what's wrong or what you want differently; the AI updates the plan before proceeding
- Approve — explicitly confirm the plan is correct. The AI does not proceed without confirmation
Sequential vs. parallel execution
Simple builds execute sequentially: schema first, then objects, then checks. Complex builds with independent components can execute in parallel using sub-agents. The AI determines which tasks are independent based on the dependency tree it built during planning.
Compatible SDD frameworks
SDD works with any framework that supports a spec, plan, task, and implement workflow. The infrahub-template repository scaffolds an Infrahub project and documents the Spec Kit setup it expects, which is the quickest starting point.
Infrahub routing for Spec Kit
The infrahub-speckit extension wires the Infrahub skills into Spec Kit's own commands. It needs Spec Kit 0.8.0 or newer:
specify extension add infrahub-speckit --from https://github.com/opsmill/infrahub-speckit/archive/refs/heads/main.zip
The extension registers four hooks against the core Spec Kit skills. All four fire whether the skill was invoked by a slash command, by another skill, or by an autonomous agent, and all four no-op in a project with no .infrahub.yml:
| Hook | When it fires | What it does |
|---|---|---|
before_specify | Before /speckit.specify writes anything | Classifies the requested artifact type, verifies the Infrahub skills are installed and the instance is reachable, then selects the matching Infrahub spec template |
before_plan | Before /speckit.plan starts research | Re-invokes the skill that matches the artifact |
before_implement | Before /speckit.implement runs its tasks | Re-invokes the matching skill for each artifact type in tasks.md |
after_implement | After /speckit.implement finishes | Checks the finished cycle for evidence that a skill's own guidance had a gap, and offers to report it |
All three before_* hooks halt with install guidance when the Infrahub skills are not present, and before_specify also halts when the instance is unreachable. The after_implement hook never halts: implementation is already done, so a missing skill, an error, or an ambiguous read all degrade to a no-op line rather than disrupting finished work.
Skill-gap detection after implement
Infrahub skills fail quietly. A missing or unclear rule does not crash the run, it produces extra round trips and repeated nudges until the model works the answer out anyway. By the time the cycle ends, that friction is invisible. The after_implement hook looks for it while the session still holds the evidence.
Detection is automatic; drafting and filing are not. On most cycles the whole hook is one line:
[infrahub-speckit] No skill-guidance friction detected this cycle.
When the evidence gate opens, you get an offer and nothing else:
[infrahub-speckit — friction offer, /speckit.implement]
Skill: infrahub-managing-schemas
Evidence: schema load failed on relationship cardinality, passed after correction
Rule coverage: no rule file covers this topic
An Infrahub skill's guidance may have a gap here. Reply "report it" to draft a
skill-friction report for review. Nothing is filed without your approval.
Ignoring it costs nothing. Replying routes the session into the Skill Gap Reporter, which searches the tracker, decides whether the skill or Infrahub itself is at fault, and drafts a redacted report. That skill cannot file: it hands the draft to the Issue Reporter, which shows you the target repository and the full body, then asks how to submit it. You can stop at either gate, and the manual submission method sends nothing from your machine.
Two probes open the gate:
- A verifier verdict: a verifier rejected an artifact and later accepted it, red to green on the same target
- A correction delta: you rewrote something the agent authored, in a way a rule could have prevented
Nothing else does:
- A missing rule file on its own: the rule-coverage read names the file that should have covered the topic, which is attribution for a finding rather than a trigger for one, and plenty of topics on a healthy cycle have no rule file
- Failures the skills do not own: authentication, connectivity, a container that never started, or a product-side 5xx
- Session-shape counters: retry counts, edit churn, repeated asks, and docs escapes rise for reasons unrelated to a skill's guidance, such as an unclear request
The hook also stays quiet unless the skill guided the authoring inside that same implement run, and unless both the Skill Gap Reporter and the Issue Reporter are installed, since the accept path needs both.
Two per-project escape hatches live on the hook's entry in .specify/extensions.yml: optional: true turns the check into an opt-in offer, and enabled: false disables it.
Example walkthrough: VLAN management domain
This example uses Spec Kit to design a VLAN management domain from scratch — schema, object data, and a validation check. Any SDD framework that follows a spec, plan, task, and implement workflow produces a similar result.
Setup
Start from infrahub-template, which scaffolds the repository layout the skills expect and ships invoke tasks for the instance:
uv tool run --from 'copier' copier copy https://github.com/opsmill/infrahub-template.git vlan-demo
cd vlan-demo
uv sync --all-packages
invoke start
Then install the skills, Spec Kit, and the Infrahub routing extension:
npx skills add opsmill/infrahub-skills
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
specify init --here --integration claude
specify extension add infrahub-speckit --from https://github.com/opsmill/infrahub-speckit/archive/refs/heads/main.zip
specify init creates the .specify/ project configuration and installs slash commands into .claude/commands/. Having the instance up matters for more than the data: before_specify gates on infrahubctl info, so the cycle stops early rather than planning against an instance it cannot reach.
Step 1: Specify
Run /speckit.specify and describe the feature:
I need a VLAN management domain for our data center network. We need to track VLANs with their numeric ID, name, operational status, and role. VLANs should be grouped into VLAN Groups. We also need a validation check that ensures no two VLANs in the same group share the same VLAN ID.
Spec Kit produces spec.md capturing the requirements — two schema nodes (VLAN, VLANGroup), a grouping relationship, a uniqueness check, and the attributes for each.
Step 2: Plan
Run /speckit.plan. The AI reads spec.md and the Infrahub skills to produce plan.md:
Plan Summary
- Schema (Schema Manager) — Define
IpamVLANandIpamVLANGroupnodes in theIpamnamespace.
IpamVLAN: attributesname(Text),vlan_id(Number),status(Dropdown),role(Text, optional). Relationship toIpamVLANGroup(many-to-one).IpamVLANGroup: attributename(Text). Relationship back toIpamVLAN(one-to-many).- Both nodes get
human_friendly_idanddisplay_label.- Uniqueness constraint on VLAN:
[vlan_id__value, group].- Object data (Object Manager) — Seed two VLAN groups and sample VLANs.
- Validation check (Check Manager) — Python check querying VLANs per group, flagging duplicates. Register in
.infrahub.yml.Dependencies: Schema must be loaded before objects. Check depends on schema being present.
The plan is validated against Infrahub skills — the AI catches that status should be kind: Dropdown (not Text), that vlan_id is used instead of id (attribute names must be 3+ characters), and that the relationship between VLAN and VLANGroup needs matching identifiers on both sides.
Step 3: Review
You review plan.md and adjust:
Looks good, but add
descriptionas an optional Text attribute on both nodes. And use "provisioned" instead of "provisioning" for the status choice.
The AI updates the plan.
Step 4: Tasks
Run /speckit.tasks. The AI breaks the plan into discrete steps in tasks.md:
- Task 1: Create
schemas/vlan_management.ymlwith IpamVLAN and IpamVLANGroup nodes- Task 2: Create
data/vlan_groups.ymlwith two seed groups- Task 3: Create
data/vlans.ymlwith sample VLANs assigned to groups- Task 4: Create
checks/check_vlan_id_uniqueness/with Python check class and GraphQL query- Task 5: Register the check in
.infrahub.yml- Task 6: Validate schema with
infrahubctl schema check
Step 5: Implement
Run /speckit.implement. The AI executes each task using the appropriate Infrahub skill. With the routing extension installed, the cycle closes with the friction check described in Skill-gap detection after implement.
Task 1 output — schemas/vlan_management.yml (via Schema Manager):
version: "1.0"
nodes:
- name: VLAN
namespace: Ipam
label: "VLAN"
icon: "mdi:lan-pending"
human_friendly_id:
- "name__value"
display_label: "{{ name__value }}"
uniqueness_constraints:
- ["vlan_id__value", "group"]
attributes:
- name: name
kind: Text
order_weight: 1000
- name: vlan_id
kind: Number
order_weight: 1100
- name: status
kind: Dropdown
order_weight: 1200
choices:
- name: active
label: Active
color: "#7fbf7f"
- name: provisioned
label: Provisioned
color: "#ffff7f"
- name: maintenance
label: Maintenance
color: "#ffd27f"
- name: decommissioning
label: Decommissioning
color: "#bf7f7f"
- name: role
kind: Text
optional: true
order_weight: 1300
- name: description
kind: Text
optional: true
order_weight: 1400
relationships:
- name: group
peer: IpamVLANGroup
kind: Attribute
cardinality: one
optional: false
identifier: "group__vlans"
- name: VLANGroup
namespace: Ipam
label: "VLAN Group"
icon: "mdi:folder-network"
human_friendly_id:
- "name__value"
display_label: "{{ name__value }}"
attributes:
- name: name
kind: Text
order_weight: 1000
- name: description
kind: Text
optional: true
order_weight: 1100
relationships:
- name: vlans
peer: IpamVLAN
kind: Attribute
cardinality: many
identifier: "group__vlans"
Task 4 output — checks/check_vlan_id_uniqueness/check.py (via Check Manager):
from infrahub_sdk.checks import InfrahubCheck
class CheckVlanIdUniqueness(InfrahubCheck):
query = "check_vlan_id_uniqueness"
def validate(self, data):
vlans = data["IpamVLAN"]["edges"]
groups = {}
for vlan in vlans:
node = vlan["node"]
group_name = node["group"]["node"]["display_label"]
vlan_id = node["vlan_id"]["value"]
key = (group_name, vlan_id)
if key in groups:
self.log_error(
message=f"Duplicate VLAN ID {vlan_id} in group {group_name}"
)
groups[key] = node["display_label"]
Task 5 output — registration added to .infrahub.yml:
check_definitions:
- name: check_vlan_id_uniqueness
class_name: CheckVlanIdUniqueness
file_path: checks/check_vlan_id_uniqueness/check.py
query: check_vlan_id_uniqueness
Step 6: Report the gap it exposed
Task 6 is where the cycle hit friction. The first version of schemas/vlan_management.yml left optional off the group relationship, and relationships default to optional, so infrahubctl schema check rejected it:
cannot use group relationship, relationship must be mandatory
The AI added optional: false, re-ran the check, and it passed. That is a verifier going red to green on the same target inside a single implement run, which is exactly what the after_implement hook looks for. When the cycle closes, it prints one offer:
[infrahub-speckit — friction offer, /speckit.implement]
Skill:
infrahub-managing-schemasEvidence:infrahubctl schema checkrejected the VLAN node, then passed onceoptional: falsewas added to the constrained relationship Rule coverage:rules/uniqueness-constraints.mdAn Infrahub skill's guidance may have a gap here. Reply "report it" to draft a skill-friction report for review. Nothing is filed without your approval.
Ignoring it ends the cycle there. Replying hands the session to the Skill Gap Reporter, which searches the tracker, classifies the finding, and drafts:
bug: infrahub-managing-schemas: state the mandatory requirement where the constraint is written
Skill: infrahub-managing-schemas Type: bug Confidence: unconfirmed single observation Tracker search:
repo:opsmill/infrahub-skills uniqueness constraint mandatory relationshipreturned no existing reportWhat was being attempted: scoping a uniqueness constraint by the parent relationship of a child node.
Rules consulted:
uniqueness-constraints.md, which does state that a relationship in a constraint must be mandatory, cardinality one, and referenced bare.Where it went wrong: the constraint was written correctly but the relationship was left at its default, so the check failed on the mandatory requirement. One failed verifier run and one correction.
What finally worked:
optional: falseon the constrained relationship.Proposed rule change: in
uniqueness-constraints.md, put the three requirements next to the constraint example rather than in a later section, so the relationship and the constraint that binds it are authored together.
It is a bug rather than a feature because a rule already claimed the ground and the model still got it wrong. Had no rule covered relationships in constraints, the same friction would have drafted as a feat: instead, and the offer would have read Rule coverage: no rule file covers this topic. That classification decides the title prefix and the target repository, which is why the coverage read runs before the draft.
The draft carries the version lines the template requires, read from infrahubctl info, and nothing that identifies your infrastructure. The Skill Gap Reporter cannot file it: it hands the draft to the Issue Reporter, which shows you the target repository and the full body, then asks whether to submit through gh, a GitHub MCP server, or copy-paste. You can stop at either gate.
Asking for the same thing without the extension installed works too: "report skill friction" reaches the Skill Gap Reporter directly. The hook only removes the need to notice the friction yourself.
Result
From a single natural-language description, the SDD workflow produced:
- A schema with correct naming, Dropdown status, matching relationship identifiers, and uniqueness constraints
- Seed data files with proper references
- A working validation check registered in
.infrahub.yml
Each artifact follows Infrahub best practices because the AI applied the relevant skill at each step — not because the user knew the conventions upfront.