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>
328 lines
13 KiB
Markdown
328 lines
13 KiB
Markdown
# 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
|
|
|
|
```rust
|
|
/// 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:
|
|
|
|
```rust
|
|
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)
|
|
|
|
```rust
|
|
/// 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:
|
|
|
|
```rust
|
|
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
|