diff --git a/src/pages/courts/CourtReportCreate.tsx b/src/pages/courts/CourtReportCreate.tsx index 493ceb3..aa9ae25 100644 --- a/src/pages/courts/CourtReportCreate.tsx +++ b/src/pages/courts/CourtReportCreate.tsx @@ -27,6 +27,7 @@ import { CourtStandingReference, CourtTargetPreview, CourtTriggerCounter, + CourtTriggerRequirement, formatCourtInstant, } from "./components/CourtPrimitives"; import { @@ -50,6 +51,7 @@ import { courtOffenseDisplay, courtReportLaneChoiceLabel, courtReportRouteDescription, + courtReportTriggerRequirement, } from "./model/courtPresentation"; import { courtErrorIssue, @@ -256,6 +258,13 @@ const CourtReportCreate: React.FC = () => { const selectedReason = capability?.reasonCapabilities.find( ({ reason }) => `${reason.offenseCode}:${reason.lane}` === reasonKey, ); + const triggerRequirement = selectedReason + ? courtReportTriggerRequirement( + selectedReason.reason.lane, + selectedReason.standing, + capability?.population, + ) + : null; const protectiveReview = selectedReason?.protectiveReview; const protectiveReviewAvailable = protectiveReview?.eligible === true && !incidentEndsAt; @@ -547,26 +556,25 @@ const CourtReportCreate: React.FC = () => {

{selectedReason ? (
- {selectedReason.reason.lane === "court_report" && - !selectedReason.standing.directStanding ? ( - capability?.population?.communityThreshold ? ( + {triggerRequirement?.kind === "community" ? ( + triggerRequirement.required ? ( ) : (

- A community Court trigger is unavailable because fewer - than three eligible Governors remain after excluding the + The Governor threshold is unavailable because fewer than + three eligible Governors remain after excluding the respondent.

) + ) : triggerRequirement?.kind === "authority" ? ( + ) : ( )} diff --git a/src/pages/courts/CourtReportDetail.tsx b/src/pages/courts/CourtReportDetail.tsx index ff9bee6..383b734 100644 --- a/src/pages/courts/CourtReportDetail.tsx +++ b/src/pages/courts/CourtReportDetail.tsx @@ -329,8 +329,10 @@ const CourtReportDetail: React.FC = () => { diff --git a/src/pages/courts/components/CourtPrimitives.tsx b/src/pages/courts/components/CourtPrimitives.tsx index a578a22..caaa59b 100644 --- a/src/pages/courts/components/CourtPrimitives.tsx +++ b/src/pages/courts/components/CourtPrimitives.tsx @@ -169,6 +169,26 @@ export function CourtTriggerCounter({ ); } +export function CourtTriggerRequirement({ + description, + label, + value, +}: { + description: string; + label: string; + value: string; +}) { + return ( +
+
+

{label}

+

{value}

+
+

{description}

+
+ ); +} + export function CourtReportActionStatus({ report, }: { diff --git a/src/pages/courts/components/CourtRecordCards.tsx b/src/pages/courts/components/CourtRecordCards.tsx index 4a3e2f0..e19d0c9 100644 --- a/src/pages/courts/components/CourtRecordCards.tsx +++ b/src/pages/courts/components/CourtRecordCards.tsx @@ -131,7 +131,7 @@ export function CourtReportCard({ report: CourtMyReportItemV2Dto; }) { const offense = courtOffenseDisplay(report.offenseCode); - const state = courtReportStateDisplay(report.state); + const state = courtReportStateDisplay(report.state, report.lane); const lane = courtLaneDisplay(report.lane); return ( diff --git a/src/pages/courts/model/courtPresentation.ts b/src/pages/courts/model/courtPresentation.ts index 8b7cc97..4db8e29 100644 --- a/src/pages/courts/model/courtPresentation.ts +++ b/src/pages/courts/model/courtPresentation.ts @@ -51,6 +51,24 @@ export type CourtReportActionProgress = { viewerCounts?: boolean; }; +export type CourtReportTriggerRequirement = + | { + kind: "community"; + required: number | null; + viewerCounts: boolean; + } + | { + kind: "single"; + description: string; + label: string; + } + | { + kind: "authority"; + description: string; + label: string; + value: string; + }; + export type CourtStandingDisplay = CourtDisplayEntry & { verification: string; }; @@ -95,6 +113,42 @@ export function courtReportRouteDescription( : "This enters the private community trigger. A case opens only after the protected reporting threshold is reached."; } +export function courtReportTriggerRequirement( + lane: CourtReportLaneV2Dto, + standing: { direct?: boolean; directStanding?: boolean }, + population?: { + communityThreshold: number | null; + viewerCountsTowardCommunity: boolean; + } | null, +): CourtReportTriggerRequirement { + const direct = standing.direct ?? standing.directStanding ?? false; + if (lane === "correction" || (lane === "court_report" && !direct)) { + return { + kind: "community", + required: population?.communityThreshold ?? null, + viewerCounts: population?.viewerCountsTowardCommunity ?? false, + }; + } + if (lane === "safety_or_protocol_incident") { + return { + kind: "authority", + label: "Court case trigger", + value: "Verified proof or authorized referral", + description: + "Governor report counts do not open a case on this lane. Evidence remains preserved for an authorized safety or protocol process.", + }; + } + return { + kind: "single", + label: + lane === "scoped_moderation" ? "Moderation action" : "Admissible reports", + description: + lane === "scoped_moderation" + ? "One admissible report routes the record to its authorized moderation process." + : "Verified direct standing allows one admissible report to enter Court review.", + }; +} + export function courtReportActionProgress( report: Pick, ): CourtReportActionProgress | null { @@ -154,12 +208,20 @@ export function courtReportProcessContext( "Follow the linked case for notice, evidence, decision, and appeal.", }; } + if (report.lane === "safety_or_protocol_incident") { + return { + basis, + destination: "Safety and protocol intake", + nextStep: + "The evidence remains preserved for authorized review. A Court case can open only through verified objective proof or an authorized emergency referral.", + }; + } if (report.state === "collecting" || report.state === "grouped") { return { basis, destination: "Private incident collection", nextStep: - "The report remains active without revealing other reporters or trigger thresholds.", + "The report remains active while reporter identities stay private. Aggregate Governor progress updates as matching reports qualify.", }; } if (report.state === "needs_amendment") { @@ -434,7 +496,18 @@ export function courtOffenseDisplay( export function courtReportStateDisplay( state: CourtReportStateV2Dto, + lane?: CourtReportLaneV2Dto, ): CourtDisplayEntry { + if ( + lane === "safety_or_protocol_incident" && + (state === "collecting" || state === "grouped") + ) { + return { + label: "Safety intake", + description: + "The incident is preserved for authorized safety or protocol review. Governor report counts do not open a Court case on this lane.", + }; + } return REPORT_STATES[state]; } diff --git a/tests/e2e/courts-v2.spec.ts b/tests/e2e/courts-v2.spec.ts index cb7db00..c1a4966 100644 --- a/tests/e2e/courts-v2.spec.ts +++ b/tests/e2e/courts-v2.spec.ts @@ -208,6 +208,7 @@ async function installCourtFixtures( notifications?: Record[]; onCapability?: (url: URL) => void; onCommand?: (command: Record) => void; + reasonCapabilities?: Record[]; reportDetail?: Record; reports?: Record[]; reportsStatus?: "available" | "unavailable"; @@ -333,7 +334,7 @@ async function installCourtFixtures( affectedId: "hmrCurrentReporter", affectedIdSource: "direct_reporter", }, - reasonCapabilities: [ + reasonCapabilities: options.reasonCapabilities ?? [ { reason: { offenseCode: "GOV-03", lane: "court_report" }, standing: { @@ -708,6 +709,90 @@ test("community report creation shows the Governor threshold and contribution", await expect(page.getByText(/Governor population: 99/)).toBeVisible(); }); +test("report creation explains each lane's actual trigger semantics", async ({ + page, +}) => { + const standing = (directStanding: boolean) => ({ + status: "verified", + directStanding, + source: directStanding ? "target-owner" : "active-governor", + }); + await installCourtFixtures(page, caseRecord, true, { + capabilityPopulation: { + source: "vortex-governor-roster:fixture", + basis: "capture_time_fallback", + effectiveAt: "2026-08-12T10:00:00.000Z", + capturedAt: "2026-08-12T10:00:00.000Z", + eligibleGovernorCount: 99, + communityThreshold: 10, + viewerCountsTowardCommunity: true, + }, + reasonCapabilities: [ + { + reason: { offenseCode: "CMP-01", lane: "correction" }, + standing: standing(true), + protectiveReview: { eligible: false }, + }, + { + reason: { offenseCode: "CMP-03", lane: "scoped_moderation" }, + standing: standing(true), + protectiveReview: { eligible: false }, + }, + { + reason: { offenseCode: "GOV-03", lane: "court_report" }, + standing: standing(true), + protectiveReview: { eligible: false }, + }, + { + reason: { + offenseCode: "SEC-03", + lane: "safety_or_protocol_incident", + }, + standing: standing(true), + protectiveReview: { + eligible: true, + authorityIds: ["protocol-safety-council"], + durationSeconds: 86_400, + }, + }, + ], + }); + await page.goto( + "/app/courts/reports/new?targetType=proposal&targetId=proposal-under-review", + ); + + const reason = page.getByLabel("Reason", { exact: true }); + await reason.selectOption("CMP-01:correction"); + await expect( + page.getByText("Governor reports", { exact: true }), + ).toBeVisible(); + await expect(page.getByText("10 required", { exact: true })).toBeVisible(); + + await reason.selectOption("CMP-03:scoped_moderation"); + await expect( + page.getByText("Moderation action", { exact: true }), + ).toBeVisible(); + await expect(page.getByText("1 required", { exact: true })).toBeVisible(); + + await reason.selectOption("GOV-03:court_report"); + await expect( + page.getByText("Admissible reports", { exact: true }), + ).toBeVisible(); + await expect(page.getByText("1 required", { exact: true })).toBeVisible(); + + await reason.selectOption("SEC-03:safety_or_protocol_incident"); + await expect( + page.getByText("Court case trigger", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Verified proof or authorized referral", { exact: true }), + ).toBeVisible(); + await expect(page.getByText("Governor reports", { exact: true })).toHaveCount( + 0, + ); + await expect(page.getByText("10 required", { exact: true })).toHaveCount(0); +}); + for (const width of [390, 1440]) { test(`dense report creation stays readable at ${width}px`, async ({ page, @@ -782,23 +867,29 @@ test("every reporter state has visible next-step guidance", async ({ route: null, }, offenseCode: - state === "routed_to_correction" - ? "CMP-01" - : index % 2 === 0 - ? "GOV-03" - : "CMP-03", + state === "grouped" + ? "SEC-03" + : state === "routed_to_correction" + ? "CMP-01" + : index % 2 === 0 + ? "GOV-03" + : "CMP-03", lane: - state === "routed_to_correction" - ? "correction" - : state === "routed_to_moderation" - ? "scoped_moderation" - : "court_report", + state === "grouped" + ? "safety_or_protocol_incident" + : state === "routed_to_correction" + ? "correction" + : state === "routed_to_moderation" + ? "scoped_moderation" + : "court_report", submittedAt: "2026-08-01T10:00:00.000Z", updatedAt: "2026-08-12T10:00:00.000Z", caseId: state === "triggered" ? caseId : null, respondentId: `human-respondent-${index}`, triggerProgress: - state === "routed_to_correction" || state === "routed_to_moderation" + state === "routed_to_correction" || + state === "routed_to_moderation" || + state === "grouped" ? null : { qualifyingReports: 2, @@ -828,7 +919,7 @@ test("every reporter state has visible next-step guidance", async ({ "Collecting", "Routed to correction", "Routed to moderation", - "Grouped", + "Safety intake", "Withdrawn", "Expired", "Closed without a case", diff --git a/tests/unit/court-presentation.test.ts b/tests/unit/court-presentation.test.ts index 8451441..ac1f602 100644 --- a/tests/unit/court-presentation.test.ts +++ b/tests/unit/court-presentation.test.ts @@ -13,8 +13,10 @@ import { courtRemedyLabel, courtReportLaneChoiceLabel, courtReportActionProgress, + courtReportProcessContext, courtReportRouteDescription, courtReportStateDisplay, + courtReportTriggerRequirement, courtRemedyExpiry, courtEventDisplay, formatCourtDuration, @@ -185,6 +187,83 @@ test("report route copy distinguishes non-case correction thresholds from Court ); }); +test("safety intake never presents Governor reports as its Court trigger", () => { + assert.deepEqual( + courtReportStateDisplay("grouped", "safety_or_protocol_incident"), + { + label: "Safety intake", + description: + "The incident is preserved for authorized safety or protocol review. Governor report counts do not open a Court case on this lane.", + }, + ); + const process = courtReportProcessContext({ + caseId: null, + lane: "safety_or_protocol_incident", + state: "grouped", + standing: { + status: "verified", + direct: true, + source: "affected_party", + }, + }); + assert.equal(process.destination, "Safety and protocol intake"); + assert.match(process.nextStep, /objective proof.*emergency referral/i); + assert.doesNotMatch(process.nextStep, /Governor threshold/i); + assert.deepEqual( + courtReportTriggerRequirement( + "safety_or_protocol_incident", + { directStanding: true }, + { communityThreshold: 10, viewerCountsTowardCommunity: true }, + ), + { + kind: "authority", + label: "Court case trigger", + value: "Verified proof or authorized referral", + description: + "Governor report counts do not open a case on this lane. Evidence remains preserved for an authorized safety or protocol process.", + }, + ); +}); + +test("creation requirements follow correction, moderation, and Court trigger rules", () => { + const population = { + communityThreshold: 7, + viewerCountsTowardCommunity: true, + }; + assert.deepEqual( + courtReportTriggerRequirement( + "correction", + { directStanding: true }, + population, + ), + { kind: "community", required: 7, viewerCounts: true }, + ); + assert.equal( + courtReportTriggerRequirement( + "scoped_moderation", + { directStanding: false }, + population, + ).kind, + "single", + ); + assert.equal( + courtReportTriggerRequirement( + "court_report", + { directStanding: true }, + population, + ).kind, + "single", + ); + assert.deepEqual( + courtReportTriggerRequirement( + "court_report", + { directStanding: false }, + population, + ), + { kind: "community", required: 7, viewerCounts: true }, + ); +}); + test("long audit values stay readable while preserving their identifying ends", () => { const digest = `sha256:${"a".repeat(64)}`; const compact = compactCourtAuditValue(digest);