Authoring notations
This is the how-to companion to notation.md. That doc defines the vocabulary (Template, Notation,
Questionnaire, Question, Answer, Rule); this one is the procedure — how you write a notation, what the toolchain
enforces, what runs after a client finishes intake, and what is still on the roadmap. If a word here is unfamiliar,
notation.md is the source of truth for what it means.
What a notation is, in one paragraph
A Template is a static blueprint: one markdown file with YAML frontmatter, checked into notation_templates/. A
Notation is that Template come to life — one running instance bound to a Person (the
respondent), exactly one Project, and optionally an Entity — advancing
through two state machines the Template declares. In client English a Notation-in-a-Project is the Engagement (or
Retainer). The Template declares; Restate runs. Everything below is about writing good Templates and growing
what their workflows can do.
Anatomy of a template file
Every template lives at notation_templates/<category>/<snake_case_name>.md and has two parts: YAML frontmatter (the
contract) and a markdown body (the document, with {{question_code}} placeholders). Here is the shipped retainer's
frontmatter (the real file wraps this block in --- fences, then the prose body follows):
title: Retainer Agreement
respondent_type: person_and_entity
code: onboarding__retainer
confidential: true
questionnaire: # the intake Q&A — what we ask the client
BEGIN: { _: client_name }
client_name: { _: client_email }
client_email: { _: project_name }
project_name: { _: product_description }
product_description: { _: END }
END: {}
workflow: # what happens after intake — render, review, sign
BEGIN: { intake_submitted: intake_persisted__client }
intake_persisted__client: { retainer_rendered: staff_review }
staff_review: { approved: document_open__retainer_pdf, rejected: END }
document_open__retainer_pdf: { pdf_persisted: sent_for_signature__pending }
sent_for_signature__pending: { signature_received: END }
END: {}
The body below the frontmatter is plain prose carrying the same {{code}} placeholders. At render time each is replaced
with the client's answer — {{client_name}} becomes the actual name, {{project_name}} the matter.
Frontmatter fields:
title— the human document title (N101 requires it non-empty).respondent_type— one ofperson,entity,person_and_entity(N102).code— the stable, unique identifier (onboarding__retainer,trusts__nevada); how every surface refers to it.confidential— an explicittrue/falsedecision, never defaulted (N105).questionnaire:— the intake state machine:BEGIN → question_code → … → END. Each step's_:is the "answered" transition. State names are<question_code>__<discriminator>; the prefix before__must be a real Questioncode.workflow:— the post-intake state machine: render, staff review, signature, filing. Transitions fire on named signals (approved,pdf_persisted,signature_received).
Two machines, one journal: questionnaire and workflow are hosted on a single Restate virtual object keyed by the
notation's id, so their signals serialize and can never interleave. State is append-only — every transition writes a
notation_events row, and the current state is the latest row's to_state. Nothing is ever updated in place, so the
full history of a matter is replayable for audit.
How to create one — the five-step recipe
New legal matters follow a fixed order (see agent-workflows.md for the long form).
Feature-first, so the composition is specified before the prose exists:
- Write the composition
.featurefirst. Describe the matter as a sequence or branching graph of reusable workflow steps, using only Person / Entity nouns fromglossary.md. The feature is the product-level spec; the template satisfies it by composing already-known steps. - Write the template + questionnaire. Create
notation_templates/<category>/<snake_case_name>.mdwith the frontmatter above. Declare thequestionnaire:walk and theworkflow:states. Body prose uses{{question_code}}placeholders. - Seed the questions. Add each new question
codetostore/seeds/Question.yaml(prompt,question_type, help text). The questionnaire's state prefixes must resolve to these codes or N104 fails. - Declare the workflow YAML. Compose the post-intake flow from the shared step registry (below) — never a one-off
handler. Reuse
staff_review, signature, and document steps so the flow stays auditable. - Wire the durable handlers. Bind new workflow steps onto the existing
workflows-serviceworker. Never stand up a per-workflow pod — one worker hosts every flow.
A template is not legally usable until an attorney has reviewed the body copy. The staff_review state is mandatory
(N106) precisely so a licensed human is always in the loop before anything is sent or filed.
Question codes should stay minimal and reusable. A Question is the stable prompt/fact type (citizenship_status,
passport_expiration_date, registered_agent); an Answer is the respondent's time-bound value for that question. If a
future workflow needs to know whether an answer is still fresh, add freshness/expiration metadata to the answer side
rather than minting a new one-off question code. That keeps questionnaires short: reuse a still-valid answer when it can
lawfully answer the question, and ask again only when the recorded answer has expired or is no longer adequate for the
matter.
The validation contract
Three rule families guard every template, enforced identically in your editor, in cli validate, and in CI — because
all three call the same rules crate. A template that is clean on your laptop is clean in the merge gate.
- N-family (notation template shape, structural). N101 title present; N102 valid
respondent_type; N103 snake_case filename; N104 both machines declareBEGIN, reachEND, questionnaire states resolve to real Question codes, and workflow states resolve to known workflow-step prefixes; N105confidentialis an explicit bool; N106 theworkflow:has a barestaff_reviewstate (the suffix formstaff_review__for_grantordoes not satisfy it — the human-review gate must be unconditional); N108codeis the stable Template identifier. N-family rules are diagnostic-only: a human must resolve them, the tool will not auto-rewrite legal structure. - M-family (markdown hygiene, ~50 rules). Headings, lists, fences, tables, spacing. Most carry a safe autofix.
- S101 (line length). 120 Unicode scalars per line, every
.md. Frontmatter is linted too; folded YAML scalars let a long value wrap and still pass.
Run it before committing any .md change:
cargo run -p cli --quiet -- validate --markdown-only --no-default-excludes <path>
Authoring in markdown with the LSP
navigator-lsp is a single Rust binary speaking LSP over stdio. It shares the exact rules engine the CLI uses, so the
editor and CI can never disagree. Supported editors ship copy-paste configs under lsp/ docs: VS Code,
Neovim, Helix, Emacs, Zed. The authoring loop for a non-engineer legal author:
- Type. Open
notation_templates/united_states/nevada/internal/trusts_and_estates/will.mdin your editor. Write legal prose and frontmatter — no proprietary tool, no markup beyond markdown. - Live diagnostics. On every keystroke the LSP lints the buffer and shows squiggles: N101 if
title:is missing, N104 if the questionnaire/workflow shape is broken, S101 past 120 chars, M-rules on shape. The CLI can add DB-backed question-code checks when invoked with--database-url. Hover any squiggle for a plain-English explanation of the rule. - Fix-all on save.
source.fixAllrewrites every mechanical issue — tabs, trailing whitespace, blank-line spacing, heading spacing — automatically. What remains is the semantic work only a human can do (an unmadeconfidentialdecision, a workflow that never reachesEND). - Open a PR. The clean
.mdis committed as a plain-text diff. CI runs the identical engine. An attorney reviews readable prose; the linter has already signed off on structure.
Why markdown + frontmatter + git, not a proprietary tool
- Ergonomics. One free binary attaches to whatever editor the author already knows. Fix-on-save removes the entire class of formatting fiddling; hover tooltips teach the rules in context, lowering the floor for a non-engineer.
- Correctness. A single rules engine is the authority — editor, CLI, and CI cannot diverge. Invariants that matter
legally (every workflow has a
staff_reviewgate,confidentialis an explicit choice, every workflow code resolves) are machine-enforced before merge, not left to reviewer vigilance. - Auditability. The template is plain text under git: every change is an attributable, reviewable diff, gated by PR. The rules themselves are versioned Rust with snapshot tests. A proprietary document-automation tool hides the document in an opaque format with no line-level diff and no enforceable structural contract.
What runs after intake — the step registry
Once the questionnaire reaches END, the workflow machine takes over. Steps are resolved from a state-name prefix to a
StepKind and an actor class (System / Staff / Respondent) in workflows/src/step.rs. Honest status of what is wired
today:
| Step | Status | Notes |
|---|---|---|
email_send__<slug> | Implemented | Durable SendGrid send via two ctx.run journals; only welcome renders today. |
intake_persisted__* | Implemented | Pass-through wait state recorded on the journal. |
staff_review | State-only | Mandatory gate; dev auto-approves. No prod review UI wired to the worker. |
client_review | State-only | Respondent approves attorney-reviewed drafts on the Phase A review surface. |
document_intake__<slug> | Implemented | Worker files a provided artifact (text/file/link) via ingest_bytes. |
extract__* | Seam | Northstar: estate inputs mined from the transcript by Ada/Gemini; advanced on completion. |
analysis__* | Seam | Contract review: web (Vertex Gemini) flags playbook deviations; System wait state. |
document_drafts__* | Implemented | Northstar: web renders drafts into review_documents rows (System wait state). |
document_open__retainer_pdf | Implemented | Worker-dispatched: render + storage persist wrapped in ctx.run. |
sent_for_signature__pending | Implemented | Wait state; e-signature webhook signals signature_received → END. |
notarization, _signature | State-only | Trust/will signing states; a human act, no worker side effect. |
firm_signature | State-only | Firm (staff) signs the closing letter ending a matter; a human act, no side effect. |
mailroom_send | Implemented | Worker records a filings row in ctx.run; reached only after staff_review. |
certified_mail, e_filing, filing__* | Implemented | Worker submission steps; record filings post-review. |
onchain__* | Scaffolded | Node attestation → durable attestations row; null attestor keeps it pending. |
mailroom_receive | State-only | Inbound mail logged by the SendGrid webhook, not a workflow step. |
witnesses | State-only | Respondent's witnesses sign (will); resolves to the Signature step kind. |
Durability is Restate's: each side effect is wrapped in ctx.run, so a replay reuses the cached result instead of
re-emailing or double-inserting. In prod the worker dials Restate Cloud; in KIND it dials the in-cluster Operator. The
"State-only" rows are the contract for steps with no worker side effect yet. The drift-guard test
workflows::step::tests::drift_guard_every_step_prefix_is_documented fails if step_kind_for gains a prefix
(STEP_PREFIXES) this table never mentions, so the status here cannot silently rot.
The onchain__* row is "Scaffolded": the step kind, the dispatch arm, and the durable attestations table are
implemented and tested, but the on-chain write itself is deferred. The chain is isolated behind the
workflows::attest::Attestor trait exactly as GCS is isolated behind cloud::StorageService — selecting Solana (or a
second chain) is a new impl Attestor, never a workflow edit. The default NullAttestor records no transaction, so the
row stays pending and no live retainer can claim an on-chain record that does not exist. The step is therefore not yet
wired into the binding onboarding__retainer_node workflow; that one-line YAML edge lands together with the
SolanaAttestor (whose open questions — firm key custody, the client wallet, public-chain confidentiality of the hash,
and finality — are decisions, not code). See workflows::attest and the Neon Law Node product page.
The registry is deliberately small. Template authors should compose these prefixes with discriminators
(document_open__articles_pdf, mailroom_send__notice_of_representation) rather than creating per-product verbs. If a
workflow needs a genuinely new act, add a reusable StepKind first, document it here, cover the mechanics in Rust, and
then compose it from a feature spec.
Adding a reusable step — the recipe
A "reusable step" is one StepKind that many notations bind to by naming a <prefix>__<slug> state — email_send__*,
document_open__*, document_intake__*. Two reference implementations show the shape; the next one is a single
registry entry, not a second dispatch match.
- Signature is the seam reference:
web::signature::SignatureProvideris a trait with a stub for KIND/tests and a concreteDocuSignSignatureProviderfor prod, selected fromAppState. Reach for a trait when the step calls an external system that has more than one real implementation you swap at runtime. - Document-intake is the registry reference:
document_intake__<slug>files a provided artifact (a transcript, an executed PDF, an ID scan) into the matter throughstore::documents::ingest_bytes. It has exactly one implementation, so it is a plain dispatch fn behind oneStepKind, not a trait.
The step layer routes through one registry, workflows::dispatch_step, keyed by StepKind. Both callers — the
workflows-service worker (notation_service::workflow_signal, which wraps the call in ctx.run) and the in-process
dev/BDD runtime (DispatchingRuntime::maybe_dispatch, which calls it inline) — share that one arm, so a new step is
added once, not twice. To add a step kind with a worker side effect:
- Name the prefix + kind. Add a
StepKindvariant and its(prefix, StepKind)row toSTEP_PREFIXESinworkflows/src/step.rs, plus the actor class inStepKind::actor. Document it in the status table above (the drift-guard test enforces this). - Write the dispatch fn + payload. Add
<kind>.rswith a serde payload (internally tagged onkind, likeDocumentPayload/IntakeArtifact) and anasync fn dispatch_<kind>(deps…, payload) -> Result<_, _>that performs the one side effect and returns — noctx.run, no journaling; durability is the caller's. - Register one arm. Add the
StepKindtodispatches_side_effectand one match arm todispatch_stepinworkflows/src/dispatch.rs, decoding the payload from the signalvalueand calling your dispatch fn with theStepDepsproviders (email,storage, optionaldb). The worker and the in-process runtime pick it up for free. - Thread the payload from the trigger. The surface that fires the transition into the step (a
webhandler) builds the payload, JSON-serializes it, and passes it as the signalvalue. The artifact for an intake step is phone-friendly: a text paste, a file, or a link — never "scan a PDF".
Keep the ctx.run boundary in the worker, never inside dispatch_step: a registry that journaled its own side effect
would reintroduce the duplicate-effect bug on replay.
Documents and PDFs
What we have. A dedicated pdf crate renders a Typst document to PDF bytes in pure Rust (no shell-out), in the firm
typeface Noto Serif, with a redaction helper. The retainer flow substitutes {{placeholder}} tokens from the notation's
answers in web, then threads the result to the worker as a DocumentPayload on the approved signal; the
document_open__retainer_pdf step calls pdf::render and persists the bytes through the cloud::StorageService seam
(FsStorage in dev, GCS in prod) at notations/<id>/retainer.pdf, wrapped in ctx.run for replay-idempotent
durability. web reads the PDF back from storage to hand to the signature provider. This is one-directional: template →
fresh PDF.
Rendering a template to PDF offline — navigator render. For an ad-hoc PDF outside the durable workflow (a demand
letter to send by hand, a draft for review), navigator render <template.md> --out <file.pdf> takes any
validation-passing notation template and compiles it in pure Rust. Because templates are authored in Markdown but
the pdf crate compiles Typst, the body is converted by pdf::markdown::to_typst (headings, emphasis, lists, block
quotes, inline code, links) before rendering — the missing seam between the two markups. The command validates the file
against the same rule set as navigator validate and refuses to render a template with any violation.
Output formats — the letterhead seam. How the document is dressed is an OutputFormat (pdf::format): plain
(page geometry + firm typeface) or letter (Neon Law letterhead with the embedded logo). A template declares its
default in an optional output: frontmatter field (validated by rule N109); --format overrides per render. New
forms — pleading paper, a fax cover — are a new OutputFormat variant plus its Typst chrome preamble; the conversion
and embedded logo are shared. Fill {{placeholder}} tokens with repeated --answer code=value flags; unfilled tokens
render verbatim.
Filling fillable government PDFs — done. pdf::fill_acroform(blank_pdf, fields) opens an existing fillable PDF (a
Nevada SoS articles form, an IRS Form 990) via lopdf, walks its AcroForm /Fields, sets each /V, and sets
/NeedAppearances so a viewer regenerates the field appearances — a read-modify-write path distinct from the Typst
render path. Blank forms live as templates in the cloud::StorageService seam (forms/<slug>.pdf); a
document_open__<form> sub-slug dispatches the fill through the same worker step as the retainer PDF (via
DocumentPayload::Acroform). The output is attorney-review-ready, never auto-filed: the workflow spec parks it at
staff_review before any filing step, enforced by workflows::staff_review_gates_filing (a spec-graph check + test).
Two loud-failure guardrails — XFA-based forms (Adobe's XML form layer, unsupported by any Rust crate) are detected and
rejected rather than silently emitting a blank, and a field name that matches no form field errors rather than being
silently dropped. Hierarchical (kids / dotted /T) field names remain out of scope.
External integrations
- Email — implemented.
email_send__*→ SendGrid viareqwest, durable, with the message id captured for the event webhook join. This is the one integration wired end-to-end. - E-signature — implemented (the production dead-end is closed). The
SignatureProvidertrait now ships a concreteDocuSignSignatureProvider(DocuSign eSignature REST viareqwest,.env-driven; the stub stays for KIND / tests). At send time the provider'senvelopeIdis persisted onnotations.signature_request_id. The inbound webhook at/webhook/esignature/:secret(web::esignature_webhook) verifies an HMAC-SHA256 signature over the raw body before parsing it — fail-closed whenDOCUSIGN_HMAC_KEYis configured (a prod invariant) — then resolves the envelope id back to its notation and signalssignature_received→ END. Only acompletedevent advances state; other events ack with 200. The engagement terms are attorney-reviewed atstaff_reviewbefore the document is sent, so signature receipt is a ministerial transition with no second human gate. Covered by a.feature(happy + forgery) and an end-to-end integration test through the real provider against a mocked endpoint. - Google Drive per-project sync — removed. The per-Project archive is the append-only git repo served from
web(seegit-project-repos.md); theprojects.drive_folder_idcolumn, theDriveSyncRestate workflow, theaida_drive_*MCP tools, and the web/CLI sync surfaces have all been dropped. Thecloud::driveOAuth door (thecli drive login/cli drive lsinstalled-app flow) is kept for ad-hoc browsing, but Drive is no longer a document-ingest surface.
Roadmap
Ordered by value, each item independently shippable. Reliability fixes are split out from the features they ride with so neither blocks the other.
Recently shipped. The full ten-template catalog is now bundled into the canonical seed, so a fresh cluster carries
every template (LLC, trust, will, annual report, dissolution, three nonprofit forms, NV MBT) without an import pass.
The signature loop is closed — DocuSignSignatureProvider plus the HMAC-verified /webhook/esignature/:secret
route advance a signed retainer to END (see External integrations above).
Close the signature loop.Shipped. A realDocuSignSignatureProviderplus an inbound webhook that verifies the provider's HMAC signature over the raw body before signalingsignature_received; the provider request-id is persisted on the notation for correlation. This ended the production dead-end atsent_for_signature__pending.Make Drive sync Restate-durable (reliability).Removed. The per-project Drive sync (theDriveSyncworkflow, thedrive_folder_idcolumn, theaida_drive_*tools) has been dropped in favour of the append-only per-Project git repo as the document surface. Drive is no longer an ingest path.Add Drive write-back (feature).Dropped with the per-project Drive sync above — the per-Project git repo is the document system of record now, not a Drive folder.AcroForm form-filling.Shipped.pdf::fill_acroform(blank_pdf, fields)(lopdf) fills a fillable government form; adocument_open__<form>sub-slug dispatches it through the worker step, with blank forms held incloud::StorageService. Output is attorney-review-ready, never auto-filed — the spec-graph guardrailstaff_review_gates_filingproves no fill→file path skipsstaff_review. XFA forms and unmatched field names fail loudly rather than emitting a silent blank.Promote the planned filing/mail steps to real handlers.Shipped.mailroom_send,certified_mail,e_filing, andfiling__*are worker-dispatched steps that record a durablefilingsrow (the firm's proof of what was filed) inctx.run; compliance flows (e.g. the Nevada annual report) run end-to-end to END instead of parking.staff_review_precedes_submissionproves — on every bundled spec — that no submission side effect fires before the review gate. (notarizationstays a human act;mailroom_receiveis inbound.)Make language access explicit in intake.Shipped.persons.preferred_language(BCP-47, defaulten) plus aquestion_translationstable of attorney-reviewed localized prompts;notation_sessionrenders every questionnaire prompt in the person's language (web form + AIDA MCP/A2A surfaces, one convergence point), falling back to the English base when a translation is absent. Spanish ships seeded for the retainer questions. Translation is reviewed copy, not runtime machine translation — thestaff_reviewgate and legal copy stay attorney-reviewed in each language. The questionnaire prompt is the only localized surface here: the template body — the binding document a client signs — stays English-only regardless of the client's language. See the English-first rule in../CLAUDE.md.Template storage and scoping.Shipped. Template bodies moved from the inlinetemplates.bodyTEXT column to blob storage (templates.blob_id→ a Blob viacloud::StorageService);templates.project_idplus two partial unique indexes add project-scoped templates alongside the shared catalog, resolved bystore::templates::resolve(prefer Project, fall back to shared). The seed +navigator importpaths ingest bodies into blobs; render paths read them back viastore::templates::body. Seenotation.md.
Why this matters — access to justice
The whole point of the notation system is to make routine legal work cheap, fast, and repeatable without removing the
attorney. Each design choice traces back to that mission (mission.md):
- One template, many matters. A lawyer encodes a matter once; every future client walks the same validated questionnaire. The marginal cost of the next LLC, trust, or annual report trends toward zero, which is what lets the firm serve people a billable-hour model prices out.
- Faster resolution, lower cost. A guided questionnaire plus automatic document generation collapses what used to be multiple back-and-forth meetings into a single self-serve intake the client finishes in minutes — answered in their own words through AIDA, on whichever surface they already use.
- The human stays in the loop.
staff_reviewis mandatory by rule, not by convention. Automation does the repetitive assembly; a licensed attorney signs off on the substance. Faster and accountable, not faster instead of accountable. - Auditable by construction. Append-only
notation_eventsmeans every matter has a complete, replayable history — the transparency a public-interest practice owes the people it serves, and the record that lets one attorney safely oversee far more matters than a paper process ever could.
Speed here is not a convenience feature; it is the access-to-justice mechanism. Every minute and dollar the notation system removes from a routine matter is one that a person who could not otherwise afford a lawyer gets to keep.