blue/.blue/docs/rfcs/0031-document-lifecycle-filenames.draft.md
Eric Garcia 0fea499957 feat: lifecycle suffixes for all document states + resolve all clippy warnings
Every document filename now mirrors its lifecycle state with a status
suffix (e.g., .draft.md, .wip.md, .accepted.md). No more bare .md for
tracked document types. Also renamed all from_str methods to parse to
avoid FromStr trait confusion, introduced StagingDeploymentParams struct,
and fixed all 19 clippy warnings across the codebase.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 12:19:46 -05:00

13 KiB

RFC 0031: Document Lifecycle Filenames

Status Draft
Date 2026-01-26
Source Spike Document Lifecycle Filenames
Supersedes RFC 0030 (ISO 8601 Document Filename Timestamps)
Dialogue document-lifecycle-filenames-rfc-design (Converged, 3 rounds, 12 experts, 100%)

Summary

Blue documents store lifecycle status in SQLite and markdown frontmatter, but filenames reveal nothing about document state. Browsing a directory of 15+ spikes or RFCs requires opening each file to determine if it's a draft, in-progress, complete, or superseded. This RFC combines ISO 8601 timestamps (from RFC 0030) with status-in-filename visibility to create a unified document lifecycle filename convention across all 9 document types.

Problem

Timestamp Problem (from RFC 0030)

Date-prefixed documents use YYYY-MM-DD format. On a productive day this creates 15+ files with identical prefixes and no temporal ordering. The 5 affected handlers also use mixed timezones (3 UTC, 2 Local).

Status Visibility Problem (new)

Nine document types have lifecycle statuses stored only in SQLite + markdown frontmatter:

Type Current Pattern Statuses Browse Problem
RFC 0030-slug.md draft, accepted, in-progress, implemented, superseded Can't tell if draft or shipped
Spike 2026-01-26-slug.md in-progress, complete (+outcome) Can't tell if resolved
ADR 0004-slug.md accepted, in-progress, implemented Can't tell if active
Decision 2026-01-26-slug.md recorded Always same (no problem)
PRD 0001-slug.md draft, approved, implemented Can't tell if approved
Postmortem 2026-01-26-slug.md open, closed Can't tell if resolved
Runbook slug.md active, archived Can't tell if current
Dialogue 2026-01-26-slug.dialogue.md draft, published Can't tell if final
Audit 2026-01-26-slug.md in-progress, complete Can't tell if done

You cannot determine document state without opening every file.

Design

Part 1: ISO 8601 Timestamps (from RFC 0030)

New Timestamp Format

YYYY-MM-DDTHHMMZ-slug.md

ISO 8601 filename-safe hybrid notation: extended date (YYYY-MM-DD) with basic time (HHMM), T separator, and Z suffix for UTC. Colons omitted for cross-platform filesystem safety.

Examples:

Before: 2026-01-26-native-kanban-apps-for-blue.md
After:  2026-01-26T0856Z-native-kanban-apps-for-blue.md

Affected Document Types (timestamps)

Document Type Handler File Current TZ Change
Spike spike.rs:33 UTC Format %Y-%m-%dT%H%MZ
Dialogue dialogue.rs:348 Local Switch to UTC + new format
Decision decision.rs:42 UTC New format
Postmortem postmortem.rs:83 Local Switch to UTC + new format
Audit audit_doc.rs:37 UTC New format

Not affected: RFCs, ADRs, PRDs, Runbooks (numbered prefixes, not dates).

Shared Timestamp Helper

/// Get current UTC timestamp in ISO 8601 filename-safe format
fn utc_timestamp() -> String {
    chrono::Utc::now().format("%Y-%m-%dT%H%MZ").to_string()
}

Part 2: Status-in-Filename

Approach: Status Suffix Before .md

Encode document lifecycle status as a dot-separated suffix before the file extension:

{prefix}-{slug}.{status}.md

When status is the default/initial state, the suffix is omitted (no visual noise for new documents).

Complete Filename Format by Type

Date-prefixed types (5 types):

2026-01-26T0856Z-slug.md                    # spike: in-progress (default, no suffix)
2026-01-26T0856Z-slug.done.md               # spike: complete (any outcome)

2026-01-26T0912Z-slug.dialogue.md           # dialogue: draft (default)
2026-01-26T0912Z-slug.dialogue.pub.md       # dialogue: published

2026-01-26T0930Z-slug.md                    # decision: recorded (always, no suffix)

2026-01-26T1015Z-slug.md                    # postmortem: open (default)
2026-01-26T1015Z-slug.closed.md             # postmortem: closed

2026-01-26T1100Z-slug.md                    # audit: in-progress (default)
2026-01-26T1100Z-slug.done.md               # audit: complete

Number-prefixed types (3 types):

0031-slug.md                                 # RFC: draft (default, no suffix)
0031-slug.accepted.md                        # RFC: accepted
0031-slug.wip.md                             # RFC: in-progress
0031-slug.impl.md                            # RFC: implemented
0031-slug.super.md                           # RFC: superseded

0004-slug.md                                 # ADR: accepted (default, no suffix)
0004-slug.impl.md                            # ADR: implemented

0001-slug.md                                 # PRD: draft (default, no suffix)
0001-slug.approved.md                        # PRD: approved
0001-slug.impl.md                            # PRD: implemented

No-prefix types (1 type):

slug.md                                      # runbook: active (default, no suffix)
slug.archived.md                             # runbook: archived

Status Abbreviation Vocabulary

A consistent set of short status tags across all document types:

Tag Meaning Used By
(none) Default/initial state All types
.done Complete/closed Spike, Audit, Postmortem
.impl Implemented RFC, ADR, PRD
.super Superseded RFC
.accepted Accepted/approved RFC
.approved Approved PRD
.wip In-progress (active work) RFC
.closed Closed Postmortem
.pub Published Dialogue
.archived Archived/inactive Runbook

Design Principle: Store Authority

The SQLite store is the authoritative source of document status. Filenames are derived views. If filename and store disagree, the store wins. blue_sync reconciles.

Default-State Omission

Files without status suffixes are in their initial state. Within each document type's directory, absence of a suffix unambiguously means the initial/default state for that type. Legacy files created before this RFC are treated identically -- no migration required.

The Rename Problem

Status-in-filename requires renaming files when status changes. Consequences:

  1. Git history: git log --follow tracks renames, but git blame shows only current name
  2. Cross-references: Markdown links like [RFC 0031](../rfcs/0031-slug.md) break on rename
  3. External bookmarks: Browser bookmarks, shell aliases break
  4. SQLite file_path: Must update documents.file_path on every rename

Mitigations:

  • Update file_path in store on every status change (already touches store + markdown)
  • Cross-references use title-based lookups, not filename -- most survive
  • Git detects renames automatically via content similarity (git diff --find-renames); no explicit git mv needed
  • Accept that external bookmarks break (they already break on file deletion)

Overwrite Protection

Document creation handlers call fs::write without checking file existence. If two documents with identical slugs are created in the same UTC minute, the second silently overwrites the first. All 5 date-prefixed handlers must check file existence before writing:

let path = docs_path.join(&filename);
if path.exists() {
    return Err(anyhow!("File already exists: {}", filename));
}
fs::write(&path, content)?;

This is a prerequisite for status suffixes, not optional future work.

Code Changes

1. Shared helpers (blue-core)

/// Get current UTC timestamp in ISO 8601 filename-safe format
pub fn utc_timestamp() -> String {
    chrono::Utc::now().format("%Y-%m-%dT%H%MZ").to_string()
}

/// Map document status to filename suffix
pub fn status_suffix(doc_type: DocType, status: &str) -> Option<&'static str> {
    match (doc_type, status) {
        // Default states: no suffix
        (DocType::Spike, "in-progress") => None,
        (DocType::Rfc, "draft") => None,
        (DocType::Adr, "accepted") => None,
        (DocType::Prd, "draft") => None,
        (DocType::Decision, "recorded") => None,
        (DocType::Postmortem, "open") => None,
        (DocType::Runbook, "active") => None,
        (DocType::Dialogue, "draft") => None,
        (DocType::Audit, "in-progress") => None,

        // Spike outcomes
        (DocType::Spike, "complete") => Some("done"),

        // RFC lifecycle
        (DocType::Rfc, "accepted") => Some("accepted"),
        (DocType::Rfc, "in-progress") => Some("wip"),
        (DocType::Rfc, "implemented") => Some("impl"),
        (DocType::Rfc, "superseded") => Some("super"),

        // ADR
        (DocType::Adr, "implemented") => Some("impl"),

        // PRD
        (DocType::Prd, "approved") => Some("approved"),
        (DocType::Prd, "implemented") => Some("impl"),

        // Postmortem
        (DocType::Postmortem, "closed") => Some("closed"),

        // Runbook
        (DocType::Runbook, "archived") => Some("archived"),

        // Dialogue
        (DocType::Dialogue, "published") => Some("pub"),

        // Audit
        (DocType::Audit, "complete") => Some("done"),

        _ => None,
    }
}

2. Rename-on-status-change

Each handler's update_status path gains a rename step. Filesystem-first with rollback:

fn rename_for_status(state: &ProjectState, doc: &Document, new_status: &str) -> Result<(), Error> {
    if let Some(ref old_path) = doc.file_path {
        let old_full = state.home.docs_path.join(old_path);
        let new_suffix = status_suffix(doc.doc_type, new_status);
        let new_filename = rebuild_filename(old_path, new_suffix);
        let new_full = state.home.docs_path.join(&new_filename);

        if old_full != new_full {
            // Step 1: Rename file (filesystem-first)
            fs::rename(&old_full, &new_full)?;

            // Step 2: Update store — rollback rename on failure
            if let Err(e) = state.store.update_document_file_path(doc.doc_type, &doc.title, &new_filename) {
                // Attempt rollback
                if let Err(rollback_err) = fs::rename(&new_full, &old_full) {
                    eprintln!("CRITICAL: rename rollback failed. File at {:?}, store expects {:?}. Rollback error: {}",
                        new_full, old_path, rollback_err);
                }
                return Err(e);
            }

            // Step 3: Update markdown frontmatter status (non-critical)
            if let Err(e) = update_markdown_status(&new_full, new_status) {
                eprintln!("WARNING: frontmatter update failed for {:?}: {}. Store is authoritative.", new_full, e);
            }
        }
    }
    Ok(())
}

3. Handler timestamp updates (5 handlers)

Same changes as RFC 0030: replace %Y-%m-%d with %Y-%m-%dT%H%MZ in spike.rs, dialogue.rs, decision.rs, postmortem.rs, audit_doc.rs. Standardize all to chrono::Utc::now().

Backwards Compatibility

No migration needed. The spike investigation confirmed:

  1. No code parses dates from filenames. The only filename regex (store.rs:2232) extracts RFC/ADR numbers (^\d{4}-), not dates.
  2. Existing files keep their names. Old 2026-01-26-slug.md files continue to work. New files get the new format.
  3. Document lookups use the SQLite store, not filename patterns.
  4. Status suffixes are additive. Existing files without suffixes are treated as default state.

Spike Outcome Visibility

For the user's specific request -- seeing spike outcomes from filenames:

Outcome Filename Example
In-progress 2026-01-26T0856Z-kanban-apps.md
Complete (any outcome) 2026-01-26T0856Z-kanban-apps.done.md

All completed spikes get .done regardless of outcome. The specific outcome (no-action, decision-made, recommends-implementation) is recorded in the markdown ## Outcome section and the SQLite outcome field. Spike-to-RFC linkage lives in the RFC's source_spike field, not the spike filename.

Test Plan

  • Unit test: utc_timestamp() produces format matching ^\d{4}-\d{2}-\d{2}T\d{4}Z$
  • Unit test: status_suffix() returns correct suffix for all 9 doc types and all statuses
  • Unit test: rebuild_filename() correctly inserts/removes/changes status suffix
  • Integration: Create one of each affected document type, verify filename matches new format
  • Integration: Change status on a document, verify file is renamed and store is updated
  • Integration: Verify existing YYYY-MM-DD-slug.md files still load and are findable by title
  • Integration: Verify scan_filesystem_max regex still works (only applies to numbered docs)
  • Integration: Verify fs::rename failure leaves store unchanged
  • Integration: Verify store update failure after rename triggers rollback rename
  • Integration: Verify legacy files (pre-RFC) without suffixes are treated as default state
  • Integration: Verify overwrite protection rejects duplicate filenames within same UTC minute

Future Work

  • Audit slug bug: audit_doc.rs:37 uses raw title instead of title_to_slug() for filenames. Fix independently.
  • Cross-reference updater: A blue_rename tool that updates markdown cross-references when files are renamed. Not required for MVP but useful long-term.
  • Auto-complete source spike: When rfc_create is called with source_spike, auto-complete the source spike with decision-made outcome. This closes the spike-to-RFC workflow loop without manual intervention.

"Right then. Let's get to it."

-- Blue