Fetching latest headlines…
Reachable Is Not the Same as Correct
NORTH AMERICA
πŸ‡ΊπŸ‡Έ United Statesβ€’May 11, 2026

Reachable Is Not the Same as Correct

0 views0 likes0 comments
Originally published byDev.to

The CLI could create a credit note.

That was the bad news.

The umbrella documents compose command was built to make all document types reachable through one stable surface. Pass a type, an entity, and a JSON body. The server looks up the per-type Zod schema, validates the body, renders the document, and returns the same envelope shape every time.

For generic documents, that is exactly right. A letterhead, proposal, quote, statement, receipt, minutes document, reference letter, or compliance letter can be a schema-gated render through the generic pipeline.

Credit notes were different. So were invoices, pro-forma invoices, and board resolutions.

Those document types do not just render. They create specialized rows, allocate numbers, enforce state machines, inherit fields from source invoices, attach payment behavior, and feed later compliance paths. When documents compose --type credit_note went through the generic renderer, it produced a document row but bypassed createCreditNote(). That meant the credit note skipped the inheritance logic that copies terms from the original invoice.

The symptom appeared later as a Factur-X validation problem. The XML layer complained about missing payment terms. The real bug was earlier: the API made a specialized document reachable through the wrong path.

I had treated coverage as correctness. It was not.

The fix was not to delete the umbrella. The umbrella is still the right operator surface. The fix was to split the server path into two routes inside the same endpoint: specialized dispatch first, generic render second.

The registry is explicit:

export const COMPOSE_SPECIALIZED_DISPATCH: Partial<Record<TemplateType, ComposeDispatcher>> = {
  invoice: invoiceDispatcher,
  pro_forma_invoice: proFormaDispatcher,
  credit_note: creditNoteDispatcher,
  board_resolution: boardResolutionDispatcher,
};

That tiny map carries a big rule. If a type has specialized business logic, compose must call the specialized service. If it does not, compose falls through to renderDocument(). Exactly one path fires.

The dispatcher contract is intentionally boring. It maps the umbrella body into the specialized service input, calls the service, and wraps the returned row in the same envelope shape as a rendered document. It does not recompute totals. It does not duplicate inheritance. It does not allocate its own number. If the specialized service owns the rule, the dispatcher is only an adapter.

Here is the credit note path:

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',
  documentNumber: row.document_number ?? '',
  status: row.status ?? 'draft',
  locale: args.locale ?? 'en',
});

That looks like extra ceremony until you compare it to the failure. The old path returned a rendered artifact while skipping the domain rule. The new path returns a draft row because specialized documents often require a later send or execute step to produce their final artifact. The response shape stays stable, but the lifecycle is honest.

What surprised me was that the bug survived because both layers were internally correct. The CLI sent a valid body. The compose endpoint validated it. The generic renderer produced a document. The Factur-X validator was right to reject the later XML. No single module was obviously broken in isolation.

The boundary was wrong.

That forced a different kind of test. Unit tests on the dispatcher are not enough. The integration test has to assert the side effect that only the specialized service can create: invoice numbering, credit-note inheritance, board-resolution numbering, generic letterhead finalization. The test is not "does compose return 201?" It is "did the right domain path fire?"

This is one of the more useful patterns in the project because it applies outside documents. An umbrella command is good when operators need one muscle memory. It becomes dangerous when the umbrella erases domain-specific behavior. Reachability is a UX property. Correctness is a domain property.

The compose endpoint now carries both.

Comments (0)

Sign in to join the discussion

Be the first to comment!