Spec-Driven, Not Vibe-Driven
Vibe coding feels fast until three people ship three versions of the same feature.
Speed without a contract is not progress. It is parallel debt.
Two Ways to Build
Vibe coding runs on momentum. Prompt, paste, adjust, prompt again. It works until the codebase stops agreeing with itself.
Spec-driven work starts differently. One file defines what must exist before any agent touches code. The agent is not the process. It executes inside one.
For a team, that difference is everything.
The Story: Billing Retry
Your team needs subscription payment retries. Three developers. Two agents. One week.
Nobody writes application code on day one. Everyone writes SPEC.md.
specs/billing-retry/SPEC.md
# SPEC
Feature: Subscription payment retry on failed renewals
Must:
- Create a retry record on failed renewal
- Apply configured backoff: 1h, 6h, 24h
- Notify customer after each failed attempt
- Expose retry state in admin
- Stop after 3 attempts
Must not:
- Send notification before state is persisted
- Retry a subscription already marked exhausted
Objects:
Subscription, PaymentAttempt, RetrySchedule, RetryPaymentJob
Tests required:
- Unit: backoff intervals per attempt number
- Feature: retry record created on failed renewal
- Feature: notification sent only after DB commit
- Feature: no retry after final attempt
Order:
1. Migration + models
2. RetrySchedule service
3. RetryPaymentJob
4. Notification listener
5. Admin endpoint
6. Tests (per list above)
One file. Every tool reads it. Every teammate reviews against it.
Spec → Plan → Execute → Test → Review → Ship
1. Spec
Humans own SPEC.md. Ambiguity dies here.
The test list is not an afterthought. It is part of the contract. Agents optimize for what you measure.
2. Plan
Agent reads the spec. Output goes to PLAN.md — not code.
specs/billing-retry/PLAN.md
Step 1: migration payment_retries (subscription_id, attempt, next_at, status)
Step 2: RetrySchedule::nextDelay(attempt) — pure function, unit test first
Step 3: RetryPaymentJob — dispatch only after RetrySchedule confirms eligibility
Step 4: FailedPaymentNotifier — queue after DB::afterCommit()
Step 5: GET /admin/subscriptions/{id}/retries
Step 6: tests from SPEC section "Tests required"
Refine two rounds. Catch the missing afterCommit before any PHP gets written.
3. Execute
Clean context. Spec and plan only. Agent implements step by step.
app/Models/PaymentRetry.php
app/Services/RetrySchedule.php
app/Jobs/RetryPaymentJob.php
app/Listeners/FailedPaymentNotifier.php
// app/Services/RetrySchedule.php
final class RetrySchedule
{
private const DELAYS = [3600, 21600, 86400]; // 1h, 6h, 24h
public function nextDelay(int $attempt): ?int
{
return self::DELAYS[$attempt - 1] ?? null;
}
public function exhausted(int $attempt): bool
{
return $this->nextDelay($attempt) === null;
}
}
// app/Jobs/RetryPaymentJob.php
final class RetryPaymentJob implements ShouldQueue
{
public function __construct(public int $subscriptionId) {}
public function handle(RetrySchedule $schedule, PaymentGateway $gateway): void
{
$retry = PaymentRetry::where('subscription_id', $this->subscriptionId)
->where('status', 'pending')
->firstOrFail();
if ($schedule->exhausted($retry->attempt)) {
$retry->update(['status' => 'exhausted']);
return;
}
$gateway->charge($retry->subscription);
}
}
Code follows the plan. The plan followed the spec.
4. Test
Tests are a gate, not a finale.
Agent writes them from the spec’s test list — before anyone calls it done.
// tests/Unit/RetryScheduleTest.php
it('returns correct backoff per attempt', function () {
$schedule = new RetrySchedule();
expect($schedule->nextDelay(1))->toBe(3600);
expect($schedule->nextDelay(2))->toBe(21600);
expect($schedule->nextDelay(3))->toBe(86400);
expect($schedule->nextDelay(4))->toBeNull();
});
it('marks attempt 4 as exhausted', function () {
expect((new RetrySchedule())->exhausted(4))->toBeTrue();
});
// tests/Feature/RetryPaymentFlowTest.php
it('does not notify before retry is persisted', function () {
Notification::fake();
$subscription = Subscription::factory()->failedRenewal()->create();
$this->artisan('billing:process-failed', ['id' => $subscription->id]);
expect(PaymentRetry::count())->toBe(1);
Notification::assertSentTimes(PaymentFailedNotification::class, 1);
});
Red means the spec or the implementation is wrong. Fix before review.
5. Review
Fresh agent or teammate. Three inputs on the table:
SPEC.mdPLAN.md- Code + test output
Checklist: every “Must” covered? Every “Must not” enforced? Every test from the spec exists and passes?
Review catches what tests miss — naming, boundaries, N+1 in the admin endpoint.
6. Ship
Commit when all three agree.
specs/billing-retry/SPEC.md
specs/billing-retry/PLAN.md
app/Services/RetrySchedule.php
app/Jobs/RetryPaymentJob.php
tests/Unit/RetryScheduleTest.php
tests/Feature/RetryPaymentFlowTest.php
PR description points to the spec section, not the chat log.
Why Teams Need This
Shared context does not scale through chat history.
Person A prompts from memory. Person B prompts from a different mental model. Both get green tests. Neither built the same system.
A spec file forces alignment before tokens get spent. Review becomes concrete: “Does this match section 3?” not “Does this feel right?”
Tests in the spec mean agents cannot skip verification. “Make it work” becomes “make it pass these cases.”
What Changes for You
You type less code. You write more clarity.
You stop asking agents to guess architecture mid-flight. You stop reviewing output in a vacuum. You stop shipping features where tests were invented after the fact.
The engineer’s job moves up a layer: define the change, define how to prove it, validate the result.
Key Takeaways
Vibe coding optimizes for output. Spec-driven work optimizes for agreement.
One SPEC.md per feature. Tests listed in the spec, not bolted on later.
The loop: spec → plan → execute → test → review → ship.
Tool-agnostic. The files live in the repo. The agent is interchangeable.
Teams that align on the spec ship once. Teams that vibe ship three times and merge the damage.