Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Documentation: Living Docs vs Self-Documenting Types

Documentation is a loaded word. To some teams, it means README files and wiki pages. To others, it means API references and architecture diagrams. To the spec-driven approach, it means a systematic pillar with automated generation and quality metrics. To the typed approach, it means "the type system IS the documentation."

Both have legitimate points. Both have blind spots.


The Spec-Driven Documentation Pillar

The Documentation-as-Code specification defines a comprehensive documentation system:

Documentation Types

  1. User Documentation — User guides, tutorials, getting-started guides. Written for end users of the software.
  2. API Documentation — Endpoint descriptions, request/response schemas, authentication flows. Written for developers consuming the API.
  3. Architecture Documentation — System diagrams, component relationships, data flows. Written for architects and new team members.
  4. Technical Documentation — Database schemas, deployment procedures, monitoring setup. Written for operations.
  5. Developer Documentation — Code style guides, contribution guidelines, testing instructions. Written for contributors.

Documentation Practices

  • Automated Generation — Documentation is generated from code where possible (OpenAPI from annotations, database docs from schema, dependency graphs from build files).
  • Living Documentation — Docs evolve with the codebase. CI checks verify that documentation matches current code.
  • Quality Measurement — Documentation coverage metrics: what percentage of APIs are documented? What percentage of modules have README files? What percentage of configuration options are explained?
  • Maintenance Workflows — Who updates documentation? When? How are stale docs detected?

Documentation Standards

DEFINE_STANDARD(documentation_quality)
  Format: markdown
  Location: alongside code (docs/ directory, inline comments)
  Update frequency: with each code change
  Coverage targets:
    - API endpoints: 100% documented
    - Configuration options: 100% documented
    - Architecture decisions: documented within 1 week
    - User workflows: documented before release

What This Gives You

A systematic approach to documentation that treats docs like code: versioned, tested, measured, maintained. Teams that adopt this get:

  1. Explicit documentation standards — Everyone knows what "documented" means.
  2. Automated generation — Reduces manual work by generating docs from code artifacts.
  3. Quality metrics — You can measure documentation coverage like code coverage.
  4. Maintenance process — Clear responsibility for keeping docs current.

What It Cannot Prevent

Documentation drift. Even with CI checks, documentation can drift from code. A CI check can verify that a README file exists — it can't verify that the README accurately describes the current behavior. A developer changes a function's behavior, updates the function, updates the tests, but forgets to update the README paragraph that describes the old behavior. The CI check passes because the README exists. The documentation is wrong.

This is the fundamental problem with all documentation: it's a secondary artifact. The code is the primary artifact. The documentation describes the code. Two artifacts, maintained independently, will drift. Always. No amount of process prevents this — it only delays it.

The AI-Era Twist: "Writing Docs" Now Means "AI Generates Docs"

In 2025, "writing documentation" increasingly means "asking an AI to write documentation." This is true for both approaches. But it exposes a critical asymmetry.

In the spec-driven approach, the AI generates documentation as text — markdown files, wiki pages, README sections. The AI reads the code, writes a description, and the developer reviews the prose. The output is a document that can drift from the code the moment it's written.

In the typed approach, the AI generates the same knowledge as compiled types instead of text. If the AI can write a markdown sentence describing a feature's acceptance criteria, it can write a C# abstract method with the same information — except the C# version compiles, is type-checked, and participates in the traceability chain.

AI generates documentation:

Spec-driven:
  AI → writes markdown → "User can cancel placed orders" → text file → drifts

Typed:
  AI → writes C# → abstract AcceptanceCriterionResult
                    CustomerCanCancelPlacedOrder(CustomerId, OrderId) → compiles → enforced

The effort is the same. The AI is writing either way. But the output in one case is a drifting document, and in the other is a compiled, enforced type. If the AI is already doing the writing, have it write types that compile instead of text that drifts. The marginal cost is zero — the AI doesn't care whether it outputs markdown or C#. The marginal benefit is enormous — you get compiler enforcement for free.

This is the AI-era argument against document-based specifications: the human bottleneck that justified "write simple text because it's easier" no longer exists. AI agents write C# as easily as they write English. The "ease of authoring" advantage of documents has evaporated. What remains is the enforcement advantage of types — and that advantage is permanent.


The Typed Specification Approach to Documentation

The typed approach takes a radically different position: the type system IS the documentation. There is no separate documentation artifact because the types themselves are navigable, readable, and self-describing.

Types as Documentation

Consider the UserRolesFeature:

public abstract record UserRolesFeature : Feature<PlatformScalabilityEpic>
{
    public override string Title => "User roles and permissions management";
    public override RequirementPriority Priority => RequirementPriority.Critical;
    public override string Owner => "Platform Team";

    /// <summary>
    /// An administrator with the ManageRoles permission can assign any role to any user.
    /// The assignment is persisted and the user's effective permissions reflect the new role.
    /// </summary>
    public abstract AcceptanceCriterionResult
        AdminCanAssignRoles(UserId actingUser, UserId targetUser, RoleId role);

    /// <summary>
    /// A Viewer can read resources in their scope but cannot create, modify, or delete.
    /// </summary>
    public abstract AcceptanceCriterionResult
        ViewerHasReadOnlyAccess(UserId viewer, ResourceId resource);

    /// <summary>
    /// Role changes take effect on the next request without re-authentication.
    /// </summary>
    public abstract AcceptanceCriterionResult
        RoleChangeTakesEffectImmediately(UserId user, RoleId previousRole, RoleId newRole);
}

This type IS the documentation for the UserRoles feature. It tells you:

  • What the feature is (Title)
  • How important it is (Priority)
  • Who owns it (Owner)
  • What it's part of (Feature<PlatformScalabilityEpic> — click to navigate)
  • What the acceptance criteria are (abstract methods)
  • What each AC expects as input (typed parameters)
  • What each AC means in human terms (XML documentation)

IDE Navigation as Documentation

In a typed specification system, documentation is navigation:

Test: Admin_with_ManageRoles_can_assign_editor_role()
  │
  └─→ [Verifies(typeof(UserRolesFeature), nameof(AdminCanAssignRoles))]
       │
       ├─→ Ctrl+Click on UserRolesFeature → Feature definition
       │   │
       │   └─→ Feature<PlatformScalabilityEpic>
       │       │
       │       └─→ Ctrl+Click → Epic definition (strategic goal)
       │
       └─→ Ctrl+Click on AdminCanAssignRoles → AC method
           │
           └─→ XML doc: "An administrator with ManageRoles permission..."

Specification: IUserRolesSpec.AssignRole(User, User, Role)
  │
  └─→ [ForRequirement(typeof(UserRolesFeature), nameof(AdminCanAssignRoles))]
       │
       └─→ Ctrl+Click → same AC method on the Feature type

Implementation: AuthorizationService.AssignRole(User, User, Role)
  │
  └─→ Implements IUserRolesSpec → Ctrl+Click → Specification interface

A developer can start anywhere — a test, a specification method, an implementation — and Ctrl+Click their way to any other part of the chain. The IDE provides the navigation. The types provide the links.

This is not a document that describes the code. It IS the code, navigated through the IDE.

Generated Documentation Artifacts

The typed approach also generates traditional documentation — but from types, not from templates:

Traceability Matrix (generated at Stage 4):

Feature                 | AC                      | Spec              | Impl                  | Tests | Status
─────────────────────────────────────────────────────────────────────────────────────────────────────────────
UserRolesFeature        | AdminCanAssignRoles     | IUserRolesSpec    | AuthorizationService  | 3     | ✓ Complete
UserRolesFeature        | ViewerHasReadOnlyAccess | IUserRolesSpec    | AuthorizationService  | 2     | ✓ Complete
UserRolesFeature        | RoleChangesImmediate    | IUserRolesSpec    | AuthorizationService  | 1     | ✓ Complete
PasswordResetFeature    | RequestResetEmail       | IPasswordSpec     | PasswordResetService  | 2     | ✓ Complete
PasswordResetFeature    | LinkExpires24h          | IPasswordSpec     | PasswordResetService  | 1     | ✓ Complete
PasswordResetFeature    | ComplexityRequirements  | IPasswordSpec     | PasswordResetService  | 1     | ✓ Complete
PasswordResetFeature    | UsedOnce                | —                 | —                     | 0     | ⚠ Not started

This matrix is always correct because it reads from the types. It cannot drift because it's generated from the same compiler pass that validates the code.

Requirement Coverage Report (generated at Stage 4):

Project: MyApp
Date: 2026-04-06
Total Features: 8
Total ACs: 27
Fully Specified: 7/8 (87.5%)
Fully Implemented: 7/8 (87.5%)
Fully Tested: 6/8 (75.0%)

Missing Specifications:
  - PasswordResetFeature.ResetLinkCanOnlyBeUsedOnce

Missing Implementations:
  - PasswordResetFeature.ResetLinkCanOnlyBeUsedOnce

Missing Tests:
  - PasswordResetFeature.ResetLinkCanOnlyBeUsedOnce
  - OrderProcessingFeature.OrderCanBeCancelled

API Documentation Enhancement

When the API layer uses [ForRequirement] attributes on controllers, the OpenAPI spec can be enriched:

[ApiController]
[Route("api/[controller]")]
[ForRequirement(typeof(UserRolesFeature))]
public class RolesController : ControllerBase
{
    [HttpPost("assign")]
    [ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
    public async Task<IActionResult> AssignRole([FromBody] AssignRoleRequest request)
    {
        // ...
    }
}

The source generator enriches the OpenAPI spec with requirement metadata:

{
  "/api/roles/assign": {
    "post": {
      "summary": "Assign a role to a user",
      "x-requirement": "UserRolesFeature",
      "x-acceptance-criterion": "AdminCanAssignRoles",
      "x-requirement-priority": "Critical"
    }
  }
}

API consumers can see which requirement each endpoint implements. This is documentation that's impossible to get wrong because it's generated from the type annotations.


The Drift Comparison

Documentation drift is the slow divergence between what documentation says and what code does. It happens in every project. The question is how each approach handles it.

Spec-Driven Drift Scenarios

Scenario 1: Feature renamed in code but not in PRD

PRD: DEFINE_FEATURE(user_roles_management)
Code: class RoleBasedAccessControl { ... }

The PRD says user_roles_management. The code says RoleBasedAccessControl. Nothing links them. The drift is invisible until someone reads both and notices.

Scenario 2: AC removed from code but not from PRD

PRD: acceptance_criteria: [..., "Admin can delete roles"]
Code: // DeleteRole method removed in commit abc123
Tests: // DeleteRole tests still exist, still pass (test dead code)

The PRD still lists the AC. The code no longer implements it. The tests test a method that was removed. Three artifacts, all drifting.

Scenario 3: New AC added in code but not in PRD

Code: Added BulkAssignRoles() method
Tests: Added bulk assignment tests
PRD: No mention of bulk assignment

The code has a feature the PRD doesn't know about. This means the feature was never formally specified, approved, or prioritized. It slipped in through a code review.

Typed Specification Drift Scenarios

Scenario 1: Feature renamed

Renaming UserRolesFeature to RoleBasedAccessFeature in the type system automatically updates every typeof(), nameof(), [ForRequirement], [Verifies], and traceability matrix entry. The rename is atomic and complete. Drift is impossible.

Scenario 2: AC removed

Deleting the AdminCanDeleteRoles abstract method from the feature record causes:

  • IUserRolesSpec.DeleteRole method's [ForRequirement(..., nameof(AdminCanDeleteRoles))] fails to compile
  • AuthorizationService.DeleteRole implementation becomes dead interface code
  • [Verifies(..., nameof(AdminCanDeleteRoles))] tests fail to compile

The compiler forces you to clean up every reference. Dead code cannot survive.

Scenario 3: New AC without specification

A developer adds a method to AuthorizationService that implements functionality not tracked by any feature. The code works. But:

  • No [ForRequirement] → the method is not linked to any requirement
  • No [Verifies] → no test formally covers it
  • The traceability matrix doesn't include it

This is technically possible — you can write code that's not linked to requirements. But the traceability report will show it as untracked code, and a team that values coverage will notice.

The typed approach doesn't prevent all drift — it prevents drift in the requirement→spec→impl→test chain. It doesn't prevent "code exists with no requirement." That requires a different mechanism (code coverage analysis showing untested, unlinked code).


What Each Approach Considers "Documentation"

Document Type Spec-Driven Typed Specifications
Requirements PRD template sections Feature types with AC methods
API reference Generated from annotations (OpenAPI) Generated from [ForRequirement]-enriched OpenAPI
Architecture Diagrams in docs/ folder Type dependency graph (IDE + generated reports)
Test coverage Coverage report (line-based) Traceability matrix (AC-based)
Decisions ADRs in docs/ Types encode the decision (attribute = implementation)
Onboarding README + wiki pages Type system IS the onboarding (write code, compiler teaches)
Compliance Checklists and audit trails Generated compliance reports from type metadata

The ADR Question

Architecture Decision Records are a popular documentation practice. The spec-driven approach supports them naturally — ADRs are documents, and the framework is document-based.

The typed approach has an interesting relationship with ADRs: the type system makes many ADRs unnecessary. Consider an ADR that says:

ADR-007: All commands must have a corresponding validator. Status: Accepted Rationale: Unvalidated commands caused data corruption in Q3.

In the spec-driven approach, this ADR exists as a document. A CI check (NetArchTest, ArchUnit) enforces it. The ADR, the enforcement code, and the actual validators are three separate artifacts.

In the typed approach:

[Validated]
public partial record CreateOrderCommand
{
    [Required] public string CustomerId { get; init; }
    [Required] public List<OrderLineDto> Lines { get; init; }
}

// Source generator produces CreateOrderCommandValidator automatically
// Roslyn analyzer produces error if [Validated] command has no generated validator

The attribute IS the decision. The source generator IS the implementation. The analyzer IS the enforcement. There's no ADR because the type system encodes the decision directly. If someone removes [Validated], the analyzer fires. The decision is self-enforcing.

But not all decisions can be encoded in types. "We chose PostgreSQL over MongoDB" is an infrastructure decision that no attribute can enforce. "We deploy to three regions for latency" is an operational decision. These still need ADRs — even in a typed specification system.


Documentation for AI Agents

This is increasingly relevant: how does each approach help AI agents understand the codebase?

Spec-Driven: Document Context

The AI receives documentation as part of its context. The Context Engineering pillar assembles relevant docs, API references, and architecture descriptions. The AI reads these and generates code accordingly.

The quality depends on:

  • How well the context is assembled (are the right docs selected?)
  • How current the docs are (have they drifted?)
  • How comprehensive the docs are (do they cover this specific question?)

Typed Specifications: Type Context

The AI reads the type system. It sees:

  • abstract record UserRolesFeature → this is a feature
  • AdminCanAssignRoles(UserId, UserId, RoleId) → this AC takes these inputs
  • [ForRequirement(typeof(UserRolesFeature))] on IUserRolesSpec → this is the specification
  • [Verifies(...)] on tests → these tests cover these ACs
  • Compiler diagnostics → these ACs need specs/impls/tests

The AI doesn't need assembled documentation because the type system IS the documentation. It navigates types the same way a developer navigates types — through references, definitions, and IDE-style lookup.

The quality depends on:

  • How well the AI understands the type system (does it know what Feature<T> means?)
  • How well the AI reads compiler diagnostics (does it understand REQ101?)
  • How complete the type annotations are (is everything annotated?)

The Bootstrap Problem

Both approaches have a bootstrap problem:

  • Spec-driven: The AI must read the specification documents before it can generate code. If it's the first time seeing these specs, it needs time to understand the framework's conventions. A comprehensive CLAUDE.md or system prompt helps.

  • Typed specifications: The AI must understand the typed specification conventions before it can write code within them. If it's the first time seeing [ForRequirement] and [Verifies], it needs onboarding. A comprehensive CLAUDE.md or system prompt helps.

Both need a "meta-document" that explains the system. The irony: even the typed specification approach — which claims "the type IS the documentation" — needs a document to explain its type system.


Summary

Dimension Spec-Driven Documentation Typed Specification Documentation
Primary form Text documents (markdown, wiki) Types (C# records, interfaces, attributes)
Drift prevention CI checks for doc existence Compiler checks for type consistency
Navigation Read docs → find code Ctrl+Click through types
Traceability Manual or generated reports Source-generated matrix (always current)
API docs OpenAPI from annotations OpenAPI enriched with requirement metadata
ADRs Standard practice Many ADRs replaced by types; infra decisions still need ADRs
AI context Assembled from documents Read from types
Breadth All documentation types covered Requirement-to-test chain only
Staleness Possible (documents drift) Impossible for typed artifacts (generated from code)

Part VIII examines the most forward-looking question: what is it like to be an AI agent working with each approach?


The Product Owner Question

A common objection to typed specifications: "Our product owner can't read C#. They need a PRD they can understand."

This is a legitimate concern. But it deserves a deeper examination than "POs can't code." The question is not whether POs can read arbitrary C# — it's whether POs can learn to read a domain language that happens to be expressed in C#.

What a Product Owner Reads in Spec-Driven

Here's a feature definition from the PRD template that a product owner reviews:

DEFINE_FEATURE(order_cancellation)
  description: "Allow customers to cancel orders before shipment"
  user_story: "As a customer, I want to cancel my order before it ships 
               so that I'm not charged for something I don't want"
  acceptance_criteria:
    - "Customer can cancel an order with status Placed or Confirmed"
    - "Cancellation is rejected for orders with status Shipped or Delivered"
    - "Cancelled orders trigger a full refund within 3 business days"
    - "Customer receives a cancellation confirmation email"
  priority: High
  complexity: Medium
  dependencies: ["payment_processing", "notification_service"]

The PO reads this and understands it. The format is accessible: plain English with light structure. The PO can verify that the ACs match their intent, that the priority is correct, and that the dependencies are complete.

But notice what the PO cannot verify from this text:

  • Is "Customer" the same as "RegisteredUser"? Or can guest users also cancel?
  • What does "cancel an order with status Placed" mean at the system level? Is it a state machine transition? A soft delete? A flag?
  • What does "full refund" mean? Does it include shipping costs? Is it to the original payment method?
  • What does "within 3 business days" mean operationally? Is it a promise to the user or an SLA to the payment processor?

The English is ambiguous. The PO thinks they understand it. The developer thinks they understand it. They may understand it differently. They won't discover the gap until implementation review or QA.

What a Product Owner Reads in Typed Specifications

Here's the same feature as a typed specification:

public abstract record OrderCancellationFeature : Feature<CustomerExperienceEpic>
{
    public override string Title => "Order cancellation before shipment";
    public override RequirementPriority Priority => RequirementPriority.High;
    public override string Owner => "Commerce Team";

    /// <summary>
    /// A registered customer (not a guest) can cancel their own order
    /// when the order status is Placed or Confirmed. The customer must
    /// be the original purchaser — cancellation by a different user is
    /// rejected with an AuthorizationException.
    /// </summary>
    public abstract AcceptanceCriterionResult
        CustomerCanCancelPlacedOrConfirmedOrder(
            CustomerId customer,
            OrderId order,
            OrderStatus currentStatus);

    /// <summary>
    /// Orders with status Shipped, Delivered, or Returned cannot be
    /// cancelled. The system returns a typed OrderNotCancellableException
    /// with the current status and the reason.
    /// </summary>
    public abstract AcceptanceCriterionResult
        CancellationRejectedForShippedOrDeliveredOrders(
            OrderId order,
            OrderStatus currentStatus);

    /// <summary>
    /// A cancelled order triggers a full refund (item total + shipping cost)
    /// to the original payment method. The refund is initiated within 1 hour
    /// and completed within 3 business days. "Completed" means the payment
    /// processor confirms the reversal.
    /// </summary>
    public abstract AcceptanceCriterionResult
        CancelledOrderTriggersFullRefund(
            OrderId order,
            PaymentMethodId originalPayment,
            Money itemTotal,
            Money shippingCost);

    /// <summary>
    /// Within 60 seconds of a successful cancellation, a confirmation email
    /// is sent to the customer's registered email address. The email includes
    /// the order ID, cancellation timestamp, and expected refund timeline.
    /// </summary>
    public abstract AcceptanceCriterionResult
        CustomerReceivesCancellationEmail(
            OrderId order,
            Email customerEmail);
}

Now: can a product owner read this? Let's be honest about what they see:

  • public abstract record OrderCancellationFeature — "This is about order cancellation." The PO reads the type name.
  • Feature<CustomerExperienceEpic> — "It's part of the Customer Experience epic." The PO reads the parent.
  • RequirementPriority.High — "High priority." Self-explanatory.
  • /// <summary> blocks — These are the requirements in plain English. The PO reads the XML comments.
  • CustomerCanCancelPlacedOrConfirmedOrder(CustomerId customer, OrderId order, OrderStatus currentStatus) — "The AC involves a customer, an order, and a status." The PO reads the method name and parameter names.

The key insight: the PO doesn't need to understand the C# syntax. They read the XML comments and the method names. The method name CustomerCanCancelPlacedOrConfirmedOrder is more readable than "Customer can cancel an order with status Placed or Confirmed" — it's one phrase, without articles, with clear subject-verb-object structure.

And the PO gains something they don't get from the PRD: precision. The XML comment says "A registered customer (not a guest)." It says "original purchaser." It says "item total + shipping cost." It says "within 1 hour." These precisions are forced by the act of writing a typed specification — because the developer who writes the feature record thinks through edge cases that the PRD author skips.

The Requirements DSL as a Product-Owner-Friendly Language

Here's the provocative claim: the Requirements DSL IS a product-owner-friendly DSL. Not because C# is inherently friendly, but because:

  1. The method names are English sentences. CustomerCanCancelPlacedOrConfirmedOrder reads as a sentence: "Customer can cancel placed or confirmed order."

  2. The parameter names are domain terms. CustomerId customer, OrderId order, OrderStatus currentStatus — these are the nouns of the business domain, not technical jargon.

  3. The XML comments are the plain-English specification. Every AC has a /// <summary> block that IS the human-readable AC, written in complete sentences.

  4. The hierarchy is visible. Feature<CustomerExperienceEpic> — the PO can see that this feature belongs to the Customer Experience epic. In the PRD, the hierarchy is a section nesting convention.

  5. The precision is forced. A method signature forces the developer to name the inputs. (CustomerId customer, OrderId order) — the PO can ask: "What's a CustomerId? Is it the email? The user ID?" The conversation happens at definition time, not at QA time.

A product owner who spends one hour learning to read typed feature records gains a skill that's more precise than reading English PRDs. English is ambiguous by nature. Method signatures with typed parameters are unambiguous by construction. The PO trades some accessibility (learning curve) for significant precision (no ambiguity).

Is this realistic for every PO? No. Some POs will never read C#, and that's fine — the XML comments and generated reports (traceability matrix, coverage report) serve them. But POs who invest in learning the format gain a superpower: they can review specifications that are impossible to misinterpret.

The Generated PO Report

For POs who truly cannot read C#, the typed approach generates a human-friendly report at build time:

# Feature Report: Order Cancellation
**Epic:** Customer Experience  
**Priority:** High  
**Owner:** Commerce Team  

## Acceptance Criteria

### 1. Customer Can Cancel Placed or Confirmed Order
A registered customer (not a guest) can cancel their own order when the 
order status is Placed or Confirmed. The customer must be the original 
purchaser — cancellation by a different user is rejected.

**Status:** ✓ Specified | ✓ Implemented | ✓ Tested (3 tests)

### 2. Cancellation Rejected for Shipped or Delivered Orders
Orders with status Shipped, Delivered, or Returned cannot be cancelled.

**Status:** ✓ Specified | ✓ Implemented | ✓ Tested (2 tests)

### 3. Cancelled Order Triggers Full Refund
A cancelled order triggers a full refund (item total + shipping cost) to 
the original payment method, initiated within 1 hour, completed within 
3 business days.

**Status:** ✓ Specified | ✓ Implemented | ⚠ Tested (1 test — below threshold)

### 4. Customer Receives Cancellation Email
Within 60 seconds of cancellation, a confirmation email is sent with 
order ID, timestamp, and refund timeline.

**Status:** ✓ Specified | ✓ Implemented | ✓ Tested (2 tests)

This report is generated from the XML comments and the type annotations. It cannot drift from the code because it IS generated from the code. The PO reads a markdown document that looks like a PRD — but unlike a PRD, it includes implementation and test status that is always current.


Documentation as Navigation: The Ctrl+Click Experience

Documentation in the traditional sense is a reference you read. Documentation in the typed specification sense is a graph you navigate. The difference is experiential — it changes how developers (and AI agents) understand a system.

Scenario: From Bug Report to Root Cause

A bug report comes in: "Customer was charged twice for order ORD-7891."

Let's trace both approaches through the investigation.

Spec-Driven Navigation

Step 1: Find the relevant specification.

The developer opens the PRD and searches for "order" or "payment" or "charge." They find:

DEFINE_FEATURE(payment_processing)
  acceptance_criteria:
    - "Payment is charged exactly once per order"
    - "Retry after timeout does not duplicate charge"
    - "Idempotency key prevents duplicate transactions"

The AC exists: "Payment is charged exactly once per order." Good — the spec anticipated this scenario.

Step 2: Find the implementation.

The developer searches the codebase for "payment" or "charge" or "idempotency." They find PaymentService.cs, PaymentGateway.cs, PaymentController.cs, PaymentRetryHandler.cs. Which one implements the idempotency check? They read each file.

After reading four files, they find the idempotency logic in PaymentGateway.cs. The idempotency key is computed from orderId + timestamp. The bug: when a retry happens within the same second (e.g., under high load), the idempotency key is identical, but the payment gateway's deduplication window expired.

Step 3: Find the test.

The developer searches for test files related to payment. They find PaymentServiceTests.cs, PaymentGatewayTests.cs, PaymentE2ETests.cs. They search for "idempotency" or "duplicate" in these files. They find a test that checks idempotency — but it uses Thread.Sleep(2000) between retries, so the same-second scenario is never tested.

Step 4: Find related documentation.

The developer checks the Architecture docs for the payment flow diagram. The diagram shows OrderService → PaymentGateway → Stripe. But the diagram doesn't mention the retry handler. The developer finds an ADR about the idempotency strategy — ADR-012, written 8 months ago. The ADR describes a different idempotency key format (orderId only, no timestamp). Someone changed the implementation but didn't update the ADR.

Total investigation time: ~45 minutes. Most of it spent searching, reading wrong files, and discovering stale documentation.

Typed Specification Navigation

Step 1: Find the relevant feature and AC.

The developer opens the IDE and types OrderProcessingFeature (or searches for [Feature] types containing "payment"). The IDE autocompletes. They Ctrl+Click to the feature record:

public abstract record OrderProcessingFeature : Feature<EcommercePlatformEpic>
{
    // ...

    /// <summary>
    /// The customer's payment method is charged for the order total.
    /// The charge is idempotent — retrying with the same OrderId does not
    /// double-charge.
    /// </summary>
    public abstract AcceptanceCriterionResult
        PaymentIsCharged(OrderId orderId, PaymentMethodId paymentMethod, Money amount);
}

The AC is right there: PaymentIsCharged. The XML doc says "idempotent" and "retrying with the same OrderId does not double-charge."

Step 2: Find the specification interface.

The developer right-clicks on PaymentIsCharged and selects "Find All References." The IDE shows:

References to PaymentIsCharged:
  1. OrderProcessingFeature.cs (definition)
  2. IPaymentSpec.cs:15 — [ForRequirement(..., nameof(PaymentIsCharged))]
  3. PaymentService.cs:42 — implements IPaymentSpec.ChargePayment
  4. PaymentProcessingTests.cs:88 — [Verifies(..., nameof(PaymentIsCharged))]
  5. PaymentIdempotencyTests.cs:12 — [Verifies(..., nameof(PaymentIsCharged))]

Five results. Every relevant file. No searching. No guessing.

Step 3: Ctrl+Click to the specification.

[ForRequirement(typeof(OrderProcessingFeature))]
public interface IPaymentSpec
{
    [ForRequirement(typeof(OrderProcessingFeature),
        nameof(OrderProcessingFeature.PaymentIsCharged))]
    Result<PaymentConfirmation, PaymentException> ChargePayment(
        OrderId orderId, PaymentMethodId method, Money amount);
}

The spec contract is explicit: takes OrderId, returns Result<PaymentConfirmation, PaymentException>.

Step 4: Ctrl+Click to the implementation.

[ForRequirement(typeof(OrderProcessingFeature))]
public class PaymentService : IPaymentSpec
{
    public Result<PaymentConfirmation, PaymentException> ChargePayment(
        OrderId orderId, PaymentMethodId method, Money amount)
    {
        var idempotencyKey = IdempotencyKey.FromOrderId(orderId);
        // ...
    }
}

The developer sees IdempotencyKey.FromOrderId(orderId). They Ctrl+Click on FromOrderId:

public static IdempotencyKey FromOrderId(OrderId orderId) =>
    new($"charge-{orderId.Value}-{DateTime.UtcNow:yyyyMMddHHmmss}");

There it is. The idempotency key includes a timestamp with second-level precision. Same-second retries get the same key, but the payment gateway might have expired the deduplication window.

Step 5: Ctrl+Click to the tests.

From the "Find All References" result, the developer clicks on PaymentIdempotencyTests.cs:12:

[Verifies(typeof(OrderProcessingFeature),
    nameof(OrderProcessingFeature.PaymentIsCharged))]
public void Retry_within_same_second_does_not_double_charge()
{
    // This test exists... but does it test the right scenario?
    var orderId = OrderId.New();
    _service.ChargePayment(orderId, _testCard, Money.From(100));
    Thread.Sleep(2000); // <-- Bug: sleeps 2 seconds, so the keys differ
    _service.ChargePayment(orderId, _testCard, Money.From(100));

    Assert.That(_gateway.Charges, Has.Count.EqualTo(1));
}

Found: the test sleeps 2 seconds, so the same-second scenario is never tested. The test name says "within_same_second" but the implementation contradicts it.

Total investigation time: ~10 minutes. Every step was a Ctrl+Click. No searching, no reading wrong files, no stale documentation.

The Navigation Comparison

Investigation Step Spec-Driven Typed Specifications
Find relevant spec Search PRD for keywords Ctrl+Click or Find References
Find implementation Search codebase for keywords Ctrl+Click on spec interface
Find tests Search test files for keywords Find All References on AC name
Find related docs Search docs folder, hope it's current No separate docs — the type IS the doc
Total steps ~8 (search, read, search, read, ...) ~5 (click, click, click, click, click)
Wrong turns Common (read 3 wrong files) Rare (references are precise)
Stale info encountered Likely (ADR outdated) None (types are current)
Total time ~45 minutes ~10 minutes

The multiplier is 4.5x for this specific scenario. For more complex investigations (cross-service bugs, multi-feature interactions), the multiplier increases because the typed approach's references scale linearly while the search-based approach scales combinatorially.

Why This Matters for Onboarding

New developers spend 50-80% of their first month understanding the codebase. The primary activity: reading code and documentation to understand what does what.

In a spec-driven codebase, the new developer reads the PRD, reads the testing spec, reads the coding practices, reads the ADRs, reads the wiki, and then opens the code and tries to connect everything. The connections are in their head — a mental model built from reading disparate documents.

In a typed specification codebase, the new developer opens any feature record and Ctrl+Clicks through the chain. The connections are in the IDE — a structural model built from type references. No mental model required; the type system IS the model.

The onboarding experience in a typed codebase feels like exploring a well-organized library where every book has footnotes linking to every other relevant book. The onboarding experience in a spec-driven codebase feels like reading six books and building a mental index yourself.


The Documentation DSL: Even Docs Can Be Typed

The spec-driven approach has a dedicated Documentation-as-Code pillar. The typed approach says "types are the documentation." But what about the documentation that doesn't naturally emerge from types — user guides, architecture overviews, API tutorials?

The answer, as with everything else in the typed approach: build a DSL. (For the full implementation of this idea — a generic Document<T> DSL that introspects any typed DSL and generates documentation automatically — see Auto-Documentation from a Typed System.)

Architecture Documentation as Types

Architecture diagrams drift because they're separate artifacts from the code. But the architecture IS the project reference graph:

// Architecture DSL — declares the intended architecture
[BoundedContext("Ordering")]
[Layer(ArchitectureLayer.Domain)]
[AllowedDependencies(typeof(SharedKernel))]
[ForbiddenDependencies(typeof(Infrastructure), typeof(Api))]
public partial class OrderingDomainContext { }

[BoundedContext("Ordering")]
[Layer(ArchitectureLayer.Infrastructure)]
[AllowedDependencies(typeof(OrderingDomainContext))]
public partial class OrderingInfrastructureContext { }

[BoundedContext("Payment")]
[Layer(ArchitectureLayer.Domain)]
[AllowedDependencies(typeof(SharedKernel))]
[IntegrationPoint(typeof(OrderingDomainContext), Protocol.DomainEvents)]
public partial class PaymentDomainContext { }

The source generator produces:

  1. A Mermaid diagram of the architecture (always current, because it reads the actual project references)
  2. ARCH1xx analyzer diagnostics if actual code violates the declared architecture
  3. An architecture overview page generated from the attributes and XML comments
Diagram
The architecture diagram cannot drift from the code because the source generator reads the actual project references at build time.

This diagram is generated at build time. It cannot drift from the code because it IS the code. Delete the Payment project → the diagram loses the Payment node. Add a dependency → the diagram gains an edge. No human needs to update anything.

API Documentation as Types

When endpoints are annotated with [ForRequirement], the OpenAPI spec is enriched with requirement metadata. But the DSL can go further:

[ApiEndpoint("POST /api/orders/{orderId}/cancel")]
[ForRequirement(typeof(OrderCancellationFeature),
    nameof(OrderCancellationFeature.CustomerCanCancelPlacedOrConfirmedOrder))]
[ApiExample(
    Name = "Successful cancellation",
    RequestBody = """{"reason": "Changed my mind"}""",
    ResponseStatus = 200,
    ResponseBody = """{"orderId": "ord-123", "status": "Cancelled", "refundInitiated": true}""")]
[ApiExample(
    Name = "Already shipped",
    RequestBody = """{"reason": "Changed my mind"}""",
    ResponseStatus = 409,
    ResponseBody = """{"error": "OrderNotCancellable", "currentStatus": "Shipped"}""")]
[ApiErrorCode(409, "OrderNotCancellable", "Order cannot be cancelled in current status")]
[ApiErrorCode(403, "Unauthorized", "User is not the order owner")]
public partial class CancelOrderEndpoint { }

The source generator produces:

  • OpenAPI spec with examples, error codes, and requirement metadata
  • API documentation page with request/response examples
  • Client SDK stubs with typed request/response classes
  • Postman collection with pre-built example requests

All from attributes. All generated. All drift-proof.

The Documentation-Free Codebase

The fully typed vision is a codebase with zero markdown documentation files. Not because documentation isn't valued — because documentation IS the type system:

Traditional codebase:                   Typed codebase:
                                        
src/                                    src/
├── README.md          ← stale         ├── MyApp.Requirements/    ← IS the PRD
├── docs/                              ├── MyApp.Architecture/    ← IS the arch docs
│   ├── architecture.md ← stale        ├── MyApp.Api/             ← IS the API docs
│   ├── api-guide.md    ← stale        │   └── (endpoints with [ApiExample])
│   ├── onboarding.md   ← stale        ├── MyApp.Operations/      ← IS the ops docs
│   └── adr/            ← stale        │   └── (alerts, SLAs, runbooks as types)
├── wiki/ (external)    ← stale        └── .claude/
│   └── 47 pages        ← stale            └── CLAUDE.md          ← 50-line bootstrap
└── code/                              
    └── (actual system)                 Zero markdown files.
                                        Zero drift.
                                        Everything navigable.
                                        Everything generated.

Is this achievable today? For the requirement chain, yes. For architecture and API docs, partially (the DSLs described above are buildable but not yet built). For user-facing documentation (tutorials, getting-started guides), probably not — those are inherently prose artifacts.

But the trajectory is clear: every domain-facing document can become a typed DSL. The only documents that survive are user-facing prose and the 50-line CLAUDE.md bootstrap. Everything else is types, generated artifacts, and compiler-enforced structure.

This is what "Documentation as Code" should actually mean: not "we version-control our markdown files" but "our documentation IS our compiled code." The spec-driven approach uses the phrase as aspiration. The typed approach makes it literal.


The Generated Report Ecosystem

One final documentation advantage of the typed approach: the reports generate themselves. (See Part VI: Generated Artifacts for a concrete example: one feature declaration producing 17 generated files — Grafana JSON, Prometheus YAML, Kubernetes probes, and more — with zero manual documentation.)

From the type metadata, the source generators can produce any report format an organization needs:

// Generated at build time: RequirementStatusReport.g.cs

public static class RequirementStatusReport
{
    public static string GenerateMarkdown() => """
        # Requirement Status Report
        Generated: 2026-04-06 14:30:00 UTC
        
        ## Summary
        - **Total Features:** 12
        - **Fully Implemented:** 10 (83%)
        - **Fully Tested:** 9 (75%)
        - **In Progress:** 2
        
        ## Features Missing Tests
        | Feature | Missing ACs |
        |---------|------------|
        | PasswordResetFeature | ResetLinkCanOnlyBeUsedOnce |
        | OrderCancellationFeature | CancellationReasonRequired |
        
        ## Features Fully Complete ✓
        - UserRolesFeature (3/3 ACs, 8 tests)
        - JwtAuthFeature (4/4 ACs, 12 tests)
        - OrderProcessingFeature (5/5 ACs, 15 tests)
        ...
        """;

    public static string GenerateCsv() => """
        Feature,AC,Spec,Implementation,Tests,Status
        UserRolesFeature,AdminCanAssignRoles,IUserRolesSpec,AuthorizationService,3,Complete
        UserRolesFeature,ViewerHasReadOnly,IUserRolesSpec,AuthorizationService,2,Complete
        ...
        """;

    public static string GenerateJson() => /* full JSON export */;
}

These reports are always correct. They can be served by a dashboard endpoint, emailed to stakeholders, or included in a CI artifact. They're not maintained — they're generated. A product owner who wants to know "what's the current status of all features?" doesn't read a document — they pull a generated report that reflects the compile-time truth.

The spec-driven approach can also generate reports — but from documents, not from types. A report generated from a PRD reflects what the document says, not what the code does. If the document has drifted, the report is wrong. A report generated from types reflects what the compiler verified, which is what the code actually does.

Part VIII examines the most forward-looking question: what is it like to be an AI agent working with each approach?

⬇ Download