Quetzal — Design Principles
Source of truth for the V1 contract. Automated agents (
contract-guardian,leak-auditor) enforce these rules on every PR. Changing a principle requires an ADR and human approval.
1. Resources, not processes (REST first)
The API exposes resources with state machines. State transitions happen via
PATCH on the resource — not via action endpoints (/finalize, /correct,
/retry, /force-transition).
✅ PATCH /v1/documents/{document_id} with {status: "submitted"}
❌ POST /v1/documents/{document_id}/finalize
✅ PATCH /v1/documents/{document_id} with {receiver: {...}} (triggers internal correction)
❌ POST /v1/documents/{document_id}/correct
If an operation cannot be expressed as a PATCH on the resource (or a POST /
DELETE on the collection), the domain is likely mismodeled — revisit before
reaching for a verb.
Documented exceptions
Two action endpoints exist in V1. Both are intentional and survived a review against the resource-only alternative. New verb endpoints require an ADR.
| Endpoint | Why it's not a PATCH |
|---|---|
POST /v1/documents/{id}/cancel | Cancellation is a regulated operation with tax-authority side effects, not a local state flip. SAT (MX) requires a motivo code (01–04); DIAN (CO) cancellation emits a credit note as a separate fiscal document; SRI (EC) and SUNAT (PE) require their own annotated cancellation calls. The result is asynchronous — the tax authority may reject the request. A request body distinct from the resource shape is needed. Stripe's POST /v1/invoices/{id}/void follows the same reasoning. |
POST /v1/documents/{id}/void | Void differs from cancel: it discards a document that was never emitted to a tax authority (stuck in draft or local error). It is a cleanup operation, semantically incompatible with cancel. |
In both cases the body carries operation-specific fields that have no home on
the Document resource itself. Concretely: reason (free-text, all
countries), motivo (SAT motivo code, MX only), and related_document_id
(reference to the credit note that materializes the cancellation, CO/DIAN only
— other countries don't expose this field). The exact per-country body shape
lives in SPEC/openapi.yaml.
2. Hard encapsulation (retry and force-transition are NOT APIs)
Retry policy lives inside the service. An internal supervisor retries
according to policy; on exhaustion it marks the document failed or
abandoned with a reason.
❌ POST /v1/documents/{id}/retry
❌ POST /v1/documents/{id}/force-transition
If ops needs to nudge a stuck document manually, the path is an internal
admin UI calling private service methods — not a REST endpoint. Audit
metadata (who, when, why) is built into the admin path.
3. Invoicing is radically abstract
Quetzal Invoicing has no knowledge of:
- Students, families, campuses, schools
- Mattilda billing modes (PUE, PPD, factoring, surcharges)
- Mattilda concepts (membership, inscription, scholarship, discount)
EntityCandidate,FiscalGroup,BillingPolicyView,campus_settings
It receives: {line_items, receiver, issuer, country, document_type, metadata}.
It returns: a Document resource with a state machine.
"It could invoice coffee." — If a proposal requires Invoicing to understand a Mattilda concept, that concept lives upstream (Billing / Cobranza), not here.
4. metadata is an escape hatch — with rules
metadata (on Document, line_items[], receiver, issuer) accepts a free
map<string, string>.
Hard rule: the executor, confirmer, side_effects, and
post_processing stages MUST NOT read metadata. Only the caller
(Cobranza, Anticipos, etc.) understands it.
If an executor needs a field, it must be first-class on the contract (with
an ADR) or already resolved into another field (receiver.tax_id,
line_items[].description, etc.).
metadata is for the caller's own use — correlation, internal references — not
for the service.
5. Owned identity, not derived
The Document resource owns its own id (a UUID generated by Quetzal). It is
NOT derived from the charge account (charge_account_id), the payment
(payment_id), or any other resource the caller owns.
To reference the resource that originated the document, use
metadata.source_ref (opaque to Quetzal) or an explicit link in
Document.references[].
6. JSON represents the entity, not the process
GET /v1/documents/{document_id} returns the state of the resource, not a
sequence of internal pipeline stages.
✅ {id, status, fiscal_id, line_items, events_url}
❌ {plan_stage: "completed", gatekeep_stage: "passed", execute_stage: "pending", ...}
Internal stages are an implementation detail. The client sees business state.
7. Semantic modularity, not granular
Anti-pattern: six tiny microservices coordinated through queues (the Klip story). Accepted pattern: cohesive modules with clear semantic responsibilities.
- Quetzal Invoicing is one service —
executor,confirmer, etc. do not live in separate repos. - Tax-authority adapters MAY live in their own repos when they predate Quetzal (FacturaGreen, Gurusoft, SAE).
- Billing (Cobranza), Anticipos, Parties are separate bounded contexts with their own repos when they are extracted.
8. Multi-tenant from day one
Every entity carries a tenant_id (mattilda, algebraix, …). Query-layer
filters are automatic; tests verify isolation.
TenantContext is injected on every request (from the JWT claim) and travels
through the full call stack.
9. Production data over synthetic
Golden tests are built from real cases (Retool corpus, audited production documents), not invented payloads. Shadow mode is mandatory before a per-country cutover.
10. Executable specs
SPEC/openapi.yaml is the source of truth. Code is generated from and
validated against it. Changing the spec requires an ADR. PRs touching the spec
are reviewed by contract-guardian.
Style Reference
Quetzal adopts the Zalando RESTful API Guidelines
(https://opensource.zalando.com/restful-api-guidelines/) as the canonical style
reference. Naming, formatting, error handling, pagination, idempotency, and
event conventions follow Zalando except where an internal principle is stricter
(see §1–2 on URL verbs, §4 on opaque metadata).
Zalando rules adopted and the deliberate divergences are detailed in the API Style Guide.
References
- Stripe Invoices API: https://docs.stripe.com/api/invoices
- Zalando RESTful API Guidelines: https://opensource.zalando.com/restful-api-guidelines/
- ADR-001 (single-repo), ADR-002 (strangler-fig), ADR-003 (multi-tenancy) in
docs/adr/ - System Design Document — full architecture
- Glossary — canonical terms
- API Style Guide — Zalando rules adopted + intentional divergences