BEHAVIOUR — Charges-review gate & the Consulta exit (module: chargesreview)
Scope: #58 / PRD-006 SYS-REQ-201, 202. Phase 3/8 (Nota de Culpa,
CHARGES_ISSUED). Two halves that only make sense together: the gate decision — a non-transition action the assigned revisor jurídico takes on the case — and the workflow edge it guards,ChargesIssued → ConsultationPortal, which this slice creates. Structured likeInqueritoPrevio/BEHAVIOUR.InqueritoPrevio.md, the precedent for a phase file owning both its own action and the exits it gates.Why this slice owns the edge. #57's spec (
BEHAVIOUR.NotaDeCulpa.md, "Deliberately out of scope") deferred "the legal-review gate and the transition it guards" to #58, and no downstream issue claims it: #59's ACs are delivery method + proof +charges_delivered_at, #61's are portal-item visibility windows. Without the edge, SYS-REQ-202 has nothing to refuse andCONSULTATION_PORTALis unreachable.This gate is not statutory. Arts. 353.º/354.º CT require the nota de culpa in writing and its service on the arguido; they mandate no second-pair-of-eyes review. The gate is a firm process control — separation of duties, grounded in
_poc/firm-qa.md's role matrix (P5 Revisor Jurídico "reviews & validates the Nota de Culpa legally before sending") and PRD-006 §4.2. That is exactly why its guard is not overridable (seePROOF/adv-004below): a gate the reviewed party's own managerial chain can bypass is not a control.Legal source: Código do Trabalho — disciplinary procedure (arts. 353.º, 354.º).
Implementation:
backend/MyLegalTeam.Domain/Cases/(the gate state, the workflow edge, the guard) andbackend/MyLegalTeam.Application/Features/Cases/Charges/DecideChargesReview/(the decision slice). Tests carrying these codes live inbackend/MyLegalTeam.Tests/andbackend/MyLegalTeam.IntegrationTests/.
Feature: The revisor jurídico decides the gate (SYS-REQ-201)
Feature: The assigned legal reviewer approves or rejects the Nota de Culpa
A non-transition action on the case — approve AND reject both leave the case in CHARGES_ISSUED
(PRD-006 §9: "Gate rejected → returns to redraft, not forward"), so this is a plain CQRS slice, not a
state-machine trigger. Authorization mirrors RecordPreventiveSuspension's shape, swapping the assigned
instructor for CaseRole.LegalReviewer — the role CaseCallerRole resolves from a CaseTeamMember row on
THIS case (#197) intersected with CompanyRole.LegalReviewer.
# @BHV-chargesreview-unit-001 — approving records the approval
Scenario: The assigned legal reviewer approves the gate
Given a case in CHARGES_ISSUED whose case team includes the caller as legal_reviewer
When the reviewer decides "approve" with notes "Enquadramento legal conforme"
Then the case's charges-review status is Approved and carries those notes
And a ChargesReviewDecided fact is on the case's audit stream
And the response reports gateStatus "Approved"
# @BHV-chargesreview-unit-002 — rejecting does not move the case
Scenario: Rejecting sends the Nota de Culpa back for redraft
Given a case in CHARGES_ISSUED whose case team includes the caller as legal_reviewer
When the reviewer decides "reject" with notes "Factos insuficientemente concretizados"
Then the case's charges-review status is Rejected and carries those notes
And the case is STILL in CHARGES_ISSUED — a rejection redrafts, it does not move the case backwards
or forwards
# @BHV-chargesreview-unit-003 — the gate can be re-decided after a redraft
Scenario: A rejected gate can be approved once the Nota de Culpa is redrafted
Given a case whose charges-review gate was Rejected
When the reviewer decides "approve" on the redrafted Nota de Culpa
Then the gate status is Approved — the last decision wins
# Deliberately NOT once-only (unlike the inquiry's InquiryAlreadyConcluded): PRD-006 §9's "returns to
# redraft" is meaningless if the redraft can never be re-reviewed.
Feature: Only the assigned legal reviewer decides (adversarial, SYS-REQ-201)
Feature: The gate is the assigned revisor jurídico's act
"Others get 403" means others who can already SEE the case. A caller with no case-role at all gets 404,
not 403 — the tenant filter makes "not visible to you" indistinguishable from "does not exist", which is
the convention every case-scoped write already follows (RecordPreventiveSuspension, UploadEvidence,
ProduceDeliverable). Stated explicitly so the 404 is not read as a deviation from the issue's literal
"403".
So the refusals split by whether the caller can see the case at all: a manager or the instructor gets
403 (they hold a case-role, just not this one), while an unassigned legal_reviewer gets 404.
# @BHV-chargesreview-adv-001 — a manager who is not the reviewer cannot decide
Scenario: A case_manager who is not this case's assigned legal reviewer is refused
Given a caller who may manage the company's cases but holds no legal_reviewer role on this case
When the caller decides the charges-review gate
Then it is forbidden (403) — code NotTheAssignedLegalReviewer
And the gate status is unchanged and nothing is audited
# @BHV-chargesreview-adv-002 — the drafter cannot approve their own Nota de Culpa
Scenario: The case's assigned instructor cannot decide the gate on their own work
Given the case's assigned instructor, who drafted the Nota de Culpa (#57) and can see the case
When the instructor decides the charges-review gate
Then it is forbidden (403) — code NotTheAssignedLegalReviewer
And the gate status is unchanged and nothing is audited
# THE separation-of-duties case, and the reason the gate exists at all: firm-qa's P5 reviews the Nota
# de Culpa "before sending", which presupposes someone else drafted it. A self-approval would satisfy
# SYS-REQ-201's letter while defeating its purpose.
# @BHV-chargesreview-adv-003 — company standing alone grants nothing, and does not leak the case
Scenario: A CompanyRole.LegalReviewer never assigned to THIS case cannot even see it
Given a caller whose company role is legal_reviewer but who has no CaseTeamMember row for this case
When the caller decides the charges-review gate
Then it is not found (404) — code CaseNotFound, NOT a 403
# Both halves must agree (the caseauth module's rule that company standing alone grants nothing on an
# unassigned case): holding CompanyRole.LegalReviewer resolves no case-role here, so RolesOf is empty
# and the caller may not even learn the case exists — 404 lands before the reviewer check, exactly as
# in RecordPreventiveSuspensionCommandTests' equivalent pair. Paired with unit-001 (the same caller,
# but assigned) this pins that the case-team lookup actually runs: hard-coding it to false would still
# leave unit-001's sibling tests green.
# @BHV-chargesreview-adv-006 — the gate only exists during the charges phase
Scenario: Deciding the gate on a case that has not issued charges is refused
Given a case still in INTAKE, and its assigned legal reviewer
When the reviewer decides the charges-review gate
Then it is a conflict (409) — code NotInChargesIssued
# Follows RecordInstructorProposal's phase gate (#49) rather than RecordPreventiveSuspension's absence
# of one: reviewing a Nota de Culpa that has not been issued reviews nothing. Fail-closed.
# @BHV-chargesreview-adv-007 — a numeric decision token is not matched positionally
Scenario: Sending "0" as the decision is rejected, not read as the first enum member
Given the case's assigned legal reviewer
When the reviewer sends decision "0"
Then it is refused (400) — code UnknownDecision
# Enum.TryParse accepts numeric strings and would silently bind "0" to Approve. Mirrors the inquerito
# module's equivalent numeric-token rule and DeliverableRequestParser.TryParseKind.
Feature: The Consulta exit is refused until the gate is approved (workflow, SYS-REQ-202)
Feature: An approved gate is the precondition of the transition to CONSULTATION_PORTAL
The edge this slice creates: ChargesIssued --OpenConsultationPortal--> ConsultationPortal, carrying one
guard, "chargesReviewNotApproved". PRD-006 §7 also places service of the charges before Consulta; that
is #59's, and it appends a SECOND guard to this same edge — the layered-guard shape IssueCharges already
uses (caducidade + checklistIncomplete on one edge).
# @BHV-chargesreview-unit-004 — approved advances
Scenario: An approved gate advances the case to CONSULTATION_PORTAL
Given a case in CHARGES_ISSUED whose charges-review gate is Approved
When OpenConsultationPortal is fired
Then the case moves to CONSULTATION_PORTAL
And a ConsultationPortalOpened fact is emitted
# @BHV-chargesreview-adv-004 — pending blocks, and the block cannot be overridden
Scenario: A case whose gate has not been decided cannot advance
Given a case in CHARGES_ISSUED whose charges-review gate is Pending
When OpenConsultationPortal is fired
Then it is blocked by the guard "chargesReviewNotApproved"
And that guard is NOT overridable — an authorised override does not advance the case either
# THE control. Every other guard except checklistIncomplete is overridable; this one is not, because
# the gate exists to make a second person check the Nota de Culpa, and an override available to the
# managerial chain being checked would dissolve exactly that separation of duties.
# @BHV-chargesreview-adv-005 — rejected blocks too
Scenario: A rejected gate does not advance the case
Given a case in CHARGES_ISSUED whose charges-review gate is Rejected
When OpenConsultationPortal is fired
Then it is blocked by the guard "chargesReviewNotApproved"
# Pending and Rejected are distinct states that block identically — which is precisely why the detail
# read must carry the gate status (unit-006): the guard identity alone cannot tell them apart.
# @BHV-chargesreview-unit-005 — the read model agrees with the machine
Scenario: Available transitions offer the Consulta exit only once the gate is approved
Given a case in CHARGES_ISSUED
When the available transitions are read while the gate is Pending
Then OpenConsultationPortal is offered but reports the chargesReviewNotApproved guard as blocking
And once the gate is Approved the same read reports it unblocked
# Pins a real drift risk: GetAvailableTransitionsQuery builds its CaseData positionally rather than
# from the aggregate, so a forgotten field would leave the read model reporting "blocked" forever
# while Fire actually passes.
Feature: The gate decision is readable back
Feature: The recorded gate decision appears on the case detail read
Same lesson as #57's SYS-REQ-104 (a write-only decision was flagged in #56's review as a frontend
contract gap). Available-transitions surfaces only "blocked or not" via the guard identity, and Pending
and Rejected block identically through the same guard — so without this the client cannot tell "never
reviewed" from "reviewed and rejected", and the rejection notes are in no read at all.
# @BHV-chargesreview-unit-006 — the gate state is on the detail read
Scenario: The detail read carries the gate status and notes
Given a case whose charges-review gate was rejected with notes
When any caller entitled to view the case reads its detail
Then the response carries chargesReview { status: "Rejected", notes: … }
And an undecided gate reads as status "Pending" with null notes — always present, never omitted,
because the gate exists from the case's birth
Feature: The gate over HTTP (int — FE contract)
Feature: The frontend drives the gate and the exit through the wired endpoints
# @BHV-chargesreview-int-001 — approve, then advance, end to end
Scenario: Approving the gate then advancing reaches CONSULTATION_PORTAL
Given a case in CHARGES_ISSUED and its assigned legal reviewer
When POST .../gates/charges-review {decision:"approve"} returns 200 {gateStatus:"Approved"}
And a manager then POSTs .../transitions {command:{type:"OpenConsultationPortal"}}
Then the case's status reads CONSULTATION_PORTAL
# @BHV-chargesreview-int-002 — the guard identity reaches the wire
Scenario: Advancing with an undecided gate is refused with the guard's identity
Given a case in CHARGES_ISSUED whose gate is Pending
When a manager POSTs .../transitions {command:{type:"OpenConsultationPortal"}}
Then it is refused (409) and the error names the guard "chargesReviewNotApproved"
Type-enforced (no test)
PROOF-chargesreview-001ChargesReviewStatus.Pendingis the zero value of the closed enum, so a case is born with its gate unapproved with no initialisation step and no nullable to forget — the fail-closed default is guaranteed by the type, not by a constructor someone must remember to call. The same property forCaseDatafollows from its default parameter value.
Deliberately out of scope
- The
chargesDeliveredguard on this same edge — #59's (PRD-006 §7 / SYS-REQ-302). Appended to the edge this slice creates; explicitly not pre-built here, since nothing yet stampscharges_delivered_at. - Requiring a
NotaDeCulpadeliverable to exist before the gate may be approved. Defensible, and arguably what a reviewer approving "the Nota de Culpa" implies — but neither #58's ACs nor PRD-006 §6.2 ask for it, and whether the firm wants the gate to hard-require the generated artifact is a legal-policy call of the same kind already routed to firm-qa (#185). Named rather than silently omitted. - Notifying the reviewer that a Nota de Culpa awaits review — no AC; the notifications system is its own feature.
- An explicit "redraft" state or a re-issue counter. PRD-006 §9's redraft is expressed by the case
simply staying in
CHARGES_ISSUED; a new status would add a state the statutory path does not have. inv(FsCheck) category — named per the convention of not skipping a category silently. The gate is a three-state field with one legal transition rule and no sequence-dependent invariant that examples do not already pin;adv-004/adv-005cover both blocking states exhaustively, which for a closed three-member enum is the exhaustive case analysis a property test would generate.