Chapter 17 Process Documentation — Code as the Living Document
Work in Progress — This chapter is not yet published.
Chapter 17 — Process Documentation: Code as the Living Document
There is a Confluence page somewhere in your organization that says “Invoice Approval Process — Last Updated: November 2022.” Nobody has touched it since. The actual invoice approval process was changed three times since then: once when the company moved to a 3-way match, once when the CFO added a second sign-off for anything over $50,000, and once when the AP team discovered that the guard that was supposed to prevent double-payment wasn’t actually being enforced.
None of those changes made it back to Confluence.
This is not a people problem. It’s a structural problem. Documentation lives in a different place from the code, so it takes extra effort to keep them in sync. Under deadline pressure, “update the wiki” is always the last item on the list — and it never gets done. The documentation drifts, then becomes wrong, then becomes actively misleading, then gets ignored, then stops being maintained at all, then gets deleted in a cleanup sweep, and the process knowledge lives in someone’s head until they leave.
FOSM solves this structurally, not culturally.
The code IS the documentation. The lifecycle block IS the specification. Every state has a name and a doc. Every event has a description. Every guard explains what it’s checking. The lifecycle block in the model file is simultaneously the specification, the implementation, and the documentation — and there is only one of them, in one place, tracked by Git.
This chapter builds the mechanics of that system: the DSL, the storage layer, the admin Process Docs hub, and the llms.txt export that makes the entire process corpus queryable by LLM agents.
The Problem With External Documentation
Let’s be precise about what goes wrong with wikis and why “we’ll be more disciplined about updating docs” never works.
The fundamental issue is distance. When documentation lives in a separate system from the code:
- Writing a guard takes 10 minutes. Opening Confluence, finding the right page, updating the process flow, and saving takes another 15 minutes. Under deadline pressure, the second step gets skipped.
- Code review catches guard logic errors. Code review does not catch Confluence drift.
- CI/CD validates that tests pass. CI/CD does not validate that the wiki matches the code.
- Git blame tells you who changed a guard and why. Git blame does not help you with the Confluence page that was never updated.
The distance creates a synchronization problem, and synchronization problems always resolve toward entropy. The code stays current because it has to — the system breaks if it’s wrong. The documentation doesn’t have to stay current, so it doesn’t.
FOSM’s answer is to eliminate the distance. Documentation goes inside the lifecycle block. The lifecycle block IS the code. Same file, same review, same commit, same Git history.
doc: keyword is structured: it's attached to a specific state, event, guard, or side-effect. It's extracted by the documentation system, stored in the database, rendered in the admin UI, and exported to the LLM context file. A code comment can silently lie. A doc: attached to a guard that no longer exists raises a warning at lifecycle parse time.
The Three Documentation Mechanisms
Mechanism 1: process_doc
The top-level description of the entire lifecycle. One paragraph or a few sentences that explain what this process does, why it exists, and what business problem it solves.
lifecycle do
process_doc "Manages the full lifecycle of vendor invoices from receipt through payment. " \
"Enforces 3-way match (PO, receipt, invoice) before approval is allowed. " \
"Requires dual sign-off for invoices above $50,000. " \
"Integrates with the payment run process — approved invoices queue for the next run."
# ...
end
This is the equivalent of the first paragraph of a Confluence page — but it lives in the model file, it’s version-controlled, and it’s impossible to forget to update because the description is literally inside the method that defines the process.
Mechanism 2: doc: Keyword
Inline documentation on individual elements. Every state, event, guard, and side-effect can carry a doc: value.
lifecycle do
state :awaiting_approval,
doc: "Invoice has been submitted and is waiting for an approver to act. " \
"SLA: 2 business days. Escalation triggers on day 3."
event :approve do
doc "Approver has reviewed and approved the invoice for payment."
from :awaiting_approval
to :approved
guards [:three_way_match_verified?, :within_approval_authority?]
side_effects [:queue_for_payment, :notify_submitter_of_approval]
end
end
The doc: on a state explains what that state means — not just its name, but its business significance, any SLAs attached to it, and what actions are available from it. The doc: on an event explains the business semantics of triggering it.
Mechanism 3: Standalone doc Method
For long-form explanations — process history, rationale, edge cases, exception handling — a standalone doc method provides a home for prose that doesn’t belong on a single element:
lifecycle do
doc :three_way_match_policy,
"Three-way match requires PO number, goods receipt confirmation, and invoice amount " \
"to align within 2% tolerance before approval is permitted. This policy was implemented " \
"in Q2 2025 after an audit found that 12% of invoices were being approved without " \
"corresponding purchase orders, creating off-PO spend that couldn't be reconciled " \
"against budget. Exception process: Finance Director can override with written justification."
end
These standalone docs are named — you can reference them from event descriptions, from guards, and from the admin UI.
The Full Documented NDA Lifecycle
Let’s go back to the NDA from Chapter 7 and show what it looks like with full documentation applied. This is the same lifecycle — same states, same events, same guards — but now every element carries its business meaning.
Listing 17.1 — app/models/nda.rb (fully documented lifecycle)
class Nda < ApplicationRecord
include Fosm::HasLifecycle
belongs_to :owner, class_name: "User"
belongs_to :counterparty, class_name: "User"
has_one_attached :document
lifecycle do
process_doc "Manages the signing lifecycle of Non-Disclosure Agreements between the " \
"company and external parties (vendors, partners, candidates). " \
"Enforces signing order flexibility — either party may sign first — " \
"while ensuring both signatures are captured before execution. " \
"Integrates with the Contact module: counterparties must be active Contacts " \
"before an NDA can be sent. Executed NDAs are retained for 7 years per " \
"legal hold policy."
states :draft, :sent, :partially_signed, :executed, :expired, :cancelled
state :draft,
doc: "NDA has been created but not yet sent. The owner may attach a document, " \
"edit terms, and designate the counterparty. No signatures collected."
state :sent,
doc: "Invitation has been sent to the counterparty. The signing window is open. " \
"Default expiry: 30 days from send date. Either party may sign first."
state :partially_signed,
doc: "One of the two required signatures has been collected. The NDA cannot " \
"be executed until both the owner and counterparty have signed. " \
"The system tracks which signature was captured first."
state :executed,
doc: "Both parties have signed. The NDA is legally binding. " \
"Execution date recorded. Document locked — no further changes permitted.",
terminal: true
state :expired,
doc: "The signing window closed without both signatures being captured. " \
"Distinct from cancellation — expiry is automatic and time-triggered. " \
"Expired NDAs can be re-sent if the business relationship is still active.",
terminal: true
state :cancelled,
doc: "Explicitly terminated by either party or by an admin. " \
"Cancellation reason required. Preserved in audit trail indefinitely.",
terminal: true
# ─── Events ──────────────────────────────────────────────────────────
event :send_invitation do
doc "Send the NDA to the counterparty. Sets the signing window. " \
"Generates a unique signing link valid for expiry_days."
from :draft
to :sent
guards [:document_attached?, :counterparty_is_active_contact?]
side_effects [:set_expiry_date, :send_invitation_email, :create_audit_entry]
end
event :sign_by_owner do
doc "Owner's signature captured. Can occur before or after counterparty signs. " \
"Records signature timestamp and IP address."
from :draft, :sent, :partially_signed
to :partially_signed
guards [:not_already_signed_by_owner?]
side_effects [:record_owner_signature]
end
event :sign_by_counter do
doc "Counterparty signature captured via signing link. " \
"Records signature timestamp and IP address."
from :sent, :partially_signed
to :partially_signed
guards [:not_already_signed_by_counter?]
side_effects [:record_counter_signature]
end
event :execute do
doc "Both signatures confirmed — mark NDA as legally binding. " \
"Execution timestamp recorded. Document hash stored for tamper detection."
from :partially_signed
to :executed
guards [:both_parties_signed?]
side_effects [:set_execution_date, :lock_document, :notify_both_parties, :create_audit_entry]
end
event :expire do
doc "Triggered automatically by ExpireNdasJob when current time exceeds expiry_date. " \
"Not a human action — do not expose this event in the UI."
from :sent, :partially_signed
to :expired
side_effects [:send_expiry_notification]
end
event :cancel do
doc "Explicitly cancel the NDA. Cancellation reason is stored in the metadata field. " \
"Available to the NDA owner and to admins. Not reversible."
from :draft, :sent, :partially_signed
to :cancelled
side_effects [:send_cancellation_notification, :create_audit_entry]
end
# ─── Guards ──────────────────────────────────────────────────────────
guard :document_attached? do
doc "NDA document must be attached before invitation can be sent. " \
"Accepted formats: PDF only. Maximum size: 10MB."
end
guard :counterparty_is_active_contact? do
doc "Counterparty must exist as an active Contact in the system. " \
"This ensures the signing invitation reaches a verified email address."
end
guard :both_parties_signed? do
doc "Both owner_signed_at and counter_signed_at must be non-null. " \
"This is the critical execution guard — partial signatures cannot execute."
end
# ─── Side Effects ─────────────────────────────────────────────────────
side_effect :set_expiry_date do
doc "Sets expiry_date to (Time.current + expiry_days.days). " \
"Default expiry_days: 30. Configurable per NDA."
end
side_effect :lock_document do
doc "Sets document_locked: true. Prevents attachment replacement post-execution. " \
"Also stores document_sha256 hash for tamper detection."
end
# ─── Long-form documentation ──────────────────────────────────────────
doc :signing_order_policy,
"Either party may sign first. This is intentional — requiring counterparty to sign " \
"before the owner creates unnecessary back-and-forth. The partially_signed state " \
"tracks which signature has been collected via owner_signed_at and counter_signed_at. " \
"The execute event guard (both_parties_signed?) enforces that both are present."
doc :re_send_policy,
"Expired NDAs can be re-sent by creating a new NDA record. We do not reuse expired " \
"NDAs because the expiry creates a clean audit break. The original expired record " \
"is preserved. A new NDA starts from draft with a fresh signing window."
end
end
This is the same code that runs the business logic. The doc: annotations aren’t comments — they’re structured data that gets extracted, stored, and rendered.
The Storage Layer
Documentation is extracted from the lifecycle DSL and stored in the fosm_definitions table. We’re adding two columns to the existing table:
Listing 17.2 — db/migrate/20260302300000_add_documentation_to_fosm_definitions.rb
class AddDocumentationToFosmDefinitions < ActiveRecord::Migration[8.0]
def change
add_column :fosm_definitions, :process_description, :text
add_column :fosm_definitions, :documentation, :jsonb, default: {}
end
end
The documentation JSON structure:
{
"states": {
"draft": "NDA has been created but not yet sent...",
"sent": "Invitation has been sent to the counterparty..."
},
"events": {
"send_invitation": "Send the NDA to the counterparty...",
"execute": "Both signatures confirmed..."
},
"guards": {
"document_attached?": "NDA document must be attached...",
"counterparty_is_active_contact?": "Counterparty must exist as..."
},
"side_effects": {
"set_expiry_date": "Sets expiry_date to (Time.current + expiry_days.days)...",
"lock_document": "Sets document_locked: true..."
},
"named_docs": {
"signing_order_policy": "Either party may sign first...",
"re_send_policy": "Expired NDAs can be re-sent..."
}
}
The extraction happens in the lifecycle DSL parser:
Listing 17.3 — lib/fosm/lifecycle_dsl.rb (documentation extraction)
module Fosm
class LifecycleDsl
# ... existing DSL parser ...
def process_doc(text)
@process_description = text
end
def doc(name, text)
(@named_docs ||= {})[name.to_s] = text
end
def build_fosm_definition(model_class)
definition = FosmDefinition.find_or_initialize_by(
object_type: model_class.name
)
definition.assign_attributes(
states: @states.map(&:name),
events: serialize_events,
process_description: @process_description,
documentation: build_documentation_hash
)
definition.save!
definition
end
private
def build_documentation_hash
{
states: doc_hash_for(@states),
events: doc_hash_for(@events),
guards: doc_hash_for(collect_guards),
side_effects: doc_hash_for(collect_side_effects),
named_docs: @named_docs || {}
}
end
def doc_hash_for(elements)
elements.each_with_object({}) do |el, hash|
hash[el.name.to_s] = el.doc if el.respond_to?(:doc) && el.doc.present?
end
end
end
end
Run the migration:
$ rails db:migrate
Reload all lifecycle definitions to extract documentation:
$ rails fosm:reload_definitions
The Fully Documented Invoice Lifecycle
The Invoice is the most business-critical object in most deployments. Here’s the full documented version:
Listing 17.4 — app/models/invoice.rb (documented lifecycle excerpt)
class Invoice < ApplicationRecord
include Fosm::HasLifecycle
belongs_to :vendor
belongs_to :submitter, class_name: "User"
belongs_to :approver, class_name: "User", optional: true
has_many :line_items, dependent: :destroy
lifecycle do
process_doc "Manages the full lifecycle of vendor invoices from receipt through payment. " \
"Enforces three-way match (PO, goods receipt, invoice) before approval. " \
"Dual sign-off required for invoices above $50,000 (configurable via " \
"InvoicePolicy.high_value_threshold). Approved invoices are queued " \
"automatically for the next payment run."
state :draft,
doc: "Invoice has been entered but not submitted. Editable. " \
"Not yet validated against PO or goods receipt."
state :submitted,
doc: "Invoice submitted for approval. 3-way match validated at submission. " \
"SLA: 2 business days for first approver action."
state :awaiting_second_approval,
doc: "High-value invoice (above threshold) has received first approval " \
"and is awaiting CFO or delegate sign-off."
state :approved,
doc: "All required approvals received. Invoice is queued for the next payment run. " \
"Vendor will be paid within terms (net-30 default)."
state :rejected,
doc: "Approval denied. Rejection reason is required and stored. " \
"Vendor is notified. Invoice may be resubmitted after correction.",
terminal: false
state :paid,
doc: "Payment processed. Transaction reference stored. " \
"Closing entry created in GL.",
terminal: true
state :voided,
doc: "Invoice cancelled — not paid, no GL entry. " \
"Void reason required. Preserves full history.",
terminal: true
event :submit do
doc "Submit invoice for approval. Triggers 3-way match validation."
from :draft, :rejected
to :submitted
guards [:three_way_match_verified?, :all_line_items_present?]
side_effects [:notify_approvers, :set_submission_date]
end
event :approve do
doc "First approver confirms invoice is correct and approved for payment. " \
"High-value invoices route to awaiting_second_approval instead of approved."
from :submitted
to ->(inv) { inv.requires_second_approval? ? :awaiting_second_approval : :approved }
guards [:within_approval_authority?]
side_effects [:record_first_approval, :notify_on_routing]
end
event :second_approve do
doc "CFO or delegate provides second sign-off on high-value invoice."
from :awaiting_second_approval
to :approved
guards [:is_senior_approver?]
side_effects [:record_second_approval, :queue_for_payment_run]
end
event :reject do
doc "Approver rejects invoice. Rejection reason stored in metadata. " \
"Vendor notified. Invoice returns to rejected state for correction."
from :submitted, :awaiting_second_approval
to :rejected
side_effects [:notify_submitter_of_rejection, :notify_vendor]
end
event :mark_paid do
doc "Payment run has processed this invoice. Transaction reference stored."
from :approved
to :paid
side_effects [:create_gl_closing_entry, :notify_vendor_of_payment]
end
event :void do
doc "Cancel invoice without payment. Void reason required in metadata."
from :draft, :submitted, :awaiting_second_approval, :approved, :rejected
to :voided
side_effects [:reverse_any_gl_entries, :notify_affected_parties]
end
guard :three_way_match_verified? do
doc "PO number must be present and match an approved PO. " \
"Goods receipt must be confirmed. Invoice amount must be within 2% of PO amount. " \
"Override available to Finance Director with written justification stored in metadata."
end
guard :within_approval_authority? do
doc "Approver's authority limit must cover the invoice total. " \
"Authority limits are set on User.invoice_approval_limit. " \
"Default: $10,000. Configurable per user by HR Admin."
end
doc :high_value_policy,
"Invoices above InvoicePolicy.high_value_threshold (default: $50,000) require " \
"two approvals: first from any user with invoice approval authority, second from " \
"a user with the :senior_approver role. The threshold is configurable without code " \
"changes via the Admin → Invoice Policy settings page."
end
end
The Admin Process Docs Hub
The Process Docs hub renders human-readable process pages from the stored documentation. Every FOSM object in the system gets a page. No Confluence, no wiki — just /admin/fosm/docs.
Listing 17.5 — app/controllers/admin/fosm/docs_controller.rb
class Admin::Fosm::DocsController < Admin::BaseController
def index
@definitions = FosmDefinition.all.order(:object_type)
end
def show
@definition = FosmDefinition.find_by!(object_type: params[:object_type])
@doc = @definition.documentation.with_indifferent_access
@states = @definition.states
@events = @definition.events
@diagram_src = generate_mermaid(@definition)
end
# GET /admin/fosm/docs.txt
def export_llms_txt
@definitions = FosmDefinition.all.order(:object_type)
render plain: LlmsTxtExporter.export(@definitions),
content_type: "text/plain"
end
private
def generate_mermaid(definition)
lines = ["stateDiagram-v2"]
lines << " [*] --> #{definition.initial_state}"
definition.events.each do |event|
froms = Array(event["from"])
to = event["to"]
next unless to.is_a?(String)
froms.each do |from|
lines << " #{from} --> #{to} : #{event['name']}"
end
end
# Terminal states
(definition.terminal_states || []).each do |state|
lines << " #{state} --> [*]"
end
lines.join("\n")
end
end
Listing 17.6 — app/views/admin/fosm/docs/index.html.erb
<div class="admin-header">
<h1>Process Documentation</h1>
<div class="admin-header__actions">
<%= link_to "Export llms.txt", export_llms_txt_admin_fosm_docs_path,
class: "btn btn-secondary",
title: "Download all process docs as a flat text file for LLM context" %>
</div>
</div>
<p class="admin-intro">
This is the living documentation for every FOSM-managed process in the system.
Documentation is generated from the model lifecycle definitions — it is always current.
</p>
<div class="docs-grid">
<% @definitions.each do |defn| %>
<div class="doc-card">
<div class="doc-card__header">
<h3><%= link_to defn.object_type, admin_fosm_doc_path(defn.object_type) %></h3>
<span class="doc-card__meta">
<%= defn.states.count %> states ·
<%= defn.events.count %> events
</span>
</div>
<% if defn.process_description.present? %>
<p class="doc-card__description">
<%= defn.process_description.truncate(200) %>
</p>
<% else %>
<p class="doc-card__description doc-card__description--missing">
No process description. Add <code>process_doc</code> to the lifecycle block.
</p>
<% end %>
<div class="doc-card__coverage">
<span title="States documented">
States: <%= documented_count(defn, :states) %>/<%= defn.states.count %>
</span>
<span title="Events documented">
Events: <%= documented_count(defn, :events) %>/<%= defn.events.count %>
</span>
</div>
</div>
<% end %>
</div>
Listing 17.7 — app/views/admin/fosm/docs/show.html.erb
<div class="admin-header">
<h1><%= @definition.object_type %></h1>
<%= link_to "← All Processes", admin_fosm_docs_path, class: "back-link" %>
</div>
<% if @definition.process_description.present? %>
<div class="process-description">
<p><%= @definition.process_description %></p>
</div>
<% end %>
<div class="process-diagram">
<h2>State Diagram</h2>
<div class="mermaid">
<%= @diagram_src %>
</div>
</div>
<div class="process-docs-grid">
<section class="doc-section">
<h2>States</h2>
<% @states.each do |state_name| %>
<div class="doc-item">
<div class="doc-item__name">
<span class="state-badge"><%= state_name %></span>
<% if @definition.terminal_states&.include?(state_name) %>
<span class="badge badge-terminal">terminal</span>
<% end %>
</div>
<% state_doc = @doc.dig(:states, state_name) %>
<% if state_doc.present? %>
<p class="doc-item__text"><%= state_doc %></p>
<% else %>
<p class="doc-item__text doc-item__text--missing">
Not documented. Add <code>doc:</code> to this state.
</p>
<% end %>
</div>
<% end %>
</section>
<section class="doc-section">
<h2>Events</h2>
<% @events.each do |event| %>
<div class="doc-item">
<div class="doc-item__name">
<span class="event-badge"><%= event['name'] %></span>
<span class="doc-item__transition">
<%= Array(event['from']).join(', ') %> → <%= event['to'] %>
</span>
</div>
<% event_doc = @doc.dig(:events, event['name']) %>
<p class="doc-item__text"><%= event_doc || "(not documented)" %></p>
<% guards = Array(event['guards']) %>
<% if guards.any? %>
<div class="doc-item__guards">
<strong>Guards:</strong>
<% guards.each do |guard| %>
<div class="guard-doc">
<code><%= guard %></code>
<% guard_doc = @doc.dig(:guards, guard) %>
<span class="guard-doc__text"><%= guard_doc || "—" %></span>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
</section>
<% if @doc[:named_docs].present? %>
<section class="doc-section doc-section--full-width">
<h2>Policy Notes</h2>
<% @doc[:named_docs].each do |name, text| %>
<div class="doc-item">
<div class="doc-item__name"><%= name.humanize %></div>
<p class="doc-item__text"><%= text %></p>
</div>
<% end %>
</section>
<% end %>
</div>
The llms.txt Export
The llms.txt format is a flat text file that gives LLM agents structured context about a system. FOSM generates one automatically from the process documentation corpus.
Listing 17.8 — app/services/llms_txt_exporter.rb
class LlmsTxtExporter
def self.export(definitions)
new(definitions).export
end
def initialize(definitions)
@definitions = definitions
@lines = []
end
def export
write_header
@definitions.each { |d| write_definition(d) }
@lines.join("\n")
end
private
def write_header
@lines << "# FOSM Process Documentation"
@lines << "# Generated: #{Time.current.iso8601}"
@lines << "# Source: #{Rails.application.class.module_parent_name}"
@lines << "#"
@lines << "# This file describes every state-machine-managed business process."
@lines << "# It is generated from the model lifecycle definitions and is always current."
@lines << "# Use this file to give an LLM agent context about business process rules."
@lines << ""
@lines << "---"
@lines << ""
end
def write_definition(defn)
doc = defn.documentation.with_indifferent_access
@lines << "## #{defn.object_type}"
@lines << ""
if defn.process_description.present?
@lines << defn.process_description
@lines << ""
end
write_states(defn, doc)
write_events(defn, doc)
write_guards(doc)
write_side_effects(doc)
write_named_docs(doc)
write_mermaid_diagram(defn)
@lines << "---"
@lines << ""
end
def write_states(defn, doc)
return if defn.states.empty?
@lines << "### States"
@lines << ""
defn.states.each do |state|
terminal = defn.terminal_states&.include?(state) ? " [terminal]" : ""
@lines << "**#{state}**#{terminal}"
state_doc = doc.dig(:states, state)
@lines << (state_doc || "(not documented)")
@lines << ""
end
end
def write_events(defn, doc)
return if defn.events.empty?
@lines << "### Events"
@lines << ""
defn.events.each do |event|
froms = Array(event["from"]).join(", ")
to = event["to"]
@lines << "**#{event['name']}** (#{froms} → #{to})"
event_doc = doc.dig(:events, event["name"])
@lines << (event_doc || "(not documented)")
guards = Array(event["guards"])
if guards.any?
@lines << "Guards: #{guards.join(', ')}"
end
side_effects = Array(event["side_effects"])
if side_effects.any?
@lines << "Side effects: #{side_effects.join(', ')}"
end
@lines << ""
end
end
def write_guards(doc)
guards = doc[:guards] || {}
return if guards.empty?
@lines << "### Guard Conditions"
@lines << ""
guards.each do |guard, text|
@lines << "**#{guard}**"
@lines << text
@lines << ""
end
end
def write_side_effects(doc)
effects = doc[:side_effects] || {}
return if effects.empty?
@lines << "### Side Effects"
@lines << ""
effects.each do |effect, text|
@lines << "**#{effect}**"
@lines << text
@lines << ""
end
end
def write_named_docs(doc)
named = doc[:named_docs] || {}
return if named.empty?
@lines << "### Policy Notes"
@lines << ""
named.each do |name, text|
@lines << "**#{name.to_s.humanize}**"
@lines << text
@lines << ""
end
end
def write_mermaid_diagram(defn)
@lines << "### State Diagram"
@lines << ""
@lines << "```mermaid"
@lines << "stateDiagram-v2"
@lines << " [*] --> #{defn.initial_state}"
defn.events.each do |event|
froms = Array(event["from"])
to = event["to"]
next unless to.is_a?(String)
froms.each { |from| @lines << " #{from} --> #{to} : #{event['name']}" }
end
(defn.terminal_states || []).each do |state|
@lines << " #{state} --> [*]"
end
@lines << "```"
@lines << ""
end
end
Call it:
$ curl https://yourapp.com/admin/fosm/docs.txt > llms.txt
Or from a rake task:
$ rails fosm:export_llms_txt > llms.txt
Listing 17.9 — lib/tasks/fosm.rake (llms.txt export task)
namespace :fosm do
desc "Export all process documentation as llms.txt"
task export_llms_txt: :environment do
definitions = FosmDefinition.all.order(:object_type)
output_path = Rails.root.join("public", "llms.txt")
File.write(output_path, LlmsTxtExporter.export(definitions))
puts "Exported #{definitions.count} process definitions to #{output_path}"
end
desc "Reload all FOSM lifecycle definitions from model files"
task reload_definitions: :environment do
count = 0
Dir[Rails.root.join("app/models/**/*.rb")].each do |file|
model_name = File.basename(file, ".rb").camelize
begin
klass = model_name.constantize
if klass.respond_to?(:fosm_lifecycle)
klass.fosm_lifecycle.sync_to_database!
count += 1
puts " ✓ #{model_name}"
end
rescue NameError, LoadError
# Not a FOSM model
end
end
puts "Reloaded #{count} FOSM definitions"
end
end
The Routes
Listing 17.10 — config/routes.rb (process docs additions)
namespace :admin do
namespace :fosm do
resources :docs, param: :object_type, only: [:index, :show] do
collection do
get :export_llms_txt, path: "docs.txt"
end
end
end
end
Documentation Coverage as a Quality Metric
The admin index page shows documentation coverage for each FOSM object — how many states have doc: entries, how many events are documented. This is intentional. Undocumented states and events are a code smell, not just a documentation gap.
A helper:
Listing 17.11 — app/helpers/fosm/docs_helper.rb
module Fosm
module DocsHelper
def documented_count(definition, section)
doc = definition.documentation.with_indifferent_access
items = doc[section] || {}
items.count { |_, text| text.present? }
end
def documentation_coverage(definition)
total = definition.states.count + definition.events.count
return 0 if total.zero?
doc = definition.documentation.with_indifferent_access
documented = documented_count(definition, :states) +
documented_count(definition, :events)
(documented.to_f / total * 100).round
end
def coverage_badge_class(pct)
case pct
when 80..100 then "badge-success"
when 50..79 then "badge-warning"
else "badge-danger"
end
end
end
end
Add a CI check to enforce minimum coverage:
Listing 17.12 — spec/fosm/documentation_coverage_spec.rb
require "rails_helper"
RSpec.describe "FOSM documentation coverage" do
MINIMUM_COVERAGE_PERCENT = 80
FosmDefinition.all.each do |definition|
describe "#{definition.object_type} lifecycle" do
it "has a process_doc description" do
expect(definition.process_description).to be_present,
"#{definition.object_type} is missing a process_doc in its lifecycle block"
end
it "has states documented above #{MINIMUM_COVERAGE_PERCENT}%" do
doc = definition.documentation.with_indifferent_access
total = definition.states.count
documented = (doc[:states] || {}).count { |_, t| t.present? }
coverage = total.zero? ? 100 : (documented.to_f / total * 100).round
expect(coverage).to be >= MINIMUM_COVERAGE_PERCENT,
"#{definition.object_type} has #{coverage}% state documentation " \
"(#{documented}/#{total}). Add doc: to undocumented states."
end
it "has events documented above #{MINIMUM_COVERAGE_PERCENT}%" do
doc = definition.documentation.with_indifferent_access
total = definition.events.count
documented = (doc[:events] || {}).count { |_, t| t.present? }
coverage = total.zero? ? 100 : (documented.to_f / total * 100).round
expect(coverage).to be >= MINIMUM_COVERAGE_PERCENT,
"#{definition.object_type} has #{coverage}% event documentation " \
"(#{documented}/#{total}). Add doc: to undocumented events."
end
end
end
end
Run it in CI:
$ bundle exec rspec spec/fosm/documentation_coverage_spec.rb
The Contact Lifecycle, Documented
To complete the picture, here’s how the Contact module from Chapter 8 looks with full documentation applied:
Listing 17.13 — app/models/contact.rb (lifecycle documentation excerpt)
lifecycle do
process_doc "Manages the full lifecycle of external contacts: candidates, vendors, " \
"partners, and customers. Contacts are the external party in NDA, hiring, " \
"and vendor management workflows. The vetting process ensures due diligence " \
"before a contact can be used in a business-critical context."
state :prospect,
doc: "Contact has been added to the system but not yet vetted. " \
"Cannot be named as NDA counterparty or invoice vendor until approved. " \
"Source tracked: referral, inbound, import, manual."
state :active,
doc: "Contact is fully vetted and usable across all modules. " \
"Can be designated as NDA counterparty, invoice vendor, hire candidate."
state :inactive,
doc: "Contact has been deactivated — typically a departed employee, " \
"terminated vendor relationship, or candidate no longer in pipeline. " \
"Historical records preserved. Cannot be used in new workflows."
event :vet do
doc "Transition from prospect to active after due diligence. " \
"Records who vetted and when. Required for regulated vendor onboarding."
from :prospect
to :active
guards [:identity_verified?, :no_sanctions_match?]
end
guard :no_sanctions_match? do
doc "Checks contact name and entity against OFAC/SDN list via SanctionsChecker service. " \
"Required for vendor and partner contacts. Skipped for internal candidates " \
"unless international flag is set."
end
end
Version Control as Documentation History
One final point that’s easy to miss but impossible to overstate.
When you add doc: to a guard, that addition is in the same commit as the guard itself. When you change a guard’s behavior, you’re expected to change its doc: in the same commit. When a PR adds a new state, the reviewer sees immediately whether the state has a doc: entry.
Git log for a FOSM model file is simultaneously the business change log. You can look at the history of app/models/invoice.rb and see exactly when the high-value approval threshold was added, who added it, and what the code comment said about why. That’s not a documentation system — that’s the natural behavior of putting documentation in the same file as the code.
$ git log --follow -p app/models/invoice.rb | grep -A5 "process_doc\|doc:"
This command shows you every commit that changed a process description or documentation entry in the invoice lifecycle. Audit trail, change history, and business rationale — all from standard Git tooling.
doc: entry for that guard is also gone — it was inside the guard block. There's no orphan documentation floating in a separate file. If you add a guard and forget to add doc:, the documentation coverage spec fails in CI. The system is structurally biased toward keeping documentation current.
The Key Insight
Let’s state the thesis explicitly, because it’s the core argument of this chapter and it’s worth being precise:
FOSM makes the code self-documenting by design. Every state has a name and a doc. Every event has a description. Every guard explains what it’s checking. The lifecycle block is simultaneously the specification, the implementation, and the documentation.
When someone new joins the team and wants to understand the invoice approval process, they read app/models/invoice.rb. Not a Confluence page. Not a README. The model file. Because the model file has the process description at the top, documented states explaining each lifecycle phase, documented events explaining each transition trigger, documented guards explaining each business rule — and it is structurally impossible for that documentation to drift from the code, because it IS the code.
That’s not just good engineering practice. It’s a different philosophy about what documentation is for. Documentation isn’t a record of what the code does. Documentation is the business knowledge that justifies why the code is written the way it is. Put that knowledge in the code, and you get the best of both worlds: executable specification and human-readable explanation, in one place, version-controlled, always current.
What You Built
- Three documentation mechanisms:
process_docfor lifecycle-level description,doc:keyword inline on states/events/guards/side-effects, and standalonedocmethod for named policy notes and long-form explanation. - Storage layer: two new columns on
fosm_definitions—process_description(text) anddocumentation(JSONB) — storing the extracted documentation corpus in a queryable, structured format. - Fully documented NDA lifecycle: every state, event, guard, and side-effect carries its business explanation, including signing-order policy and re-send policy as named docs.
- Fully documented Invoice lifecycle: dual-approval policy, three-way match rationale, high-value threshold explanation, and rejection/void distinction all captured in the model file.
- Admin Process Docs hub at
/admin/fosm/docs: index showing coverage metrics for every FOSM object, detail pages rendering state diagrams (Mermaid) and formatted documentation per element. LlmsTxtExporter: converts the entire process documentation corpus into a flatllms.txtfile — one source of truth for developers, business architects, and LLM agents.- Rake tasks:
rails fosm:reload_definitionsandrails fosm:export_llms_txtfor CI/CD integration. - Documentation coverage RSpec suite: enforces minimum 80% documentation coverage per lifecycle in CI — underdocumented objects fail the build.
- Git as change history: because documentation lives in the model file,
git logis the business change log. No separate audit trail needed.