PRD-004: Case Workflow — Abertura / Intake
Author: MLT backend team | Date: 2026-08-18 | Status: Draft | Version: v0.2.3
Revision History
| Version | Date | Author | Description of Change |
|---|---|---|---|
| v0.1.0 | 2026-08-18 | MLT backend team | Initial draft — first phase of the Case Workflow; also establishes the workflow engine reused by all later phases. |
| v0.2.0 | 2026-08-25 | MLT backend team | Frontend review: add the read model (case list/detail, transitions/available, deadlines/deliverables/checklist reads) that unblocks the FE slices; pin the machine-readable guard identity on 409s and the caducidade override shape (justification + authorised role). |
| v0.2.1 | 2026-08-26 | MLT backend team | ATDD spec pass: model the Despacho Liminar archive exit (→ ARCHIVED); gate the proceed exit on the verified checklist (checklistIncomplete); pin the case-outcome vocabulary. |
| v0.2.2 | 2026-09-01 | MLT backend team | Drop phasePath from the case detail response — the phase order is static workflow knowledge (a branching graph, not a per-case list); the client derives it from the state-machine-generated workflow. Returns, graph-derived, once later phases are modelled. |
| v0.2.3 | 2026-09-01 | MLT backend team | Deliverables (#42): generation goes through a renderer abstraction (IDocumentRenderer, HTML concrete now); filled from the case's own data (single source of truth, fields marked source: case|input); a produced deliverable can be refreshed until Validated (then locked). |
1. Context & Business Rationale
A Portuguese disciplinary process (processo disciplinar, Código do Trabalho) runs through eight legally-anchored phases. This PRD covers the first phase — Abertura / Intake — and, because it is the first phase built, it also establishes the reusable case-workflow engine that every later phase composes.
Part of the Case Workflow theme (GitHub label theme: case-workflow): eight phase epics
(Abertura → Relatório Final) plus a shared Dossier epic. Abertura is the "fat" phase — it births the
engine, the deliverable mechanism, the checklist mechanism, and the holiday-aware deadline
calculator; the later phases mostly reuse them.
The vibecoded PoC ran the workflow client-side (bypassable, non-transactional, three
uncoordinated status vocabularies). This rebuild makes it server-authoritative. Design (settled):
plain DDD — a guarded state machine on the Case aggregate (guarded transition methods that
mutate status, stamp the legal date, and raise a domain event; no public status setter), driven by a
transactional command handler per user-action, with side-effects as domain-event handlers on
commit and deadlines as scheduled jobs. It is not an external-settlement saga: transitions are
internal, synchronous user actions (advance, review, decide), so there is no handler-map / witness /
inbound-settlement machinery. Builds on the E15 platform primitives (AggregateRoot + domain events +
Result<T>).
Sourcing: the firm brief (_poc/firm-qa.md, Abertura checklist) and the 2026-08-11 client
meeting (_poc/meeting-notes-2026-08-11.md). PoC autopsy:
client-side integrity,
non-atomic transitions,
three status generations.
2. Problem Statement
There is no Case entity, no workflow, and no way to open or progress a disciplinary process server-side. Abertura must let an HR team lawfully open a case (with the legally-required intake records) and advance it, on an engine that cannot be bypassed.
3. Goals, Non-Goals, and Success Metrics
3.1 Goals
- A Case aggregate with a canonical status and guarded transition methods that raise domain events; status is server-owned (no direct writes).
- A transactional Advance command — load → guarded transition → commit once; side-effects via domain-event handlers.
- Open a case: the legally-required intake records (Registo da Notícia, Verificação de Competência, Nomeação do Instrutor) + the branch flags and the employee's protected status.
- The Abertura deliverables (Participação Disciplinar, Despacho Inicial, Termo de Abertura) and checklist, building the reusable deliverable + checklist mechanisms.
- The holiday-aware deadline calculator (reusable) and the guarded exit from INTAKE (has_preliminary_inquiry branch + caducidade/prescrição guard).
- A read model the frontend consumes without re-implementing the graph: case list + detail, the available-transitions endpoint, and the phase's deadlines/deliverables/checklist reads — plus a machine-readable guard identity on blocked transitions and an audited override path.
3.2 Non-Goals
- The other seven phases — separate epics under this theme.
- The case-team assignment capability itself (E02 follow-up) — Abertura calls it to nominate the instructor; it is a dependency, not built here.
- The final Dossier — its own shared epic.
- A PDF renderer / signing — the dossier-format decision is elsewhere.
3.3 Success Metrics
| Metric Type | Definition | Baseline | Target |
|---|---|---|---|
| Primary | A case_manager opens a case and advances it out of Abertura, server-side | none (only Dummy) | works end-to-end |
| Guard Rail | Status can be changed only via a guarded transition | — | 0 direct-status-write paths |
| Guard Rail | Deadline math correct across PT holidays/DST | — | cited test per rule, green |
4. User Personas & Actors
- Case manager (
case_manager) — opens the case, nominates the instructor, drives Abertura. - Instructor (
instructor_internal/external) — appointed here; conducts later phases. - Case (aggregate) — the state authority.
5. User Stories
- As a case manager, I want to open a case with the required intake records so the process starts lawfully.
- As a case manager, I want to nominate the instructor so the investigation has an owner.
- As a case manager, I want to produce the Abertura documents and track the phase checklist.
- As a case manager, I want to advance the case out of Abertura only when it is lawful to (deadline + branch).
6. System Requirements
6.1 Engine (born here)
- SYS-REQ-101: A canonical
CaseStatus(INTAKE, PRELIMINARY_INQUIRY, CHARGES_ISSUED, CONSULTATION_PORTAL, RESPONSE_WINDOW, EVIDENCE_PHASE, BODIES_OPINION, FINAL_REPORT + CLOSED, ARCHIVED, SUSPENDED). No raw-string or client-supplied status. - SYS-REQ-102: The Case aggregate exposes guarded transition methods that mutate status, stamp the legal date, and raise a domain event. No public status setter.
- SYS-REQ-103: An Advance command loads the case, invokes the transition, and commits once, with optimistic concurrency + an idempotency key; side-effects run as domain-event handlers on commit.
6.2 Abertura content
- SYS-REQ-201: Open a case captures employee + protected status, factual description, knowledge source + date, the case flags (has_preliminary_inquiry, dismissal_intent), Verificação de Competência, and nominates the instructor (via the case-team capability).
- SYS-REQ-202: The reusable deliverable mechanism (typed state, escaped generation, template/upload)
produces Participação Disciplinar, Despacho Inicial, Termo de Abertura; the checklist mechanism tracks
Abertura's items independently ("produced" ≠ "verified"). Generation goes through a renderer
abstraction (
IDocumentRenderer, HTML the initial concrete; PDF a later add — still a non-goal here) and is filled from the case's own data — the case is the single source of truth, so case-held values are never re-entered and cannot drift. A produced deliverable can be refreshed (regenerate from the case's current data, or replace the upload) until it is Validated, after which it is locked (dossier-bound). - SYS-REQ-203: The deadline calculator computes calendar/working-day due dates in Europe/Lisbon
across PT public holidays. The exit is a reasoned Despacho Liminar — proceed (branching on
has_preliminary_inquiry) or immediately archive (→ ARCHIVED). Proceeding is gated by the
caducidade/prescrição guard and by the Abertura checklist being verified
(
checklistIncomplete); the archive branch is exempt from both.
6.3 Read model (added per frontend review — 2026-08-25)
Every task above specifies a write; the frontend slices (#82/#83/#85/#86) are blocked on reads that must ship alongside them. The graph and its guards live server-side, so the browser reads them — it never re-implements them (the defect this epic exists to remove).
- SYS-REQ-301: Available transitions — for a case's current state and facts, the server returns each candidate target with whether it is allowed now and, if not, the guards blocking it. This lets the UI offer only lawful actions (and render a remedy) instead of offering everything and collecting 409s.
- SYS-REQ-302: Machine-readable guard identity — a blocked transition (whether listed by
SYS-REQ-301 or returned as a 409 by Advance) carries a structured
{ rule, message, overridable }, never prose only, so the UI keys a remedy offruleand offers an override whenoverridable. - SYS-REQ-303: Caducidade override — an
overridableguard may be overridden only with a recorded justification and only by an authorised role (legal reviewer / lawyer); the override is audited (who, when, why). A case_manager alone cannot override. - SYS-REQ-304: Case reads — a paginated, filterable case list (by phase, assigned-to-me)
and a case detail (status, flags, employee, milestone dates, outcome, the caller's case role +
permissions). The case outcome vocabulary is
ARCHIVED | WARNING | SUSPENSION | TERMINATION | OTHER(in Abertura onlyARCHIVEDis reachable, via the Despacho Liminar; the sanctions come from later phases).- The detail response does not carry a
phasePath(the full expected phase journey). The phase order is static workflow knowledge, not per-case data — the only case-specific input (the preliminary-inquiry flag) is already inflags— and the journey is a branching graph, not the single list such a field would model. The client renders phase progress from the workflow generated off the state machine (the same source as the transitions), not from a field the endpoint recomputes per request. It can return, derived from the graph, once the state machine models the later phases.
- The detail response does not carry a
- SYS-REQ-305: Phase reads — the case's deadlines (the statutory clocks — the product's
core value), its deliverables (list + one + a download path, plus the template list and per-kind
field schema the generate form renders — each field marked
source: case | inputso case-held values pre-fill read-only from the case, never re-entered), and its checklist items.
7. Workflow
Open case (INTAKE) ── nominate instructor ── produce deliverables + verify checklist
│
▼
Despacho Liminar (reasoned, guarded):
├─ archive → ARCHIVED (facts don't warrant a process; no caducidade or checklist check)
└─ proceed: has_preliminary_inquiry ? → PRELIMINARY_INQUIRY : → CHARGES_ISSUED
└─ blocked unless the Abertura checklist is verified (guard `checklistIncomplete`) AND the
caducidade/prescrição window (from the knowledge date) has not passed (guard `caducidade`,
overridable only with a recorded justification by an authorised role — §6.3, SYS-REQ-302/303)
8. Open Questions
- Dossier format (sealed PDF vs ZIP) — decided in the Dossier epic, not here.
- Override authorisation — resolved (2026-08-25, frontend review): an
overridableguard (caducidade) is overridden with a justification recorded on the case and only by an authorised role (legal reviewer / lawyer), audited; a case_manager cannot self-override. The exact role mapping is confirmed against RBAC (PRD-002 / E02).
Resolved: the caducidade/prescrição figures — firm-qa (the lawyer's brief) gives caducidade = 60 calendar days from knowledge of the infraction and prescrição = 1 year from the fact, matching Código do Trabalho art. 329.º. The PoC's 30-day value is the Inquérito Prévio first-diligence deadline (art. 352.º), used in phase 2/8, not here. Only the article numbering needs a final counsel check; the figures are firm-confirmed.
9. Edge Cases & Exception Handling
- Past caducidade: Advance is blocked and returns the guard identity
caducidade(overridable: true); the case does not move and no domain event is raised. An override needs a justification + an authorised role (SYS-REQ-303). - Invalid transition: a command with no available edge from the current state (e.g. opening a preliminary inquiry when none exists) is rejected as an invalid transition (409), case unchanged.
- Intake branch: with
has_preliminary_inquirythe case advances to PRELIMINARY_INQUIRY; without it, straight to CHARGES_ISSUED. - Immediate archive (Despacho Liminar): the exit may instead archive the case (→ ARCHIVED, outcome
ARCHIVED) — a reasoned decision needing no caducidade or checklist check. - Checklist incomplete: advancing to inquiry/charges is blocked (
checklistIncomplete, not overridable) until the Abertura checklist is verified; unlike the PoC, producing a document never auto-verifies an item. - Suspend / resume: a case may be suspended (recording the reason and where to resume) and later resumes to the recorded state. Suspension does not stop statutory clocks unless the law provides for it (the deadline calculator's concern).
- Concurrency / idempotency: two concurrent advances resolve to one (optimistic concurrency + idempotency key); a retried Advance with the same key is a no-op, not a double transition.
10. Given-When-Then Acceptance Criteria
Per-task GWT criteria live on each Task issue; the contracts the frontend consumes are pinned here
as endpoint blocks (the verify-contracts grammar). Note: guard rules like caducidade are dynamic
identities in the V2 error envelope, not enum error keys, so they are described in prose rather
than as <status> ErrorKey lines.
Advance a case (guarded transition):
POST /companies/{companyId}/cases/{caseId}/transitions
req: command, idempotencyKey
200: status, transitionedAt
409 InvalidTransition
404 CaseNotFound
A guard-blocked advance returns 409 with the V2 error envelope: each error's code is the guard's
rule (e.g. caducidade) and meta.overridable its override eligibility (SYS-REQ-302). An override
is a distinct, authorised action (SYS-REQ-303) — its own endpoint, specified on the read-model/override task.
Available transitions (read model — the architecturally important one):
GET /companies/{companyId}/cases/{caseId}/transitions/available
200: targets[{command, toStatus, allowed, blockedBy}]
404 CaseNotFound
Each target carries the command to fire and its derived toStatus; blockedBy is an array of
{ rule, message, overridable } — the same identity the 409 carries.
Open a case (intake write — the Abertura entry point):
POST /companies/{companyId}/cases
req: employee, factsSummary, knowledgeSource, knowledgeDate, flags, competenciaConfirmed, instructorAccountId, secretaryAccountId, instructorLegalTrainingPreference
201: caseId, status
403 NotAuthorizedToOpenCase
403 InstructorNotEligible
422 IntakeFieldMissing
422 CompetenciaNotConfirmed
employee = { name, department, protectedStatus{ unionRepresentative, worksCouncilMember, pregnantOrParentalLeave } };
flags = { hasPreliminaryInquiry, dismissalIntent }; secretaryAccountId and instructorLegalTrainingPreference
are optional (the legal-training preference is recorded on the appointment, not enforced).
Case list & detail (unblock #82 / #83):
GET /companies/{companyId}/cases
200: items[{id, status, employee, assignedToMe, nextDeadline}], page, pageSize, total
403 NotAuthorizedToViewCases
GET /companies/{companyId}/cases/{caseId}
200: id, status, flags, employee, factsSummary, knowledgeSource, instructor, milestones, outcome, callerRole, permissions[], phasePath[{phase, state}], chargesReview, preventiveSuspension, inquiryStatus
404 CaseNotFound
factsSummary and knowledgeSource are the Registo da Noticia, read back verbatim (#278). instructor =
{ accountId, email } — Account carries no display name, so the email is the identity, and it is blank
when the id resolves to nothing. milestones = { knowledgeDate }; chargesReview = { status, notes }.
preventiveSuspension = { suspended, reason } and inquiryStatus = { concluded, proposal } are each
null rather than defaulted, so a client can tell "nothing decided yet" from a recorded decision.
Phase reads (unblock #85 / #86) — deadlines, deliverables (+ one + a download path), checklist —
are specified with their own endpoint blocks on the companion read-model task and the phase tasks
(#42 / #43).
11. Technical Constraints & Dependencies
- Built on PRD-003 Platform Infrastructure (AggregateRoot + domain events +
Result<T>, Testcontainers harness) and PRD-002 Tenancy & RBAC (tenant scoping, case-team assignment). - Backend TDD (the
tddskill governs backend code); the deadline/guard rules ship with cited tests. - The deliverable + checklist + deadline mechanisms established here are reused by all later phases.