The CLI could create a credit note.
That was not the success case. That was the defect.
documents compose was added so an operator could create any supported document through one command. The command accepts a document type and a JSON body, then calls POST /api/documents/compose. That surface was correct. A single operator entry point is easier to test, script, and document than fourteen separate mental models.
The server path was wrong for specialized documents.
Generic documents can be schema-gated and rendered. Letterhead, reference letters, minutes, compliance letters, statements, quotes, and proposals can all pass through the shared composer as long as their body matches the registered schema and the entity/type matrix allows them.
Invoices, pro-forma invoices, credit notes, and board resolutions are different. They do not only render. They create domain rows. They allocate numbers. They enforce status transitions. They inherit fields from source documents. They feed later send, payment, signing, and audit paths.
When the umbrella route sent a credit note through generic rendering, it created something that looked like a credit note but skipped createCreditNote(). The missing inheritance appeared later as a Factur-X problem: payment terms were absent from the XML path. The root cause was not XML. The root cause was compose crossing the wrong boundary.
The fix was a dispatch map that runs before generic rendering:
export const COMPOSE_SPECIALIZED_DISPATCH: Partial<Record<TemplateType, ComposeDispatcher>> = {
invoice: invoiceDispatcher,
pro_forma_invoice: proFormaDispatcher,
credit_note: creditNoteDispatcher,
board_resolution: boardResolutionDispatcher,
};
That map is small because the rule is small: if a type has a service that owns legal behavior, compose must call that service. If no specialized service exists, compose may render generically.
The dispatcher does not reimplement the service. It adapts the umbrella request shape to the domain service, then wraps the output in a compose response:
const row = await createCreditNote({
db: args.db,
pool: args.pool,
entityId: args.entityId,
input,
apiKeyId: args.apiKeyId ?? null,
requestId: args.requestId ?? null,
});
return wrapRowAsOutput({
documentId: row.document_id,
entityId: args.entityId,
type: "credit_note",
status: row.status ?? "draft",
});
This is intentionally plain. The adapter is not a second credit-note engine. It is a bridge back to the first one.
The CLI mattered because it created operator pressure against the API. Unit tests can prove a function works. A route test can prove a response shape. The CLI proved the path an operator would actually use.
That changed the definition of coverage for this project. The question is no longer "Can the type be created?" The question is "Did the type reach the module that owns its invariants?"
The coverage matrix now tests every allowed entity/type cell and several blocked cells. It also checks side effects for specialized types: invoice rows exist, pro-forma rows exist, credit-note inheritance metadata exists, and board resolutions use the resolution numbering path.
The deeper lesson was uncomfortable but useful. An API can be too generic. A generic endpoint is a good surface only when it preserves the domain boundary behind it. If the endpoint turns specialized behavior into a render option, it becomes a liability.
Klevar Docs keeps the umbrella. It also makes the umbrella humble.