BEHAVIOUR — The response window & the arguido's defence (module: resposta)
Scope: #64, epic #63. Phase 5/8. Once the nota de culpa is served (
chargesDeliveredAt, #59), the arguido has a statutory window to respond — 10 working days under the CT régime, 15 under public sector (art. 355.º/1) — and staff record that defence (text, attachments, witnesses, counsel flag) once it arrives.ConsultationPortal → ResponseWindow(OpenResponseWindow) is an administrative advance with no guard of its own: everything it needs (review approved, charges served) is already enforced getting intoConsultationPortal. Recording a response is gated onchargesDeliveredAt is not null, not on the case's status, so a defence can never be rejected as "too early" by a status transition someone forgot to fire.A late response is accepted and flagged, never rejected — the window closing is a fact about the case, not a validation error a caller can be blocked by.
The witness cap (art. 356.º/3) applies only when
dismissal_intentis set — a case without it has no cap today. The same article settles both figures: 3 per fact and 10 in total, both enforced here.Legal source: Código do Trabalho — disciplinary procedure (arts. 353.º, 355.º/1, 356.º/3).
Implementation:
backend/MyLegalTeam.Domain/Entities/Company.cs+CompanyRegime.cs(the régime setting, unsettable pending #257),backend/MyLegalTeam.Domain/Cases/Deadlines/DeadlineCalculator.cs(the régime-aware term),backend/MyLegalTeam.Domain/Cases/CaseWorkflow.cs(OpenResponseWindow),backend/MyLegalTeam.Domain/Cases/Resposta/(CaseFact.cs,CaseResponse.cs),backend/MyLegalTeam.Domain/Cases/Events/RespostaEvents.cs(the audited facts),backend/MyLegalTeam.Application/Features/Cases/Resposta/(RegisterFact/,RecordResponse/,Reads/),backend/MyLegalTeam.Api/Controllers/CaseFactsController.cs,CaseResponseController.cs. Tests:MyLegalTeam.Tests/Domain/CompanyTests.cs,DeadlineCalculatorTests.cs,CaseTests.cs,CaseWorkflowResponseWindowTests.cs,CaseFactTests.cs,CaseResponseTests.cs,MyLegalTeam.Tests/Commands/Cases/GetCaseDeadlinesQueryTests.cs,RegisterFactCommandTests.cs,GetFactsQueryTests.cs,RecordResponseCommandTests.cs,GetResponseQueryTests.cs,MyLegalTeam.Tests/Mappings/CaseMapperTests.cs.
Endpoints
POST /companies/{companyId}/cases/{caseId}/facts
req { description }
201 { factId }
GET /companies/{companyId}/cases/{caseId}/facts
200 { items: [{ factId, description }] }
POST /companies/{companyId}/cases/{caseId}/response
req { text, hasCounsel, witnesses: [{ factId, name, role? }],
attachments: [{ fileContentBase64, fileName, contentType }] }
201 { responseId }
403 NotAuthorizedToRecordResponse | 404 CaseNotFound
409 ChargesNotServed | ResponseAlreadyRecorded
422 WitnessCapExceeded { meta: { factId, witnessCount, cap } }
| TotalWitnessCapExceeded { meta: { witnessCount, cap } } | UnknownFact
| AttachmentTooLarge | InvalidAttachmentContent
GET /companies/{companyId}/cases/{caseId}/response
200 { responseId, text, witnesses[], attachments[], hasCounsel, submittedAt,
submittedBy, isLate, regime }
404 ResponseNotRecorded
Unit — the régime and its clock
# @BHV-resposta-unit-001 — Resposta is 10 working days under the CT régime
Scenario: The default régime's term is 10 working days
Given a nota de culpa served on a given date
When the Resposta due date is computed for the Ct régime
Then it falls 10 working days after service
# @BHV-resposta-unit-002 — Resposta is 15 working days under the public-sector régime
Scenario: The public-sector régime's term is 15 working days
Given the same anchor
When the Resposta due date is computed for the PublicSector régime
Then it falls 15 working days after service
# @BHV-resposta-unit-003 — every other rule is unaffected by régime
Scenario: The régime-less overload still resolves Resposta to the Ct term
Given a nota de culpa served on a given date
When the due date is computed with no régime argument
Then it matches the Ct-régime result exactly
# @BHV-resposta-unit-004 — the case-level clock threads the régime through
Scenario: A case's own Resposta clock reflects its company's régime
Given a case whose charges were served on a given date
When its deadlines are computed once for the Ct régime and once for PublicSector
Then the two runs report different Resposta due dates for the same anchor
# @BHV-resposta-unit-008 — the régime has a safe default
Scenario: A company defaults to the Ct régime when none is given
Given a company created with no régime specified
Then its régime is Ct
# @BHV-resposta-unit-009 — the régime is settable at creation
Scenario: A company can be created under the public-sector régime
Given a company created with régime PublicSector
Then its régime is PublicSector
Invariant — the clock never lands on a non-working day
# @BHV-resposta-inv-001 — no off-by-one across régimes or holiday boundaries _(property)_
Scenario: The Resposta due date is always a real working day, whichever régime applies
Given any anchor date across a full calendar year (crossing every weekend and PT national holiday
at least once, e.g. Dia do Trabalhador and Corpo de Deus)
When the Resposta due date is computed for the Ct régime and again for PublicSector
Then both results always land on a genuine working day
# #59 already shipped one off-by-one on a statutory clock (the Caducidade interruption, caught only
# after the fact) — this is the same class of mistake, on the clock this issue adds.
Unit — opening the window
# @BHV-resposta-unit-005 — the administrative advance out of ConsultationPortal
Scenario: OpenResponseWindow moves the case and raises the fact
Given a case in ConsultationPortal
When OpenResponseWindow is fired
Then the case moves to ResponseWindow
And a ResponseWindowOpened fact is raised
# No guard: chargesReviewApproved and chargesDelivered are already enforced getting INTO
# ConsultationPortal, so re-checking them here would only ever refuse a case that can't exist.
Unit — facts (a witness needs something real to reference)
# @BHV-resposta-unit-006 — a fact is a first-class, persisted record
Scenario: A fact is registered against a case with its own identity
Given a case
When a fact is registered against it with a description
Then it is retrievable by that case, carrying its own id and description
# @BHV-resposta-unit-020 — registering a fact is an audited act
Scenario: Registering a fact raises an audited entry on the case's stream
Given a case
When a fact is registered against it
Then a CaseFactRegistered fact is raised, carrying the case id so it lands on the case's own
audit stream even though CaseFact is its own aggregate
# @BHV-resposta-unit-010 — the write endpoint round-trips the id
Scenario: A case manager registers a fact and gets its id back
Given a case manager on a visible case
When they register a fact
Then the response carries the new fact's id
# @BHV-resposta-unit-011 — the read lists what was registered
Scenario: Listing a case's facts returns everything registered against it
Given a case with one or more registered facts
When its facts are listed
Then every registered fact is returned
# @BHV-resposta-unit-012 — the list read is a total projection
Scenario: The fact list mapping never drops a row
Given any list of facts
When mapped to the wire shape
Then every fact in the input appears exactly once in the output
Unit — recording and reading the response
# @BHV-resposta-unit-007 — the aggregate holds everything a response carries
Scenario: A response is recorded with its text, counsel flag, witnesses and attachments
Given a case, a submitter and a submission date
When a response is recorded with text, hasCounsel, one witness and no attachments
Then all of it — including the witness's fact reference — is held on the response
# @BHV-resposta-unit-013 — the write endpoint round-trips the id
Scenario: A case manager records the response and gets its id back
Given a case whose charges have been served
When the case manager records a response
Then the response carries the new response's id
# @BHV-resposta-unit-014 — the cap is conditional, not universal
Scenario: A witness list over 3-per-fact succeeds when dismissal_intent does not apply
Given a case with dismissal_intent NOT set
When a response is recorded with 4 witnesses referencing the same fact
Then it is accepted — the art. 356.º/3 cap only binds when dismissal_intent is set
# @BHV-resposta-unit-019 — the total cap's boundary is inclusive
Scenario: Exactly 10 total witnesses across facts succeeds when dismissal_intent applies
Given a case with dismissal_intent set and 4 registered facts
When a response is recorded with 10 witnesses spread across those facts, none over 3 per fact
Then it is accepted — art. 356.º/3 says "não mais de 10", so the 10th is still within it
# @BHV-resposta-unit-015 — within the window is not late
Scenario: A response submitted within the régime's window is not late
Given a case served under the Ct régime
When the response is recorded before the 10-working-day term elapses
Then it is recorded with isLate = false
# @BHV-resposta-unit-016 — past the window is accepted and flagged, never rejected
Scenario: A response submitted past the régime's window is accepted and flagged late
Given a case served under the Ct régime
When the response is recorded after the 10-working-day term has elapsed
Then it is recorded (201) with isLate = true
# @BHV-resposta-unit-017 — attachments follow the evidence content-addressing pattern
Scenario: An attachment is stored by its content hash and referenced, not embedded
Given a response recorded with one attachment
Then the attachment's bytes are written to the file store under its SHA-256 key
And the response holds the file name, content type and hash, not the bytes
# @BHV-resposta-unit-018 — the read exposes the régime the clock used
Scenario: Reading a recorded response reports which régime governed it
Given a company under the PublicSector régime with a recorded response
When the response is read back
Then its text, witnesses, flags and timestamps match what was recorded
And regime reads "PublicSector"
# @BHV-resposta-unit-021 — recording a response is an audited act
Scenario: Recording a response raises an audited entry on the case's stream
Given a case
When a response is recorded against it
Then a CaseResponseRecorded fact is raised, carrying the case id, the response id and isLate —
found missing in review of PR #254, since neither CaseFact nor CaseResponse raised anything
Integration — the deadlines read crosses the case/company boundary
# @BHV-resposta-int-001 — the deadlines read resolves régime from the case's own company
Scenario: The Resposta clock on the deadlines read reflects the case's company régime
Given a case whose company is under the PublicSector régime, served on a given date
When the case's deadlines are read
Then the Resposta due date reflects the 15-working-day PublicSector term, not the Ct default
# This is the one behaviour here that spans two aggregates (Case + Company) rather than one — hence
# "int" over "unit" — though it runs against Moq repositories, not the Testcontainers harness (see
# "Deliberately out of scope" for why no HTTP-level int coverage exists yet for this module).
Adversarial
# @BHV-resposta-adv-001 — facts: existence is not leaked
Scenario: A caller who cannot see the case is refused registering a fact
Given a caller with no standing on the case
When they attempt to register a fact
Then the response is 404 CaseNotFound
# @BHV-resposta-adv-002 — facts: registration is a management/instructor act
Scenario: A legal reviewer, neither manager nor instructor, cannot register a fact
Given a caller holding only the legal-reviewer case-role
When they attempt to register a fact
Then the response is 403 NotAuthorizedToRegisterFact
# @BHV-resposta-adv-003 — facts: an empty description is meaningless
Scenario: An empty fact description is rejected by validation
Given a request with a blank description
When it is submitted
Then it is rejected (400) before anything is loaded
# @BHV-resposta-adv-004 — facts: the read leaks nothing either
Scenario: A caller who cannot see the case is refused listing its facts
Given a caller with no standing on the case
When they list its facts
Then the response is 404 CaseNotFound
# @BHV-resposta-adv-005 — response: existence is not leaked
Scenario: A caller who cannot see the case is refused recording a response
Given a caller with no standing on the case
When they attempt to record a response
Then the response is 404 CaseNotFound
# @BHV-resposta-adv-006 — response: recording is a management/instructor act
Scenario: A legal reviewer, neither manager nor instructor, cannot record a response
Given a caller holding only the legal-reviewer case-role
When they attempt to record a response
Then the response is 403 NotAuthorizedToRecordResponse
# @BHV-resposta-adv-007 — response: nothing to answer before the charges are served
Scenario: Recording a response before the nota de culpa is served is a conflict
Given a case whose charges have not been delivered
When a response is recorded
Then the response is 409 ChargesNotServed and nothing is persisted
# @BHV-resposta-adv-008 — response: once-only per case
Scenario: A second response for the same case is refused
Given a case that already has a recorded response
When another response is recorded
Then the response is 409 ResponseAlreadyRecorded
# @BHV-resposta-adv-009 — response: the cap names the offending fact and counts (art. 356.º/3)
Scenario: A witness list over 3-per-fact is refused when dismissal_intent applies
Given a case with dismissal_intent set
When a response is recorded with 4 witnesses referencing the same fact
Then the response is 422 WitnessCapExceeded
And its meta carries that fact's id, its witness count, and the cap (3)
And nothing is persisted
# @BHV-resposta-adv-010 — response: a witness must reference a real fact
Scenario: A witness referencing an unregistered fact is refused
Given a witness whose factId does not match any fact registered against the case
When the response is recorded
Then the response is 422 UnknownFact and nothing is persisted
# @BHV-resposta-adv-013 — response: the total cap binds independently of the per-fact cap (art. 356.º/3)
Scenario: A witness list over 10 in total is refused when dismissal_intent applies
Given a case with dismissal_intent set and 4 registered facts
When a response is recorded with 12 witnesses spread across those facts, none over 3 per fact
Then the response is 422 TotalWitnessCapExceeded
And its meta carries the witness count (12) and the cap (10)
And nothing is persisted
# @BHV-resposta-adv-014 — response: a genuine race loses cleanly, not as a server error
Scenario: A commit lost to a concurrent duplicate is reported as a conflict
Given two callers who both pass the once-only pre-check for the same case
When the loser's commit fails against the database's unique index on CaseId
Then the handler re-checks and reports 409 ResponseAlreadyRecorded, not a 500
# Found in review of PR #254: CommitOutcome.ConcurrencyConflict is raised only from
# DbUpdateConcurrencyException (optimistic concurrency), never from a unique-constraint violation
# (DbUpdateException) — so a naive read of CommitOutcome alone cannot tell this race apart from a
# genuine server error. The fix re-checks the repository rather than adding new SqlState detection
# to UnitOfWork, which is out of this slice's scope.
# @BHV-resposta-adv-015 — response: a rejected request writes nothing to the file store
Scenario: An invalid later attachment leaves no earlier attachment's bytes written
Given two attachments, the second with invalid base64 content
When the response is recorded
Then the response is 422 InvalidAttachmentContent
And the file store was never called for either attachment
# Found in review of PR #254: attachments were validated and written one at a time in the same
# loop, so a later attachment's validation failure still orphaned an earlier valid attachment's
# bytes for a request that was never going to be persisted at all. The write-before-commit ordering
# that DOES stay (crash safety between the last attachment's write and the row's commit) is
# unaffected — this only moves validation ahead of every write, not the write-before-commit rule.
# @BHV-resposta-adv-011 — read: nothing to read before recording
Scenario: Reading the response before one has been recorded is a 404
Given a case with no recorded response
When the response is read
Then the response is 404 ResponseNotRecorded
# @BHV-resposta-adv-012 — read: existence is not leaked
Scenario: A caller who cannot see the case is refused reading its response
Given a caller with no standing on the case
When they read its response
Then the response is 404 CaseNotFound
Type-enforced (no test)
PROOF-resposta-001ACaseFactis constructible only throughRegister(private ctor,requiredmembers) — a fact with no case or description is unrepresentable.PROOF-resposta-002ACaseResponse, itsResponseWitnesses andResponseAttachments are constructible only through their own factories (private ctors) — a response missing its case, submitter, or a witness missing its fact reference is unrepresentable.
Deliberately out of scope (named, not silently skipped)
- No FsCheck-generated property tests for this module. The one true invariant here — the Resposta
clock never lands on a non-working day (
BHV-resposta-inv-001) — is swept deterministically over a full year of anchors rather than via FsCheck, mirroring the same deterministic-sweep technique thedeadlinemodule's own working-day invariant already uses. The remaining rules (the cap binds only underdismissal_intent, a response is once-only, a witness must reference a real fact, the total cap is independent of the per-fact one) are each a single deterministic rule over a small, enumerable state space, not a property — each is covered by its own adversarial example instead (BHV-resposta-adv-008/009/010/013). - No dedicated Testcontainers/HTTP-level integration tests for the
facts/responseendpoints. The issue's own verification step runs the existingMyLegalTeam.IntegrationTestssuite to confirm the three new migrations apply cleanly against real Postgres, not new endpoint-level e2e coverage — that is a reasonable follow-up once these screens have a frontend to drive them end to end (mirrors howcase-charges-delivery's ownintcoverage landed once its UI existed). - The arguido's own portal submission (#230/#226) — this slice is staff entering the defence on the arguido's behalf only.
- Migrating
Case.FactsSummaryinto discrete facts, or retrofitting Abertura/NotaDeCulpa to useCaseFact. Facts are additive; the free-text summary is untouched. - Any way to set
Regimeat all. Not just "a company-settings screen for changing it after creation" —ProvisionCompanyRequesthas noRegimefield either, so nothing can set it at creation.Company.Create's third parameter defaults toCt, and the migration backfills existing rows toCt. Every company is thereforeCttoday;PublicSectoris reachable only in tests. This is the deliberate, safe interim state per #257 (an unreachablePublicSectorcannot produce a wrong public-sector deadline, whereas a settable one immediately would — onlyRespostais régime-aware; every other clock inDeadlineCalculatorstill hard-codes CT figures). #257 owns deciding the actual source (company-level vs. case-level) and closing this gap. - The submittedBy provenance contract (internal-entry vs. a future portal submission, #230) — #64 assigned defining this to this slice; deferred instead to #259, mirroring #257's own pattern of an explicitly-filed follow-up rather than a guessed wire shape.