§ Writlog

How Writlog works

1. The event log is the source of truth

Each matter has an append-only matter_events table: (matter_id, seq) is unique, and a Postgres trigger raises on any UPDATE or DELETE. The columns on matters (state, deadlines, balance) are a projection. They are written in exactly one place, Workflow.append!, which row-locks the matter, folds the new event into the current projection with a pure function, inserts the event with seq = count + 1, and writes the new projection, all in one transaction.

Because the fold is pure, the projection can be rebuilt from the log at any time. Every matter page does it on load and shows the result next to the state badge. The test suite checks the same property across randomly generated legal event sequences.

2. The state machine

Intake → demand sent → suit filed → served → answered or in default → judgment entered → enforcement (restraining notice, information subpoena, execution) → settled, satisfied or closed. Illegal transitions are rejected before anything is written: a default cannot be noted before the answer deadline has passed, events cannot be back-dated before the previous event, and a satisfied matter accepts no more workflow events.

3. Deadlines

Service sets the answer deadline under CPLR 320(a): 20 days after personal delivery, otherwise 30 days after service is complete (10 days after proof is filed for 308(2) and 308(4)). A default starts the one-year clock of CPLR 3215(c). A restraining notice expires one year after service (CPLR 5222(b)). Every date is rolled forward past Saturdays, Sundays and New York public holidays per General Construction Law 25-a, and the reasons for each roll are kept. See the calculator.

4. Documents, exactly once

Templates are versioned rows with {{merge_fields}}. Asking for a document resolves the fields against the matter and hashes (template id, template version, sorted values). The row is keyed by a unique index on (matter_id, document_template_id, inputs_digest), so the same request made twice, or fifty times in parallel, yields one row and one enqueued job. If the balance changes, the digest changes and a new version is produced; history is kept.

GenerateDocumentJob takes a row lock, returns early if the document is already ready, renders HTML and a PDF (Prawn, pure Ruby), stores the PDF with Active Storage, and appends a document_generated event, which a unique partial index limits to one per document. The job uses only the ActiveJob API. Here it runs on Solid Queue inside Puma; changing one adapter line moves it to Sidekiq.

5. Communications

Letters, emails, calls and filings are logged against the matter, optionally with a generated document as the enclosure, and each one also lands on the event log in the same transaction.