documents
Seed documents.template/documents.template_variable rows without a dfl-schema migration, and author
documents.document rows from any template category (or from a literal body) for any entity.
| Endpoint | https://engineering.mcp.devfellowship.com/mcp |
| Tools | 4 |
| Backing data | documents.template, documents.template_variable, documents.document; read of public.diagrams and work.epics for diagram injection and entity resolution. |
Documents
Section titled “Documents”| Tool | Description |
|---|---|
seed_template | Create or update a documents.template + its documents.template_variable rows, without a dfl-schema migration. Idempotent by template name — re-running with the same name updates it in place and replaces the full variable set. Reusable for any template, not handoff-specific. |
get_handoff_document | Read documents.document rows. Pass id for a single document, or epic_id to list every document linked to a work.epics id. |
write_document | The generic document writer. Create or update a documents.document row from a template category, an explicit template_id, or literal content. Not tied to any one template kind. |
write_handoff_document | Thin wrapper over write_document for the category: "handoff" case: resolves a work.epics id to its name and injects {{dfl-diagram:UUID}} tokens for every diagram already created for that epic. |
One writer, not two
Section titled “One writer, not two”write_handoff_document does not have its own write path — it resolves the epic and then calls the same
writeDocument core as write_document (src/tools/documents/write-document-core.ts). Everything below —
required fields, duplicate handling, RLS behaviour, the audit entry — is therefore identical for both tools.
write_document contract
Section titled “write_document contract”Required — title. documents.document.title is NOT NULL with no default.
Required — exactly one body source, out of category, template_id, or content. category picks the most
recently updated active template in that category; template_id names one explicitly (an inactive one is
refused); content writes a literal body with no template. Zero sources, or more than one, is an error — the
tool does not guess.
Optional:
variables—variable_key→ value map stored invariables_data. Substitution happens in the frontend.document_type— defaults to the template’s type, else"other".status—"draft"(default) or"pending"only. The signature-lifecycle values (sent,partially_signed,completed,finished,cancelled) belong to the signing flow and are rejected by the input schema: an agent must not be able to declare a document signed.entity_id/entity_name— free linkage to whatever row the document is about.diagram_entity_id— injects diagram tokens for that entity (see below).document_id— update this row instead of creating one.on_duplicate—"error"(default),"update", or"create".
Update is in scope. Pass document_id to rewrite an existing row in place; the result reports
action: "created" or action: "updated". There is no delete tool.
Duplicates are refused, not silently multiplied. documents.document has no uniqueness constraint, so
nothing at the database level stops two rows with the same title. Before inserting, write_document looks for
an existing document with the same title and the same entity_id (or the same title with a null
entity_id). If it finds one, the default on_duplicate: "error" refuses and returns
existing_document_ids so the caller can decide; "update" rewrites the most recently updated match; "create"
inserts another row anyway.
RLS, attribution, and why a failed write is never a success
Section titled “RLS, attribution, and why a failed write is never a success”Every call runs on the caller’s user JWT (createSupabaseClient(jwt)), never service_role — the fleet rule
from Tainan, 2026-06-17. documents.document carries a single policy, document_write (ALL for
authenticated, USING/WITH CHECK = iam.is_member()), so a caller who is not a DFL member cannot read or
write these rows at all.
That has a sharp edge worth stating: under RLS, an UPDATE on a row you may not see affects zero rows and
returns no error — indistinguishable from “the row does not exist”, and easy to mistake for success. This tool
treats a zero-row update as a failure, with an explicit message saying nothing was written. Likewise, passing
diagram_entity_id when the body has no diagram sentinel is an error rather than a quiet no-op.
documents.document has no created_by column, so authorship is recorded separately: each successful write
inserts a documents.document_log row with event_type: "mcp_document_created" / "mcp_document_updated" and
event_data.actor_user_id set to the calling user. The document is committed before that log is attempted, so a
log failure is reported honestly as audit_logged: false rather than failing or hiding the write.
Why seed via a tool, not a migration
Section titled “Why seed via a tool, not a migration”documents.template/documents.template_variable are reference/seed data, not schema — per the pipeline plan
(Tainan, 2026-07-09), seeding them goes through this tool, not a PR touching dfl-schema/supabase/migrations/
(that repo’s own AGENTS.md forbids INSERT in migrations; see PR #143 there, rejected for exactly this).
seed_template is idempotent by name, so re-seeding the same template (e.g. after editing its content) is
safe to run repeatedly.
No document_type: "handoff" — use category: "handoff" instead
Section titled “No document_type: "handoff" — use category: "handoff" instead”documents.document_type is a fixed Postgres enum (contract | proposal | nda | sow | other | amendment) — it
has no "handoff" value. The convention used here is document_type: "other" (or "sow") plus the free-form
category: "handoff" column, which the dfl-documents frontend already uses to group templates. Passing
document_type: "handoff" to seed_template fails at the database level with an enum error.
Diagram injection does not render images — it inserts tokens
Section titled “Diagram injection does not render images — it inserts tokens”Neither tool renders Mermaid/PlantUML to an image server-side. Given diagram_entity_id (which
write_handoff_document sets to the epic id), the writer queries public.diagrams for that entity (same filter
as list_diagrams) and replaces the body’s diagram sentinel with real {{dfl-diagram:UUID}} tokens — one per diagram found. The dfl-documents frontend
(src/utils/diagram-token.ts) already resolves that token into a rendered diagram when the document is viewed
or edited there. This was a deliberate scope decision (confirmed by Tainan): porting the Mermaid/PlantUML parser
into this Node package was judged unnecessary complexity once the client-side token mechanism was found to
already cover the need.
Seeding a handoff template
Section titled “Seeding a handoff template”{ "name": "Handoff Técnico v2", "category": "handoff", "document_type": "other", "content": "...markdown with {{variable_key}} placeholders and one {{dfl-diagram:00000000-0000-0000-0000-000000000000}} sentinel...", "variables": [ { "variable_key": "projeto_nome", "label": "Nome do projeto", "is_required": true }, { "variable_key": "estimativa_pontos", "label": "Estimativa (pontos de história)", "variable_type": "number" } ]}Estimates in a handoff template must use variable_type: "number" with the unit spelled out in the label
(pontos or horas) — never variable_type: "currency". documents.variable_type has no dedicated points/hours
value, and the plan’s business rule is that a handoff document never exposes a monetary value.
Authoring a document in an arbitrary category
Section titled “Authoring a document in an arbitrary category”write_document is category-parameterised, so a new kind of document needs no new tool — seed a template with
seed_template, then instantiate it:
{ "title": "Runbook — deploy do dfl-mcp-engineering", "category": "runbook", "entity_id": "3f1c…", "entity_name": "Deploy pipeline", "variables": { "responsavel": "William", "ambiente": "prod" }}Or with no template at all, for a one-off body:
{ "title": "Notas da reunião 2026-08-05", "content": "# Notas\n\n- ..." }