PRD-012: External Portal (E08)
Author: Product Management | Date: 2026-09-07 | Status: Draft | Version: v0.1.0
Revision History
| Version | Date | Author | Description of Change |
|---|---|---|---|
| v0.1.0 | 2026-09-07 | Product Management | Initial draft compiled from conversation context and codebase exploration, revised after an independent verification pass (8 confirmed corrections, 6 material omissions added, 6 integration doubts surfaced) |
1. Context & Business Rationale
A disciplinary process is only lawful if the accused worker (arguido) can actually exercise two rights: to inspect the file held against them (consulta do processo, phase 4/8) and to respond in writing to the charges (resposta do arguido, phase 5/8). Both rights belong to a person who is not a user of this product — they have no account, no company membership, and must never acquire one.
"E08" is the capability code the arguido-facing half of both phases has been filed under since the rebuild began. It is referenced by name in PRD-007 and PRD-008, and at least six issues defer to it — #20, #61, #62, #64, #107, #109, and #112 — each of which correctly recorded its own decision and deferred the build. Nobody created the thing being deferred to. #107 states the gap most plainly: "the external-facing view is a second frontend application — unauthenticated, token-scoped, rate-limited, visibility-window-enforced, with an access log and a submission path."
The consequence is concrete: #107 publishes case items nobody can view, and #109 records a defence that has no way to arrive. Neither phase can be exercised end-to-end without this feature — but see §8 and §3.3 for why "internally complete" overstates where both phases actually stand today.
2. Problem Statement
The arguido and their counsel have no way to reach the case. Disclosure exists as server-side state (#61,
in-flight PR #218) with no external read path; the defence can only be entered by firm staff on the
arguido's behalf, which leaves the platform unable to evidence who authored a defence or whether
disclosure was ever taken up. The registo de acesso — the record proving the right of defence was
honoured, which becomes part of the court-defensible dossier — has nothing to record, because no
consultation can occur. Baseline volume figures (cases per month reaching phase 4/8, share of arguidos who
consult) are TBD.
3. Goals, Non-Goals, and Success Metrics
3.1 Goals
- Give the arguido and their counsel accountless access to exactly one case, via an opaque, revocable, expiring token.
- Let them read the items the firm has disclosed, within the visibility window, and no more.
- Record every consultation as a legally recordable act, so the registo de acesso is trustworthy.
- Let them submit their written defence and attachments directly, when the firm permits it.
- Make the surface safe to expose to the public internet.
3.2 Non-Goals
- The firm-side disclosure state — #61 (in flight on PR #218) owns which items are published and
the visibility window. It does not currently model to whom —
PortalDisclosure(as built) is{CompanyId, CaseId, ItemId, VisibleFrom, VisibleUntil}, with no party/recipient field, so disclosure is case-wide: every valid token for a case sees every published item. Whether adding the party dimension belongs to #61 or to this feature is an open question (see §8) — it is called out here rather than silently assumed, because §6.3's requirements below describe the target (per-party) behaviour, not today's. - The access-log table and its firm-facing read-back (
GET /access-log) — #112 owns the entity and the read-back. This feature owns the externally reachable route that triggers the write; #112's own acceptance criterion 1 cannot close without this feature's code, so the two are not independently shippable in the sense "either can ship without the other" — only independently codeable (see §8). - The internal defence record and the response-window rule — #64 owns both. An external submission lands as #64's record and defers to its window rule rather than reimplementing either. #64 is currently open with a blocking product decision (the response régime source — CT vs public sector); until it resolves, this feature's submission path has no record to land in (see §8).
- Certidões (certified copies) — #62, firm-side issuance.
- The arguido-facing frontend. There is currently no issue tracking it. #107 and #108 are both
admin-app screens (the firm's publish picker and the firm's access-log/certidões view, respectively) —
neither is the
/portal/:tokenapplication the arguido opens. This PRD does not create that issue; it is named here so the gap is visible rather than assumed covered. - Migrating the PoC's legacy
cases.access_password(plaintext, whistleblower-era). The portal is token-based; the password is not carried over.
3.3 Success Metrics
| Metric Type | Metric Definition | Baseline | Target |
|---|---|---|---|
| Primary | The portal API is reachable and correctly gated for a valid token — read, consult, and (where permitted) submit, over real HTTP | Not possible | 100% of the backend contract exercisable via a token |
| Secondary | Share of disclosed cases where a consultation is recorded (proof the right was taken up) | TBD | TBD |
| Secondary | Submissions land as attributable response records (not blank submittedBy) | N/A (path does not exist) | 100% |
| Guard Rail | Unauthorized reach — a token reading a case, item, or window it was not granted | N/A (surface does not exist) | 0 |
| Guard Rail | Portal availability for a legitimate party during the visibility window, under the rate limiter | N/A | Not regressed |
The end-to-end phase-level metric ("4/8 and 5–6/8 exercisable by an external party") is not the primary metric here, because it also depends on the arguido-facing frontend, which this PRD does not own and which currently has no owner (§3.2). It belongs to whichever PRD eventually owns that frontend, once one exists.
4. User Personas & Actors
4.1 Arguido (Primary, external)
- Role: the accused worker. Opens a link sent with the charges, reads what the firm disclosed, and submits a written defence within the response window.
- Frustrations: has no account and cannot be given one; today can only receive the file on paper and hand a defence back through the employer — the very party they are in dispute with.
4.2 External counsel (Primary, external)
- Role: the arguido's lawyer, acting under a procuração. Needs the same access, attributed distinctly — who consulted matters legally.
- Frustrations: none of their access is currently attributable, because none of it goes through the system.
4.3 Case manager (Secondary, internal)
- Role: issues and revokes the portal link, decides whether that party may submit, and later produces the registo de acesso for the file.
4.4 Witness (Future, external)
- Recognised as a portal party in #20 but not used by any current phase. Out of scope here; the party model must not make adding one a breaking change.
5. User Stories
- As a case manager, I want to issue a portal link for a named party on a case, with an expiry and whether they may submit, so the arguido can reach the file lawfully.
- As a case manager, I want to revoke a portal link immediately, so access ends when it should — and any outstanding access already granted under it ends too.
- As a case manager, I want the list of links to show who holds access without exposing the tokens themselves, so the credential is not re-readable after issuance.
- As the arguido/counsel, I want to open a link and see the items disclosed within their window, so I can prepare a defence.
- As the arguido/counsel, I want to open a disclosed item, so I can read the material held against me, without the server trusting whatever content type I'm told the file is.
- As the firm, I want every consultation recorded as it is granted, so the registo de acesso proves the right of defence was honoured.
- As the arguido/counsel, I want to submit my written defence and attachments, so my response is mine rather than transcribed by the employer.
- As the firm, I want to be notified when a defence arrives, so the response window is not missed.
- As the platform, I want token guessing throttled by repeated failure, not by how much a legitimate party reads, so a disciplinary file is not reachable by brute force without locking out its intended reader.
6. System Requirements
6.1 Token lifecycle
- SYS-REQ-101: A portal link shall be issuable for a single case and a named party, carrying the
party's role (
Employee/ExternalCounsel), an expiry, and whether submission is permitted. - SYS-REQ-102: The token shall be opaque and generated from a cryptographic RNG; it shall not be derived from case, company, or party identifiers.
- SYS-REQ-103: The token shall be stored hashed, not in plaintext — it is the single credential standing between the public internet and a disciplinary file, and the PoC's plaintext storage is exactly the pattern §3.2 already rejects for the legacy access password.
- SYS-REQ-104: The token shall be returned only at issuance. Subsequent reads shall expose link metadata but never the token (consistent with SYS-REQ-103 — there is nothing to return afterwards).
- SYS-REQ-105: A link shall be revocable with immediate effect; the record shall be retained rather than deleted (it is evidence that disclosure occurred), and any outstanding access grant issued under it (SYS-REQ-304) shall be invalidated at the same time.
- SYS-REQ-106: A token shall grant access to its own case only.
6.2 Authentication
- SYS-REQ-201: The system shall authenticate a portal party from the token in the URL as a distinct
authentication scheme, separate from the account/cookie scheme (today's is the only scheme registered —
AddCookiewithPostgresTicketStore— so this is new, not an extension). - SYS-REQ-202: A portal principal shall carry no
CompanyRoleand noCaseRole, and shall not satisfy any firm-side authorization; an account session shall likewise not satisfy portal authorization. - SYS-REQ-203: The portal principal shall carry the token's
companyId, and the request pipeline shall set tenant context from it — every case-scoped table is guarded by a fail-closed tenant filter that otherwise returns nothing for a caller with no resolved company. - SYS-REQ-204: An expired, revoked, unknown, or malformed token shall be rejected as unauthenticated, without distinguishing which.
- SYS-REQ-205: Whether authentication requires a second factor (OTP) in addition to the token is undecided (see §8) — this requirement intentionally leaves room for it rather than ruling it out.
6.3 Read surface
- SYS-REQ-301: The party shall see the items disclosed and currently inside their visibility window. Whether "disclosed" is scoped per-party or case-wide depends on the §8/§3.2 disclosure-targeting question; until it resolves, this requirement is satisfiable at the case-wide grain #61 currently provides.
- SYS-REQ-302: Each item's read/download permission shall be surfaced and enforced independently — a party may be able to view an item without being permitted to download it.
- SYS-REQ-303: The visibility window shall be enforced both when listing and again when consulting, so
a window closing between the two denies. Where an item carries more than one disclosure window (#61's
aggregate is additive — publishing again adds a row rather than replacing one), the item is visible if
any of its windows is currently open; the resolution rule for conflicting
allowDownloadvalues across overlapping windows is open (see §8). - SYS-REQ-304: Consulting an item shall record the access and return a single-use, short-TTL grant
scoped to that item and party; the content query shall stream bytes only against a valid grant. No
distributed or in-memory cache exists on
devtoday, so this grant needs new persistent storage. - SYS-REQ-305: No path shall serve item content without recording the consultation, and none shall record one without serving — the logged-iff-granted invariant (PRD-007 SYS-REQ-202).
- SYS-REQ-306: Served content shall not trust a caller- or upload-supplied content type when
responding — the internal evidence-download path already forces a safe generic type
(
application/octet-stream) specifically to close a stored-XSS path on unvalidated content types; this public-facing surface needs at least the same discipline.
6.4 Submission
- SYS-REQ-401: A party whose link permits submission shall be able to submit defence text and attachments; a party whose link does not shall be refused.
- SYS-REQ-402: A submission shall land as the response record owned by #64, attributed to the
submitting party rather than to firm staff. #64's current read contract exposes
submittedBywith no defined value for a non-account submitter — closing this requirement means extending #64's contract, not only consuming it; that dependency should be made explicit when #64 is scoped. - SYS-REQ-403: Whether the response window has closed shall be decided by #64's rule; an external submission shall produce the same outcome as the equivalent internal entry. #64 is currently blocked on a product decision (the response régime source) that this requirement transitively depends on.
- SYS-REQ-404: Submission text shall be bounded server-side (the PoC used a 5000-character cap).
Attachments shall obey the same size limit as internal uploads (
FileStorageSettings.MaxUploadBytes, 15 MiB). There is no existing content-type validation to inherit — the internal evidence-upload path only checks the field is non-empty — so this feature must define and enforce its own content-type allowlist rather than assuming one exists. - SYS-REQ-405: A submission shall notify the case's handlers, using the existing notification
dispatcher (
INotificationDispatcher) rather than a new mechanism.
6.5 Abuse protection
- SYS-REQ-501: Invalid-token attempts shall be rate limited, and the limit shall be driven by
failures, not total request volume, so a legitimate party reading many disclosed items is not
throttled. The existing
RateLimitingExtensionspolicies (login-attempts,ai-gateway) both use ASP.NET's fixed-window limiter, which consumes a permit per request before the endpoint runs and therefore cannot express failure-driven throttling as-is; this needs either a custom limiter that consumes only on failure, or a separate failure counter feeding the existing mechanism. - SYS-REQ-502: Consultations and submissions shall be bounded per token.
- SYS-REQ-503: A throttled response shall not reveal whether the token was valid.
7. Workflows & Usage Scenarios
The route shapes below are illustrative, not a fixed contract — §8 flags an unresolved conflict between #112's case-scoped route and #229's token-scoped route, and the diagram deliberately elides the prefix rather than picking a side.
CHARGES SERVED (#59, currently open — charges_delivered_at is not modelled yet)
│ case manager issues a portal link ──► token sent to the arguido (timing: see §8)
▼
/portal/:token ── validate: exists · not revoked · not expired
│ │
│ └── invalid ──► 401 (reason not distinguished) ──► throttled (SYS-REQ-501)
▼
GET items ── published (#61: currently case-wide, see §3.2) · in-window
│
├── POST consultations { itemId } ── authorize + RECORD ACCESS (#112) [logged-iff-granted]
│ │
│ └──► { reqId } ──► GET consultations/{reqId}/content ──► bytes (safe content-type, SYS-REQ-306)
│ (single-use · short-TTL · scoped to item + party)
│
└── POST submissions { text, attachments } (only if submission permitted)
│
└──► lands as #64's response record, party-attributed ──► handlers notified
7.1 Usage Scenario 1: Ana consults the file and responds
(One possible resolution of the open token-delivery and disclosure-targeting questions in §8; shown for concreteness, not as a decision.)
- The case manager serves the Nota de Culpa and issues a portal link for Ana (role
Employee, submission permitted). - Ana opens
/portal/<token>and sees the items the firm has published for the case, each showing whether it may be downloaded and when it became available. - She opens the Nota de Culpa. The system records the consultation before returning the document, using a safe content type regardless of what was stored — the registo de acesso now evidences that she took up her right.
- Within the response window she submits her defence text and two attachments.
- The handlers are notified; the submission appears as the case's response record, attributed to her.
7.2 Usage Scenario 2: Counsel joins, and the window closes
- Ana constitutes a lawyer. The case manager issues a second link for the counsel
(
ExternalCounsel) — whether this requires the procuração to already exist on the case is open (§8) — leaving Ana's link active. - Counsel consults items; each consultation is attributed to counsel, not to Ana.
- The visibility window on an item closes. Counsel's next attempt to consult it is denied.
- The case manager revokes both links once the phase ends. Revocation also invalidates any grant either party was still holding; the link records themselves are retained as evidence.
8. Open Questions
Scope / ownership
- Disclosure targeting.
PortalDisclosure(#61, PR #218) has no party dimension — disclosure is case-wide today, so SYS-REQ-301/303's "disclosed to them" is aspirational, not current behaviour. Should the party dimension be added to #61's aggregate, or does this feature own it? This is the single largest open item — it changes #61's contract, not just this feature's. - The #112 interlock. #112 lists this epic under Blocked by ("E08 owns the
POST /consultationscommand that writes the log"), and #112's own acceptance criterion 1 — "written ... at the authorizing POST" — cannot close without this feature's code. The reverse is also true: this feature's read surface needs #112's log table to write into. Confirm the split explicitly (who ships which half, in which order) rather than discovering the circular dependency at branch-cut time. #112 also leaves its own storage undecided — "a normal server-owned table (or entries in the audit ledger #44)" — which bears on §8's audit-ledger question below. - Route shape. #112 specifies
POST /portal/cases/{caseId}/consultations; #229 (already drafted as a sub-issue) specifiesPOST /portal/{token}/consultations. PRD-007 SYS-REQ-202 fixes the request/response shape but elides the route prefix, so it doesn't settle which. Needs deciding before #229 is cut. - The arguido-facing frontend has no owner. Confirm who cuts it and when, since the primary success metric that matters to the business (phases exercisable end-to-end) depends on it and this PRD does not.
Design
- OTP. The PoC carried
otp_required(default false, unused); PRD-007's workflow diagram and #112's guard both mention "token + OTP" informally, but no formal requirement anywhere fixes it. Is a second factor required, or is the opaque token sufficient? - Token delivery. Does the portal link ride #59's own notification (charges + proof of receipt), or is it issued as a separate act? This decides whether SYS-REQ-101 is blocked by #59 or merely sequenced after it.
- Counsel provenance. Does issuing an
ExternalCounsellink require the procuração to already exist on the case (recorded via #64), or can counsel be invited ahead of it? - Overlapping disclosure windows. Given #61's additive-only model, an item can carry several windows
with different
allowDownloadvalues. SYS-REQ-303 resolves visibility as "any window open" — what should govern the download permission when windows disagree? - Grant storage. SYS-REQ-304's single-use grant needs a home; there is no cache infrastructure on
devto reuse. A new table is the default answer, but worth deciding deliberately rather than by default. - Two-step vs. one-step read. #112's POST-then-GET split exists so the access record commits before bytes are served and so a browser link (which can't POST) doesn't need to. #205 (unmerged) just shipped the analogous "read that writes" internally as a one-step proxied download, firing its audit event at authorization rather than on completion, with no separate grant store. Worth confirming the two-step design is still right here rather than inherited from an earlier draft.
- Audit actor for a non-account party.
AuditActorMiddlewarestamps the actor from the account-id claim; an unauthenticated request defaults to aSystemactor. A portal consultation attributed toSystemis not a useful registo de acesso.AuditEntry.Actoris a bare string, so a non-account id will fit mechanically — but its semantics need deciding, and #227's issue/revoke "audited" requirement has the identical question in reverse (who audits as, when the actor is internal but the subject is external). - PII retention. #227's link entity stores
invitedEmail/invitedNamefor someone who must never become a user. Retention and erasure for that data, and for the access log, is unaddressed.
Metrics
- Baseline figures for §3.3 (cases reaching phase 4/8 per month; consultation take-up rate) are
TBD.
9. Edge Cases & Exception Handling
(added by refine-feature after the team interview)
10. Given-When-Then Acceptance Criteria
(added by refine-feature — top-level, one entry per key user story; per-ticket acceptance criteria are still written independently by to-issues for each story issue)
11. Technical Constraints & Dependencies
(added by refine-feature, from the interview's Multi-layer Dependencies answers)