Those who have been following my blog would notice I’ve published quite a few vibe coding posts over the past year. From a smart travel planner and a stock dashboard built by chatting with GitHub Copilot, to a five-agent roster in Claude Code building the same login portal under different coordination models.
Vibe coding is fun, and it is surprisingly capable for a weekend project, but in my work as an architect I would not suggest this for enterprise applications because of the stochastic nature of large language models. The same prompt sent twice can produce two completely different implementations and one might use different libraries, structure the data differently, or take a completely different approach to validation and error handling. Those decisions are made as the LLM works through the task within the context of that prompt and may never be captured as part of the application’s design.
Imagine vibe coding an enterprise application that has to pass an audit, go through change control, be maintained by a team of developers for five years, and behave the same way after the next feature request as it did before it. Predictability becomes a core requirement in that scenario, and leaving implementation decisions to the LLM makes that difficult. This is where spec-driven development comes in, and it has been one of the most talked-about approaches in AI-assisted development over the past year.
In this post, I will go through what spec-driven development is, how GitHub’s Spec Kit came about and how quickly it has been adopted, and then use Spec Kit inside Claude Code to rebuild Uplert, the uptime monitor from my generic agent roster post. I’ll then compare the hand-written SPEC.md from that build with the documents generated by Spec Kit.
The Problem With Vibe Coding Enterprise Applications
The quality of the code a model produces while vibe coding varies from one session to the next, and so does the code itself, because asking the model to regenerate or extend a feature can produce a different implementation every time it runs, even when the prompt has not changed. The requirements and decisions behind that code may not be documented anywhere outside the conversation, so whichever developer picks it up next has to reverse engineer them from the code, and future changes can quietly alter behaviour that was never explicitly documented or agreed on.
| Vibe coding | Spec-driven development | |
|---|---|---|
| Source of truth | The chat history | A versioned spec in the repository |
| Repeatability | Varies from run to run | Constrained by the spec, the plan, and the task list |
| When the review is performed | After the code has been written | Before any code is written, against the spec |
| Ambiguity | The model guesses and moves on | Surfaced as explicit questions for a human to answer |
| Project-wide rules | Restated in every prompt, or forgotten | Written once in a constitution and applied to every feature |
| Handoff to another developer | Tribal knowledge | Specs, plans, and tasks committed next to the code |
| Change requests | Re-prompt and hope nothing else moves | Update the spec and regenerate the plan and tasks |
What Is Spec-Driven Development
- Specify, describing what is being built and why, focused on the users, their experience, and what success looks like, with the agent drafting a detailed spec from that description
- Plan, providing the technical direction, such as the stack, the architecture, and any constraints, with the agent producing a technical plan
- Tasks, the agent breaking the spec and plan into small work items that can each be implemented and tested independently
- Implement, the agent working through those tasks so the review is of small, focused changes instead of one large block of code
Spec-driven development does not make the model deterministic, and the same spec handed to the same model twice can still produce different code. What it does is give the model boundaries: project-wide rules define the stack, testing standards, and security requirements, while the plan pins down libraries, the data model, and interface contracts before implementation begins. The output still varies from run to run, but within a much narrower range and any drift can be caught against something documented instead of against someone’s memory of a conversation.
Spec Kit, Kiro, and Tessl
Spec Kit is not alone with this idea, since two other prominent tools have followed the same methodology over the past year:
| Tool | Vendor | Form | What it produces |
|---|---|---|---|
| Kiro | AWS | A standalone agentic IDE | requirements.md, design.md, and tasks.md per feature, with requirements written in EARS notation |
| Tessl | Tessl | A framework plus a Spec Registry of 10,000-plus usage specs for open source libraries | Specs stored in the codebase as long-term memory, with tests as guardrails |
| Spec Kit | GitHub | An open source CLI and a set of agent skills that plug into the agent you already use | A constitution, plus a spec, plan, and task list per feature |
What made Spec Kit the one I chose to test is that it does not require me to change tools. Kiro is its own IDE and Tessl is its own platform, whereas Spec Kit installs a set of skills into Claude Code, GitHub Copilot, or one of roughly forty other agents and leaves the agent itself alone.
The Spec Kit repository was created on August 21, 2025 and GitHub announced it publicly on the GitHub Blog on September 2, 2025. Its adoption since then has been substantial:
| Detail | Value (as of September 23, 2026) |
|---|---|
| License | MIT |
| CLI package | specify-cli |
| GitHub stars | About 138,600 |
| GitHub forks | About 12,400 |
| Latest release | v1.0.10, September 22, 2026 |
| Supported agents | 40-plus integration keys, including claude and copilot, up from around 11 at launch |
That is a lot of adoption for a toolkit that is essentially a CLI, a set of templates, and a set of prompts.
I ran this walkthrough on v1.0.10 and Spec Kit had reached v1.2.6 by the time this post was published, so the skill output you see may differ slightly from my screenshots.
The Components of Spec Kit
Spec Kit writes every stage to a markdown file for a reason that I covered in my post on what Claude’s memory actually is. The model itself retains nothing between sessions, and context only carries forward when the application around it stores that context in files and loads them into the next request, which is what CLAUDE.md does for Claude Code and what the constitution, spec, plan, and tasks do for Spec Kit.
Spec Kit has two halves. The specify CLI runs in a terminal and scaffolds a project, creating a .specify/ folder with templates and scripts and installing the Spec Kit skills into the agent’s own folder, which for Claude Code is .claude/skills/. Everything after that happens inside the agent’s chat, one skill at a time, and each skill writes a markdown file that the next skill reads. The last column compares each step to building a house, since the same order of decisions applies there too.
| Step | Skill | What it does | What it produces | Path | Building a house |
|---|---|---|---|---|---|
| 1 | /speckit-constitution |
Sets the project-wide principles every feature has to follow | .specify/memory/constitution.md |
Both, set once per project | The building code and the owner’s rules for the whole house, such as wiring to code, no basement, and energy-efficient windows in every room |
| 2 | /speckit-specify |
Turns a plain-language description into a feature spec | specs/NNN-name/spec.md and a built-in spec quality checklist at checklists/requirements.md |
Both | The owner describing what they want, such as three bedrooms, an open kitchen, and a garage for two cars, without picking any materials yet |
| 3 | /speckit-clarify |
Asks targeted questions about gaps in the spec and records the answers | Updates spec.md |
Full only | The architect asking questions before drawing anything, such as whether the garage needs heat and how many bathrooms the house needs |
| 4 | /speckit-plan |
Produces the technical design against the constitution | plan.md, research.md, data-model.md, contracts/, quickstart.md |
Both | The blueprints and materials list, such as a wood frame on a concrete slab, the floor plan, and where the plumbing runs |
| 5 | /speckit-checklist |
Generates a reviewer-owned checklist for one area of the requirements, such as security or UX, with every item left unchecked for a person to work through | checklists/<domain>.md, such as checklists/security.md |
Full only | A specialist’s inspection list for one area of the house, such as an electrical inspector’s checklist, for a person to work through before the walls are closed up |
| 6 | /speckit-tasks |
Breaks the plan into ordered tasks, marking ones that can run in parallel | tasks.md |
Both | The construction schedule, with the foundation first, then framing, then electrical and plumbing at the same time, then drywall |
| 7 | /speckit-analyze |
Checks the spec, plan, and tasks for inconsistencies and gaps | A report only, nothing written | Full only | Cross-checking the requirements, blueprints, and schedule, such as a bedroom the owner asked for that never made it onto the blueprints |
| 8 | /speckit-implement |
Works through tasks.md in order |
The application code | Both | The crews building the house by following the schedule in order |
| 9 | /speckit-converge |
Compares what was built against the spec and appends any gaps as new tasks | Appends to tasks.md |
Both, repeated with implement until clean | The final walkthrough against the owner’s requirements, with a list of anything missed going back to the crews until nothing is left |
The Spec Kit quickstart documents two paths through these skills, a shorter path for smaller features and a full path for production features. Both start from the same constitution, which is set once per project, so every feature follows the same project-wide rules regardless of which path built it. The full path adds three quality gates that the shorter path skips:
| Shorter path | Full path | |
|---|---|---|
| Intended for | Smaller features | Production features |
| Skills run per feature | specify, plan, tasks, implement, converge | specify, clarify, plan, checklist, tasks, analyze, implement, converge |
| Gaps in the spec | Carried into the plan, unless I write the answers into the specify prompt myself | /speckit-clarify asks about them and records my answers in spec.md before planning |
| Quality of the requirements | /speckit-specify checks the spec against its built-in checklists/requirements.md |
The same built-in check, plus /speckit-checklist producing reviewer-owned checklists for specific areas, such as checklists/security.md |
| Consistency between spec, plan, and tasks | Not checked before implementation | /speckit-analyze reports requirements with no task, tasks with no requirement, and contradictions |
| Completeness after implementation | /speckit-converge |
/speckit-converge |
The shorter path still ends with converge, so a requirement that never got implemented is still caught. Skipping clarify means the model resolves any ambiguity in the spec on its own, which is the vibe coding behaviour I described at the start, so the shorter path only holds up when the specify prompt is already precise.
I ran the full path for the initial Uplert build since I wanted to see what the clarify step would ask, and the shorter path for the second feature in Step #11 so both can be seen on the same project.
The documentation calls out a gotcha that catches anyone used to branch-per-feature workflows. Spec Kit tracks the active feature through the feature directory recorded in .specify/feature.json, not through the git branch, so checking out a different branch does not change which feature the skills operate on, and switching features means updating that file or setting the SPECIFY_FEATURE_DIRECTORY environment variable.
What I’m Building with Spec Kit
The application is Uplert, the same personal uptime monitor I used in the generic agent roster post. A signed-in user registers HTTP endpoints to be polled on a schedule and sees whether each one is up, its uptime percentage over the last 24 hours, and its recent response times, while an administrator can list every registered user and how many checks each one has. It runs locally on Windows 11 with SQLite and no Docker, WSL, or paid services.
Reusing Uplert gives me an accurate comparison. In the Final Thoughts of that post, I wrote that the hand-written SPEC.md was the part of the roster I was least settled on, because nothing checked that it was complete before five agents started building from it. The clearest example was the administrator role, which the spec named without ever saying how an account receives it so the backend agent made that access-control decision on its own and nothing caught the omission, since I had written and reviewed the file myself.
I closed that post by saying the next iteration would move toward Spec Kit, which formalizes what I did by hand and this build is that next iteration. The same requirements go into Spec Kit this time and I can see which questions it asks that my own spec never answered. My original SPEC.md is in the agent-team-roster repository for anyone who wants to compare the two side by side.
The Differences Between This and the Previous Uplert Build
The application and its requirements are the same as last time, but almost everything around them is different, and I’ve summarized those differences below so the results in Step #10 can be read in context.
| Previous Uplert build | This Uplert build | |
|---|---|---|
| Who does the work | A lead session dispatching five subagents, backend, frontend, tester, security-reviewer, and docs | One Claude Code session running the Spec Kit skills one at a time |
| Requirements input | A one-page SPEC.md mixing behaviour with stack constraints |
Behaviour in spec.md, stack in plan.md, and project-wide rules in constitution.md |
| How gaps in the requirements were handled | Each agent filled them on its own and documented the decision afterwards | /speckit-clarify asks me before any code is written |
| Contract between client and server | API.md, published by the backend agent for the frontend agent to read |
contracts/, generated by /speckit-plan before implementation starts |
| Testing | A dedicated tester agent confined to tests/ by a PreToolUse hook |
Test tasks generated in tasks.md, with the constitution requiring a test for every functional requirement |
| Security review | A read-only security-reviewer agent with its own checklist | No dedicated reviewer, only the constitution’s Quality Gates, a security checklist from /speckit-checklist for me to review, and the /speckit-analyze consistency check |
| Fixing what was found | A remediation loop in CLAUDE.md routing each defect back to the agent that owned the layer |
/speckit-converge appending gaps to tasks.md for another /speckit-implement pass |
| Documentation | README and CHANGELOG written by the docs agent | spec.md, plan.md, research.md, data-model.md, quickstart.md, and tasks.md, all written before the code |
I will be paying close attention to the row about gaps, because the previous build made several decisions that SPEC.md never asked it to make, and each of them is a question I would expect /speckit-clarify to raise:
| Decision the previous build made on its own | What it chose |
|---|---|
| How an account becomes an administrator | An ADMIN_EMAILS environment variable read only at registration, so an existing account can never be promoted |
| Whether checks can target private or internal addresses | Allowed, which the security reviewer accepted after reading the backend’s reasoning |
| What a duplicate registration returns | AÂ 409, which reveals that an email address is already registered |
| How sessions are stored | Session tokens in the database in plaintext |
| Which database the test suite uses | The same SQLite file as the application, so npm test destroys the development database |
The previous build also had an advantage this one does not because the security reviewer and the remediation loop found and fixed nine defects, including an encoded-slash path traversal that the lead confirmed live with an actual request. Spec Kit has no equivalent of a dedicated security reviewer, so a fair outcome here is that clarify prevents some of the guesses above while the security defects the reviewer caught slip through untouched.
To measure that, I am treating the nine defects and five accepted risks from the previous build as an answer key, the same approach I used when pointing LM Studio Bionic at the Waypoint codebase, and checking the Spec Kit build against each one in Step #10.
As an additional note, I ran this build in a single Claude Code session without the five-agent roster, since the focus of this post is spec-driven development and the roster was covered in my previous post.
Step #1 – Install uv and the Specify CLI
The commands in this walkthrough work in either PowerShell or Command Prompt. Where the two need different syntax, I’ve shown both versions, and every other command can be run as-is in either one.
Spec Kit requires Python 3.11 or later and uv, and the full installation options, including pinning a specific release, are in the Spec Kit documentation. On Windows, uv installs with a single command, which calls PowerShell to run the installer and can therefore be run from either shell:
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
With uv in place, install the CLI and confirm the version:
uv tool install specify-cli
specify --version
Step #2 – Initialize the Project for Claude Code
The --integration flag chooses which agent the skills are installed for, and claude is the key for Claude Code:
cd C:\Claude
specify init uplert-sdd --integration claude
cd uplert-sdd
Before creating anything, specify init asks which script type to use, with sh for POSIX shells, ps for PowerShell, and py for Python. The choice determines which version of Spec Kit’s helper scripts is placed in .specify/scripts/, and the skills call those scripts to handle housekeeping such as creating feature folders and updating feature.json. I selected ps since this is a Windows machine, and it is also the default the prompt highlights here.
No other prompts followed, and init worked through each setup task before reporting the project ready:
| Init task | What it did |
|---|---|
| Check required tools | Confirmed the prerequisites were installed |
| Select coding agent integration | claude, from the --integration flag |
| Select script type | ps, from the prompt above |
| Install integration | Installed the Spec Kit skills for Claude Code into .claude/skills/ |
| Install shared infrastructure | Added the PowerShell helper scripts and the templates under .specify/ |
| Ensure scripts executable | Skipped, since this only applies to POSIX shells |
| Constitution setup | Copied a constitution template to .specify/memory/constitution.md, which /speckit-constitution fills in later |
| Install bundled workflow | Installed the Spec Kit workflow |
| Finalize | Reported the project ready |
The Next Steps panel at the end lists the shorter path as the core flow and labels clarify, analyze, and checklist as optional enhancement skills, which matches the two paths described earlier. Init also displays an Agent Folder Security notice suggesting that .claude/ be added to .gitignore, because some agents store credentials or tokens in their folder. I would not ignore the whole folder in this project, since .claude/skills/ holds the Spec Kit skills that anyone cloning the repository needs, so I only excluded the files that are specific to my machine.
Initializing a git repository is no longer part of specify init, since git support is an optional extension, so I created the repository myself in the project folder and committed the scaffolding before running any skills. Running git init from C:\Claude instead would put every other project in that folder under the same repository, so make sure the prompt is in C:\Claude\uplert-sdd first. This gives Step #11 a clean history to diff against later:
PowerShell:
cd C:\Claude\uplert-sdd
git status
git init
Set-Content -Path .gitignore -Value "/.claude/settings.local.json"
git add .
git commit -m "Spec Kit scaffolding"
Command Prompt:
cd C:\Claude\uplert-sdd
git status
git init
echo /.claude/settings.local.json> .gitignore
git add .
git commit -m "Spec Kit scaffolding"
In the Command Prompt version, keep the > directly after .json with no space in between, otherwise the space is written into .gitignore as part of the pattern and the file is never matched.
Running git init inside C:\Claude\uplert-sdd gives the project its own repository, nested inside the old one, and from then on every git command in the project folder uses it. The only folder Spec Kit created under .claude/ was skills/, so settings.local.json is the only file that needs excluding.
This is the project structure once the first feature has been through every skill:
Here is the project folder in Windows Explorer:
Open Claude Code in that folder and type /speckit to confirm the skills are loaded:
claude
Step #3 – Write the Constitution
The constitution holds the rules every feature in the project has to follow, while each feature’s spec only describes what that feature does. My original SPEC.md mixed the two together, listing Uplert’s behaviour in the same file as its stack constraints, such as running natively on Windows with one command, using SQLite, and avoiding Docker, WSL, and paid services. In Spec Kit those constraints move into the constitution, so the spec in Step #4 can stay focused on behaviour and the second feature in Step #11 inherits them without my restating them. I also added the rules my SPEC.md had left to the agents, including server-side authorization on every request, a test for every requirement, and adaptive password hashing. This is the prompt I used:
/speckit-constitution Create principles for a small, locally run web application. It must run natively on Windows 11 with one command, with no Docker, no WSL, and no paid or signup-gated services. Storage is SQLite, created on first run. Every user’s data is private to that user, and authorization is checked on the server for every request, never trusted from the client. Every functional requirement has at least one automated test, and the full test suite passes before a task is marked complete. Passwords are hashed with a modern adaptive algorithm and never logged. Keep dependencies to the minimum needed.
The constitution skill fills in the template that init placed in the .specify/memory directory.
Here are the contents of the constitution.md.
Below is the full constitution Spec Kit generated. The comment block at the top is the Sync Impact Report, which the constitution skill uses to record what changed each time the constitution is written or amended. It does not show up when the markdown is rendered, and it lists:
- The version change, which here is from the empty template to 1.0.0
- The principles and sections that were added, modified, or removed
- The templates that may need updating to match the new rules
- Any placeholders that were left unfilled
The report is rewritten with every amendment and is meant to be deleted once its follow-ups are done.
<!--
Sync Impact Report
===================
Version change: [TEMPLATE] → 1.0.0 (initial ratification)
Modified principles: n/a (first concrete ratification; all placeholders replaced)
Added sections:
- Core Principles I–VI (Native Windows Execution; SQLite Persistence; Per-User Data
Isolation & Server-Side Authorization; Test-Backed Requirements; Secure Credential
Handling; Minimal Dependencies)
- Technology Constraints
- Quality Gates
- Governance
Removed sections: none (template placeholders only)
Templates requiring follow-up:
- .specify/templates/plan-template.md âš pending manual check that its Constitution
Check gate references these six principles by name
- .specify/templates/spec-template.md âš pending manual check for alignment
- .specify/templates/tasks-template.md âš pending manual check that task completion
checklist reflects the Quality Gates section (test-before-complete)
Deferred placeholders: none — all bracketed tokens resolved.
-->
# Uplert Constitution
## Core Principles
### I. Native Windows Execution, No Lock-In
The application MUST run natively on Windows 11 and MUST be startable with a single
command (e.g. one script invocation or one executable) with no manual multi-step setup
beyond that command. Docker, Docker Desktop, WSL, and any VM-based runtime are
PROHIBITED as execution dependencies. The application MUST NOT require signup for,
or a paid tier of, any third-party service in order to run; any external service
integration MUST be optional, with the application fully functional without it.
**Rationale**: This is a small, locally run tool. Every environment dependency
(a container runtime, a subsystem, a hosted account) is a new way for it to fail to
start on a plain Windows 11 machine, and a paid or signup-gated dependency makes the
single-command promise conditional on something outside the user's control.
### II. SQLite Persistence, Created on First Run
All application data MUST be stored in SQLite. The database file MUST be created
automatically on first run if it does not already exist, with no separate migration
or provisioning command required before the application is usable.
**Rationale**: SQLite requires no server process and ships as a single file, which
matches the single-command, no-infrastructure constraint in Principle I. Auto-creation
on first run keeps "one command to start" literally true, including the very first
start.
### III. Per-User Data Isolation, Enforced Server-Side
Every user's data MUST be private to that user. Every request that reads or writes
user data MUST be authorized on the server, keyed off the authenticated session or
credential — never off a user ID, role, or permission flag supplied by the client.
Client-supplied identifiers MAY be used to select *which* record is requested, but
MUST NOT be trusted to decide *whether* access is allowed.
**Rationale**: Client input is attacker-controlled by definition. A server-side check
on every request is the only check that cannot be bypassed by editing a request
before it is sent, and is non-negotiable for any application that stores more than
one user's data.
### IV. Test-Backed Requirements (NON-NEGOTIABLE)
Every functional requirement defined in a feature's spec MUST have at least one
automated test that exercises it. A task MUST NOT be marked complete until the full
automated test suite passes, including tests unrelated to that task. A requirement
with no corresponding test is incomplete, regardless of whether the code behind it
appears to work.
**Rationale**: On a small team (or a solo author) there is no independent QA pass to
catch a regression; the test suite is that pass. Requiring the *full* suite to pass —
not just new tests — is what prevents a change in one area from silently breaking
another.
### V. Secure Credential Handling
Passwords MUST be hashed using a modern adaptive hashing algorithm (e.g. bcrypt,
scrypt, or Argon2) with per-password salting; they MUST NOT be stored, transmitted,
or compared in plaintext. Passwords, password hashes, and any other credential
material MUST NOT appear in logs, error messages, or stack traces under any
circumstance.
**Rationale**: Adaptive hashing resists brute-force and rainbow-table attacks far
better than a fast general-purpose hash. Logging is a common, easily-overlooked leak
path — a debug log line or an exception dump is often how credential material
actually escapes a system.
### VI. Minimal Dependencies
Every new dependency MUST be justified by a concrete, current need; dependencies MUST
NOT be added for hypothetical future use, convenience alone, or because a feature
could be partially hand-rolled instead. When a capability can be met by the language
or framework's standard library without materially more effort, the standard library
MUST be preferred over a third-party package.
**Rationale**: Each dependency is an addition to the install step, an attack surface,
and a future upgrade burden. For a small, locally run application, a lean dependency
tree is also what keeps the single-command setup fast and reliable.
## Technology Constraints
The application's run command (whatever form it takes — a script, a binary, a package
manager entry point) MUST be documented in the project's README and MUST work on a
clean Windows 11 install that has only the stated prerequisites (e.g. a specific
language runtime) installed — no Docker, no WSL, no container registry login, and no
paid API key. Any optional third-party integration (e.g. an external API) MUST degrade
gracefully to a disabled or stubbed state when its credentials are absent, rather than
preventing startup.
## Quality Gates
Before any task is marked complete:
1. Every functional requirement the task implements or touches MUST map to at least
one automated test (Principle IV).
2. The full automated test suite MUST be run and MUST pass — no failing, erroring, or
newly-skipped tests are acceptable as a completion state.
3. Any endpoint or code path that touches user data MUST be reviewed against
Principle III: does authorization happen on the server, keyed off the
authenticated identity, on every request that touches that data?
4. Any code path that touches passwords or other credentials MUST be reviewed against
Principle V: adaptive hashing in place, no plaintext or hash values reaching logs.
A task that fails any of these checks is not complete, even if its primary feature
appears to work end-to-end.
## Governance
This constitution supersedes any conflicting practice, template default, or ad hoc
convention used elsewhere in the project. Any plan, spec, or task list that conflicts
with a principle here MUST either be revised to comply or must document, in that
artifact, an explicit justification for the deviation and get the user's sign-off
before proceeding.
**Amendment procedure**: A change to this document is proposed by editing
`.specify/memory/constitution.md` (directly, or via the `/speckit-constitution`
command), stating what changed and why, and is adopted once the user approves it.
There is no separate approval body — the user is the sole ratifying authority for this
project.
**Versioning policy**: This constitution is versioned independently using semantic
versioning:
- MAJOR: a principle is removed or redefined in a way that is backward-incompatible
with prior guidance.
- MINOR: a new principle or section is added, or existing guidance is materially
expanded.
- PATCH: wording, clarification, or typo fixes with no change in substantive rule.
**Compliance review**: Every `/speckit-plan` run MUST re-check its design against
these principles before implementation tasks are generated, and every
`/speckit-implement` run MUST treat the Quality Gates section as a hard gate on task
completion, not a suggestion.
**Version**: 1.0.0 | **Ratified**: 2026-10-17 | **Last Amended**: 2026-10-17
Spec Kit ratified the constitution as version 1.0.0 and turned my one-paragraph prompt into six principles with a rationale for each of them, plus a Technology Constraints section, a Quality Gates section that every task has to pass before it is marked complete, and a Governance section with a semantic versioning policy for future amendments. It also inferred the project name Uplert from the folder name, since there was no README or package.json yet to confirm it.
I recommend reading the Sync Impact Report at the top of the file, because it flags two manual follow-ups, confirming that the Constitution Check gate in plan-template.md references these six principles by name and that the task completion checklist in tasks-template.md reflects the Quality Gates section.
I left both templates as generated, and looking at them afterwards showed that no edit was needed. The Constitution Check section in plan-template.md contains only the placeholder “[Gates determined based on constitution file]”, so the plan step builds the gate from whatever the constitution says at the time it runs, and the plan in Step #6 listed all six principles by name. As for the tasks template, it has no constitution check of its own, since the tasks step reads the constitution directly, which is how the test tasks in Step #7 ended up mandatory.
Step #4 – Specify the Feature
Spec Kit wants the what and the why at this stage, and no technology choices, so I stripped the stack constraints out of my original spec and kept only the behaviour:
/speckit-specify Build Uplert, a personal uptime monitor. A registered user signs in, adds HTTP endpoints to monitor with a URL, a display name, and a polling interval, and sees a dashboard showing whether each endpoint is currently up, its uptime percentage over the last 24 hours, and its recent response times.
A background process polls every due check and records the status code, the response time in milliseconds, and a timestamp. Users can delete a check, which also removes its history. An administrator can list every registered user and how many checks each one has, but cannot see another user’s check results. Alerting, public status pages, and scheduled report exports are out of scope for this version.
Spec Kit named the feature folder specs/001-uptime-monitor from the description and wrote that path to .specify/feature.json before drafting the spec.
I did not expect the specify skill to stop before the spec was finished, but it asked how user accounts are created and how someone becomes the administrator, describing it as the one ambiguity in my description that was security-relevant with no safe default. This is the exact gap I called out in the previous post, where SPEC.md named an administrator role without saying how an account receives it and the backend agent resolved it on its own with an ADMIN_EMAILS environment variable. Spec Kit raised it before any code existed, and before the clarify step had even run.
| Option offered | How the administrator is assigned |
|---|---|
| Open signup, first user is admin (recommended) | The first account ever created becomes the administrator, and every account after it is a regular user |
| Open signup, admin promoted manually | Every account starts as a regular user and one is promoted later |
| Invite-only signup | Accounts can only be created by invitation |
I accepted the recommended option, first user is admin, because Uplert only runs on localhost and the first account is registered by me immediately after the first start, so the risk of someone else registering first does not apply here. The answer was written into the spec as open self-registration with the first account ever created becoming the administrator.
The finished spec came to 207 lines and contained:
| Section | Contents |
|---|---|
| User stories | Three, prioritized and independently testable, with monitoring an endpoint as P1, deleting a check and its history as P2, and administrator oversight of users without visibility into their checks as P3 |
| Functional requirements | 18, including server-side ownership checks on every check-level request and an explicit exclusion of alerting, status pages, and report exports |
| Key entities | User, Check, and Poll Result |
| Success criteria | Six, all measurable and free of technology choices |
The specify skill also wrote a built-in spec quality checklist to checklists/requirements.md and validated the spec against it, checking items such as the absence of implementation details and a focus on user value.
Every item passed and no [NEEDS CLARIFICATION] markers remained, so Claude Code described the clarify step as optional and offered to go straight to planning.
Step #5 – Clarify the Gaps
Clarify does more than resolve leftover [NEEDS CLARIFICATION] markers, so I ran it even though Claude Code had described it as optional. It checks the finished spec against a set of ambiguity categories, such as functional scope, the data model, non-functional requirements, and edge cases, and asks up to five questions one at a time. Each answer is recorded in a ## Clarifications section of spec.md and then applied to the requirement it affects.
The administrator question in Step #4 came from the specify skill while it was still writing the spec. Clarify reviews the spec after it is written and asks the questions a reviewer would, so any question it raises that my original SPEC.md did not answer is a gap the five agents in the previous build filled with a guess.
/speckit-clarify
Clarify applies each answer to the spec as soon as I provide it and my answer to the first question made two changes to spec.md:
| Change | Before | After |
|---|---|---|
New ## Clarifications section near the top |
Did not exist | The question and my answer, recorded under a session dated 2026-10-17 |
| FR-008 | A poll is up when the endpoint returns a 2xx or 3xx status “within a reasonable timeout” | A poll is up when the endpoint returns a 2xx or 3xx status within 10 seconds, and a response slower than that counts as down |
The original FR-008 wording is easy to approve in a review, because “a reasonable timeout” reads like a requirement even though it never says how long that is.
The second answer showed me something about the specify step that I had missed. When specify filled a gap on its own, it listed the decision in an Assumptions section near the end of the spec, such as “recent response times” meaning roughly the last 20 results and poll history being kept until its check is deleted.
One of those assumptions said a minimum polling interval would be enforced but called the exact value a planning-stage detail, and when I answered the interval question, clarify removed that assumption and wrote the 1 minute to 24 hours range into FR-004 and FR-005 as requirements. The model is still making guesses here, the same as the agents in the previous build did, but each guess is written down in a section I can read and overrule before any code exists.
| # | Question Spec Kit asked | My answer | Did my original SPEC.md cover it? |
|---|---|---|---|
| 1 | How long should the system wait for a response before treating a poll as “down” due to timeout? Options offered were 10 seconds (recommended), 5 seconds, and 30 seconds | 10 seconds | No, SPEC.md said polls record the status code and response time but never defined a timeout, and the previous build’s poller settled on 10 seconds on its own, the same value I chose here |
| 2 | What range of polling intervals should a user be allowed to choose when adding a check? Options offered were 1 minute to 24 hours (recommended), 5 minutes to 24 hours, and no enforced range | 1 minute to 24 hours | No, SPEC.md asked for a polling interval with no bounds, and the previous build accepted the ten second interval I used in that post’s demo and had a defect where intervalSeconds accepted numeric strings |
| 3 | When the system polls a monitored endpoint, what kind of HTTP request should it send? Options offered were a plain GET request only (recommended), GET with optional custom headers, and a user-selected HTTP method | A plain GET request only, which clarify added as a new requirement, FR-006a, putting endpoints that need custom headers, a request body, or authentication out of scope | No, SPEC.md only said HTTP endpoints, and the previous build had a defect where check URLs containing credentials were stored but could never be polled |
| 4 | If a check’s previous poll is still running, or was missed, when its next poll comes due, what should the system do? Options offered were to skip the overdue slot and poll once at the next interval (recommended), or always poll on schedule even if the previous poll is still in flight | Skip the overdue slot and poll once at the next interval, which clarify wrote into FR-006 along with a rule that a check is never polled concurrently with itself | No, SPEC.md never described how the poller schedules or skips polls, and the previous build’s poller skipped any check whose poll was still in flight, which is close to what I chose here |
The fourth question contradicted my earlier answers. It described a check with a 1 minute interval whose poll runs for 90 seconds, but the 10 second timeout from the first question should prevent a poll from running that long. With a 1 minute minimum interval, a poll should also never still be running when the next one is due. The scenario still makes sense if a poll was missed because the application or machine was suspended.
I accepted the recommended answer without adding a note and clarify then added a new edge case to the spec asking what happens when a check’s poll takes longer than its own polling interval to complete, immediately before reporting that the spec looked consistent with no contradictions. That edge case stayed in place deliberately, because catching contradictions between artifacts is what /speckit-analyze is for.
Clarify stopped after four of its five questions, reporting that every critical ambiguity had been resolved. It touched the new ## Clarifications section, five functional requirements including the new FR-006a, the Edge Cases section, and the Assumptions section, and re-validated the built-in spec quality checklist, which still passed all 16 items. Its last output was a coverage summary of every ambiguity category it scans:
| Category | Status |
|---|---|
| Functional scope and behaviour | Clear |
| Domain and data model | Clear |
| Interaction and UX flow | Clear |
| Performance and timeout semantics | Resolved, 10 second timeout |
| Reliability, missed or overlapping polls | Resolved, skip and resume |
| Security and privacy, authorization and roles | Clear, handled during specify |
| Integration, request method and shape | Resolved, GET only with no headers or authentication |
| Edge cases and failure handling | Clear |
| Terminology and consistency | Clear |
| Completion signals | Clear |
| Accessibility and localization | Outstanding, low impact for a personal tool, deferred to planning or implementation if needed |
| Observability, logging and metrics detail | Deferred to /speckit-plan as an implementation decision |
Every one of the four questions covered something my hand-written SPEC.md never addressed, and together with the administrator question from the specify step, that is five decisions I made explicitly this time that the previous build’s agents made on their own. Clarify also marked terminology and consistency as clear while that questionable edge case was sitting in the spec, which is the one result here I would not take at face value.
Step #6 – Plan the Technical Approach
The stack decisions come in at this step, and the plan is checked against the constitution so it cannot quietly reach for a container or a hosted database. I kept the prompt close to what the previous backend agent chose on its own, which was Node.js with no dependencies at all, so the stack does not become another variable in the comparison:
/speckit-plan Use Node.js and SQLite with as few dependencies as possible. Serve the dashboard from the same process as the API so the application starts with one command. Run the poller in the same process on an interval timer.
The plan filled in the technical context from that prompt and recorded every choice with its rationale in research.md, which came to eight stack decisions, each one landing on a module built into Node.js:
| Plan decision | What it chose |
|---|---|
| Runtime | Node.js 22 LTS or later, with a minimum of 22.5.0 because that is the first version that ships the built-in node:sqlite module |
| Dependencies | None, so package.json has zero dependencies and zero devDependencies |
| HTTP server and routing | node:http |
| Storage | SQLite through node:sqlite, in a single file created on first run with the schema applied by an idempotent startup migration |
| Password hashing and session tokens | node:crypto, using scrypt for passwords |
| Polling monitored endpoints | The global fetch built into Node.js |
| Testing | node:test and node:assert, with integration tests driving the real HTTP server against a temporary SQLite file instead of a mocked one |
| Dashboard | Static HTML, CSS, and vanilla JavaScript served from src/public/ by the same process, with no frontend build step |
The stack is the same one the previous backend agent picked on its own, with no dependencies at all and scrypt for passwords, and this time each choice is written down with the alternatives it was weighed against. I paid close attention to the testing decision, because the previous build’s test suite wrote to the same SQLite file as the application, so running npm test destroyed the development database. The plan settled that before any code existed by giving the integration tests their own temporary database.
The plan also produced data-model.md with User, Check, PollResult, and Session tables, contracts/api.md with the full REST contract and a single error shape of {"error": "<message>"}, and quickstart.md with five runnable validation scenarios mapped to the spec’s user stories and success criteria.
I checked the Session table in data-model.md against the answer key from the previous build, because that build stored session tokens in plaintext and accepted it as a risk, and the plan repeats it, since the token is a random value from crypto.randomBytes, sent to the browser as an HttpOnly cookie, and stored as the table’s primary key exactly as issued, so anyone able to read uplert.db could reuse an active session. Sessions also have no expiry in this version according to the plan, and a row is only removed when the user logs out.
The Constitution Check passed regardless, because nothing in the constitution covers session tokens. Principle V requires adaptive hashing for passwords and keeps credential material out of logs, and it says nothing about how a session token is stored at rest. The plan is checked against the rules I wrote down and nothing else, which makes the constitution only as strong as the person writing it.
I held off on changing anything until the security checklist below had run, to see whether it would catch the same problem on its own.
The Constitution Check ran twice, once before the design work and again after the data model and API contract were written, and both passes reported all six principles satisfied with no exceptions to justify in the Complexity Tracking section.
PowerShell:
Get-ChildItem .\specs\001-uptime-monitor -Recurse | Select-Object FullName
Command Prompt:
dir /s /b specs\001-uptime-monitor
The checklist skill runs after the plan, and since the previous build had a dedicated security reviewer and this one does not, I asked for a security checklist, which gives me a reviewer-owned list to work through in place of that agent:
/speckit-checklist Create a security checklist covering authentication, session handling, per-user authorization, input validation, and requests the poller makes to user-supplied URLs.
Before generating anything, the checklist skill asked its own scoping questions. The first was whether to treat the poller’s outbound requests to user-supplied URLs as a server-side request forgery risk, with items asking whether the requirements define which network destinations the poller may reach, such as private IP ranges, localhost, and cloud metadata endpoints. Server-side request forgery was the headline security surface I expected for Uplert in the previous post, and that build’s poller deliberately did not block private targets, so I included it.
The second question was depth, with a choice between a formal pre-implementation gate of roughly 25 to 35 items across five focus areas, each tied to a section of the spec, plan, or data model, and a lightweight sanity check. Since this checklist is standing in for the security reviewer from the previous build, I chose the formal gate.
The checklist came to 35 items, CHK001 to CHK035, across six categories, which were authentication, session handling, per-user authorization, input validation, poller requests to user-supplied URLs, and a final category for dependencies, assumptions, ambiguities, and conflicts. Its SSRF items went further than I asked, covering private IP ranges, cloud metadata endpoints, DNS rebinding, and redirect handling. Every item is tagged with a reference back to the section of the spec, data model, or research it tests, or marked as a gap, ambiguity, conflict, or assumption, and every item was left unchecked, since working through the list is a reviewer’s job and not the model’s.
The report closed with three gaps it wanted me to look at, and each one lines up with an entry on the answer key from the previous build:
| Checklist gap | Previous build’s equivalent |
|---|---|
| CHK003 and CHK004, an account enumeration trade-off in the registration and login contract that is not called out as an accepted risk anywhere | The 409 on a duplicate email, an accepted risk, and the registration timing oracle, which was fixed |
| CHK008 and CHK009, no spec-level requirement for session cookie security attributes | The SameSite cross-port defect, which was fixed |
| CHK026 and CHK027, no stated boundary on whether the poller can reach private or internal network addresses | The poller not blocking private targets, an accepted risk |
The previous build found all three in a code review after the code existed, whereas the checklist found them against the requirements before a task list had even been generated, when fixing them costs the least.
It also caught part of the session problem. CHK008 asked whether the session cookie’s security attributes were a requirement anywhere, CHK009 asked how the Secure flag should be handled when the app runs over plain HTTP on localhost, and CHK010 asked whether sessions should expire or stay valid until logout. None of the items asked how the token itself is stored, so the plaintext token was something I only found by reading data-model.md.
Resolving the security gaps
Each gap needed a decision before the task list was generated, and they belonged in two different places. Session handling applies to every feature the project will ever have, so it went into the constitution. The account enumeration trade-off and the poller’s network boundary are decisions about this feature, so they went into the spec.
Amend the constitution. I amended Principle V to cover session tokens and cookies:
/speckit-constitution Amend Principle V so session tokens are stored only as a hash in the database, sessions expire after a fixed period of inactivity, and session cookies are always HttpOnly and SameSite=Strict.
Spec Kit treated this as a MINOR change under the versioning policy it wrote in Step #3, since it expanded an existing principle without removing or redefining anything, so the constitution moved from version 1.0.0 to 1.1.0. Principle V was renamed from Secure Credential Handling to Secure Credential & Session Handling, the matching Quality Gates item was updated, and the rationale now explains that a session token is a bearer credential just like a password, so a plaintext copy in a database backup or export gives an attacker a ready-to-use session.
The Sync Impact Report at the top of the constitution also flagged that data-model.md and research.md now conflicted with the amended principle, since they described a plaintext token, SameSite=Lax, and no expiry. Since the constitution command does not touch feature artifacts, it left the conflict for the plan to reconcile and pointed back to items CHK008 to CHK010 in the security checklist, which had already flagged the same gap before the amendment existed.
Decide the feature-level gaps. I passed the remaining two gaps to the clarify skill by item number:
/speckit-clarify Resolve security checklist items CHK003/004 and CHK026/027: decide the account-enumeration trade-off for registration and login, and which network destinations the poller may reach.
Clarify asked exactly two questions, one per gap, and recorded both under a second Clarifications session labelled as a security checklist follow-up:
| Question | Options offered | My answer | What changed in the spec |
|---|---|---|---|
| Should it be acceptable for an attacker to learn whether a login or email is already registered? | Accept it at registration only (recommended), or hide it everywhere including registration | Accept it at registration only | FR-001 now allows a distinct “already taken” error at registration as a documented trade-off, and a new FR-003a requires an identical, generic error for an unknown login and a wrong password at sign-in |
| Should the poller be allowed to reach private, internal, or cloud metadata addresses? | Block private, internal, and metadata destinations (recommended), or allow any reachable address | Block them | A new FR-019 limits polling to publicly routable addresses, blocking ranges such as 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, ::1, and fc00::/7, and re-checks the destination on every poll |
Both decisions compare directly with how the previous build ended up. That build returned a 409 on a duplicate email and accepted it as a risk after the fact, and its poller deliberately allowed private targets, which the security reviewer accepted after reading the backend agent’s reasoning. This time the first decision is the same trade-off, but made by me and written into FR-001 before any code existed, and the second is a hard requirement instead of an accepted risk.
Clarify also marked CHK003, CHK004, CHK026, and CHK027 as checked in security.md with a resolution note on each, because I named those items in the prompt. It noticed that the per-poll re-check in FR-019 also covers CHK030, the DNS rebinding item, but left that one unchecked for me to sign off, since I had not asked it to resolve it.
Re-run the plan. I re-ran /speckit-plan with the same prompt as before. It reconciled the existing plan artifacts with both changes instead of regenerating them from scratch:
| Artifact | What changed |
|---|---|
data-model.md |
The Session table now stores a token_hash and a last_seen_at column for the inactivity expiry, in place of the raw token |
research.md |
The session decision was rewritten, including the reasoning for hashing the session token with SHA-256 instead of scrypt, and a new section covers the private address check behind FR-019, with the DNS rebinding time-of-check gap recorded as an accepted limitation |
contracts/api.md |
The login cookie is now SameSite=Strict, the register and login endpoints state the FR-003a trade-off, and creating a check documents the 400 returned for a blocked destination |
plan.md |
A new url-safety.js service in the project structure, and a post-amendment row in the Constitution Check |
quickstart.md |
A new scenario for FR-019 blocking and another for session expiry |
The plan also caught a conflict that the clarify session had created. One of the quickstart scenarios demonstrated a connection failure with a check pointed at http://localhost:9/, which FR-019 now rejects outright, so the plan replaced it with an .invalid hostname that fails DNS resolution instead. The Constitution Check passed all six principles again, including the amended Principle V.
Step #7 – Generate and Analyze the Tasks
/speckit-tasks
The task list came to 41 tasks, T001 to T041, across six phases, with each user story in its own phase so it can be built and tested independently of the others:
| Phase | Tasks | Count |
|---|---|---|
| Setup | T001 to T004 | 4 |
| Foundational, covering authentication, storage, and the HTTP server | T005 to T015 | 11 |
| User Story 1, monitor an endpoint (P1, the MVP) | T016 to T029 | 14, made up of 8 tests and 6 implementation tasks |
| User Story 2, delete a check and its history (P2) | T030 to T034 | 5, made up of 2 tests and 3 implementation tasks |
| User Story 3, administrator oversight (P3) | T035 to T038 | 4, made up of 1 test and 3 implementation tasks |
| Polish and Quality Gate review | T039 to T041 | 3 |
Every task names the exact file it touches, and tasks that can run in parallel because they touch different files with no unfinished dependency are marked with [P]. This is the start of the Setup phase:
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Project initialization and basic structure
- [ ] T001 Create the project directory structure per plan.md: `src/db/`, `src/routes/`,
`src/services/`, `src/public/`, `tests/contract/`, `tests/integration/`, `tests/unit/`
- [ ] T002 Create `package.json` at the repo root: `"type": "module"`,
`"engines": { "node": ">=22.5.0" }` (research.md §1 — minimum version shipping
`node:sqlite`), `"scripts": { "start": "node src/server.js", "test": "node --test" }`,
and no `dependencies`/`devDependencies` entries at all (Constitution Principle VI;
research.md Summary: "zero entries... zero in devDependencies")
- [ ] T003 [P] Create `README.md` documenting the single `npm start` command, the Node.js
22.5+ prerequisite, the expected `node:sqlite` experimental-module startup warning
(research.md §1 Risk/mitigation), and that `uplert.db` is created automatically on first
run with no separate migration step (Constitution Principle II)
- [ ] T004 [P] Create `.gitignore` at the repo root excluding `node_modules/` and `*.db`
The constitution changed the task list in a way I did not ask for. Spec Kit’s task template treats tests as optional by default, but Principle IV requires at least one automated test for every functional requirement, so the tasks skill included test tasks throughout and mapped every requirement to at least one of them. The only exception is FR-018, which is a statement of what is out of scope and has no behaviour to test.
Before implementing, the analyze step cross-checks the spec, plan, and tasks for requirements with no task, tasks with no requirement, and contradictions between the documents:
/speckit-analyze
Analyze is read-only, so it reports findings without changing anything. It found eight issues, none of them critical:
| ID | Category | Severity | Finding |
|---|---|---|---|
| I1 | Inconsistency | Medium | tasks.md claims tests are written first throughout, but the Foundational phase lists its implementation tasks before their tests |
| U1 | Underspecification | Medium | No maximum length is defined for the login, password, URL, or display name, which the security checklist had already raised in CHK020 |
| G1 | Coverage gap | Medium | The plan’s performance goal of a dashboard response well under one second with a few hundred checks has no task testing it |
| C1 | Constitution alignment | Medium | quickstart.md lists npm install before npm start, which reads as a two-command setup against Principle I, even though zero dependencies make the install step a no-op |
| G2 | Coverage gap | Low | The success criterion for adding a check in under two minutes is only covered indirectly |
| G3 | Coverage gap | Low | The edge case of two users adding a check for the same URL has no dedicated test |
| A1 | Ambiguity | Low | “FR-001 through FR-019” in a task description could be read as excluding FR-003a and FR-006a |
| I2 | Inconsistency | Low | The Sync Impact Report in the constitution still says the session design needs reconciling, which the plan re-run already did |
It also produced a coverage table mapping every requirement to its tasks, with 20 of 21 functional requirements covered, the exception being FR-018 again, and all six success criteria covered with one only partially. There were no duplicated requirements and one ambiguity.
Analyze did not flag the edge case clarify added in Step #5, about a poll taking longer than its own polling interval. Looking at it again, I am not sure it should have. The edge case is only impossible if the 10 second timeout covers the whole request, from opening the connection to reading the last byte of the response. If it only covers the wait for the first byte, a slow response could keep a poll running well past the next one’s due time. The security checklist had already asked exactly that question in CHK029, and nothing in the spec answers it yet, so the contradiction I thought clarify had created was really an ambiguity that neither skill resolved.
Analyze ended by offering remediation edits and stating it would not apply anything until I agreed, so I replied with the decisions it needed from me, including the maximum lengths and an answer to CHK029:
Yes, suggest and then apply remediation edits for I1, U1, G1, C1, and A1. For U1, use maximum lengths of 254 characters for the login, 1024 for the password, 2048 for the URL, and 100 for the display name. For G1, document the performance goal as an untested target, not a new test. Also resolve security checklist item CHK029 by making FR-008’s 10-second timeout cover the whole request, from connecting through reading the full response.
The 1024 character cap on the password is there for a security reason as well as a tidy one, since scrypt is deliberately expensive and an unbounded password field lets anyone make the server hash a very large input on demand. It laid out a remediation plan first, applied it, and recorded the two new decisions in a third Clarifications session in spec.md, labelled as analysis remediation:
| Finding | Fix applied | Files touched |
|---|---|---|
| I1 | Reworded the tests-first claim to describe the Foundational phase as built first and tested afterwards, since there is nothing to test before the server, database, and authentication exist | tasks.md |
| U1 | Added the four maximum lengths to the requirements, the database schema, the API contract, and the contract tests | spec.md, data-model.md, contracts/api.md, tasks.md |
| G1 | Marked the dashboard performance goal as an explicit untested target | plan.md, tasks.md |
| C1 | Removed npm install from the quickstart, so setup is one command |
quickstart.md |
| A1 | Named FR-003a and FR-006a explicitly in the task description | tasks.md |
| CHK029 | FR-008 now states that the 10 second timeout covers the whole request, from connecting through reading the full response body, which finally makes the edge case from Step #5 impossible | spec.md, data-model.md, research.md, tasks.md, checklists/security.md |
It also checked off CHK020 in the security checklist, since the U1 fix resolved it too, and left I2 alone because I had not asked for it.
Re-running /speckit-analyze confirmed five of the six fixes, and then caught a problem with the sixth. The tests-first claim appeared in two places in tasks.md, and the I1 fix had only reworded one of them, so the Notes section still contradicted the actual task order. Analyze reported it as a new medium finding, I1b, which means the analyze step caught an incomplete fix made by the remediation it had proposed itself.
The remaining findings were all low severity, the ambiguity count dropped to zero, and there were still no critical issues. I asked it to apply the I1b fix and delete the stale Sync Impact Report comment from I2 at the same time, and left G2 and G3 as documented low-risk gaps.
The I1b fix added the same caveat about the Foundational phase to the Notes section, so both places in tasks.md now agree that tests come first within each user story phase. Fixing I2 removed the Sync Impact Report comment from the top of the constitution entirely, since Spec Kit treats it as working notes meant to be deleted once the follow-up it describes is done, and the constitution now opens directly with its title while keeping version 1.1.0.
A third run of /speckit-analyze came back with zero issues. Every finding from both earlier passes was resolved except G2 and G3, which it listed as knowingly deferred instead of reporting them as new findings, and it made no remediation offer because there was nothing left to fix, after three analyze runs, one remediation round, and a follow-up fix.
Step #8 – Implement
/speckit-implement
Before writing any code, the implement skill checks every checklist in the feature folder. The built-in requirements.md passed with all 16 items checked, but security.md still had 29 of its 35 items unchecked, since only the six items resolved along the way had been signed off, so it stopped and asked whether to proceed anyway or stop and review the security checklist first.
I chose to proceed. Six items were checked off by then, CHK003, CHK004, CHK026, and CHK027 from the clarify session and CHK020 and CHK029 from the analyze remediation, and the constitution amendment had also covered the cookie attributes in CHK008 even though that item was never checked off. The other 29 are questions about whether the requirements cover a topic, such as login rate limiting in CHK005, a maximum response size in CHK028, redirect handling, and the scrypt cost settings in CHK006. That still means 29 security questions went into implementation without anyone reviewing them, and an enterprise team should not make that trade without a reason written down somewhere, so the answer key in Step #10 records what it cost.
Implement then worked through the task list in order, marking each task [X] in tasks.md as it finished and writing the tests for each user story before the code that satisfies them.
All 41 tasks were marked complete, and the full test suite passed with 48 of 48 tests, spread across contract, integration, and unit tests and mapped to every functional requirement except FR-018. Implement also validated all seven quickstart scenarios by hand against a real npm start process polling the live internet, including the first account becoming administrator, a pending check turning up against https://example.com, an unreachable domain showing as down, private and loopback addresses being rejected with a 400, cross-user isolation, the administrator roster, and the session cookie carrying both HttpOnly and SameSite=Strict.
The finished application matches the plan, with a single Node.js process in src/server.js and no third-party dependencies:
| Area | Files | What it does |
|---|---|---|
| Storage | src/db/schema.sql, src/db/index.js |
Four tables created automatically on first run, with the length limits from U1 enforced as CHECK constraints |
| Authentication | src/services/auth-service.js, src/routes/auth.js |
scrypt password hashing, SHA-256 hashed session tokens with a 30 minute inactivity expiry, and HttpOnly and SameSite=Strict cookies |
| Checks | src/services/check-service.js, src/services/uptime-service.js, src/routes/checks.js |
Creating, listing, and deleting checks with ownership enforced, input validation, the 24 hour uptime percentage, and recent response times |
| Poller | src/services/url-safety.js, src/services/poller.js |
An in-process interval timer sending GET requests with a 10 second whole-request timeout, re-checking private and reserved destinations on every poll |
| Administration | src/routes/admin.js |
The user and check count roster, with no access to other users’ check details |
| Dashboard | src/public/ |
Static HTML, CSS, and vanilla JavaScript with no build step |
Implement found and fixed one bug in its own code along the way. url-safety.js originally treated a DNS lookup failure, such as a domain ending in .invalid, the same as an address that resolved to a private range, so it would have rejected a legitimate but unreachable URL when the check was created. FR-007 and FR-008 say such a check should be accepted and show as down, so implement changed the check so that only an address that actually resolves to a private or reserved range is blocked, and confirmed it against the quickstart scenario that uses a .invalid domain.
The whole implement run took about 16 minutes.
Once implementation finishes, the application should start with the single command recorded in quickstart.md:
npm start
The plan chose zero npm dependencies, so there is nothing to install before starting. Whoever registers first becomes the administrator, which is the answer I gave in Step #4, so register your own account before anyone else can reach the application.
The application listens on http://localhost:3000, and opening it shows a sign-in form and a registration form side by side.
I registered first, which made my account the administrator, and added four checks chosen to fail in different ways, two working sites, a GitHub page that does not exist, and a domain ending in .invalid that can never resolve:
| Check | Result | Response time |
|---|---|---|
https://www.microsoft.com |
Up, 100% uptime | 335 ms |
https://github.com |
Up, 100% uptime | 206 ms |
https://github.com/uplert-page-that-does-not-exist |
Down, since GitHub returned a 404 | 158 ms |
https://uplert-test.invalid |
Down, since the domain fails DNS resolution | 6 ms |
The two down checks are down for different reasons, and the response times show it, since the 404 took as long as a real round trip to GitHub while the DNS failure was over almost immediately. That .invalid check is also the case implement fixed in its own code, since the first version of url-safety.js would have rejected it when it was created instead of letting it through to show as down.
I then tried adding http://localhost:9999, and Uplert refused it with “url resolves to a private, loopback, link-local, or metadata address and cannot be monitored,” which is the FR-019 decision from Step #6 working as written.
The administrator roster appears on the same page as the checks, below the check table, only for the administrator account, and lists each user’s login, role, and number of checks without any way to see the checks themselves.
To test the roster, I registered a second account in a private browser window with the login admin@contoso.com. Despite the name, it was created with the user role, because FR-002 makes only the very first account the administrator and every account after it a regular user, so the role cannot be claimed by choosing a convincing login. After refreshing, the roster showed both accounts with their roles and check counts. By then the poller had also run three times, so each check showed its three most recent response times.
Signed in as admin@contoso.com, the same page shows only that account’s own empty check list, with no administrator section and none of the four checks belonging to the first account.
Step #9 – Converge
The converge step compares what was built against the spec and appends any gaps to tasks.md as new work, and the documented loop is to run implement and converge again until converge has nothing left to add:
/speckit-converge
Converge did not trust the checkboxes in tasks.md. It re-ran the full test suite, still 48 of 48 passing, re-read all 21 functional requirements and six success criteria against the code, re-checked all six constitution principles against the actual behaviour, including session hashing, the cookie attributes, the authorization paths, and the zero-dependency rule, and compared the project structure in plan.md with the files on disk.
It found one gap, F1, rated low. plan.md listed a src/routes/static.js module for serving the dashboard, but the static file serving had been written inline in src/server.js instead, because the wording of task T009 had put it there. The behaviour was correct and covered by the quickstart validation, so this was purely a mismatch between the documented structure and the code. Converge appended a single new task, T042, under a new Phase 7 at the bottom of tasks.md, without touching spec.md, plan.md, or any of the existing 41 tasks. It also left out G2 and G3, the two low gaps I had deliberately deferred during analyze, instead of reporting them again.
Converge checks the build against what is written down, and nothing more. The scrypt call in auth-service.js uses Node’s default cost settings, which is the same weakness the previous build’s security reviewer flagged, and converge did not mention it because no requirement and no constitution principle says what the cost should be.
Running /speckit-implement again stopped at the same checklist gate, since the 29 unreviewed security items had not changed, and after I approved it again it completed only T042. It moved the static file serving out of server.js into its own src/routes/static.js module, re-ran the full suite with all 48 tests still passing, and ran a quick smoke test against a live server to confirm the dashboard files, the fallback to index.html, and the 401 for an unauthenticated API call all behaved exactly as before the refactor.
The second /speckit-converge re-ran the tests, compared the file tree against plan.md again, and checked that the extraction had moved code without adding anything new, then reported no gaps at all and left tasks.md unchanged:
| Check | Result |
|---|---|
| Requirements and acceptance criteria checked | 27, made up of 21 functional requirements and six success criteria |
| Plan decisions checked | 8, with the project structure now fully matched |
| Constitution principles checked | 6 of 6, against version 1.1.0 |
| Findings | None missing, partial, contradicting, or unrequested |
The feature took two implement and converge passes, finishing with all 42 tasks complete across seven phases.
Step #10 – Compare Against the Hand-Written SPEC.md Build
| Previous Uplert build | Uplert from Spec Kit | |
|---|---|---|
| Human-written input | One SPEC.md, one CLAUDE.md, five agent files, and a hook script |
A constitution prompt and one amendment, the specify and plan prompts, a checklist prompt and nine decisions, seven of them answers to questions Spec Kit asked and two given to analyze during remediation |
| Decisions made without asking me | At least five, listed in the table above | Four of those five were raised as questions or checklist findings and decided by me, and the fifth, where the test suite writes its data, was settled in the plan with its reasoning in research.md. Every other gap specify filled on its own is listed in the spec’s Assumptions section |
| Documents produced | SPEC.md, API.md, SECURITY-REVIEW.md, README.md, and CHANGELOG.md |
constitution.md, spec.md, plan.md, research.md, data-model.md, contracts/api.md, quickstart.md, tasks.md, checklists/requirements.md, checklists/security.md, and README.md |
| Stack chosen | Node.js with no dependencies, and scrypt password hashing | Node.js 22.5 or later with no dependencies, node:sqlite, and scrypt password hashing through node:crypto, with each choice recorded in research.md |
| Tests at completion | 139, all passing, 99 integration and 40 unit | 48, all passing in about 2 seconds, across contract, integration, and unit tests, with every functional requirement except FR-018 mapped to at least one |
| Defects found and fixed during the build | Nine, through the remediation loop | Nine analyze findings fixed before any code existed, one bug implement found and fixed in its own code, and one structural gap converge found and closed |
| Accepted risks documented | Five, with the reasoning in the README | The account enumeration trade-off at registration in FR-001, the DNS rebinding gap in research.md, and the two low test gaps G2 and G3, plus 29 security checklist items that were never reviewed |
| Elapsed time | 1 hour 5 minutes | 49 minutes 46 seconds of model time, inside 4 hours 12 minutes of wall-clock time that includes my own reviewing, answering questions, and screenshots |
| Token usage | Not captured as a token count, only as 87% of a Claude Pro session limit partway through the run | About 5,000 input and 265,500 output tokens on Claude Sonnet 5, with 110.2 million tokens read from cache, for a session cost of $41.10, with /speckit-implement accounting for the largest share, measured after the first feature was finished |
Checked against the answer key from the previous build:
| Previous build’s finding | Status in the previous build | Present in the Spec Kit build? |
|---|---|---|
Oversized bodies reset the socket instead of returning 413 |
Fixed | Present, since posting a 2 MB registration body returned 100 Continue and then the connection was reset, with no 413 response, which is the same behaviour the previous build’s tester reported. No requirement or checklist item I resolved defined a request body size limit or the response to exceeding it. The server stayed up and kept serving requests afterwards, so the reset is a poor response and not a crash |
npm test script did not run on Windows |
Fixed | Not present, since npm test ran all 48 tests on Windows with no failures |
| Registration timing oracle undermined login’s anti-enumeration | Fixed | Present in a different form. Sign-in returns the same message for an unknown login and a wrong password, as FR-003a requires, but five attempts with an unknown login took about 2 to 3 ms after the first, while five wrong passwords for a real login took 60 to 105 ms, because scrypt only runs when the account exists, so the response time still reveals which logins are registered |
SameSite ignores port, so cross-port origins could drive four routes |
Fixed | Not present, since session cookies are SameSite=Strict under the amended constitution, the dashboard and API share one origin, and the test suite checks the cookie attributes on a successful login |
| Static-file traversal via encoded slashes | Fixed | Not present, since requesting /..%2fpublic-backup%2fsecrets.env and /../public-backup/secrets.env with a test file in src/public-backup both returned the dashboard HTML through the index.html fallback, without reaching the file |
| Credentialed check URLs stored, and could never poll | Fixed | Present, since https://user:pass@example.com was accepted with a 201 and returned with the username and password intact, even though FR-006a puts authenticated endpoints out of scope, so the credentials are stored, and shown in plain text on the dashboard, for a check that can never succeed. Its first poll came back down in 11 ms, even though the same https://example.com without credentials is up |
intervalSeconds accepted numeric strings |
Fixed | Not present, since sending "60" as a string returned 400 with “intervalSeconds must be an integer between 60 and 86400” |
| scrypt cost below current guidance | Fixed | Present. auth-service.js calls scryptSync(password, salt, 64) with no cost options, so it uses Node’s default cost of N=16384, below the N=2^17 that OWASP currently recommends, and CHK006, which asked whether the scrypt parameters were specified, was one of the 29 unreviewed checklist items |
| “Sign out” shown to signed-out users | Fixed | Not present, since the signed-out page shows only the sign-in and registration forms |
409Â on a duplicate email reveals the address is registered |
Accepted risk | Kept deliberately, since registering admin@contoso.com a second time returned “login already taken” as FR-001 allows, while sign-in returns an identical 401 for an unknown login and a wrong password under FR-003a, which the test suite covers |
| Session tokens stored in plaintext | Accepted risk | Not present. auth-service.js stores only a SHA-256 hash of each session token, deletes sessions after 30 minutes of inactivity, and the test suite includes a test that session tokens are never stored in plaintext |
| Rate limiter is effectively one bucket on loopback | Accepted risk | Worse, since there is no rate limiting of any kind in src/, and CHK005, which asked whether login rate limiting was required or explicitly out of scope, was one of the 29 unreviewed checklist items |
| No Content-Security-Policy | Accepted risk | Present, since the response headers for the dashboard contain no Content-Security-Policy or any other security header, and no requirement, constitution principle, or checklist item I resolved asked for one |
| Poller does not block private targets | Accepted risk | Blocked by FR-019, re-checked on every poll, with the DNS rebinding gap recorded as an accepted limitation, and adding http://localhost:9999 was rejected with an error naming private, loopback, link-local, and metadata addresses |
The evidence for each result:
The usage summary behind the token row in the comparison table:
Creating a test file next to the dashboard folder and starting the server for the traversal test:
The static file traversal attempts, both returning the dashboard instead of the test file:
The full test suite on Windows:
The scryptSync call with no cost options:
The duplicate registration message:
The response headers, with no Content-Security-Policy:
The 2 MB registration body resetting the connection:
Signing in with a masked password prompt before the next two tests:
The interval sent as a string being rejected, and the URL with embedded credentials being accepted:
The credential URL check showing as down:
The search for rate limiting, which returned nothing:
Of the 14 entries on the answer key, seven did not come back, one was kept deliberately as a documented trade-off, and six came back or were worse:
| Outcome | Answer key entries |
|---|---|
| Not present | npm test on Windows, the SameSite cross-port issue, the static file traversal, numeric strings for the interval, “Sign out” shown when signed out, plaintext session tokens, and polling private targets |
| Kept deliberately | The duplicate login message at registration, now a documented trade-off in FR-001 |
| Came back or worse | The scrypt cost, the connection reset on an oversized body, credentials stored in check URLs, the missing Content-Security-Policy, rate limiting, which is now missing entirely, and a timing difference at sign-in that reveals which logins exist |
Every weakness that ended up written into a requirement or the constitution stayed fixed in this build, and every weakness that came back was never written into either. Two of them trace directly to checklist items I let through the implement gate without reviewing, CHK006 for the scrypt cost and CHK005 for rate limiting, so Spec Kit asked me the right questions before any code existed and I chose to skip them.
The timing difference at sign-in is the result I did not expect, because its checklist item was marked as resolved. CHK004 asked whether the identical sign-in error in FR-003a also covered response timing, or only the wording of the message. When I asked clarify to resolve CHK003 and CHK004 together, it asked one question about whether to hide that a login exists, wrote FR-003a about the message, and checked off both items, even though the timing half of CHK004 was never answered. A checked item in a Spec Kit checklist means a model decided the question had been dealt with, and in this case nobody, including me, read closely enough to notice that half of it had not.
The other three, the oversized body, the credentials in check URLs, and the security headers, were not covered by anything at all, and on those three the previous build’s dedicated tester and security reviewer still had the advantage, because they read and exercised the code looking for problems nobody had thought to specify.
Step #11 – Add a New Feature
An application is rarely finished after its first feature, and Spec Kit’s Evolving Specs in Existing Projects guide describes three ways to carry changes forward once a feature has been implemented:
| Approach | When to use it | What happens to the existing spec | Skills to run |
|---|---|---|---|
| Flow-forward | A new feature, or a substantial follow-up change | Left untouched as a historical record, and the new work gets its own feature folder | specify, plan, tasks, implement, converge |
| Living spec | The intended behaviour of an existing feature changes | spec.md is revised first, then plan.md and tasks.md are updated to match |
clarify or a manual edit, plan, tasks, analyze, implement, converge |
| Flow-back | Implementation uncovered something that should change the spec, plan, or tasks | Whichever artifacts disagree with the accepted direction are brought back into alignment | analyze, then continue implementing |
Alerting was explicitly listed as out of scope for the first version of Uplert, which makes it the natural second feature, and it is a new feature so flow-forward is the right approach. Email alerting would need an outbound mail service, which the constitution rules out, so I kept the feature to webhooks.
Commit the first feature
Commit the finished first feature before starting the second one, so the changes the second feature makes to existing code show up clearly in a diff:
git status
git add .
git commit -m "Uplert core feature, built with Spec Kit"
Specify the second feature
Because I am using the shorter path for this feature, there is no clarify step to ask about gaps, so I wrote the decisions I would otherwise have been asked about directly into the prompt:
/speckit-specify Add webhook alerting to Uplert. A user can optionally set a webhook URL on each of their checks. When a check changes state from up to down, or from down to up, Uplert sends a single HTTP POST to that check’s webhook URL with the check’s display name, URL, new state, status code, response time, and the time of the change. Repeated results in the same state do not send another alert.
A failed webhook delivery is recorded and does not affect polling or the check’s state. Users can only see and change webhook URLs on their own checks. Webhook URLs follow the same destination rules as check URLs, so private, internal, loopback, and cloud metadata addresses are rejected. Email, SMS, and alert escalation are out of scope.
Running specify a second time creates a new numbered feature folder and points .specify/feature.json at it, which can be confirmed from a terminal:
PowerShell:
Get-ChildItem .\specs
Get-Content .\.specify\feature.json
Command Prompt:
dir specs
type .specify\feature.json
Spec Kit named the second feature folder specs/002-webhook-alerting and switched feature.json to point at it, so every skill from here on works on the new feature while 001-uptime-monitor stays as it was.
Even though this is the shorter path with no clarify step, specify still stopped to ask a question before drafting. My prompt said a failed webhook delivery “is recorded,” without saying where the record goes or who can see it, and specify asked how much of it should appear on the dashboard, offering the last delivery’s outcome for each check, a full delivery history for each check, or nothing visible to the user at all. I chose the last outcome only, which shows a single line such as “Webhook: delivered 2m ago” on each check, since a full history would need its own table and view and push the feature beyond what the shorter path is meant for.
The finished spec came to 199 lines, with two user stories, being alerted when a check changes between up and down as P1 and seeing whether the most recent alert was delivered as P2, along with 11 functional requirements and six success criteria. Specify carried the destination rules from FR-019 over to webhook URLs and went one step further than my prompt by re-checking the destination on every delivery, not just when the URL is saved. The built-in quality checklist passed all 16 items on the first validation.
The spec also dealt with the conflict between the two features without being asked. Feature 001’s spec lists alerting as out of scope, and instead of editing it, the new spec states that it narrows that exclusion rather than contradicting it, which is the flow-forward approach doing what the guide describes, with 001-uptime-monitor left as a historical record and the relationship between the two features written down in the newer one.
Plan, tasks, implement, and converge
The plan prompt tells Spec Kit to build on what already exists instead of designing alerting as a standalone component:
/speckit-plan Extend the existing Uplert application. Reuse the existing poller, data store, and authorization checks, store the webhook URL on the existing check record, and add no new dependencies.
The plan treated alerting as an extension of the existing application from its first line, and it read the first feature’s code to do it, naming the existing files and functions it would build on:
| Plan decision | Existing code it builds on |
|---|---|
Store the webhook URL, the last delivery outcome, and the time of the last attempt as three new nullable columns on the existing checks table, with no new table |
src/db/schema.sql and src/db/index.js |
| Trigger a delivery only when a poll result flips between up and down | The existing pollOneCheck in src/services/poller.js, which compares the status before and after each poll |
Send the webhook with the same 10 second AbortController timeout the poller already uses |
src/services/poller.js |
| Validate webhook URLs with the same syntax, length, and destination rules as check URLs, re-checked on every delivery | isSafeToFetch in src/services/url-safety.js, and the URL validation in src/services/check-service.js |
Add a PATCH /api/checks/:id endpoint to set or clear a webhook URL, using the same ownership check as the existing endpoints |
getSessionUser in src/services/auth-service.js, and the identical 404 pattern used by DELETE |
The plan caught one detail I had not thought about. Because the first feature creates its tables with CREATE TABLE IF NOT EXISTS, which does nothing to a table that already exists, an uplert.db created by the first feature would never receive the three new columns. The plan added a small, idempotent step that checks for each column on startup and adds it if it is missing, so an existing database upgrades itself without a separate migration command, which keeps Principle II of the constitution intact.
The only new file is src/services/webhook-service.js, the API contract documents just the changes from the first feature’s contract instead of repeating it, and the Constitution Check passed all six principles before and after the design work.
The task list for the second feature came to 21 tasks, numbered from T001 again since each feature keeps its own tasks.md:
| Phase | Tasks | Count |
|---|---|---|
| Setup, covering the schema change and the column migration | T001 to T003 | 3 |
| User Story 1, get alerted on a status change (P1, the MVP) | T004 to T015 | 12, made up of 4 tests and 8 implementation tasks |
| User Story 2, see whether alerts are getting through (P2) | T016 to T018 | 3, made up of 1 test and 2 implementation tasks |
| Polish and Quality Gate review | T019 to T021 | 3 |
Almost every task modifies a file the first feature created, including poller.js, check-service.js, uptime-service.js, routes/checks.js, server.js, db/index.js, and dashboard.js. The only new files are webhook-service.js and three test files. Without my restating it, the constitution’s test requirement carried over to this feature, so every functional requirement except FR-011, which is the out-of-scope statement, maps to at least one test task.
/speckit-tasks
/speckit-implement
/speckit-converge
Since the shorter path has no analyze step, I went straight from tasks to implement. The checklist gate did not appear this time, because the only checklist in the second feature’s folder is the built-in requirements.md, which had already passed all 16 items, and the first feature’s security checklist belongs to the first feature’s folder.
Implement started by running the existing test suite to confirm a clean baseline of 48 passing tests before changing anything, then worked through the 21 tasks:
| Area | What changed |
|---|---|
| Database | Three nullable columns added to the checks table in schema.sql, with CHECK constraints on the URL length and the outcome values, plus an addMissingColumns step in db/index.js that adds any missing column to an existing database on startup |
src/services/webhook-service.js |
The one new file, which validates a webhook URL with the same rules as check URLs and sends the alert using the poller’s existing fetch and 10 second timeout pattern, without ever throwing |
src/services/poller.js |
Extended to detect a change between up and down after each poll and fire the webhook from inside the same pollOneCheck cycle |
src/routes/checks.js |
The new PATCH /api/checks/:id endpoint for setting or clearing a webhook URL, using the same session and ownership pattern as DELETE |
src/public/dashboard.js |
A webhook URL field with save and clear buttons on each check, and a delivered or failed indicator showing the latest attempt |
All 21 tasks were marked complete, and the full suite passed with 66 of 66 tests, the first feature’s original 48 plus 18 new ones covering every functional requirement except FR-011, the out-of-scope statement. Implement also tested the column migration properly, by building a database with the first feature’s checks table and no webhook columns, starting Uplert against it through the UPLERT_DB_PATH environment variable, and confirming the columns were added and the new endpoint worked straight away.
It caught one problem in its own task list along the way. tasks.md had check-service.js calling into webhook-service.js to validate a webhook URL, while webhook-service.js already needed check-service.js, which would have created a circular import. Implement moved the validation into the route handler, which now validates the URL through webhook-service.js first and then saves it through check-service.js, with the same behaviour from the outside and no circular dependency.
Reading through the implement output, I noticed one difference that none of the automated checks would catch. A fresh database gets the three new columns from schema.sql with their CHECK constraints, but the addMissingColumns step adds them to an upgraded database as plain TEXT columns, so a database created by the first feature ends up with the columns and without the constraints. The application validates the URL length and the outcome values itself, so nothing breaks today, but a fresh database and an upgraded database are no longer identical.
What happened to my database
When I came back to the dashboard after implementing the second feature, my four checks and the admin@contoso.com account from Step #8 were gone, and the administrator roster listed only my own account. I had not deleted uplert.db, so I asked Claude Code in the same session to list every command it had run that touched the file.
According to its answer, while running the manual quickstart validation for task T020, implement tried to delete uplert.db to start from a fresh database, and the delete failed because the file was locked. It listed the file, saw a 53 KB database with an older timestamp, and assumed it was a leftover from its own smoke testing during the first feature. Next, it found two Node.js processes holding the file, which were the npm start I had left running for my manual testing, stopped both of them, and deleted the database. It repeated the same create, test, and delete cycle several more times during T022, each time removing whatever uplert.db existed at that point.
I found the summary it gave afterwards more concerning than the deletion itself, because it stated that every uplert.db it had touched was one it had created itself and that none of it was real data, even though its own list of commands shows it deleting a file it did not create, after stopping processes it did not start. uplert.db is excluded in .gitignore, so there was no copy to recover.
This is the same kind of problem the previous build had, where npm test wrote to the development database and destroyed it. The plan in Step #6 fixed that for the automated tests by giving them their own temporary database, and implement even used the UPLERT_DB_PATH environment variable to point its migration test at a separate file, but the manual quickstart validation ran against the default path in my project folder.
Nothing in the spec, the plan, or the constitution said that the agent must leave existing files and running processes in the working folder alone, so it had no rule to check its assumption against, and it also meant the automatic column upgrade never ran against my real database, only against the copy implement built for the migration test.
If I were starting this project again, I would add a principle to the constitution before the first feature stating that validation must never delete, overwrite, or stop anything it did not create in the same run, and that manual smoke tests must use UPLERT_DB_PATH with a temporary file. That would put the rule in front of every feature, which is the same lesson the security results in Step #10 pointed to, since anything that is not written down is left to the model’s judgment.
Converge the Second Feature
Converge re-ran the suite with 66 of 66 passing, read every new and modified source file in full, checked the plan’s structure against the files on disk, and walked every requirement and acceptance scenario in the spec against the actual code. It found one gap, and this time it was rated high.
FR-001 and the fifth acceptance scenario of User Story 1 both say a webhook URL can be set either when adding a check or by updating it afterwards, but the implementation only supported the second. A webhookUrl sent when creating a check was silently dropped. Converge traced the cause back to the plan, where research.md had decided that a webhook URL would not be settable at creation in this version, and it stated plainly that this narrowing had been made during /speckit-plan without being put to me as a decision. It appended T022 under a new Phase 5 to accept webhookUrl when a check is created, validated with the same rules the PATCH endpoint uses, along with a contract test.
This gap is a fair test of the shorter path. The plan contradicted the spec it was built from, which is the kind of inconsistency /speckit-analyze checks for before implementation, and on the first feature analyze reported nine findings across its runs before any code existed. Skipping it here meant the contradiction was only found after the code was written, by converge, at the cost of another implement and converge pass.
Converge also did not flag the missing CHECK constraints on upgraded databases and reported the schema migration as faithfully realized, since it measures the code against the plan, and nothing in the plan said a migrated column had to carry the same constraints as a fresh one.
The second /speckit-implement pass completed only T022. Creating a check now accepts an optional webhookUrl, validated with the same function the PATCH endpoint uses, and an invalid one rejects the whole request with a 400 before any check is created, so a bad webhook URL can never leave behind a half-created check. Four new tests brought the suite to 70 of 70 passing, and a smoke test against a running server confirmed both behaviours.
Implement also corrected the two design documents that described the old behaviour. The API contract now documents the new field, and research.md kept its original decision with a correction note added beneath it, recording that converge caught the narrowing and what changed as a result. Both the plan’s mistake and its fix are still in the record, which is the kind of history a team picking up this feature later would want to find.
The second /speckit-converge re-ran the suite with 70 of 70 passing, re-read routes/checks.js after the fix, and walked FR-001 and its acceptance scenario again specifically, to confirm the gap was closed in the code and not only checked off in tasks.md. It found no gaps across the 11 functional requirements, six success criteria, seven plan decisions, and six constitution principles, and left tasks.md unchanged.
The second feature took two implement and converge passes, the same as the first, finishing with all 22 tasks complete across five phases and 70 tests passing across both features.
Test the alert
FR-019 from Step #6 makes testing the alert less straightforward than it would have been in the previous build, because Uplert now refuses to poll localhost or any private address, and the webhook URL follows the same rule. The test needs two endpoints on public addresses, one for Uplert to monitor that I can stop and start to change its state, and one to receive the webhook. Cloudflare’s free quick tunnels provide both without an account, by giving a local port a temporary public trycloudflare.com address.
Install cloudflared with winget, which works from either shell:
winget install --id Cloudflare.cloudflared
cloudflared is available in any new terminal window after the install. A quick tunnel needs no Cloudflare account, and it prints a warning that account-less tunnels come with no uptime guarantee, which is fine for a test like this. It also logs two ERR lines about a missing origin certificate and certificate pool, which apply to named tunnels and can be ignored here, since the tunnel still reports a registered connection.
The monitored endpoint is Python’s built-in web server, since Python is already installed for Spec Kit. Run it in its own window:
PowerShell:
mkdir C:\Temp\uplert-target -Force
cd C:\Temp\uplert-target
python -m http.server 9001
Command Prompt:
if not exist C:\Temp\uplert-target mkdir C:\Temp\uplert-target
cd /d C:\Temp\uplert-target
python -m http.server 9001
Then open a public tunnel to it in a second window, and note the https://...trycloudflare.com address it prints. Opening that address in a browser should show the Python server’s directory listing, although it can take up to 30 seconds before the tunnel starts responding:
cloudflared tunnel --url http://localhost:9001
The webhook receiver is a small PowerShell listener that prints every request it receives. Although the script itself is PowerShell, it can be launched from either shell. Save it as webhook-listener.ps1:
# webhook-listener.ps1
# Prints every request sent to http://localhost:9000/ so Uplert's webhook alerts can be checked.
$listener = [System.Net.HttpListener]::new()
$listener.Prefixes.Add("http://localhost:9000/")
$listener.Start()
Write-Host "Listening on http://localhost:9000/ (Ctrl+C to stop)"
try {
while ($listener.IsListening) {
$context = $listener.GetContext()
$reader = [System.IO.StreamReader]::new($context.Request.InputStream)
$body = $reader.ReadToEnd()
Write-Host "$(Get-Date -Format s) $($context.Request.HttpMethod) $body"
$context.Response.StatusCode = 200
$context.Response.Close()
}
}
finally {
$listener.Stop()
}
Run it in a third window. Launching it through powershell -File works from both PowerShell and Command Prompt, and -ExecutionPolicy Bypass lets this one script run without changing the machine’s execution policy, which is set to block scripts by default on Windows client editions:
powershell -ExecutionPolicy Bypass -File .\webhook-listener.ps1
Open a second tunnel for the listener in a fourth window. The listener only accepts requests addressed to localhost:9000, so the tunnel has to rewrite the Host header, otherwise every webhook is rejected before the script sees it:
cloudflared tunnel --url http://localhost:9000 --http-host-header localhost:9000
Before pointing Uplert at it, send a test request through the second tunnel from a fifth window, replacing the address with the one the listener’s tunnel printed:
curl.exe -X POST -d test https://<listener-tunnel>.trycloudflare.com/
The listener window should print a line ending in POST test, which confirms the request reached the script through the tunnel with the rewritten Host header.
With all four windows running, add a check in Uplert for the first tunnel’s address with a polling interval of 1 minute, the shortest the spec allows, and set its webhook URL to the second tunnel’s address. Once the check shows as up, stop the Python server with Ctrl+C, which makes the tunnel return an error for the next poll, and then start the server again. Each state change can take up to a minute to show up, since that is how long the poller waits between polls, so expect a short wait between stopping the server and seeing the alert.
Before setting the real webhook URL, I checked that the destination rules hold for the new field as well. Setting the webhook URL to http://localhost:9000/ directly was rejected on the dashboard with “webhookUrl resolves to a private, loopback, link-local, or metadata address and cannot be used,” the same rule FR-019 applies to check URLs.
While the Python server is stopped, the first tunnel logs each poll it could not pass through to the server:
With the server running again, the check shows as up with the tunnel address saved as its webhook URL:
Confirm the first feature was left alone
Flow-forward should leave the first feature’s spec untouched. Commit the second feature, then compare it against the first feature’s commit, once for the first feature’s spec folder and once for the source code and tests:
git add .
git commit -m "Webhook alerting feature, built with Spec Kit"
git diff --stat HEAD~1 HEAD -- specs/001-uptime-monitor
git diff --stat HEAD~1 HEAD -- src tests
The diff for specs/001-uptime-monitor was empty, so the first feature’s spec, plan, and tasks were not touched. On the source side, the diff showed 16 files changed with 794 lines added and 9 removed, made up of nine files from the first feature that were extended, db/index.js, db/schema.sql, public/dashboard.js, public/index.html, routes/checks.js, server.js, check-service.js, poller.js, and uptime-service.js, plus webhook-service.js and six new test files.
All 48 of the first feature’s tests still passed as part of the 66 in the combined suite, so the second feature changed several of the first feature’s files without breaking any of the behaviour they were tested for.
The full project, including the constitution and both feature folders under specs/, is on GitHub: https://github.com/terenceluk/uplert-sdd. I committed the specs folder deliberately, because in spec-driven development the specs are as much a part of the deliverable as the code.
Final Thoughts on Spec-Driven Development
The full path also required more involvement from me. The model itself worked for 49 minutes and 46 seconds compared with an hour and five minutes for the roster build, but the whole run took just over four hours once I included reviewing the documents, answering questions, deciding how to address the security gaps, and capturing screenshots. The review also shifts from reading code to reading several markdown documents per feature, which only helps if someone actually reads them.
For an application that has to survive an audit, a team handoff, or the next five change requests, I would reach for the full path, because the documents it produces are the ones an enterprise project would have to write anyway.
Adding webhook alerting as a second feature went better than I expected on the design side since the plan read the first feature’s code and extended it file by file, all 48 of the original tests kept passing, and converge caught the one place where the plan had quietly narrowed the spec. Implement also deleted my development database along the way, because nothing in the spec, the plan, or the constitution told it to leave existing files alone.
One scenario I have not tested yet is the living spec approach, where the behaviour of an existing feature changes and its spec.md, plan.md, and tasks.md all have to be revised and reconciled against code that is already running. Enterprise applications spend most of their time in that state, and the waterfall criticism would show up there first if it holds, so I plan to test it on Uplert and will write another post once I have had the time to work through it.
The constitution, both feature folders under specs/, and the generated application are on GitHub: https://github.com/terenceluk/uplert-sdd.














































































































