diff --git a/backend/.env.example b/backend/.env.example index d006aa695..d82b93f74 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,8 @@ PORT=3001 FRONTEND_URL=http://localhost:3000 +ROSS_ENV=local +# Comma-separated exact browser origins. FRONTEND_URL remains a compatibility fallback. +CORS_ALLOWED_ORIGINS=http://localhost:3000 # HMAC key used to sign /download/:token URLs. Required at startup. # Generate with: openssl rand -hex 32 @@ -21,3 +24,17 @@ USER_API_KEYS_ENCRYPTION_SECRET=your-long-random-secret # Optional: enables higher-rate CourtListener case law/citation lookup tools. COURTLISTENER_API_TOKEN=your-courtlistener-token + +# Licensed-source connectors are disabled by default. Do not enable without an +# executed provider agreement and an approved transport adapter. +CANLII_CONNECTOR_ENABLED=false +CANLII_CONTRACT_ID= +CANLII_ORGANIZATION_ID= +CANLII_API_KEY= +CANLII_API_BASE_URL= +CANLII_APPROVED_TRANSPORT= +CANLII_ALLOWED_OPERATIONS= +CANLII_FULL_TEXT_ENTITLED=false +CANLII_METADATA_RETENTION_DAYS=0 +CANLII_FULL_TEXT_RETENTION_DAYS=0 +CANLII_REDISTRIBUTION_ALLOWED=false diff --git a/backend/migrations/20260716_01_ontario_jurisdiction_settings.sql b/backend/migrations/20260716_01_ontario_jurisdiction_settings.sql new file mode 100644 index 000000000..90ca117f4 --- /dev/null +++ b/backend/migrations/20260716_01_ontario_jurisdiction_settings.sql @@ -0,0 +1,65 @@ +-- ROSS-100: provider-neutral legal research settings with Ontario defaults. +-- +-- The released legal_research_us column remains for backwards compatibility. +-- New application code reads the generic fields and derives the legacy flag. + +alter table public.user_profiles + add column if not exists legal_research_enabled boolean not null default true, + add column if not exists default_country text not null default 'CA', + add column if not exists default_province text default 'ON', + add column if not exists enabled_jurisdictions text[] not null + default array['CA-ON', 'CA', 'US']::text[], + add column if not exists enabled_source_providers text[] not null + default array['a2aj-canada', 'ontario-elaws', 'justice-laws-canada', 'courtlistener-us']::text[]; + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conname = 'user_profiles_default_country_check' + and conrelid = 'public.user_profiles'::regclass + ) then + alter table public.user_profiles + add constraint user_profiles_default_country_check + check (default_country in ('CA', 'US')); + end if; + + if not exists ( + select 1 from pg_constraint + where conname = 'user_profiles_default_province_check' + and conrelid = 'public.user_profiles'::regclass + ) then + alter table public.user_profiles + add constraint user_profiles_default_province_check + check (default_province is null or default_province = 'ON'); + end if; +end; +$$; + +-- Respect an existing user's U.S. opt-out while adding Ontario and federal +-- Canada. No existing U.S. entitlement is removed. +update public.user_profiles +set + enabled_jurisdictions = case + when legal_research_us then array['CA-ON', 'CA', 'US']::text[] + else array['CA-ON', 'CA']::text[] + end, + enabled_source_providers = case + when legal_research_us then array['a2aj-canada', 'ontario-elaws', 'justice-laws-canada', 'courtlistener-us']::text[] + else array['a2aj-canada', 'ontario-elaws', 'justice-laws-canada']::text[] + end +where enabled_jurisdictions = array['CA-ON', 'CA', 'US']::text[] + and enabled_source_providers = array['a2aj-canada', 'ontario-elaws', 'justice-laws-canada', 'courtlistener-us']::text[]; + +alter table public.projects + add column if not exists jurisdictions text[] not null + default array['CA-ON', 'CA']::text[]; + +alter table public.chats + add column if not exists jurisdictions text[] not null + default array['CA-ON', 'CA']::text[], + add column if not exists legal_as_of_date date; + +alter table public.workflows + alter column practice set default 'Civil Litigation', + alter column jurisdictions set default array['Canada / Ontario']::text[]; diff --git a/backend/migrations/20260716_02_ontario_procedure_sources.sql b/backend/migrations/20260716_02_ontario_procedure_sources.sql new file mode 100644 index 000000000..738415a8c --- /dev/null +++ b/backend/migrations/20260716_02_ontario_procedure_sources.sql @@ -0,0 +1,24 @@ +-- ROSS-120: immutable audit records for official Ontario procedure-source +-- metadata checks. Source text and court forms are not copied into this table. + +create table if not exists public.legal_source_version_checks ( + id bigint generated by default as identity primary key, + source_id text not null, + source_url text not null, + checked_at timestamptz not null, + reachable boolean not null, + etag text, + last_modified text, + metadata_hash text not null, + created_at timestamptz not null default now(), + constraint legal_source_version_checks_metadata_hash_format + check (metadata_hash ~ '^[a-f0-9]{64}$'), + constraint legal_source_version_checks_source_metadata_unique + unique (source_id, metadata_hash) +); + +alter table public.legal_source_version_checks enable row level security; + +-- Checks are written and read only by the backend service role. No browser +-- grant or permissive RLS policy is intentionally created. +revoke all on table public.legal_source_version_checks from anon, authenticated; diff --git a/backend/package.json b/backend/package.json index 195a6acfc..3eb32b5b6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", + "test:legal-sources": "node --import tsx --test src/lib/legalSources/*.test.ts", "start": "node dist/index.js" }, "dependencies": { diff --git a/backend/schema.sql b/backend/schema.sql index 5e48fa51a..da5f34000 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -23,6 +23,11 @@ create table if not exists public.user_profiles ( quote_model text, mfa_on_login boolean not null default false, legal_research_us boolean not null default true, + legal_research_enabled boolean not null default true, + default_country text not null default 'CA' check (default_country in ('CA', 'US')), + default_province text default 'ON' check (default_province is null or default_province = 'ON'), + enabled_jurisdictions text[] not null default array['CA-ON', 'CA', 'US']::text[], + enabled_source_providers text[] not null default array['a2aj-canada', 'ontario-elaws', 'justice-laws-canada', 'courtlistener-us']::text[], created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); @@ -197,6 +202,7 @@ create table if not exists public.projects ( name text not null, cm_number text, practice text, + jurisdictions text[] not null default array['CA-ON', 'CA']::text[], visibility text not null default 'private', shared_with jsonb not null default '[]'::jsonb, created_at timestamptz not null default now(), @@ -335,8 +341,8 @@ create table if not exists public.workflows ( prompt_md text, columns_config jsonb, language text default 'English', - practice text default 'General Transactions', - jurisdictions text[] default array['General']::text[], + practice text default 'Civil Litigation', + jurisdictions text[] default array['Canada / Ontario']::text[], created_at timestamptz not null default now() ); @@ -474,6 +480,8 @@ create table if not exists public.chats ( project_id uuid references public.projects(id) on delete cascade, user_id text not null, title text, + jurisdictions text[] not null default array['CA-ON', 'CA']::text[], + legal_as_of_date date, created_at timestamptz not null default now() ); diff --git a/backend/src/config/runtime.ts b/backend/src/config/runtime.ts new file mode 100644 index 000000000..70daf4007 --- /dev/null +++ b/backend/src/config/runtime.ts @@ -0,0 +1,83 @@ +export type RossEnvironment = "local" | "test" | "staging" | "production"; + +export type RuntimeConfig = { + environment: RossEnvironment; + port: number; + allowedOrigins: string[]; +}; + +const PLACEHOLDER = /(^|[.:/])(example\.invalid|localhost)([/:]|$)|your-|replace-with/i; + +function cleanUrl(value: string, name: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error(`${name} must be an absolute URL.`); + } + if (!/^https?:$/.test(parsed.protocol)) { + throw new Error(`${name} must use http or https.`); + } + return parsed.origin; +} + +export function parseAllowedOrigins(value?: string): string[] { + const configured = value?.trim() || "http://localhost:3000"; + const origins = Array.from( + new Set( + configured + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean) + .map((origin) => cleanUrl(origin, "CORS_ALLOWED_ORIGINS")), + ), + ); + if (!origins.length) throw new Error("At least one CORS origin is required."); + return origins; +} + +function requiredProductionValue(name: string): string { + const value = process.env[name]?.trim(); + if (!value || PLACEHOLDER.test(value)) { + throw new Error(`${name} must be configured with a non-placeholder production value.`); + } + return value; +} + +function environment(): RossEnvironment { + const value = (process.env.ROSS_ENV ?? process.env.NODE_ENV ?? "local").toLowerCase(); + if (value === "development") return "local"; + if (value === "local" || value === "test" || value === "staging" || value === "production") { + return value; + } + throw new Error(`Unsupported ROSS_ENV: ${value}`); +} + +export function loadRuntimeConfig(): RuntimeConfig { + const currentEnvironment = environment(); + const allowedOrigins = parseAllowedOrigins( + process.env.CORS_ALLOWED_ORIGINS ?? process.env.FRONTEND_URL, + ); + + if (currentEnvironment === "production") { + for (const name of [ + "SUPABASE_URL", + "SUPABASE_SECRET_KEY", + "DOWNLOAD_SIGNING_SECRET", + "R2_ENDPOINT_URL", + "R2_ACCESS_KEY_ID", + "R2_SECRET_ACCESS_KEY", + "R2_BUCKET_NAME", + ]) requiredProductionValue(name); + if (allowedOrigins.some((origin) => PLACEHOLDER.test(origin))) { + throw new Error("Production CORS origins cannot use localhost or placeholder domains."); + } + } + + const requestedPort = Number.parseInt(process.env.PORT ?? "3001", 10); + return { + environment: currentEnvironment, + port: Number.isFinite(requestedPort) && requestedPort > 0 ? requestedPort : 3001, + allowedOrigins, + }; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index cd99edc44..0dd08e954 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,9 +12,12 @@ import { workflowsRouter } from "./routes/workflows"; import { userRouter } from "./routes/user"; import { downloadsRouter } from "./routes/downloads"; import { caseLawRouter } from "./routes/caseLaw"; +import { legalSourcesRouter } from "./routes/legalSources"; +import { loadRuntimeConfig } from "./config/runtime"; const app = express(); -const PORT = process.env.PORT ?? 3001; +const runtime = loadRuntimeConfig(); +const PORT = runtime.port; const isProduction = process.env.NODE_ENV === "production"; function envInt(name: string, fallback: number): number { @@ -113,7 +116,13 @@ app.use( app.use( cors({ - origin: process.env.FRONTEND_URL ?? "http://localhost:3000", + origin(origin, callback) { + if (!origin || runtime.allowedOrigins.includes(origin)) { + callback(null, true); + return; + } + callback(new Error("Origin is not allowed by ROSS CORS policy.")); + }, credentials: true, }), ); @@ -155,9 +164,12 @@ app.use("/user", userRouter); app.use("/users", userRouter); app.use("/download", downloadsRouter); app.use("/case-law", caseLawRouter); +app.use("/legal-sources", legalSourcesRouter); -app.get("/health", (_req, res) => res.json({ ok: true })); +app.get("/health", (_req, res) => + res.json({ ok: true, service: "ross-api", environment: runtime.environment }), +); app.listen(PORT, () => { - console.log(`Mike backend running on port ${PORT}`); + console.log(`ROSS API running on port ${PORT} (${runtime.environment})`); }); diff --git a/backend/src/lib/chat/contextBuilders.ts b/backend/src/lib/chat/contextBuilders.ts index e58011383..6d64d5e38 100644 --- a/backend/src/lib/chat/contextBuilders.ts +++ b/backend/src/lib/chat/contextBuilders.ts @@ -11,7 +11,7 @@ import { type AskInputResponseItem, devLog, } from "./types"; -import { buildSystemPrompt } from "./prompts"; +import { buildSystemPrompt, type ResearchPromptSettings } from "./prompts"; import { parseCitations, createCitation } from "./citations"; import type { AssistantEvent } from "./streaming"; @@ -131,10 +131,10 @@ export function buildMessages( }[], systemPromptExtra?: string, docIndex?: DocIndex, - includeResearchTools = true, + researchSettings: boolean | ResearchPromptSettings = true, ) { const formatted: unknown[] = []; - let systemContent = buildSystemPrompt(includeResearchTools); + let systemContent = buildSystemPrompt(researchSettings); if (systemPromptExtra) { systemContent += `\n\n${systemPromptExtra.trim()}`; diff --git a/backend/src/lib/chat/prompts.ts b/backend/src/lib/chat/prompts.ts index d7f7b88e5..701d1c7c8 100644 --- a/backend/src/lib/chat/prompts.ts +++ b/backend/src/lib/chat/prompts.ts @@ -1,9 +1,11 @@ import { COURTLISTENER_SYSTEM_PROMPT } from "./tools/courtlistenerTools"; -const SYSTEM_PROMPT_BEFORE_RESEARCH = `You are Mike, an AI legal assistant for lawyers and legal professionals. Help analyze documents, answer legal questions, and draft legal documents. +const SYSTEM_PROMPT_BEFORE_RESEARCH = `You are ROSS, an AI legal work assistant for lawyers and legal professionals. Help analyse documents, answer legal questions, and draft legal documents. CORE RULES: - Be precise, professional, and evidence-aware. +- Do not imply that you are a lawyer, law firm, court service, or government service. +- A lawyer or licensed paralegal must review pleadings, contracts, factums, affidavits, filing materials, and legal conclusions before reliance or filing. - Do not fabricate document content. - Use at most 10 tool-use rounds per response. Batch independent tool calls and leave room for the final answer. - Read each relevant document/version at most once per response. After read_document or fetch_documents returns a document's full text, do not call either tool again for that same document/version in the same response; use the prior result, call find_in_document for targeted checks, or proceed to the next required tool. @@ -64,20 +66,78 @@ const SYSTEM_PROMPT_AFTER_RESEARCH = `DOCUMENT NAMES IN PROSE: GENERAL GUIDANCE: - Cite the exact document or fetched opinion passage for evidence-backed claims. -- If no documents are provided, answer from legal knowledge. +- Never invent a case, citation, quotation, statutory provision, court form, deadline, or procedural requirement. +- If research is unavailable or incomplete, say what was not verified. Do not silently substitute model memory for an unavailable legal source. - Do not use emojis. `; +export type ResearchPromptSettings = { + enabled: boolean; + defaultCountry: "CA" | "US"; + defaultProvince: "ON" | null; + enabledJurisdictions: Array<"CA-ON" | "CA" | "US">; + enabledSourceProviders: string[]; +}; + +const DEFAULT_RESEARCH_SETTINGS: ResearchPromptSettings = { + enabled: true, + defaultCountry: "CA", + defaultProvince: "ON", + enabledJurisdictions: ["CA-ON", "CA", "US"], + enabledSourceProviders: [ + "a2aj-canada", + "ontario-elaws", + "justice-laws-canada", + "courtlistener-us", + ], +}; + +function researchInstructions(settings: ResearchPromptSettings) { + if (!settings.enabled) { + return `LEGAL RESEARCH STATUS: +- Legal research tools are disabled. Clearly label every legal authority or proposition as not verified unless the user supplied the source text.`; + } + const enabled = settings.enabledJurisdictions.join(", ") || "none"; + const defaultLabel = + settings.defaultCountry === "CA" && settings.defaultProvince === "ON" + ? "Ontario, Canada" + : "United States"; + return `ONTARIO AND CANADIAN LEGAL RESEARCH: +- Default jurisdiction: ${defaultLabel}. Enabled jurisdiction codes: ${enabled}. +- Apply Ontario law and applicable federal Canadian law only when the matter is identified as Ontario. Ask one focused question when the governing jurisdiction, court, region, or material date is genuinely ambiguous. +- Identify the jurisdiction and the requested or current as-of date before giving a legal conclusion. +- Prefer binding primary authority. Distinguish binding, persuasive, and secondary authority. +- For every legal proposition researched in this turn, retrieve the exact supporting passage. Do not cite an authority merely because its title or citation appeared in search results. +- Use search_legal_sources for discovery, fetch_legal_source for source metadata, find_in_legal_source for the exact passage, and verify_legal_citations for citation checks. Do not skip the passage step for a researched proposition. +- Prefer neutral citations and paragraph-level pinpoints. Preserve authoritative English and French source text without silently translating official titles. +- Official, current source metadata controls over model memory. Do not substitute current law for historical law without disclosure. +- Track citation verification, passage verification, currency, and treatment separately. The absence of negative treatment does not prove good law when comprehensive current treatment data is unavailable. +- Use Canadian spelling, explicit CAD currency, and unambiguous dates such as 15 July 2026 or 2026-07-15 unless the user or source requires another style. +- If a requested court, tribunal, date, form, or regional practice direction is outside published coverage, identify the exact gap and stop short of claiming verification.`; +} + /** - * Assemble the chat system prompt. When `includeResearchTools` is true the - * CourtListener (US case-law) research instructions are spliced in; when - * false they are omitted entirely so the model is not told about tools it - * does not have. + * Assemble the Ontario-first prompt while accepting the old boolean argument + * used by inherited Mike callers. CourtListener instructions remain additive + * and appear only when the U.S. jurisdiction and provider are enabled. */ -export function buildSystemPrompt(includeResearchTools = true): string { - return includeResearchTools - ? `${SYSTEM_PROMPT_BEFORE_RESEARCH}\n\n${COURTLISTENER_SYSTEM_PROMPT}\n${SYSTEM_PROMPT_AFTER_RESEARCH}` - : `${SYSTEM_PROMPT_BEFORE_RESEARCH}\n\n${SYSTEM_PROMPT_AFTER_RESEARCH}`; +export function buildSystemPrompt( + input: boolean | ResearchPromptSettings = DEFAULT_RESEARCH_SETTINGS, +): string { + const settings = + typeof input === "boolean" + ? { ...DEFAULT_RESEARCH_SETTINGS, enabled: input } + : input; + const includeCourtListener = + settings.enabled && + settings.enabledJurisdictions.includes("US") && + settings.enabledSourceProviders.includes("courtlistener-us"); + return [ + SYSTEM_PROMPT_BEFORE_RESEARCH, + researchInstructions(settings), + ...(includeCourtListener ? [COURTLISTENER_SYSTEM_PROMPT] : []), + SYSTEM_PROMPT_AFTER_RESEARCH, + ].join("\n\n"); } -export const SYSTEM_PROMPT = buildSystemPrompt(true); +export const SYSTEM_PROMPT = buildSystemPrompt(DEFAULT_RESEARCH_SETTINGS); diff --git a/backend/src/lib/chat/streaming.ts b/backend/src/lib/chat/streaming.ts index f6ddacb2d..adf442494 100644 --- a/backend/src/lib/chat/streaming.ts +++ b/backend/src/lib/chat/streaming.ts @@ -7,15 +7,16 @@ import { } from "../llm"; import { safeErrorMessage } from "../safeError"; import { createServerSupabase } from "../supabase"; -import { - buildUserMcpTools, - type McpToolEvent, -} from "../mcpConnectors"; +import { buildUserMcpTools, type McpToolEvent } from "../mcpConnectors"; import { COURTLISTENER_TOOLS, type CaseCitationEvent, type CourtlistenerToolEvent, } from "./tools/courtlistenerTools"; +import { + LEGAL_SOURCE_TOOLS, + type LegalSourceToolEvent, +} from "./tools/legalSourceTools"; import { type DocStore, type DocIndex, @@ -37,11 +38,7 @@ import { runToolCalls, type CourtlistenerTurnState, } from "./tools/toolDispatcher"; -import { - type TurnEditState, - type TurnReadState, -} from "./tools/documentOps"; - +import { type TurnEditState, type TurnReadState } from "./tools/documentOps"; export type AssistantEvent = | { type: "reasoning"; text: string } @@ -97,6 +94,7 @@ export type AssistantEvent = } | CaseCitationEvent | CourtlistenerToolEvent + | LegalSourceToolEvent | McpToolEvent | { type: "case_opinions"; cluster_id: number; case: unknown } | { type: "content"; text: string } @@ -131,9 +129,7 @@ class AssistantStreamAskInputsPause extends Error { export function isAbortError(error: unknown): boolean { if (!error || typeof error !== "object") return false; const record = error as { name?: unknown; message?: unknown }; - return ( - record.name === "AbortError" || record.message === "Stream aborted." - ); + return record.name === "AbortError" || record.message === "Stream aborted."; } function throwIfAborted(signal?: AbortSignal) { @@ -186,7 +182,9 @@ export async function runLLMStream(params: { signal, projectId, } = params; - const researchTools = includeResearchTools ? COURTLISTENER_TOOLS : []; + const researchTools = includeResearchTools + ? [...LEGAL_SOURCE_TOOLS, ...COURTLISTENER_TOOLS] + : []; const mcpTools = await buildUserMcpTools(userId, db); const baseTools = [...TOOLS, ...researchTools, ...WORKFLOW_TOOLS]; const activeTools = extraTools?.length @@ -217,8 +215,8 @@ export async function runLLMStream(params: { // changes that document so a post-edit verification read can still happen. const turnReadState: TurnReadState = new Map(); const courtlistenerTurnState: CourtlistenerTurnState = { - casesByClusterId: new Map(), - }; + casesByClusterId: new Map(), + }; let fullText = ""; let iterText = ""; let iterVisibleText = ""; @@ -233,7 +231,9 @@ export async function runLLMStream(params: { citations: unknown[], ) => { if (buildCitations) return; - write(`data: ${JSON.stringify({ type: "citations", status, citations })}\n\n`); + write( + `data: ${JSON.stringify({ type: "citations", status, citations })}\n\n`, + ); }; const streamHiddenCitationContent = (delta: string) => { @@ -243,11 +243,7 @@ export async function runLLMStream(params: { if (partial.length <= streamedCitationCount) return; streamedCitationCount = partial.length; const citations = partial.map((c) => - createCitation( - c, - docIndex, - courtlistenerTurnState.casesByClusterId, - ), + createCitation(c, docIndex, courtlistenerTurnState.casesByClusterId), ); emitCitationStreamSnapshot("partial", citations); }; @@ -399,6 +395,7 @@ export async function runLLMStream(params: { askInputsEvents, courtlistenerEvents, caseCitationEvents, + legalSourceEvents, mcpEvents, } = await runToolCalls( toolCalls, @@ -474,6 +471,9 @@ export async function runLLMStream(params: { for (const event of courtlistenerEvents) { events.push(event); } + for (const event of legalSourceEvents) { + events.push(event); + } for (const event of mcpEvents) { events.push(event); } @@ -530,11 +530,7 @@ export async function runLLMStream(params: { const citations = buildCitations ? buildCitations(fullText) : parsedCitations.map((c) => - createCitation( - c, - docIndex, - courtlistenerTurnState.casesByClusterId, - ), + createCitation(c, docIndex, courtlistenerTurnState.casesByClusterId), ); devLog("[chat/stream] final citations", { hasCitationsBlock: citationDiagnostics.hasBlock, diff --git a/backend/src/lib/chat/tools/legalSourceTools.ts b/backend/src/lib/chat/tools/legalSourceTools.ts new file mode 100644 index 000000000..ad055f619 --- /dev/null +++ b/backend/src/lib/chat/tools/legalSourceTools.ts @@ -0,0 +1,117 @@ +export const LEGAL_SOURCE_TOOL_NAMES = { + search: "search_legal_sources", + fetch: "fetch_legal_source", + find: "find_in_legal_source", + verify: "verify_legal_citations", +} as const; + +export const LEGAL_SOURCE_TOOLS = [ + { + type: "function", + function: { + name: LEGAL_SOURCE_TOOL_NAMES.search, + description: + "Search an enabled legal-source provider. Returns metadata only; fetch and find an exact passage before citing an authority.", + parameters: { + type: "object", + properties: { + query: { type: "string" }, + jurisdiction: { type: "string", enum: ["CA-ON", "CA", "US"] }, + material_type: { + type: "string", + enum: ["decision", "legislation", "regulation", "rule"], + }, + provider_id: { type: "string" }, + court: { type: "string" }, + language: { type: "string", enum: ["en", "fr"] }, + from: { type: "string", description: "YYYY-MM-DD" }, + to: { type: "string", description: "YYYY-MM-DD" }, + limit: { type: "integer", minimum: 1, maximum: 20 }, + }, + required: ["query", "jurisdiction", "material_type"], + }, + }, + }, + { + type: "function", + function: { + name: LEGAL_SOURCE_TOOL_NAMES.fetch, + description: + "Fetch authoritative metadata for one search result. This does not by itself verify a proposition; use find_in_legal_source for the exact passage.", + parameters: { + type: "object", + properties: { + provider_id: { type: "string" }, + source_id: { type: "string" }, + material_type: { + type: "string", + enum: ["decision", "legislation", "regulation", "rule"], + }, + language: { type: "string", enum: ["en", "fr"] }, + section: { type: "string" }, + version_date: { type: "string", description: "YYYY-MM-DD" }, + }, + required: ["provider_id", "source_id", "material_type"], + }, + }, + }, + { + type: "function", + function: { + name: LEGAL_SOURCE_TOOL_NAMES.find, + description: + "Retrieve exact matching passages from a decision, statute, regulation, or rule. Use the returned passage URL and verification state when citing.", + parameters: { + type: "object", + properties: { + provider_id: { type: "string" }, + source_id: { type: "string" }, + material_type: { + type: "string", + enum: ["decision", "legislation", "regulation", "rule"], + }, + query: { type: "string" }, + section: { type: "string" }, + language: { type: "string", enum: ["en", "fr"] }, + max_results: { type: "integer", minimum: 1, maximum: 10 }, + }, + required: ["provider_id", "source_id", "material_type", "query"], + }, + }, + }, + { + type: "function", + function: { + name: LEGAL_SOURCE_TOOL_NAMES.verify, + description: + "Parse and verify Canadian citations through enabled authorized providers. Citation, passage, currency, and treatment status remain separate.", + parameters: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, + }, + }, +]; + +export type LegalSourceToolEvent = + | { + type: "legal_source_search"; + provider_id: string | null; + provider_name: string | null; + query: string; + result_count: number; + coverage_warning?: string; + error?: string; + } + | { + type: "legal_authority"; + action: "fetched" | "passages" | "verified"; + provider_id: string | null; + provider_name: string | null; + authority?: Record; + passage_count?: number; + error?: string; + }; diff --git a/backend/src/lib/chat/tools/toolDispatcher.ts b/backend/src/lib/chat/tools/toolDispatcher.ts index 3e6f67447..a241939ed 100644 --- a/backend/src/lib/chat/tools/toolDispatcher.ts +++ b/backend/src/lib/chat/tools/toolDispatcher.ts @@ -9,9 +9,18 @@ import { type CourtlistenerToolEvent, } from "./courtlistenerTools"; import { - executeMcpToolCall, - type McpToolEvent, -} from "../../mcpConnectors"; + LEGAL_SOURCE_TOOL_NAMES, + type LegalSourceToolEvent, +} from "./legalSourceTools"; +import { + createLegalSourceRegistry, + parseCanadianCitations, + verifyCanadianCitations, + type JurisdictionCode, + type LegalSourceProvider, +} from "../../legalSources"; +import { getUserModelSettings } from "../../userSettings"; +import { executeMcpToolCall, type McpToolEvent } from "../../mcpConnectors"; import { createServerSupabase } from "../../supabase"; import { type DocStore, @@ -25,11 +34,7 @@ import { devLog, resolveDocLabel, } from "../types"; -import { - downloadFile, - storageKey, - uploadFile, -} from "../../storage"; +import { downloadFile, storageKey, uploadFile } from "../../storage"; import { convertedPdfKey } from "../../convert"; import { contentTypeForDocumentType } from "../../documentTypes"; import { buildDownloadUrl } from "../../downloadTokens"; @@ -56,7 +61,6 @@ import { type TextMatch, } from "./documentOps"; - type CourtlistenerCaseRecord = { clusterId: number; caseName: string | null; @@ -91,7 +95,9 @@ function cleanAskInputString(value: unknown, fallback = ""): string { return text || fallback; } -function normalizeAskInputsEvent(args: Record): AskInputsEvent { +function normalizeAskInputsEvent( + args: Record, +): AskInputsEvent { const rawItems = Array.isArray(args.items) ? args.items : []; const items = rawItems .map((item, index): AskInputItem | null => { @@ -168,20 +174,21 @@ function upsertCourtlistenerCases( ): CourtlistenerCaseRecord[] { const records: CourtlistenerCaseRecord[] = []; for (const input of inputs) { - if (typeof input.clusterId !== "number" || !Number.isFinite(input.clusterId)) { + if ( + typeof input.clusterId !== "number" || + !Number.isFinite(input.clusterId) + ) { continue; } const clusterId = Math.floor(input.clusterId); - const current = - state.casesByClusterId.get(clusterId) ?? - { - clusterId, - caseName: null, - citations: [], - url: null, - pdfUrl: null, - dateFiled: null, - }; + const current = state.casesByClusterId.get(clusterId) ?? { + clusterId, + caseName: null, + citations: [], + url: null, + pdfUrl: null, + dateFiled: null, + }; const nextCitations = [ ...current.citations, ...(input.citation ? [input.citation] : []), @@ -259,7 +266,9 @@ function courtlistenerCaseInputFromFetchedCase( ): CourtlistenerCaseInput { const record = recordFromUnknown(fetchedCase); const clusterId = - numberField(record, "clusterId") ?? numberField(record, "id") ?? fallbackClusterId; + numberField(record, "clusterId") ?? + numberField(record, "id") ?? + fallbackClusterId; return { clusterId, caseName: stringField(record, "caseName"), @@ -285,8 +294,7 @@ function courtlistenerOpinionMetadata(raw: unknown) { ? stripCaseOpinionHtml(stringField(opinion, "html")!) : null); return { - opinion_id: - numberField(opinion, "opinionId") ?? numberField(opinion, "id"), + opinion_id: numberField(opinion, "opinionId") ?? numberField(opinion, "id"), type: stringField(opinion, "type"), author: stringField(opinion, "author"), per_curiam: stringField(opinion, "per_curiam"), @@ -395,7 +403,8 @@ function parseFindInCaseArgs(args: Record): FindInCaseArgs { clusterId: typeof args.clusterId === "number" && Number.isFinite(args.clusterId) ? Math.floor(args.clusterId) - : typeof args.cluster_id === "number" && Number.isFinite(args.cluster_id) + : typeof args.cluster_id === "number" && + Number.isFinite(args.cluster_id) ? Math.floor(args.cluster_id) : null, query: typeof args.query === "string" ? args.query : "", @@ -411,7 +420,10 @@ function parseFindInCaseArgs(args: Record): FindInCaseArgs { } function findInCaseSearchSummary( - event: Extract, + event: Extract< + CourtlistenerToolEvent, + { type: "courtlistener_find_in_case" } + >, ) { return { cluster_id: event.cluster_id, @@ -432,6 +444,336 @@ function cachedCaseNotFetchedResult(clusterId: number | null) { }; } +const LEGAL_SOURCE_NAMES = new Set( + Object.values(LEGAL_SOURCE_TOOL_NAMES), +); + +function legalSourceProviderContext( + providerId: string, + db: ReturnType, + apiKeys?: import("../../llm").UserApiKeys, +) { + return { + db, + apiToken: providerId === "courtlistener-us" ? apiKeys?.courtlistener : null, + }; +} + +function cleanLegalAuthority( + provider: LegalSourceProvider, + document: Record, + passages?: unknown[], +) { + return { + providerId: provider.descriptor.id, + providerName: provider.descriptor.name, + official: provider.descriptor.official, + fullTextStatus: provider.descriptor.fullTextStatus, + sourceId: document.sourceId ?? null, + kind: document.kind ?? "decision", + title: document.title ?? document.caseName ?? null, + citation: document.citation ?? null, + court: document.court ?? null, + jurisdiction: document.jurisdiction ?? null, + decisionDate: document.decisionDate ?? null, + currentToDate: document.currentToDate ?? null, + lastAmendedDate: document.lastAmendedDate ?? null, + retrievedAt: document.retrievedAt ?? null, + language: document.language ?? null, + canonicalUrl: document.canonicalUrl ?? null, + alternateLanguageUrl: document.alternateLanguageUrl ?? null, + verification: document.verification ?? "unverified", + reproductionIsOfficial: document.reproductionIsOfficial ?? null, + passages: passages ?? [], + }; +} + +async function executeLegalSourceTool(args: { + name: string; + input: Record; + userId: string; + db: ReturnType; + apiKeys?: import("../../llm").UserApiKeys; +}): Promise<{ content: string; event: LegalSourceToolEvent }> { + const { name, input, userId, db, apiKeys } = args; + const settings = (await getUserModelSettings(userId, db)).legal_research; + const registry = createLegalSourceRegistry(); + const jurisdiction = + input.jurisdiction === "CA-ON" || + input.jurisdiction === "CA" || + input.jurisdiction === "US" + ? (input.jurisdiction as JurisdictionCode) + : null; + const requestedProvider = + typeof input.provider_id === "string" ? input.provider_id : null; + const materialType = + typeof input.material_type === "string" ? input.material_type : "decision"; + const candidates = registry + .list({ jurisdiction: jurisdiction ?? undefined }) + .filter( + (provider) => + settings.enabled && + settings.enabledSourceProviders.includes(provider.descriptor.id) && + (!jurisdiction || + settings.enabledJurisdictions.includes(jurisdiction as never)) && + (!requestedProvider || provider.descriptor.id === requestedProvider) && + (materialType === "decision" + ? !!provider.searchDecisions || !!provider.fetchDecision + : !!provider.searchLegislation || !!provider.fetchLegislation), + ); + const provider = candidates[0]; + if (!provider) { + const message = requestedProvider + ? `Provider ${requestedProvider} is not enabled for this jurisdiction and material type.` + : `No enabled provider covers ${jurisdiction ?? "the requested jurisdiction"} ${materialType}.`; + return { + content: JSON.stringify({ error: message, coverage_gap: true }), + event: { + type: + name === LEGAL_SOURCE_TOOL_NAMES.search + ? "legal_source_search" + : "legal_authority", + ...(name === LEGAL_SOURCE_TOOL_NAMES.search + ? { + provider_id: null, + provider_name: null, + query: typeof input.query === "string" ? input.query : "", + result_count: 0, + } + : { + action: + name === LEGAL_SOURCE_TOOL_NAMES.verify + ? "verified" + : name === LEGAL_SOURCE_TOOL_NAMES.find + ? "passages" + : "fetched", + provider_id: null, + provider_name: null, + }), + error: message, + } as LegalSourceToolEvent, + }; + } + const context = legalSourceProviderContext( + provider.descriptor.id, + db, + apiKeys, + ); + + try { + if (name === LEGAL_SOURCE_TOOL_NAMES.search) { + const query = typeof input.query === "string" ? input.query.trim() : ""; + const limit = Math.min(20, Math.max(1, Number(input.limit) || 10)); + const results = + materialType === "decision" && provider.searchDecisions + ? await provider.searchDecisions( + { + query, + jurisdiction: jurisdiction ?? undefined, + court: + typeof input.court === "string" ? input.court : undefined, + language: input.language === "fr" ? "fr" : "en", + from: typeof input.from === "string" ? input.from : undefined, + to: typeof input.to === "string" ? input.to : undefined, + limit, + }, + context, + ) + : provider.searchLegislation + ? await provider.searchLegislation( + { + query, + jurisdiction: jurisdiction ?? undefined, + language: input.language === "fr" ? "fr" : "en", + kind: + materialType === "legislation" || + materialType === "regulation" || + materialType === "rule" + ? materialType + : undefined, + limit, + }, + context, + ) + : []; + const coverageWarning = + provider.descriptor.id === "a2aj-canada" && + typeof input.court === "string" && + ["ONSC", "ONCJ", "SMALL CLAIMS", "HRTO", "ONLTB"].includes( + input.court.toUpperCase(), + ) + ? `Published A2AJ coverage does not establish coverage for ${input.court}. Treat this as a known gap.` + : undefined; + return { + content: JSON.stringify({ + provider: provider.descriptor, + results, + ...(coverageWarning ? { coverage_warning: coverageWarning } : {}), + next_required_action: + "Fetch a selected source and find the exact supporting passage before citing it.", + }), + event: { + type: "legal_source_search", + provider_id: provider.descriptor.id, + provider_name: provider.descriptor.name, + query, + result_count: results.length, + ...(coverageWarning ? { coverage_warning: coverageWarning } : {}), + }, + }; + } + + if (name === LEGAL_SOURCE_TOOL_NAMES.verify) { + const text = + typeof input.text === "string" ? input.text.slice(0, 20_000) : ""; + const providers = registry + .list() + .filter((item) => + settings.enabledSourceProviders.includes(item.descriptor.id), + ); + const results = await verifyCanadianCitations( + parseCanadianCitations(text), + providers, + ); + return { + content: JSON.stringify({ + results, + warning: + "Citation, passage, currency, and treatment verification are separate.", + }), + event: { + type: "legal_authority", + action: "verified", + provider_id: null, + provider_name: null, + passage_count: results.filter( + (result) => result.passageVerification === "verified", + ).length, + }, + }; + } + + const sourceId = + typeof input.source_id === "string" ? input.source_id.trim() : ""; + const document = + materialType === "decision" && provider.fetchDecision + ? await provider.fetchDecision(sourceId, context) + : provider.fetchLegislation + ? await provider.fetchLegislation( + sourceId, + { + language: input.language === "fr" ? "fr" : "en", + section: + typeof input.section === "string" ? input.section : undefined, + versionDate: + typeof input.version_date === "string" + ? input.version_date + : undefined, + }, + context, + ) + : null; + if (!document) throw new Error("Provider cannot fetch this material type."); + + if (name === LEGAL_SOURCE_TOOL_NAMES.find) { + const query = typeof input.query === "string" ? input.query.trim() : ""; + const maxResults = Math.min( + 10, + Math.max(1, Number(input.max_results) || 5), + ); + const passages = + "passages" in document && provider.findPassages + ? provider.findPassages(document, query, maxResults) + : "sections" in document + ? document.sections + .filter( + (section) => + section.label === input.section || + section.text + .toLocaleLowerCase("en-CA") + .includes(query.toLocaleLowerCase("en-CA")), + ) + .slice(0, maxResults) + .map((section) => ({ + text: section.text, + language: document.language, + section: section.label, + heading: section.heading, + sourceUrl: section.sourceUrl, + verification: document.verification, + })) + : []; + const authority = cleanLegalAuthority( + provider, + document as unknown as Record, + passages, + ); + return { + content: JSON.stringify({ authority, passages }), + event: { + type: "legal_authority", + action: "passages", + provider_id: provider.descriptor.id, + provider_name: provider.descriptor.name, + authority, + passage_count: passages.length, + }, + }; + } + + const authority = cleanLegalAuthority( + provider, + document as unknown as Record, + ); + return { + content: JSON.stringify({ + authority, + next_required_action: + "Call find_in_legal_source and retrieve the exact supporting passage before citing this source.", + }), + event: { + type: "legal_authority", + action: "fetched", + provider_id: provider.descriptor.id, + provider_name: provider.descriptor.name, + authority, + }, + }; + } catch (error) { + const message = + error instanceof Error + ? error.message.slice(0, 500) + : "Legal source request failed."; + return { + content: JSON.stringify({ error: message }), + event: { + type: + name === LEGAL_SOURCE_TOOL_NAMES.search + ? "legal_source_search" + : "legal_authority", + ...(name === LEGAL_SOURCE_TOOL_NAMES.search + ? { + provider_id: provider.descriptor.id, + provider_name: provider.descriptor.name, + query: typeof input.query === "string" ? input.query : "", + result_count: 0, + } + : { + action: + name === LEGAL_SOURCE_TOOL_NAMES.verify + ? "verified" + : name === LEGAL_SOURCE_TOOL_NAMES.find + ? "passages" + : "fetched", + provider_id: provider.descriptor.id, + provider_name: provider.descriptor.name, + }), + error: message, + } as LegalSourceToolEvent, + }; + } +} + export async function runToolCalls( toolCalls: ToolCall[], docStore: DocStore, @@ -457,6 +799,7 @@ export async function runToolCalls( askInputsEvents: AskInputsEvent[]; courtlistenerEvents: CourtlistenerToolEvent[]; caseCitationEvents: CaseCitationEvent[]; + legalSourceEvents: LegalSourceToolEvent[]; mcpEvents: McpToolEvent[]; }> { const toolResults: unknown[] = []; @@ -473,12 +816,11 @@ export async function runToolCalls( const askInputsEvents: AskInputsEvent[] = []; const courtlistenerEvents: CourtlistenerToolEvent[] = []; const caseCitationEvents: CaseCitationEvent[] = []; + const legalSourceEvents: LegalSourceToolEvent[] = []; const mcpEvents: McpToolEvent[] = []; - const courtState: CourtlistenerTurnState = - courtlistenerState ?? - { - casesByClusterId: new Map(), - }; + const courtState: CourtlistenerTurnState = courtlistenerState ?? { + casesByClusterId: new Map(), + }; const groupedFindInCaseSearches = toolCalls .filter((tc) => tc.function.name === COURTLISTENER_TOOL_NAMES.findInCase) .map((tc) => { @@ -585,6 +927,24 @@ export async function runToolCalls( /* ignore */ } + if (LEGAL_SOURCE_NAMES.has(tc.function.name)) { + const result = await executeLegalSourceTool({ + name: tc.function.name, + input: args, + userId, + db, + apiKeys, + }); + legalSourceEvents.push(result.event); + write(`data: ${JSON.stringify(result.event)}\n\n`); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: result.content, + }); + continue; + } + if (tc.function.name.startsWith("mcp_")) { write( `data: ${JSON.stringify({ @@ -1061,7 +1421,9 @@ export async function runToolCalls( } const record = - typeof clusterId === "number" ? courtState.casesByClusterId.get(clusterId) : undefined; + typeof clusterId === "number" + ? courtState.casesByClusterId.get(clusterId) + : undefined; if (!record) { const payload = cachedCaseNotFetchedResult(clusterId); const event: CourtlistenerToolEvent = { @@ -1158,7 +1520,9 @@ export async function runToolCalls( ); const record = - typeof clusterId === "number" ? courtState.casesByClusterId.get(clusterId) : undefined; + typeof clusterId === "number" + ? courtState.casesByClusterId.get(clusterId) + : undefined; if (!record) { const payload = cachedCaseNotFetchedResult(clusterId); const event: CourtlistenerToolEvent = { @@ -1202,8 +1566,7 @@ export async function runToolCalls( opinions: (record.opinions ?? []) .map(courtlistenerOpinionMetadata) .filter( - (opinion): opinion is NonNullable => - !!opinion, + (opinion): opinion is NonNullable => !!opinion, ), error: multipleOpinions ? "Multiple opinions are available. Call courtlistener_read_case again with the opinionId or opinionIds needed." @@ -1891,6 +2254,7 @@ export async function runToolCalls( askInputsEvents, courtlistenerEvents, caseCitationEvents, + legalSourceEvents, mcpEvents, }; } diff --git a/backend/src/lib/legalSources/a2ajClient.ts b/backend/src/lib/legalSources/a2ajClient.ts new file mode 100644 index 000000000..c59c7d4df --- /dev/null +++ b/backend/src/lib/legalSources/a2ajClient.ts @@ -0,0 +1,245 @@ +import { z } from "zod"; + +const A2AJ_DEFAULT_BASE_URL = "https://api.a2aj.ca"; +const DEFAULT_TIMEOUT_MS = 15_000; +const DEFAULT_MAX_RETRIES = 2; +const DEFAULT_FAILURE_THRESHOLD = 3; +const DEFAULT_CIRCUIT_RESET_MS = 30_000; + +const a2ajDocumentSchema = z + .object({ + dataset: z.string().trim().min(1), + citation_en: z.string().nullish(), + citation_fr: z.string().nullish(), + citation2_en: z.string().nullish(), + citation2_fr: z.string().nullish(), + name_en: z.string().nullish(), + name_fr: z.string().nullish(), + document_date_en: z.string().nullish(), + document_date_fr: z.string().nullish(), + url_en: z.string().nullish(), + url_fr: z.string().nullish(), + unofficial_text_en: z.string().nullish(), + unofficial_text_fr: z.string().nullish(), + scraped_timestamp_en: z.string().nullish(), + scraped_timestamp_fr: z.string().nullish(), + cases_cited_en: z.array(z.string()).nullish(), + cases_cited_fr: z.array(z.string()).nullish(), + cases_citing_en: z.array(z.string()).nullish(), + cases_citing_fr: z.array(z.string()).nullish(), + citing_cases_count: z.number().int().nonnegative().nullish(), + upstream_license: z.string().nullish(), + }) + .passthrough(); + +const searchResponseSchema = z + .object({ + results: z.array(a2ajDocumentSchema), + total: z.number().int().nonnegative().optional(), + }) + .passthrough(); + +const documentResultSchema = z + .object({ result: a2ajDocumentSchema }) + .passthrough(); +const documentResultsSchema = z + .object({ results: z.array(a2ajDocumentSchema).min(1) }) + .passthrough(); +const coverageRowsSchema = z.array(z.record(z.unknown())); + +export type A2ajDocument = z.infer; +export type A2ajCoverageRow = Record; + +export class A2ajApiError extends Error { + constructor( + message: string, + readonly status?: number, + readonly retryAfterSeconds?: number, + ) { + super(message); + this.name = "A2ajApiError"; + } +} + +type A2ajClientOptions = { + baseUrl?: string; + timeoutMs?: number; + maxRetries?: number; + failureThreshold?: number; + circuitResetMs?: number; + fetchImpl?: typeof fetch; + now?: () => number; + sleep?: (milliseconds: number) => Promise; +}; + +export class A2ajClient { + private readonly baseUrl: string; + private readonly timeoutMs: number; + private readonly maxRetries: number; + private readonly failureThreshold: number; + private readonly circuitResetMs: number; + private readonly fetchImpl: typeof fetch; + private readonly now: () => number; + private readonly sleep: (milliseconds: number) => Promise; + private consecutiveFailures = 0; + private circuitOpenedAt: number | null = null; + + constructor(options: A2ajClientOptions = {}) { + this.baseUrl = ( + options.baseUrl ?? + process.env.A2AJ_API_BASE_URL ?? + A2AJ_DEFAULT_BASE_URL + ).replace(/\/$/, ""); + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; + this.failureThreshold = + options.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD; + this.circuitResetMs = + options.circuitResetMs ?? DEFAULT_CIRCUIT_RESET_MS; + this.fetchImpl = options.fetchImpl ?? fetch; + this.now = options.now ?? Date.now; + this.sleep = + options.sleep ?? + ((milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds))); + } + + async search(input: { + query: string; + dataset?: string; + language?: "en" | "fr"; + from?: string; + to?: string; + size?: number; + offset?: number; + }) { + const params = new URLSearchParams({ + query: input.query, + doc_type: "cases", + size: String(Math.min(100, Math.max(1, input.size ?? 10))), + }); + if (input.dataset) params.set("dataset", input.dataset); + if (input.language) params.set("language", input.language); + if (input.from) params.set("date_from", input.from); + if (input.to) params.set("date_to", input.to); + if (input.offset) params.set("from", String(Math.max(0, input.offset))); + return searchResponseSchema.parse( + await this.request(`/search?${params}`), + ); + } + + async fetchByCitation(citation: string) { + const params = new URLSearchParams({ citation, doc_type: "cases" }); + const raw = await this.request(`/fetch?${params}`); + const direct = a2ajDocumentSchema.safeParse(raw); + if (direct.success) return direct.data; + const result = documentResultSchema.safeParse(raw); + if (result.success) return result.data.result; + return documentResultsSchema.parse(raw).results[0]; + } + + async coverage(): Promise { + const raw = await this.request("/coverage"); + const direct = coverageRowsSchema.safeParse(raw); + if (direct.success) return direct.data; + const wrapper = z.record(z.unknown()).parse(raw); + for (const key of ["coverage", "datasets", "results"] as const) { + const rows = coverageRowsSchema.safeParse(wrapper[key]); + if (rows.success) return rows.data; + } + throw new A2ajApiError("A2AJ returned an invalid coverage response."); + } + + private assertCircuitAvailable() { + if (this.circuitOpenedAt === null) return; + if (this.now() - this.circuitOpenedAt >= this.circuitResetMs) { + this.circuitOpenedAt = null; + this.consecutiveFailures = 0; + return; + } + throw new A2ajApiError( + "A2AJ is temporarily unavailable because its circuit breaker is open.", + ); + } + + private noteSuccess() { + this.consecutiveFailures = 0; + this.circuitOpenedAt = null; + } + + private noteFailure() { + this.consecutiveFailures += 1; + if (this.consecutiveFailures >= this.failureThreshold) + this.circuitOpenedAt = this.now(); + } + + private async request(path: string): Promise { + this.assertCircuitAvailable(); + let lastError: unknown; + for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) { + try { + const response = await this.fetchImpl( + `${this.baseUrl}${path}`, + { + headers: { + Accept: "application/json", + "User-Agent": "ROSS-RanadeOSS/0.1", + }, + signal: AbortSignal.timeout(this.timeoutMs), + }, + ); + if (!response.ok) { + const detail = ( + await response.text().catch(() => "") + ).trim(); + const retryAfterSeconds = parseRetryAfter( + response.headers.get("retry-after"), + ); + throw new A2ajApiError( + detail + ? `A2AJ error (${response.status}): ${detail}` + : `A2AJ error (${response.status})`, + response.status, + retryAfterSeconds, + ); + } + const json = (await response.json()) as unknown; + this.noteSuccess(); + return json; + } catch (error) { + lastError = error; + if (!isRetriable(error) || attempt === this.maxRetries) break; + const retryAfter = + error instanceof A2ajApiError + ? error.retryAfterSeconds + : undefined; + const delay = + retryAfter !== undefined + ? Math.min(5_000, retryAfter * 1_000) + : 250 * 2 ** attempt; + await this.sleep(delay); + } + } + this.noteFailure(); + if (lastError instanceof Error) throw lastError; + throw new A2ajApiError("A2AJ request failed."); + } +} + +function parseRetryAfter(value: string | null): number | undefined { + if (!value) return undefined; + const seconds = Number(value); + return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined; +} + +function isRetriable(error: unknown) { + if (!(error instanceof A2ajApiError)) + return ( + error instanceof TypeError || + (error instanceof Error && error.name === "TimeoutError") + ); + return ( + error.status === 429 || + (error.status !== undefined && error.status >= 500) + ); +} diff --git a/backend/src/lib/legalSources/a2ajProvider.test.ts b/backend/src/lib/legalSources/a2ajProvider.test.ts new file mode 100644 index 000000000..3b9a60dd0 --- /dev/null +++ b/backend/src/lib/legalSources/a2ajProvider.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { A2ajApiError, A2ajClient } from "./a2ajClient"; +import { A2ajProvider } from "./a2ajProvider"; + +const syntheticDecision = { + dataset: "ONCA", + citation_en: "2025 ONCA 999", + citation_fr: "", + citation2_en: "", + citation2_fr: "", + name_en: "Synthetic Applicant v. Synthetic Respondent", + name_fr: "", + document_date_en: "2025-03-04T00:00:00", + document_date_fr: "", + url_en: "https://example.invalid/official/2025-onca-999", + url_fr: "", + unofficial_text_en: + "[1] This is a SYNTHETIC decision.\n\n[2] The synthetic housing issue is allowed.", + unofficial_text_fr: "", + scraped_timestamp_en: "2025-03-05T12:00:00Z", + scraped_timestamp_fr: "", + cases_cited_en: ["2020 SCC 5"], + cases_cited_fr: null, + cases_citing_en: [], + cases_citing_fr: null, + citing_cases_count: 0, + upstream_license: "SYNTHETIC TEST LICENCE", +}; + +test("A2AJ client sends bounded documented search parameters", async () => { + const urls: string[] = []; + const client = new A2ajClient({ + fetchImpl: async (input) => { + urls.push(String(input)); + return Response.json({ results: [syntheticDecision], total: 1 }); + }, + }); + const result = await client.search({ + query: "housing", + dataset: "ONCA", + language: "en", + size: 5, + offset: 10, + }); + assert.equal(result.results[0].citation_en, "2025 ONCA 999"); + const url = new URL(urls[0]); + assert.equal(url.pathname, "/search"); + assert.equal(url.searchParams.get("doc_type"), "cases"); + assert.equal(url.searchParams.get("dataset"), "ONCA"); + assert.equal(url.searchParams.get("size"), "5"); + assert.equal(url.searchParams.get("from"), "10"); +}); + +test("A2AJ client retries transient failures and opens its circuit", async () => { + let calls = 0; + const delays: number[] = []; + const retrying = new A2ajClient({ + maxRetries: 1, + sleep: async (milliseconds) => { + delays.push(milliseconds); + }, + fetchImpl: async () => { + calls += 1; + return calls === 1 + ? new Response("busy", { status: 503 }) + : Response.json({ results: [syntheticDecision] }); + }, + }); + await retrying.search({ query: "housing" }); + assert.equal(calls, 2); + assert.deepEqual(delays, [250]); + + const failing = new A2ajClient({ + maxRetries: 0, + failureThreshold: 2, + fetchImpl: async () => new Response("busy", { status: 503 }), + }); + await assert.rejects( + () => failing.coverage(), + (error) => error instanceof A2ajApiError && error.status === 503, + ); + await assert.rejects( + () => failing.coverage(), + (error) => error instanceof A2ajApiError && error.status === 503, + ); + await assert.rejects(() => failing.coverage(), /circuit breaker is open/); +}); + +test("A2AJ provider maps Ontario metadata and grounded passages", async () => { + const client = { + search: async () => ({ results: [syntheticDecision], total: 1 }), + fetchByCitation: async () => syntheticDecision, + coverage: async () => [ + { + dataset: "ONCA", + count: 1, + first_date: "2025-03-04", + last_date: "2025-03-04", + }, + ], + } as unknown as A2ajClient; + const provider = new A2ajProvider(client); + const [summary] = await provider.searchDecisions({ + query: "housing", + jurisdiction: "CA-ON", + }); + assert.equal(summary.jurisdiction, "CA-ON"); + assert.equal(summary.court, "Ontario Court of Appeal"); + assert.equal(summary.fullTextStatus, "unofficial"); + assert.equal(summary.canonicalUrl, syntheticDecision.url_en); + assert.equal(summary.upstreamLicense, "SYNTHETIC TEST LICENCE"); + + const document = await provider.fetchDecision("2025 ONCA 999"); + const passages = provider.findPassages(document, "housing allowed"); + assert.equal(passages.length, 1); + assert.equal(passages[0].paragraphStart, 2); + assert.match(passages[0].text, /housing issue is allowed/); + assert.equal(passages[0].verification, "partial"); + + const coverage = await provider.coverage(); + assert.equal(coverage[0].dataset, "ONCA"); + assert.equal(coverage[0].documentCount, 1); +}); diff --git a/backend/src/lib/legalSources/a2ajProvider.ts b/backend/src/lib/legalSources/a2ajProvider.ts new file mode 100644 index 000000000..ce5130295 --- /dev/null +++ b/backend/src/lib/legalSources/a2ajProvider.ts @@ -0,0 +1,361 @@ +import { + A2ajClient, + type A2ajCoverageRow, + type A2ajDocument, +} from "./a2ajClient"; +import type { + JurisdictionCode, + LegalCitationResult, + LegalDecisionDocument, + LegalDecisionSummary, + LegalSourceCoverage, + LegalSourceLanguage, + LegalSourceProvider, + LegalSourcePassage, +} from "./types"; + +const CANADIAN_DATASETS: Record< + string, + { label: string; jurisdiction: JurisdictionCode } +> = { + ONCA: { label: "Ontario Court of Appeal", jurisdiction: "CA-ON" }, + SCC: { label: "Supreme Court of Canada", jurisdiction: "CA" }, + FCA: { label: "Federal Court of Appeal", jurisdiction: "CA" }, + FC: { label: "Federal Court", jurisdiction: "CA" }, + TCC: { label: "Tax Court of Canada", jurisdiction: "CA" }, + CMAC: { label: "Court Martial Appeal Court", jurisdiction: "CA" }, +}; + +const text = (value: unknown): string | null => + typeof value === "string" && value.trim() ? value.trim() : null; +const number = (value: unknown): number | null => { + if (typeof value === "number" && Number.isFinite(value)) return value; + const parsed = + typeof value === "string" ? Number.parseInt(value, 10) : Number.NaN; + return Number.isFinite(parsed) ? parsed : null; +}; + +export class A2ajProvider implements LegalSourceProvider { + readonly descriptor = { + id: "a2aj-canada", + name: "A2AJ Canadian Legal Data", + jurisdictions: ["CA" as const, "CA-ON" as const], + kinds: ["decision" as const], + official: false, + fullTextStatus: "unofficial" as const, + enabledByDefault: true, + }; + + private coverageCache: { + expiresAt: number; + rows: LegalSourceCoverage[]; + } | null = null; + + constructor(private readonly client = new A2ajClient()) {} + + async health() { + try { + const coverage = await this.coverage(); + return coverage.length + ? { + ok: true, + detail: `${coverage.length} supported Canadian datasets reported by A2AJ.`, + } + : { + ok: false, + detail: "A2AJ returned no supported Canadian coverage.", + }; + } catch (error) { + return { + ok: false, + detail: + error instanceof Error + ? error.message + : "A2AJ health check failed.", + }; + } + } + + async searchDecisions(input: { + query: string; + court?: string; + jurisdiction?: JurisdictionCode; + language?: LegalSourceLanguage; + from?: string; + to?: string; + limit?: number; + offset?: number; + }): Promise { + const dataset = normalizeDataset(input.court); + if (input.jurisdiction === "CA-ON" && dataset && dataset !== "ONCA") + return []; + const response = await this.client.search({ + query: input.query, + dataset: + dataset ?? + (input.jurisdiction === "CA-ON" ? "ONCA" : undefined), + language: input.language, + from: input.from, + to: input.to, + size: input.limit, + offset: input.offset, + }); + return response.results.map((row) => this.summary(row, input.language)); + } + + async fetchDecision(sourceId: string): Promise { + const row = await this.client.fetchByCitation(sourceId); + const language = preferredLanguage(row); + const summary = this.summary(row, language); + const fullText = languageValue(row, "unofficial_text", language); + return { + ...summary, + retrievedAt: new Date().toISOString(), + fullText, + passages: fullText + ? [ + { + text: fullText, + language, + paragraphStart: null, + paragraphEnd: null, + sourceUrl: summary.canonicalUrl, + verification: "partial", + }, + ] + : [], + providerPayload: row, + }; + } + + async verifyCitations(citations: string[]): Promise { + return Promise.all( + citations.map(async (citation) => { + try { + const row = await this.client.fetchByCitation(citation); + const summary = this.summary(row); + const matches = [ + text(row.citation_en), + text(row.citation_fr), + text(row.citation2_en), + text(row.citation2_fr), + ] + .filter(Boolean) + .some( + (candidate) => + normalizeCitation(candidate!) === + normalizeCitation(citation), + ); + return { + input: citation, + providerId: this.descriptor.id, + status: matches + ? ("verified" as const) + : ("partial" as const), + sourceId: summary.sourceId, + canonicalUrl: summary.canonicalUrl, + providerPayload: row, + }; + } catch { + return { + input: citation, + providerId: this.descriptor.id, + status: "unavailable" as const, + sourceId: null, + canonicalUrl: null, + }; + } + }), + ); + } + + async coverage(): Promise { + if (this.coverageCache && this.coverageCache.expiresAt > Date.now()) + return this.coverageCache.rows; + const checkedAt = new Date().toISOString(); + const raw = await this.client.coverage(); + const rows = raw + .map((row) => + normalizeCoverageRow(row, checkedAt, this.descriptor.id), + ) + .filter((row): row is LegalSourceCoverage => row !== null); + this.coverageCache = { expiresAt: Date.now() + 15 * 60_000, rows }; + return rows; + } + + findPassages( + document: LegalDecisionDocument, + query: string, + limit = 5, + ): LegalSourcePassage[] { + return findA2ajPassages(document, query, limit); + } + + private summary( + row: A2ajDocument, + requestedLanguage?: LegalSourceLanguage, + ): LegalDecisionSummary { + const language = + requestedLanguage && languageHasContent(row, requestedLanguage) + ? requestedLanguage + : preferredLanguage(row); + const dataset = row.dataset.toUpperCase(); + const datasetInfo = CANADIAN_DATASETS[dataset]; + return { + providerId: this.descriptor.id, + sourceId: + languageValue(row, "citation", language) ?? + text(row.citation_en) ?? + text(row.citation_fr) ?? + `${dataset}:unknown`, + jurisdiction: datasetInfo?.jurisdiction ?? "CA", + caseName: languageValue(row, "name", language), + citation: languageValue(row, "citation", language), + court: datasetInfo?.label ?? dataset, + decisionDate: normalizeDate( + languageValue(row, "document_date", language), + ), + canonicalUrl: languageValue(row, "url", language), + snippet: snippet(languageValue(row, "unofficial_text", language)), + language, + alternateLanguageUrl: languageValue( + row, + "url", + language === "en" ? "fr" : "en", + ), + fullTextStatus: this.descriptor.fullTextStatus, + upstreamLicense: text(row.upstream_license), + verification: "partial", + }; + } +} + +export function findA2ajPassages( + document: LegalDecisionDocument, + query: string, + limit = 5, +): LegalSourcePassage[] { + if (!document.fullText?.trim() || !query.trim()) return []; + const terms = query + .toLocaleLowerCase("en-CA") + .split(/\s+/) + .filter((term) => term.length >= 3); + const paragraphs = document.fullText + .split(/\n\s*\n|(?=\[\d+\]\s)/) + .map((value) => value.trim()) + .filter(Boolean); + return paragraphs + .map((paragraph, index) => ({ + paragraph, + index, + score: terms.reduce( + (score, term) => + score + + (paragraph.toLocaleLowerCase("en-CA").includes(term) + ? 1 + : 0), + 0, + ), + })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score || a.index - b.index) + .slice(0, Math.min(10, Math.max(1, limit))) + .map(({ paragraph, index }) => ({ + text: paragraph, + language: document.language ?? "en", + paragraphStart: paragraphNumber(paragraph) ?? index + 1, + paragraphEnd: paragraphNumber(paragraph) ?? index + 1, + sourceUrl: document.canonicalUrl, + verification: "partial", + })); +} + +function normalizeCoverageRow( + row: A2ajCoverageRow, + checkedAt: string, + providerId: string, +): LegalSourceCoverage | null { + const dataset = ( + text(row.dataset) ?? + text(row.code) ?? + text(row.name) + )?.toUpperCase(); + if (!dataset || !CANADIAN_DATASETS[dataset]) return null; + const info = CANADIAN_DATASETS[dataset]; + return { + providerId, + dataset, + jurisdiction: info.jurisdiction, + label: + text(row.label) ?? + text(row.court) ?? + text(row.tribunal) ?? + info.label, + documentCount: + number(row.document_count) ?? number(row.count) ?? number(row.rows), + firstDocumentDate: normalizeDate( + text(row.first_document_date) ?? + text(row.first_date) ?? + text(row.min_date), + ), + lastDocumentDate: normalizeDate( + text(row.last_document_date) ?? + text(row.last_date) ?? + text(row.max_date), + ), + checkedAt, + }; +} + +function normalizeDataset(value?: string) { + if (!value?.trim()) return undefined; + const normalized = value.trim().toUpperCase(); + if (CANADIAN_DATASETS[normalized]) return normalized; + return Object.entries(CANADIAN_DATASETS).find( + ([, item]) => item.label.toUpperCase() === normalized, + )?.[0]; +} + +function preferredLanguage(row: A2ajDocument): LegalSourceLanguage { + return languageHasContent(row, "en") ? "en" : "fr"; +} + +function languageHasContent(row: A2ajDocument, language: LegalSourceLanguage) { + return Boolean( + languageValue(row, "citation", language) || + languageValue(row, "name", language) || + languageValue(row, "unofficial_text", language), + ); +} + +function languageValue( + row: A2ajDocument, + field: "citation" | "name" | "document_date" | "url" | "unofficial_text", + language: LegalSourceLanguage, +) { + return text(row[`${field}_${language}`]); +} + +function normalizeDate(value: string | null) { + if (!value) return null; + const match = value.match(/^\d{4}-\d{2}-\d{2}/); + return match?.[0] ?? null; +} + +function normalizeCitation(value: string) { + return value + .replace(/[.,]/g, " ") + .replace(/\s+/g, " ") + .trim() + .toUpperCase(); +} + +function snippet(value: string | null) { + if (!value) return null; + return value.length > 500 ? `${value.slice(0, 497).trimEnd()}...` : value; +} + +function paragraphNumber(value: string) { + const parsed = Number.parseInt(value.match(/^\[(\d+)\]/)?.[1] ?? "", 10); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/backend/src/lib/legalSources/canadianCitations.test.ts b/backend/src/lib/legalSources/canadianCitations.test.ts new file mode 100644 index 000000000..e1e6206ea --- /dev/null +++ b/backend/src/lib/legalSources/canadianCitations.test.ts @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + parseCanadianCitations, + renderCanadianCitation, + verifyCanadianCitations, +} from "./canadianCitations"; +import type { LegalSourceProvider } from "./types"; + +test("parses Ontario and federal neutral citations with paragraph pinpoints", () => { + const citations = parseCanadianCitations( + "See Synthetic v. Example, 2024 ONCA 123, at paras. 12–14 and 2023 SCC 17 at para. 8.", + ); + assert.equal(citations.length, 2); + assert.deepEqual(citations[0], { + raw: "2024 ONCA 123, at paras. 12–14", + normalized: "2024 ONCA 123, at paras. 12-14", + canonicalId: "neutral-case:2024:onca:123:paragraph:12-14", + kind: "neutral-case", + jurisdiction: "CA-ON", + court: "ONCA", + year: 2024, + sequence: 123, + pinpoint: { type: "paragraph", start: "12", end: "14" }, + citationVerification: "unverified", + }); + assert.equal( + renderCanadianCitation(citations[0], { + caseName: "Synthetic v. Example", + profile: "onca", + }), + "Synthetic v. Example, 2024 ONCA 123, at paras. 12-14", + ); +}); + +test("parses CanLII and reporter citations without treating syntax as verification", () => { + const citations = parseCanadianCitations( + "2006 CanLII 25417 (ON SC); [2001] 3 S.C.R. 28, at p. 30; (2014) 110 O.R. (4th) 443.", + ); + assert.deepEqual( + citations.map((item) => item.kind), + ["canlii-case", "reporter-case", "reporter-case"], + ); + assert.equal(citations[0].jurisdiction, "CA-ON"); + assert.equal(citations[1].court, "SCC"); + assert.equal(citations[1].pinpoint?.type, "page"); + assert.ok( + citations.every((item) => item.citationVerification === "unverified"), + ); +}); + +test("parses statutes, regulations, schedules, sections, rules, and French Ontario forms", () => { + const citations = parseCanadianCitations( + [ + "R.S.O. 1990, c. C.43, s. 5", + "S.O. 2002, c. 24, Sched. B, s. 4(1)", + "R.S.C. 1985, c. C-46, s. 718.2", + "S.C. 2019, c. 16, s. 1", + "O. Reg. 258/98, r. 1.04", + "R.R.O. 1990, Reg. 194, rr. 2.1-2.2", + "SOR/98-106, r. 3", + "DORS/97-175, r. 2", + "Règl. de l’Ont. 258/98, r. 1", + ].join("; "), + ); + assert.equal(citations.length, 9); + assert.deepEqual( + citations.map((item) => item.kind), + [ + "statute", + "statute", + "statute", + "statute", + "regulation", + "regulation", + "regulation", + "regulation", + "regulation", + ], + ); + assert.equal(citations[0].jurisdiction, "CA-ON"); + assert.equal(citations[0].pinpoint?.start, "5"); + assert.equal(citations[5].pinpoint?.type, "rule"); + assert.equal(citations[5].pinpoint?.end, "2.2"); +}); + +test("deduplicates identical citations and ignores malformed lookalikes", () => { + const citations = parseCanadianCitations( + "2024 ONCA 12; 2024 ONCA 12 (CanLII); ONCA 2024 twelve; 2024 UNKNOWN 1; RSO chapter maybe.", + ); + assert.equal(citations.length, 1); + assert.equal(citations[0].normalized, "2024 ONCA 12"); +}); + +test("verification keeps citation, passage, currency, and treatment states separate", async () => { + const caseProvider: LegalSourceProvider = { + descriptor: { + id: "synthetic-cases", + name: "Synthetic cases", + jurisdictions: ["CA-ON"], + kinds: ["decision"], + official: false, + fullTextStatus: "unofficial", + enabledByDefault: true, + }, + health: async () => ({ ok: true }), + verifyCitations: async ([input]) => [ + { + input, + providerId: "synthetic-cases", + status: "verified", + sourceId: "2024 ONCA 123", + canonicalUrl: "https://example.invalid/2024-onca-123", + }, + ], + }; + const legislationProvider: LegalSourceProvider = { + descriptor: { + id: "synthetic-legislation", + name: "Synthetic legislation", + jurisdictions: ["CA-ON"], + kinds: ["legislation"], + official: true, + fullTextStatus: "unofficial", + enabledByDefault: true, + }, + health: async () => ({ ok: true }), + searchLegislation: async () => [ + { + providerId: "synthetic-legislation", + sourceId: "synthetic-act", + jurisdiction: "CA-ON", + kind: "legislation", + title: "Synthetic Act", + citation: "R.S.O. 1990, c. C.43", + language: "en", + canonicalUrl: "https://example.invalid/synthetic-act", + alternateLanguageUrl: null, + currentToDate: null, + lastAmendedDate: null, + inForceStatus: "unknown", + verification: "unverified", + }, + ], + fetchLegislation: async () => ({ + providerId: "synthetic-legislation", + sourceId: "synthetic-act", + jurisdiction: "CA-ON", + kind: "legislation", + title: "Synthetic Act", + citation: "R.S.O. 1990, c. C.43", + language: "en", + canonicalUrl: "https://example.invalid/synthetic-act", + alternateLanguageUrl: null, + currentToDate: "2026-07-10", + lastAmendedDate: "2026-06-01", + inForceStatus: "in-force", + verification: "verified", + retrievedAt: "2026-07-16T00:00:00.000Z", + sections: [ + { + label: "5", + heading: null, + text: "SYNTHETIC section text.", + sourceUrl: "https://example.invalid/synthetic-act#5", + inForceFrom: null, + lastAmendedDate: null, + }, + ], + fullText: "SYNTHETIC section text.", + sourceHash: null, + reproductionIsOfficial: false, + providerPayload: {}, + }), + }; + const parsed = parseCanadianCitations( + "2024 ONCA 123, at para. 12; R.S.O. 1990, c. C.43, s. 5", + ); + const [caseResult, statuteResult] = await verifyCanadianCitations(parsed, [ + caseProvider, + legislationProvider, + ]); + assert.equal(caseResult.citationVerification, "verified"); + assert.equal(caseResult.passageVerification, "unverified"); + assert.equal(caseResult.treatmentVerification, "unavailable"); + assert.equal(statuteResult.citationVerification, "verified"); + assert.equal(statuteResult.passageVerification, "verified"); + assert.equal(statuteResult.currencyVerification, "verified"); +}); diff --git a/backend/src/lib/legalSources/canadianCitations.ts b/backend/src/lib/legalSources/canadianCitations.ts new file mode 100644 index 000000000..f74e318a6 --- /dev/null +++ b/backend/src/lib/legalSources/canadianCitations.ts @@ -0,0 +1,413 @@ +import type { + JurisdictionCode, + LegalSourceProvider, + VerificationState, +} from "./types"; + +export type CanadianCitationKind = + | "neutral-case" + | "canlii-case" + | "reporter-case" + | "statute" + | "regulation"; + +export type CanadianCitationPinpoint = { + type: "paragraph" | "page" | "section" | "rule"; + start: string; + end: string | null; +}; + +export type ParsedCanadianCitation = { + raw: string; + normalized: string; + canonicalId: string; + kind: CanadianCitationKind; + jurisdiction: JurisdictionCode; + court: string | null; + year: number | null; + sequence: number | null; + pinpoint: CanadianCitationPinpoint | null; + citationVerification: VerificationState; +}; + +export type CanadianCitationVerification = { + citation: ParsedCanadianCitation; + providerId: string | null; + sourceId: string | null; + canonicalUrl: string | null; + citationVerification: VerificationState; + passageVerification: VerificationState; + currencyVerification: VerificationState; + treatmentVerification: VerificationState; +}; + +type CitationPattern = { + kind: CanadianCitationKind; + expression: RegExp; + map( + match: RegExpExecArray, + ): Omit< + ParsedCanadianCitation, + | "raw" + | "normalized" + | "canonicalId" + | "pinpoint" + | "citationVerification" + >; +}; + +const NEUTRAL_COURTS: Record = { + SCC: "CA", + FCA: "CA", + FC: "CA", + TCC: "CA", + CMAC: "CA", + ONCA: "CA-ON", + ONSC: "CA-ON", + ONCJ: "CA-ON", + ONSCFC: "CA-ON", + ONCD: "CA-ON", +}; + +const PATTERNS: CitationPattern[] = [ + { + kind: "canlii-case", + expression: + /\b((?:18|19|20)\d{2})\s+CanLII\s+(\d+)\s*\(([A-Z]{2,4}(?:\s+[A-Z]{2,5})?)\)/giu, + map: (match) => ({ + kind: "canlii-case", + jurisdiction: jurisdictionFromCourt(match[3]), + court: normalizeSpace(match[3]).toUpperCase(), + year: Number(match[1]), + sequence: Number(match[2]), + }), + }, + { + kind: "neutral-case", + expression: + /\b((?:18|19|20)\d{2})\s+(SCC|FCA|FC|TCC|CMAC|ONCA|ONSCFC|ONSC|ONCJ|ONCD)\s+(\d+)\b(?:\s*\(CanLII\))?/giu, + map: (match) => ({ + kind: "neutral-case", + jurisdiction: NEUTRAL_COURTS[match[2].toUpperCase()] ?? "CA", + court: match[2].toUpperCase(), + year: Number(match[1]), + sequence: Number(match[3]), + }), + }, + { + kind: "reporter-case", + expression: + /(?:\[((?:18|19|20)\d{2})\]|\(((?:18|19|20)\d{2})\))\s*(\d+)\s+(S\.?C\.?R\.?|O\.?R\.?(?:\s*\(\d+(?:st|nd|rd|th)\))?|D\.?L\.?R\.?(?:\s*\(\d+(?:st|nd|rd|th)\))?)\s+(\d+)\b/giu, + map: (match) => ({ + kind: "reporter-case", + jurisdiction: /O\.?R/i.test(match[4]) ? "CA-ON" : "CA", + court: /S\.?C\.?R/i.test(match[4]) ? "SCC" : null, + year: Number(match[1] ?? match[2]), + sequence: null, + }), + }, + { + kind: "statute", + expression: + /\b(R\.?S\.?O\.?\s+1990|S\.?O\.?\s+(?:18|19|20)\d{2}|R\.?S\.?C\.?\s+1985|S\.?C\.?\s+(?:18|19|20)\d{2})\s*,?\s*c\.?\s*([A-Z0-9.-]+)(?:\s*,?\s*(Sched\.?|Sch\.?|annexe)\s*([A-Z0-9.-]+))?/giu, + map: (match) => ({ + kind: "statute", + jurisdiction: /O/i.test(match[1]) ? "CA-ON" : "CA", + court: null, + year: Number(match[1].match(/\d{4}/)?.[0] ?? 0) || null, + sequence: null, + }), + }, + { + kind: "regulation", + expression: + /\b(O\.?\s*Reg\.?\s*\d+\/\d+|R\.?R\.?O\.?\s+1990\s*,?\s*Reg\.?\s*\d+|R[èe]gl\.?\s+de\s+l['’]Ont\.?\s*\d+\/\d+|SOR\/\d{2,4}-\d+|DORS\/\d{2,4}-\d+)\b/giu, + map: (match) => ({ + kind: "regulation", + jurisdiction: /(?:O\.?\s*Reg|R\.?R\.?O|Ont)/i.test(match[1]) + ? "CA-ON" + : "CA", + court: null, + year: regulationYear(match[1]), + sequence: null, + }), + }, +]; + +const PINPOINT_PATTERN = + /^\s*,?\s*(?:at\s+)?(?:(paras?\.?|¶¶?)\s*([\d.]+)(?:\s*[-–]\s*([\d.]+))?|(pp?\.?)\s*([\d.]+)(?:\s*[-–]\s*([\d.]+))?|(ss?\.?)\s*([\d.()a-z]+)(?:\s*[-–]\s*([\d.()a-z]+))?|(rr?\.?)\s*([\d.()a-z]+)(?:\s*[-–]\s*([\d.()a-z]+))?)/iu; +const PINPOINT_SUFFIX_PATTERN = + /\s*,?\s*(?:at\s+)?(?:(?:paras?\.?|¶¶?)\s*[\d.]+(?:\s*[-–]\s*[\d.]+)?|(?:pp?\.?)\s*[\d.]+(?:\s*[-–]\s*[\d.]+)?|(?:ss?\.?)\s*[\d.()a-z]+(?:\s*[-–]\s*[\d.()a-z]+)?|(?:rr?\.?)\s*[\d.()a-z]+(?:\s*[-–]\s*[\d.()a-z]+)?)\s*$/iu; + +export function parseCanadianCitations( + input: string, +): ParsedCanadianCitation[] { + const found: Array< + ParsedCanadianCitation & { index: number; end: number } + > = []; + for (const pattern of PATTERNS) { + pattern.expression.lastIndex = 0; + for ( + let match = pattern.expression.exec(input); + match; + match = pattern.expression.exec(input) + ) { + const end = match.index + match[0].length; + if ( + found.some( + (citation) => + match!.index < citation.end && end > citation.index, + ) + ) + continue; + const pinpointMatch = input + .slice(end, end + 80) + .match(PINPOINT_PATTERN); + const pinpoint = pinpointMatch + ? parsePinpoint(pinpointMatch) + : null; + const raw = `${match[0]}${pinpointMatch?.[0] ?? ""}` + .trim() + .replace(/[,;.]$/, ""); + const normalized = normalizeCanadianCitation(raw); + const mapped = pattern.map(match); + found.push({ + ...mapped, + raw, + normalized, + canonicalId: canonicalCitationId( + mapped.kind, + normalized, + pinpoint, + ), + pinpoint, + citationVerification: "unverified", + index: match.index, + end: end + (pinpointMatch?.[0].length ?? 0), + }); + } + } + const deduplicated = new Map(); + for (const citation of found.sort((a, b) => a.index - b.index)) { + const { index: _index, end: _end, ...publicCitation } = citation; + if (!deduplicated.has(publicCitation.canonicalId)) + deduplicated.set(publicCitation.canonicalId, publicCitation); + } + return [...deduplicated.values()]; +} + +export function normalizeCanadianCitation(value: string) { + return value + .normalize("NFKC") + .replace(/[‐‑‒–—]/g, "-") + .replace(/\s+/g, " ") + .replace( + /(\b(?:18|19|20)\d{2}\s+[A-Z]{2,8}\s+\d+)\s*\(CanLII\)/giu, + "$1", + ) + .replace(/\s+,/g, ",") + .trim(); +} + +export function renderCanadianCitation( + citation: ParsedCanadianCitation, + options: { + caseName?: string; + title?: string; + profile?: "onca" | "mcgill-compatible"; + } = {}, +) { + const name = options.caseName ?? options.title; + const base = name + ? `${name}, ${stripPinpoint(citation.normalized)}` + : stripPinpoint(citation.normalized); + if (!citation.pinpoint) return base; + const pinpoint = citation.pinpoint; + const range = pinpoint.end + ? `${pinpoint.start}-${pinpoint.end}` + : pinpoint.start; + const label = + pinpoint.type === "paragraph" + ? pinpoint.end + ? "at paras." + : "at para." + : pinpoint.type === "page" + ? pinpoint.end + ? "at pp." + : "at p." + : pinpoint.type === "section" + ? pinpoint.end + ? "ss." + : "s." + : pinpoint.end + ? "rr." + : "r."; + return `${base}, ${label} ${range}`; +} + +export async function verifyCanadianCitations( + citations: ParsedCanadianCitation[], + providers: LegalSourceProvider[], +): Promise { + return Promise.all( + citations.map((citation) => verifyOne(citation, providers)), + ); +} + +async function verifyOne( + citation: ParsedCanadianCitation, + providers: LegalSourceProvider[], +): Promise { + const base: CanadianCitationVerification = { + citation, + providerId: null, + sourceId: null, + canonicalUrl: null, + citationVerification: "unverified", + passageVerification: "unverified", + currencyVerification: "unverified", + treatmentVerification: "unavailable", + }; + if ( + ["neutral-case", "canlii-case", "reporter-case"].includes(citation.kind) + ) { + for (const provider of providers.filter( + (item) => + item.verifyCitations && + item.descriptor.jurisdictions.includes(citation.jurisdiction), + )) { + try { + const result = ( + await provider.verifyCitations!([ + stripPinpoint(citation.normalized), + ]) + )[0]; + if ( + result?.status === "verified" || + result?.status === "partial" + ) + return { + ...base, + providerId: provider.descriptor.id, + sourceId: result.sourceId, + canonicalUrl: result.canonicalUrl, + citationVerification: result.status, + }; + } catch { + // Try the next authorized provider without upgrading verification. + } + } + return base; + } + + for (const provider of providers.filter( + (item) => + item.searchLegislation && + item.fetchLegislation && + item.descriptor.jurisdictions.includes(citation.jurisdiction), + )) { + try { + const matches = await provider.searchLegislation!({ + query: stripPinpoint(citation.normalized), + jurisdiction: citation.jurisdiction, + limit: 10, + }); + const match = matches.find((item) => + citationEquivalent(item.citation, citation.normalized), + ); + if (!match) continue; + const document = await provider.fetchLegislation!( + match.sourceId, + citation.pinpoint?.type === "section" || + citation.pinpoint?.type === "rule" + ? { section: citation.pinpoint.start } + : undefined, + ); + return { + ...base, + providerId: provider.descriptor.id, + sourceId: match.sourceId, + canonicalUrl: document.canonicalUrl, + citationVerification: document.verification, + passageVerification: citation.pinpoint + ? document.sections.length + ? "verified" + : "unverified" + : "unverified", + currencyVerification: document.currentToDate + ? "verified" + : "unverified", + }; + } catch { + // Try the next official provider without upgrading verification. + } + } + return base; +} + +function parsePinpoint(match: RegExpMatchArray): CanadianCitationPinpoint { + if (match[1]) + return { type: "paragraph", start: match[2], end: match[3] ?? null }; + if (match[4]) + return { type: "page", start: match[5], end: match[6] ?? null }; + if (match[7]) + return { type: "section", start: match[8], end: match[9] ?? null }; + return { type: "rule", start: match[11], end: match[12] ?? null }; +} + +function canonicalCitationId( + kind: CanadianCitationKind, + normalized: string, + pinpoint: CanadianCitationPinpoint | null, +) { + const base = stripPinpoint(normalized) + .toLocaleLowerCase("en-CA") + .replace(/[^a-z0-9]+/g, ":") + .replace(/^:|:$/g, ""); + const point = pinpoint + ? `:${pinpoint.type}:${pinpoint.start}${pinpoint.end ? `-${pinpoint.end}` : ""}` + : ""; + return `${kind}:${base}${point}`; +} + +function stripPinpoint(value: string) { + return value + .replace(PINPOINT_SUFFIX_PATTERN, "") + .replace(/[,;.]$/, "") + .trim(); +} + +function citationEquivalent(left: string, right: string) { + const canonical = (value: string) => + stripPinpoint(value) + .toUpperCase() + .replace(/[^A-Z0-9]/g, ""); + return ( + canonical(left) === canonical(right) || + canonical(right).includes(canonical(left)) || + canonical(left).includes(canonical(right)) + ); +} + +function jurisdictionFromCourt(court: string): JurisdictionCode { + return normalizeSpace(court).toUpperCase().startsWith("ON") + ? "CA-ON" + : "CA"; +} + +function normalizeSpace(value: string) { + return value.replace(/\s+/g, " ").trim(); +} + +function regulationYear(value: string) { + const slashYear = value.match(/\/(\d{2,4})(?:-|\b)/)?.[1]; + if (slashYear) { + const year = Number(slashYear); + return slashYear.length === 2 + ? year >= 50 + ? 1900 + year + : 2000 + year + : year; + } + return Number(value.match(/\b(?:18|19|20)\d{2}\b/)?.[0] ?? 0) || null; +} diff --git a/backend/src/lib/legalSources/courtlistenerProvider.ts b/backend/src/lib/legalSources/courtlistenerProvider.ts new file mode 100644 index 000000000..dbf110f7a --- /dev/null +++ b/backend/src/lib/legalSources/courtlistenerProvider.ts @@ -0,0 +1,171 @@ +import { + getCourtlistenerCaseOpinions, + searchCourtlistenerCaseLaw, + verifyCourtlistenerCitations, +} from "../courtlistener"; +import type { + LegalCitationResult, + LegalDecisionDocument, + LegalDecisionSummary, + LegalSourceContext, + LegalSourceProvider, +} from "./types"; + +type JsonRecord = Record; +const record = (value: unknown): JsonRecord => + value && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : {}; +const text = (value: unknown): string | null => + typeof value === "string" && value.trim() ? value.trim() : null; + +export class CourtListenerProvider implements LegalSourceProvider { + readonly descriptor = { + id: "courtlistener-us", + name: "CourtListener", + jurisdictions: ["US" as const], + kinds: ["decision" as const], + official: false, + fullTextStatus: "unofficial" as const, + enabledByDefault: true, + }; + + async health(context?: LegalSourceContext) { + const configured = Boolean( + context?.apiToken?.trim() || + process.env.COURTLISTENER_API_TOKEN?.trim() || + process.env.COURTLISTENER_BULK_DATA_ENABLED === "true", + ); + return configured + ? { ok: true } + : { + ok: false, + detail: "CourtListener API or bulk data is not configured.", + }; + } + + async searchDecisions( + input: { + query: string; + court?: string; + jurisdiction?: "US" | "CA" | `CA-${string}`; + language?: "en" | "fr"; + from?: string; + to?: string; + limit?: number; + offset?: number; + }, + context?: LegalSourceContext, + ): Promise { + const response = record( + await searchCourtlistenerCaseLaw({ + query: input.query, + court: input.court, + filedAfter: input.from, + filedBefore: input.to, + limit: input.limit, + apiToken: context?.apiToken, + }), + ); + const results = Array.isArray(response.results) ? response.results : []; + return results.map((value) => { + const row = record(value); + const clusterId = + typeof row.clusterId === "number" ? row.clusterId : null; + return { + providerId: this.descriptor.id, + sourceId: clusterId + ? String(clusterId) + : (text(row.url) ?? "unknown"), + jurisdiction: "US", + caseName: text(row.caseName), + citation: text(row.citation), + court: text(row.court), + decisionDate: text(row.dateFiled), + canonicalUrl: text(row.url), + snippet: text(row.snippet), + language: "en", + alternateLanguageUrl: null, + fullTextStatus: this.descriptor.fullTextStatus, + upstreamLicense: null, + verification: "unverified", + }; + }); + } + + async fetchDecision( + sourceId: string, + context?: LegalSourceContext, + ): Promise { + const clusterId = Number.parseInt(sourceId, 10); + if (!Number.isFinite(clusterId) || clusterId <= 0) { + throw new Error( + "CourtListener sourceId must be a positive cluster ID.", + ); + } + const payload = record( + await getCourtlistenerCaseOpinions({ + clusterId, + includeFullText: true, + maxChars: 50000, + db: context?.db, + apiToken: context?.apiToken, + }), + ); + return { + providerId: this.descriptor.id, + sourceId: String(clusterId), + jurisdiction: "US", + caseName: text(payload.caseName) ?? text(payload.case_name), + citation: text(payload.citation), + court: text(payload.court), + decisionDate: text(payload.dateFiled) ?? text(payload.date_filed), + canonicalUrl: text(payload.url) ?? text(payload.absolute_url), + snippet: null, + language: "en", + alternateLanguageUrl: null, + fullTextStatus: this.descriptor.fullTextStatus, + upstreamLicense: null, + verification: payload.error ? "unavailable" : "partial", + retrievedAt: new Date().toISOString(), + fullText: null, + passages: [], + providerPayload: payload, + }; + } + + async verifyCitations( + citations: string[], + context?: LegalSourceContext, + ): Promise { + const response = record( + await verifyCourtlistenerCitations({ + citations, + db: context?.db, + apiToken: context?.apiToken, + }), + ); + const results = Array.isArray(response.results) ? response.results : []; + return results.map((value, index) => { + const row = record(value); + const status = text(row.status); + const sourceId = + typeof row.clusterId === "number" + ? String(row.clusterId) + : typeof row.cluster_id === "number" + ? String(row.cluster_id) + : null; + return { + input: text(row.citation) ?? citations[index] ?? "", + providerId: this.descriptor.id, + status: + status === "matched" || status === "verified" + ? "verified" + : "unverified", + sourceId, + canonicalUrl: text(row.url) ?? text(row.absolute_url), + providerPayload: row, + }; + }); + } +} diff --git a/backend/src/lib/legalSources/index.ts b/backend/src/lib/legalSources/index.ts new file mode 100644 index 000000000..b8daca296 --- /dev/null +++ b/backend/src/lib/legalSources/index.ts @@ -0,0 +1,53 @@ +import { A2ajProvider } from "./a2ajProvider"; +import { CourtListenerProvider } from "./courtlistenerProvider"; +import { CanLiiLicensedProvider } from "./licensedConnector"; +import { + JusticeLawsProvider, + OntarioELawsProvider, +} from "./officialLegislation"; +import { LegalSourceRegistry } from "./registry"; + +export { + normalizeCanadianCitation, + parseCanadianCitations, + renderCanadianCitation, + verifyCanadianCitations, +} from "./canadianCitations"; + +export * from "./types"; +export { A2ajClient, A2ajApiError } from "./a2ajClient"; +export { A2ajProvider, findA2ajPassages } from "./a2ajProvider"; +export { CourtListenerProvider } from "./courtlistenerProvider"; +export { + JusticeLawsProvider, + OntarioELawsProvider, + parseJusticeXml, + parseOntarioSections, +} from "./officialLegislation"; +export { + CanLiiLicensedProvider, + LicensedConnectorGate, + loadCanLiiEntitlement, +} from "./licensedConnector"; +export { + ONTARIO_COURT_FORMS, + ONTARIO_PROCEDURE_SOURCES, + calculateOntarioDeadline, + checkOntarioProcedureSources, +} from "./ontarioProcedure"; +export type { + OntarioCourtForm, + OntarioDeadlineInput, + OntarioDeadlineResult, + OntarioProcedureSource, +} from "./ontarioProcedure"; +export { LegalSourceRegistry } from "./registry"; + +export function createLegalSourceRegistry() { + return new LegalSourceRegistry() + .register(new CourtListenerProvider()) + .register(new A2ajProvider()) + .register(new OntarioELawsProvider()) + .register(new JusticeLawsProvider()) + .register(new CanLiiLicensedProvider()); +} diff --git a/backend/src/lib/legalSources/licensedConnector.test.ts b/backend/src/lib/legalSources/licensedConnector.test.ts new file mode 100644 index 000000000..9f713cd6b --- /dev/null +++ b/backend/src/lib/legalSources/licensedConnector.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + CanLiiLicensedProvider, + LicensedConnectorGate, + loadCanLiiEntitlement, + type LicensedConnectorAuditEvent, +} from "./licensedConnector"; + +test("CanLII connector is disabled and credential-safe by default", async () => { + const provider = new CanLiiLicensedProvider({}); + const health = await provider.health(); + assert.equal(health.ok, false); + assert.match(health.detail ?? "", /does not scrape CanLII/); + assert.deepEqual(provider.gate.status(), { + configured: false, + enabled: false, + allowedOperations: [], + metadataRetentionDays: 0, + fullTextRetentionDays: 0, + redistributionAllowed: false, + }); +}); + +test("licensed gate requires the complete approved entitlement", async () => { + const events: LicensedConnectorAuditEvent[] = []; + const gate = new LicensedConnectorGate( + loadCanLiiEntitlement({ CANLII_CONNECTOR_ENABLED: "true" }), + (event) => { + events.push(event); + }, + ); + await assert.rejects( + () => gate.authorize("metadata-search"), + /contract and organization identifiers are required/, + ); + assert.equal(events.length, 1); + assert.equal(events[0].allowed, false); + assert.equal(events[0].operation, "metadata-search"); +}); + +test("full text requires an explicit operation and entitlement", () => { + assert.throws( + () => + loadCanLiiEntitlement({ + CANLII_ALLOWED_OPERATIONS: "full-text-fetch", + }), + /CANLII_FULL_TEXT_ENTITLED=true/, + ); +}); + +test("complete licensed metadata configuration exposes no secret", async () => { + const provider = new CanLiiLicensedProvider({ + CANLII_CONNECTOR_ENABLED: "true", + CANLII_CONTRACT_ID: "synthetic-contract", + CANLII_ORGANIZATION_ID: "synthetic-org", + CANLII_API_KEY: "SYNTHETIC-SECRET-NEVER-RETURN", + CANLII_API_BASE_URL: "https://api.canlii.org/v1", + CANLII_APPROVED_TRANSPORT: "contract-v1", + CANLII_ALLOWED_OPERATIONS: "metadata-search,citator", + CANLII_METADATA_RETENTION_DAYS: "30", + }); + const health = await provider.health(); + assert.equal(health.ok, true); + assert.doesNotMatch( + JSON.stringify(provider.gate.status()), + /SYNTHETIC-SECRET/, + ); + await provider.gate.authorize("metadata-search"); + await assert.rejects( + () => provider.gate.authorize("full-text-fetch"), + /does not allow full-text-fetch/, + ); +}); diff --git a/backend/src/lib/legalSources/licensedConnector.ts b/backend/src/lib/legalSources/licensedConnector.ts new file mode 100644 index 000000000..cea74fd7e --- /dev/null +++ b/backend/src/lib/legalSources/licensedConnector.ts @@ -0,0 +1,215 @@ +import type { + LegalSourceProvider, + LegalSourceProviderDescriptor, +} from "./types"; + +export type LicensedConnectorOperation = + | "metadata-search" + | "citation-lookup" + | "citator" + | "full-text-fetch"; + +export type LicensedConnectorEntitlement = { + providerId: string; + enabled: boolean; + contractId: string | null; + organizationId: string | null; + allowedOperations: ReadonlySet; + metadataRetentionDays: number; + fullTextRetentionDays: number; + redistributionAllowed: boolean; + credentialConfigured: boolean; + transportConfigured: boolean; +}; + +export type LicensedConnectorAuditEvent = { + providerId: string; + operation: LicensedConnectorOperation; + organizationId: string; + contractId: string; + allowed: boolean; + occurredAt: string; + reason?: string; +}; + +export class LicensedConnectorGate { + constructor( + readonly entitlement: LicensedConnectorEntitlement, + private readonly audit: ( + event: LicensedConnectorAuditEvent, + ) => Promise | void = () => undefined, + ) {} + + async authorize(operation: LicensedConnectorOperation) { + const reason = this.denialReason(operation); + const event: LicensedConnectorAuditEvent = { + providerId: this.entitlement.providerId, + operation, + organizationId: this.entitlement.organizationId ?? "unconfigured", + contractId: this.entitlement.contractId ?? "unconfigured", + allowed: !reason, + occurredAt: new Date().toISOString(), + ...(reason ? { reason } : {}), + }; + await this.audit(event); + if (reason) throw new Error(reason); + return this.entitlement; + } + + status() { + const configured = + this.entitlement.enabled && + this.entitlement.credentialConfigured && + this.entitlement.transportConfigured && + Boolean(this.entitlement.contractId) && + Boolean(this.entitlement.organizationId); + return { + configured, + enabled: this.entitlement.enabled, + allowedOperations: [...this.entitlement.allowedOperations], + metadataRetentionDays: this.entitlement.metadataRetentionDays, + fullTextRetentionDays: this.entitlement.fullTextRetentionDays, + redistributionAllowed: this.entitlement.redistributionAllowed, + }; + } + + private denialReason(operation: LicensedConnectorOperation) { + if (!this.entitlement.enabled) + return `${this.entitlement.providerId} is disabled. An approved organization entitlement is required.`; + if (!this.entitlement.contractId || !this.entitlement.organizationId) + return `${this.entitlement.providerId} contract and organization identifiers are required.`; + if (!this.entitlement.credentialConfigured) + return `${this.entitlement.providerId} credentials are not configured.`; + if (!this.entitlement.transportConfigured) + return `${this.entitlement.providerId} has no approved contract transport adapter.`; + if (!this.entitlement.allowedOperations.has(operation)) + return `${this.entitlement.providerId} entitlement does not allow ${operation}.`; + return null; + } +} + +export class CanLiiLicensedProvider implements LegalSourceProvider { + readonly descriptor: LegalSourceProviderDescriptor = { + id: "canlii-licensed", + name: "CanLII authorized connector", + jurisdictions: ["CA", "CA-ON"], + kinds: ["decision", "legislation", "regulation"], + official: false, + fullTextStatus: "metadata-only", + enabledByDefault: false, + }; + + readonly gate: LicensedConnectorGate; + + constructor( + environment: NodeJS.ProcessEnv = process.env, + audit?: (event: LicensedConnectorAuditEvent) => Promise | void, + ) { + this.gate = new LicensedConnectorGate( + loadCanLiiEntitlement(environment), + audit, + ); + } + + async health() { + const status = this.gate.status(); + if (!status.enabled) + return { + ok: false, + detail: "Disabled by default. ROSS does not scrape CanLII; an authorized contract connector is required.", + }; + if (!status.configured) + return { + ok: false, + detail: "Entitlement is incomplete. Contract, organization, credential, and approved transport configuration are required.", + }; + return { + ok: true, + detail: `Authorized operations: ${status.allowedOperations.join(", ") || "none"}.`, + }; + } +} + +export function loadCanLiiEntitlement( + environment: NodeJS.ProcessEnv, +): LicensedConnectorEntitlement { + const operations = new Set(); + for (const value of (environment.CANLII_ALLOWED_OPERATIONS ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean)) { + if (isLicensedOperation(value)) operations.add(value); + else + throw new Error( + `Unsupported CanLII operation in configuration: ${value}`, + ); + } + + if ( + operations.has("full-text-fetch") && + environment.CANLII_FULL_TEXT_ENTITLED !== "true" + ) + throw new Error( + "CANLII_FULL_TEXT_ENTITLED=true is required for full-text-fetch.", + ); + + return { + providerId: "canlii-licensed", + enabled: environment.CANLII_CONNECTOR_ENABLED === "true", + contractId: nonSecret(environment.CANLII_CONTRACT_ID), + organizationId: nonSecret(environment.CANLII_ORGANIZATION_ID), + allowedOperations: operations, + metadataRetentionDays: boundedDays( + environment.CANLII_METADATA_RETENTION_DAYS, + 0, + ), + fullTextRetentionDays: boundedDays( + environment.CANLII_FULL_TEXT_RETENTION_DAYS, + 0, + ), + redistributionAllowed: + environment.CANLII_REDISTRIBUTION_ALLOWED === "true", + credentialConfigured: Boolean(environment.CANLII_API_KEY?.trim()), + transportConfigured: + environment.CANLII_APPROVED_TRANSPORT === "contract-v1" && + isApprovedCanLiiBaseUrl(environment.CANLII_API_BASE_URL), + }; +} + +function isLicensedOperation( + value: string, +): value is LicensedConnectorOperation { + return [ + "metadata-search", + "citation-lookup", + "citator", + "full-text-fetch", + ].includes(value); +} + +function boundedDays(value: string | undefined, fallback: number) { + if (!value?.trim()) return fallback; + const days = Number.parseInt(value, 10); + if (!Number.isInteger(days) || days < 0 || days > 3650) + throw new Error("Connector retention days must be between 0 and 3650."); + return days; +} + +function isApprovedCanLiiBaseUrl(value: string | undefined) { + if (!value?.trim()) return false; + try { + const url = new URL(value); + return ( + url.protocol === "https:" && + url.hostname === "api.canlii.org" && + !url.username && + !url.password + ); + } catch { + return false; + } +} + +function nonSecret(value: string | undefined) { + return value?.trim() || null; +} diff --git a/backend/src/lib/legalSources/officialLegislation.test.ts b/backend/src/lib/legalSources/officialLegislation.test.ts new file mode 100644 index 000000000..9080479f4 --- /dev/null +++ b/backend/src/lib/legalSources/officialLegislation.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + JusticeLawsProvider, + OntarioELawsProvider, +} from "./officialLegislation"; + +const syntheticFederalXml = ` + + Synthetic Federal Act + +
+ Synthetic dutyA synthetic person must use synthetic material. +
+
This is a SYNTHETIC test provision.
+ +
`; + +const syntheticOntarioHtml = ` +

Current up-to-date to July 10, 2026.

+
1 (1) A synthetic person must use synthetic material.
+
2 This is a SYNTHETIC test provision.
+`; + +test("Justice Laws provider parses official XML metadata and a requested section", async () => { + const urls: string[] = []; + const provider = new JusticeLawsProvider(async (input) => { + urls.push(String(input)); + return new Response(syntheticFederalXml, { + status: 200, + headers: { "content-type": "application/xml" }, + }); + }); + const document = await provider.fetchLegislation("federal-act-d-3.4", { + section: "1", + }); + assert.match( + urls[0], + /justicecanada\/laws-lois-xml\/main\/eng\/acts\/D-3\.4\.xml$/, + ); + assert.equal(document.currentToDate, "2026-07-10"); + assert.equal(document.lastAmendedDate, "2026-06-01"); + assert.equal(document.sections.length, 1); + assert.equal(document.sections[0].label, "1"); + assert.match(document.sections[0].text, /synthetic material/); + assert.match(document.sections[0].sourceUrl, /section-1\.html$/); + assert.equal(document.verification, "verified"); + assert.equal(document.reproductionIsOfficial, false); + assert.match(document.sourceHash ?? "", /^[a-f0-9]{64}$/); +}); + +test("Ontario e-Laws provider uses allowlisted official pages and extracts currency", async () => { + const urls: string[] = []; + const provider = new OntarioELawsProvider(async (input) => { + urls.push(String(input)); + return new Response(syntheticOntarioHtml, { + status: 200, + headers: { "content-type": "text/html" }, + }); + }); + const results = await provider.searchLegislation({ + query: "small claims rules", + }); + assert.equal(results[0].sourceId, "ontario-regulation-980258"); + const document = await provider.fetchLegislation( + "ontario-regulation-980258", + { section: "1(1)" }, + ); + assert.equal(urls[0], "https://www.ontario.ca/laws/regulation/980258"); + assert.equal(document.currentToDate, "2026-07-10"); + assert.equal(document.sections.length, 1); + assert.equal(document.sections[0].label, "1(1)"); + assert.equal(document.reproductionIsOfficial, false); +}); + +test("official providers fail closed for inferred historical versions", async () => { + const provider = new JusticeLawsProvider(async () => + Response.json({}, { status: 200 }), + ); + await assert.rejects( + () => + provider.fetchLegislation("federal-act-d-3.4", { + versionDate: "2020-01-01", + }), + /historical-version retrieval/, + ); +}); diff --git a/backend/src/lib/legalSources/officialLegislation.ts b/backend/src/lib/legalSources/officialLegislation.ts new file mode 100644 index 000000000..6b9dc5395 --- /dev/null +++ b/backend/src/lib/legalSources/officialLegislation.ts @@ -0,0 +1,656 @@ +import { createHash } from "node:crypto"; +import { XMLParser } from "fast-xml-parser"; +import type { + JurisdictionCode, + LegalLegislationDocument, + LegalLegislationSection, + LegalLegislationSummary, + LegalSourceLanguage, + LegalSourceProvider, +} from "./types"; + +type LegislationEntry = { + sourceId: string; + jurisdiction: JurisdictionCode; + kind: "legislation" | "regulation" | "rule"; + title: string; + citation: string; + canonicalUrl: string; + alternateLanguageUrl: string | null; + englishPath?: string; + frenchPath?: string; +}; + +const ONTARIO_ENTRIES: LegislationEntry[] = [ + entry( + "ontario-statute-90c43", + "legislation", + "Courts of Justice Act", + "R.S.O. 1990, c. C.43", + "statute/90c43", + ), + entry( + "ontario-regulation-900194", + "rule", + "Rules of Civil Procedure", + "R.R.O. 1990, Reg. 194", + "regulation/900194", + ), + entry( + "ontario-regulation-980258", + "rule", + "Rules of the Small Claims Court", + "O. Reg. 258/98", + "regulation/980258", + ), + entry( + "ontario-statute-02l24", + "legislation", + "Limitations Act, 2002", + "S.O. 2002, c. 24, Sched. B", + "statute/02l24", + ), + entry( + "ontario-statute-90e23", + "legislation", + "Evidence Act", + "R.S.O. 1990, c. E.23", + "statute/90e23", + ), + entry( + "ontario-statute-90f03", + "legislation", + "Family Law Act", + "R.S.O. 1990, c. F.3", + "statute/90f03", + ), + entry( + "ontario-statute-90l08", + "legislation", + "Law Society Act", + "R.S.O. 1990, c. L.8", + "statute/90l08", + ), + entry( + "ontario-statute-90s26", + "legislation", + "Succession Law Reform Act", + "R.S.O. 1990, c. S.26", + "statute/90s26", + ), +]; + +const FEDERAL_ENTRIES: LegislationEntry[] = [ + federalEntry( + "federal-act-c-46", + "legislation", + "Criminal Code", + "R.S.C. 1985, c. C-46", + "C-46", + ), + federalEntry( + "federal-act-d-3.4", + "legislation", + "Divorce Act", + "R.S.C. 1985, c. 3 (2nd Supp.)", + "D-3.4", + ), + federalEntry( + "federal-act-c-5", + "legislation", + "Canada Evidence Act", + "R.S.C. 1985, c. C-5", + "C-5", + ), + federalEntry( + "federal-act-f-7", + "legislation", + "Federal Courts Act", + "R.S.C. 1985, c. F-7", + "F-7", + ), + federalEntry( + "federal-act-b-3", + "legislation", + "Bankruptcy and Insolvency Act", + "R.S.C. 1985, c. B-3", + "B-3", + ), + federalRegulation( + "federal-regulation-sor-98-106", + "rule", + "Federal Courts Rules", + "SOR/98-106", + "SOR-98-106", + ), + federalRegulation( + "federal-regulation-sor-97-175", + "regulation", + "Federal Child Support Guidelines", + "SOR/97-175", + "SOR-97-175", + ), +]; + +export class OntarioELawsProvider implements LegalSourceProvider { + readonly descriptor = { + id: "ontario-elaws", + name: "Ontario e-Laws", + jurisdictions: ["CA-ON" as const], + kinds: ["legislation" as const, "regulation" as const, "rule" as const], + official: true, + fullTextStatus: "unofficial" as const, + enabledByDefault: true, + }; + + constructor(private readonly fetchImpl: typeof fetch = fetch) {} + + async health() { + return { + ok: true, + detail: "Official Ontario e-Laws links and permitted live retrieval are configured.", + }; + } + + async searchLegislation(input: { + query: string; + language?: LegalSourceLanguage; + kind?: "legislation" | "regulation" | "rule"; + limit?: number; + }) { + return searchEntries(ONTARIO_ENTRIES, input, this.descriptor.id); + } + + async fetchLegislation( + sourceId: string, + input: { + language?: LegalSourceLanguage; + section?: string; + versionDate?: string; + } = {}, + ) { + if (input.versionDate) + throw new Error( + "Ontario historical-version retrieval is not enabled until an official stable interface is validated.", + ); + const item = requireEntry(ONTARIO_ENTRIES, sourceId); + const language = input.language ?? "en"; + const url = + language === "fr" && item.alternateLanguageUrl + ? item.alternateLanguageUrl + : item.canonicalUrl; + const html = await safeOfficialFetch(this.fetchImpl, url, [ + "www.ontario.ca", + ]); + const fullText = htmlToText(html); + const allSections = parseOntarioSections(fullText, url); + const sections = input.section + ? filterSection(allSections, input.section) + : allSections; + const summary = legislationSummary(item, this.descriptor.id, language, { + currentToDate: extractCurrencyDate(fullText), + lastAmendedDate: extractLabeledDate(fullText, "last amended"), + verification: "verified", + }); + return document(summary, html, fullText, sections, { + source: "Ontario e-Laws live HTML", + officialDisplayUrl: url, + }); + } +} + +export class JusticeLawsProvider implements LegalSourceProvider { + readonly descriptor = { + id: "justice-laws-canada", + name: "Justice Laws Website", + jurisdictions: ["CA" as const], + kinds: ["legislation" as const, "regulation" as const, "rule" as const], + official: true, + fullTextStatus: "unofficial" as const, + enabledByDefault: true, + }; + + constructor(private readonly fetchImpl: typeof fetch = fetch) {} + + async health() { + return { + ok: true, + detail: "Department of Justice XML repository and official stable links are configured.", + }; + } + + async searchLegislation(input: { + query: string; + language?: LegalSourceLanguage; + kind?: "legislation" | "regulation" | "rule"; + limit?: number; + }) { + return searchEntries(FEDERAL_ENTRIES, input, this.descriptor.id); + } + + async fetchLegislation( + sourceId: string, + input: { + language?: LegalSourceLanguage; + section?: string; + versionDate?: string; + } = {}, + ) { + if (input.versionDate) + throw new Error( + "Federal historical-version retrieval requires an explicit archived version and is not inferred from the current XML repository.", + ); + const item = requireEntry(FEDERAL_ENTRIES, sourceId); + const language = input.language ?? "en"; + const path = language === "fr" ? item.frenchPath : item.englishPath; + if (!path) + throw new Error( + `The requested ${language} XML path is not configured for ${item.title}.`, + ); + const xmlUrl = `https://raw.githubusercontent.com/justicecanada/laws-lois-xml/main/${path}`; + const xml = await safeOfficialFetch(this.fetchImpl, xmlUrl, [ + "raw.githubusercontent.com", + ]); + const parsed = parseJusticeXml(xml, item, language); + const sections = input.section + ? filterSection(parsed.sections, input.section) + : parsed.sections; + const summary = legislationSummary(item, this.descriptor.id, language, { + currentToDate: parsed.currentToDate, + lastAmendedDate: parsed.lastAmendedDate, + verification: "verified", + }); + return document(summary, xml, parsed.fullText, sections, { + source: "justicecanada/laws-lois-xml", + xmlUrl, + officialDisplayUrl: + language === "fr" + ? item.alternateLanguageUrl + : item.canonicalUrl, + }); + } +} + +export function parseJusticeXml( + xml: string, + item: LegislationEntry, + language: LegalSourceLanguage, +) { + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@", + trimValues: true, + parseTagValue: false, + }); + const parsed = parser.parse(xml) as Record; + const root = record(parsed.Statute ?? parsed.Regulation); + if (!Object.keys(root).length) + throw new Error( + "Justice Laws XML did not contain a Statute or Regulation root.", + ); + const sections: LegalLegislationSection[] = []; + collectJusticeSections( + root, + sections, + language === "fr" + ? (item.alternateLanguageUrl ?? item.canonicalUrl) + : item.canonicalUrl, + ); + return { + currentToDate: normalizedDate(text(root["@lims:current-date"])), + lastAmendedDate: normalizedDate(text(root["@lims:lastAmendedDate"])), + fullText: nodeText(root), + sections, + }; +} + +export function parseOntarioSections( + fullText: string, + sourceUrl: string, +): LegalLegislationSection[] { + const lines = fullText + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const sections: LegalLegislationSection[] = []; + for (const line of lines) { + const match = line.match( + /^(\d+(?:\.\d+)*(?:\s*\([^)]+\))?)\s+(.{8,})$/, + ); + if (!match) continue; + sections.push({ + label: match[1].replace(/\s+/g, ""), + heading: null, + text: match[2], + sourceUrl, + inForceFrom: null, + lastAmendedDate: null, + }); + } + return sections; +} + +function collectJusticeSections( + value: unknown, + output: LegalLegislationSection[], + sourceUrl: string, +) { + if (Array.isArray(value)) { + value.forEach((item) => + collectJusticeSections(item, output, sourceUrl), + ); + return; + } + if (!value || typeof value !== "object") return; + const row = value as Record; + const sections = array(row.Section); + for (const rawSection of sections) { + const section = record(rawSection); + const label = nodeText(section.Label); + if (label) + output.push({ + label, + heading: nodeText(section.MarginalNote) || null, + text: nodeText(section), + sourceUrl: stableSectionUrl(sourceUrl, label), + inForceFrom: normalizedDate( + text(section["@lims:inforce-start-date"]), + ), + lastAmendedDate: normalizedDate( + text(section["@lims:lastAmendedDate"]), + ), + }); + } + for (const [key, child] of Object.entries(row)) + if (key !== "Section") collectJusticeSections(child, output, sourceUrl); +} + +async function safeOfficialFetch( + fetchImpl: typeof fetch, + url: string, + allowedHosts: string[], +) { + const parsed = new URL(url); + if (parsed.protocol !== "https:" || !allowedHosts.includes(parsed.hostname)) + throw new Error("Official-source URL is not allowlisted."); + const response = await fetchImpl(url, { + headers: { Accept: "text/html, application/xml, text/xml" }, + signal: AbortSignal.timeout(20_000), + }); + if (!response.ok) + throw new Error( + `Official legislation source returned HTTP ${response.status}.`, + ); + const declaredLength = Number(response.headers.get("content-length") ?? 0); + if (declaredLength > 15_000_000) + throw new Error( + "Official legislation response exceeds the 15 MB safety limit.", + ); + const body = await response.text(); + if (body.length > 15_000_000) + throw new Error( + "Official legislation response exceeds the 15 MB safety limit.", + ); + return body; +} + +function searchEntries( + entries: LegislationEntry[], + input: { + query: string; + language?: LegalSourceLanguage; + kind?: "legislation" | "regulation" | "rule"; + limit?: number; + }, + providerId: string, +) { + const words = input.query + .toLocaleLowerCase("en-CA") + .split(/\s+/) + .filter(Boolean); + return entries + .filter((item) => !input.kind || item.kind === input.kind) + .map((item) => ({ + item, + score: words.reduce( + (score, word) => + score + + (`${item.title} ${item.citation}` + .toLocaleLowerCase("en-CA") + .includes(word) + ? 1 + : 0), + 0, + ), + })) + .filter(({ score }) => score > 0) + .sort( + (a, b) => + b.score - a.score || a.item.title.localeCompare(b.item.title), + ) + .slice(0, Math.min(50, Math.max(1, input.limit ?? 10))) + .map(({ item }) => + legislationSummary(item, providerId, input.language ?? "en"), + ); +} + +function legislationSummary( + item: LegislationEntry, + providerId: string, + language: LegalSourceLanguage, + override: Partial = {}, +): LegalLegislationSummary { + return { + providerId, + sourceId: item.sourceId, + jurisdiction: item.jurisdiction, + kind: item.kind, + title: item.title, + citation: item.citation, + language, + canonicalUrl: + language === "fr" && item.alternateLanguageUrl + ? item.alternateLanguageUrl + : item.canonicalUrl, + alternateLanguageUrl: + language === "fr" ? item.canonicalUrl : item.alternateLanguageUrl, + currentToDate: null, + lastAmendedDate: null, + inForceStatus: "unknown", + verification: "unverified", + ...override, + }; +} + +function document( + summary: LegalLegislationSummary, + source: string, + fullText: string, + sections: LegalLegislationSection[], + providerPayload: Record, +): LegalLegislationDocument { + return { + ...summary, + retrievedAt: new Date().toISOString(), + sections, + fullText, + sourceHash: createHash("sha256").update(source).digest("hex"), + reproductionIsOfficial: false, + providerPayload, + }; +} + +function entry( + sourceId: string, + kind: LegislationEntry["kind"], + title: string, + citation: string, + path: string, +): LegislationEntry { + return { + sourceId, + jurisdiction: "CA-ON", + kind, + title, + citation, + canonicalUrl: `https://www.ontario.ca/laws/${path}`, + alternateLanguageUrl: `https://www.ontario.ca/fr/lois/${path.replace("statute", "loi").replace("regulation", "reglement")}`, + }; +} + +function federalEntry( + sourceId: string, + kind: LegislationEntry["kind"], + title: string, + citation: string, + code: string, +): LegislationEntry { + return { + sourceId, + jurisdiction: "CA", + kind, + title, + citation, + canonicalUrl: `https://laws-lois.justice.gc.ca/eng/acts/${code}/`, + alternateLanguageUrl: `https://laws-lois.justice.gc.ca/fra/lois/${code}/`, + englishPath: `eng/acts/${code}.xml`, + frenchPath: `fra/lois/${code}.xml`, + }; +} + +function federalRegulation( + sourceId: string, + kind: LegislationEntry["kind"], + title: string, + citation: string, + code: string, +): LegislationEntry { + const frenchCode = code.replace(/^SOR-/, "DORS-"); + return { + sourceId, + jurisdiction: "CA", + kind, + title, + citation, + canonicalUrl: `https://laws-lois.justice.gc.ca/eng/regulations/${code}/`, + alternateLanguageUrl: `https://laws-lois.justice.gc.ca/fra/reglements/${frenchCode}/`, + englishPath: `eng/regulations/${code}.xml`, + frenchPath: `fra/reglements/${frenchCode}.xml`, + }; +} + +function requireEntry(entries: LegislationEntry[], sourceId: string) { + const item = entries.find((entry) => entry.sourceId === sourceId); + if (!item) throw new Error(`Unknown legislation source: ${sourceId}`); + return item; +} + +function filterSection(sections: LegalLegislationSection[], requested: string) { + const normalized = requested + .replace(/^s(?:ection)?\.?\s*/i, "") + .replace(/\s+/g, ""); + return sections.filter( + (section) => + section.label.replace(/\s+/g, "").toLocaleLowerCase("en-CA") === + normalized.toLocaleLowerCase("en-CA"), + ); +} + +function stableSectionUrl(url: string, label: string) { + if (!url.includes("laws-lois.justice.gc.ca")) return url; + return `${url.replace(/\/$/, "")}/section-${encodeURIComponent(label.replace(/\(.*/, ""))}.html`; +} + +function htmlToText(html: string) { + return decodeHtml( + html + .replace(/]*>[\s\S]*?<\/script>/gi, " ") + .replace(/]*>[\s\S]*?<\/style>/gi, " ") + .replace(/<\/(?:p|div|li|h[1-6]|section|article|tr)>/gi, "\n") + .replace(//gi, "\n") + .replace(/<[^>]+>/g, " "), + ) + .replace(/[ \t]+/g, " ") + .replace(/\n\s*\n+/g, "\n") + .trim(); +} + +function decodeHtml(value: string) { + const entities: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", + }; + return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (_, entity: string) => { + if (entity.startsWith("#x")) + return String.fromCodePoint(Number.parseInt(entity.slice(2), 16)); + if (entity.startsWith("#")) + return String.fromCodePoint(Number.parseInt(entity.slice(1), 10)); + return entities[entity.toLowerCase()] ?? `&${entity};`; + }); +} + +function extractCurrencyDate(value: string) { + return normalizedDate( + value + .match(/(?:current|up-to-date)\s+(?:as of|to)\s+([^\n.;]+)/i)?.[1] + ?.trim() ?? null, + ); +} + +function extractLabeledDate(value: string, label: string) { + return normalizedDate( + value + .match(new RegExp(`${label}\\s*:?\\s*([^\\n.;]+)`, "i"))?.[1] + ?.trim() ?? null, + ); +} + +function normalizedDate(value: string | null) { + if (!value) return null; + const iso = value.match(/^\d{4}-\d{2}-\d{2}/)?.[0]; + if (iso) return iso; + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) + ? null + : parsed.toISOString().slice(0, 10); +} + +function nodeText(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "string" || typeof value === "number") + return String(value).trim(); + if (Array.isArray(value)) + return value + .map(nodeText) + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + if (typeof value !== "object") return ""; + return Object.entries(value as Record) + .filter(([key]) => !key.startsWith("@")) + .map(([, child]) => nodeText(child)) + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim(); +} + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function array(value: unknown): unknown[] { + if (value === undefined || value === null) return []; + return Array.isArray(value) ? value : [value]; +} + +function text(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} diff --git a/backend/src/lib/legalSources/ontarioProcedure.test.ts b/backend/src/lib/legalSources/ontarioProcedure.test.ts new file mode 100644 index 000000000..7bf2b5bce --- /dev/null +++ b/backend/src/lib/legalSources/ontarioProcedure.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + ONTARIO_COURT_FORMS, + ONTARIO_PROCEDURE_SOURCES, + calculateOntarioDeadline, + checkOntarioProcedureSources, +} from "./ontarioProcedure"; + +test("procedure registry uses official Ontario sources and link-only forms", () => { + assert.ok(ONTARIO_PROCEDURE_SOURCES.length >= 6); + assert.ok( + ONTARIO_PROCEDURE_SOURCES.every((source) => + ["www.ontario.ca", "www.ontariocourts.ca"].includes( + new URL(source.officialUrl).hostname, + ), + ), + ); + assert.ok(ONTARIO_COURT_FORMS.length >= 6); + assert.ok( + ONTARIO_COURT_FORMS.every( + (form) => + form.revisionDate === null && + form.status === "check-official-current-version", + ), + ); +}); + +test("source checks retain response metadata without copying source text", async () => { + const results = await checkOntarioProcedureSources( + async () => + new Response(null, { + status: 200, + headers: { + etag: '"synthetic-v1"', + "last-modified": "Thu, 16 Jul 2026 12:00:00 GMT", + }, + }), + ); + assert.equal(results.length, ONTARIO_PROCEDURE_SOURCES.length); + assert.equal(results[0].etag, '"synthetic-v1"'); + assert.match(results[0].metadataHash, /^[a-f0-9]{64}$/); + assert.deepEqual(Object.keys(results[0]).sort(), [ + "checkedAt", + "etag", + "lastModified", + "metadataHash", + "ok", + "sourceId", + ]); +}); + +test("civil periods of seven days or less exclude Ontario court holidays", () => { + const result = calculateOntarioDeadline({ + profile: "ontario-civil-rule-3", + triggerDate: "2026-07-31", + days: 7, + calculationTimestamp: "2026-07-16T12:00:00.000Z", + }); + assert.equal(result.dueDate, "2026-08-12"); + assert.deepEqual( + result.excludedDates.map(({ date }) => date), + ["2026-08-01", "2026-08-02", "2026-08-03", "2026-08-08", "2026-08-09"], + ); + assert.equal(result.requiresUserConfirmation, true); +}); + +test("long civil periods count intermediate holidays but move a holiday due date", () => { + const result = calculateOntarioDeadline({ + profile: "ontario-civil-rule-3", + triggerDate: "2026-12-15", + days: 10, + }); + assert.equal(result.countedDates.at(-1), "2026-12-25"); + assert.equal(result.dueDate, "2026-12-29"); +}); + +test("Small Claims counts intermediate holidays and moves only a holiday due date", () => { + const result = calculateOntarioDeadline({ + profile: "ontario-small-claims-rule-3", + triggerDate: "2026-07-31", + days: 3, + }); + assert.deepEqual(result.countedDates, [ + "2026-08-01", + "2026-08-02", + "2026-08-03", + ]); + assert.equal(result.dueDate, "2026-08-04"); +}); + +test("civil non-originating service after 4 p.m. is deemed on the next non-holiday", () => { + const result = calculateOntarioDeadline({ + profile: "ontario-civil-rule-3", + triggerDate: "2026-07-31", + days: 1, + serviceLocalTime: "16:01", + originatingProcess: false, + }); + assert.equal(result.adjustedTriggerDate, "2026-08-04"); + assert.equal(result.dueDate, "2026-08-05"); + assert.match(result.assumptions.join(" "), /after 4:00 p\.m\./i); +}); + +test("user-supplied holidays and closures are transparent inputs", () => { + const result = calculateOntarioDeadline({ + profile: "ontario-civil-rule-3", + triggerDate: "2026-07-13", + days: 2, + additionalHolidays: ["2026-07-14"], + courtClosures: ["2026-07-15"], + }); + assert.equal(result.dueDate, "2026-07-17"); + assert.deepEqual( + result.excludedDates.map(({ reason }) => reason), + ["User-supplied additional holiday", "User-supplied court closure"], + ); +}); + +test("deadline inputs fail closed for invalid calendar and time values", () => { + assert.throws( + () => + calculateOntarioDeadline({ + profile: "ontario-civil-rule-3", + triggerDate: "2026-02-30", + days: 1, + }), + /valid calendar date/, + ); + assert.throws( + () => + calculateOntarioDeadline({ + profile: "ontario-civil-rule-3", + triggerDate: "2026-07-16", + days: 1, + serviceLocalTime: "4:30 PM", + }), + /24-hour HH:MM/, + ); +}); diff --git a/backend/src/lib/legalSources/ontarioProcedure.ts b/backend/src/lib/legalSources/ontarioProcedure.ts new file mode 100644 index 000000000..785151c7b --- /dev/null +++ b/backend/src/lib/legalSources/ontarioProcedure.ts @@ -0,0 +1,451 @@ +import crypto from "node:crypto"; + +export type OntarioProcedureSource = { + id: string; + kind: "rule" | "practice-direction" | "form-catalogue"; + title: string; + jurisdiction: "CA-ON"; + court: string; + scope: "provincewide" | "regional"; + region: string | null; + language: "en" | "fr" | "bilingual"; + citation: string | null; + officialUrl: string; + updateCadence: "daily" | "weekly"; + lastReviewedDate: string; + copyingPolicy: "link-only" | "official-text-adapter"; +}; + +export type OntarioCourtForm = { + number: string; + title: string; + court: string; + language: "en" | "fr" | "bilingual"; + officialCatalogueUrl: string; + revisionDate: string | null; + status: "check-official-current-version"; +}; + +export const ONTARIO_PROCEDURE_SOURCES: OntarioProcedureSource[] = [ + { + id: "ontario-rules-civil-procedure", + kind: "rule", + title: "Rules of Civil Procedure", + jurisdiction: "CA-ON", + court: "Ontario Superior Court of Justice and Court of Appeal for Ontario", + scope: "provincewide", + region: null, + language: "bilingual", + citation: "R.R.O. 1990, Reg. 194", + officialUrl: "https://www.ontario.ca/laws/regulation/900194", + updateCadence: "daily", + lastReviewedDate: "2026-07-16", + copyingPolicy: "official-text-adapter", + }, + { + id: "ontario-rules-small-claims", + kind: "rule", + title: "Rules of the Small Claims Court", + jurisdiction: "CA-ON", + court: "Ontario Small Claims Court", + scope: "provincewide", + region: null, + language: "bilingual", + citation: "O. Reg. 258/98", + officialUrl: "https://www.ontario.ca/laws/regulation/980258", + updateCadence: "daily", + lastReviewedDate: "2026-07-16", + copyingPolicy: "official-text-adapter", + }, + { + id: "ontario-scj-practice-directions", + kind: "practice-direction", + title: "Superior Court of Justice Practice Directions", + jurisdiction: "CA-ON", + court: "Ontario Superior Court of Justice", + scope: "regional", + region: "User must identify the applicable one of eight regions", + language: "en", + citation: null, + officialUrl: "https://www.ontariocourts.ca/scj/practice-directions/", + updateCadence: "daily", + lastReviewedDate: "2026-07-16", + copyingPolicy: "link-only", + }, + { + id: "ontario-coa-general-practice-direction", + kind: "practice-direction", + title: "General Practice Direction Regarding All Proceedings in the Court of Appeal", + jurisdiction: "CA-ON", + court: "Court of Appeal for Ontario", + scope: "provincewide", + region: null, + language: "en", + citation: null, + officialUrl: + "https://www.ontariocourts.ca/coa/how-to-proceed-court/general/", + updateCadence: "daily", + lastReviewedDate: "2026-07-16", + copyingPolicy: "link-only", + }, + { + id: "ontario-scj-civil-forms", + kind: "form-catalogue", + title: "Rules of Civil Procedure Forms", + jurisdiction: "CA-ON", + court: "Ontario Superior Court of Justice", + scope: "provincewide", + region: null, + language: "bilingual", + citation: null, + officialUrl: + "https://www.ontariocourts.ca/scj/filing-procedures/rules/civil/", + updateCadence: "daily", + lastReviewedDate: "2026-07-16", + copyingPolicy: "link-only", + }, + { + id: "ontario-small-claims-forms", + kind: "form-catalogue", + title: "Rules of the Small Claims Court Forms", + jurisdiction: "CA-ON", + court: "Ontario Small Claims Court", + scope: "provincewide", + region: null, + language: "bilingual", + citation: null, + officialUrl: + "https://www.ontariocourts.ca/scj/filing-procedures/rules/small-claims/", + updateCadence: "daily", + lastReviewedDate: "2026-07-16", + copyingPolicy: "link-only", + }, +]; + +export const ONTARIO_COURT_FORMS: OntarioCourtForm[] = [ + form( + "14A", + "Statement of Claim", + "Ontario Superior Court of Justice", + "civil", + ), + form( + "18A", + "Notice of Intent to Defend", + "Ontario Superior Court of Justice", + "civil", + ), + form( + "7A", + "Plaintiff's Claim", + "Ontario Small Claims Court", + "small-claims", + ), + form( + "8A", + "Affidavit of Service", + "Ontario Small Claims Court", + "small-claims", + ), + form("9A", "Defence", "Ontario Small Claims Court", "small-claims"), + form( + "10A", + "Defendant's Claim", + "Ontario Small Claims Court", + "small-claims", + ), +]; + +function form( + number: string, + title: string, + court: string, + catalogue: "civil" | "small-claims", +): OntarioCourtForm { + return { + number, + title, + court, + language: "bilingual", + officialCatalogueUrl: `https://www.ontariocourts.ca/scj/filing-procedures/rules/${catalogue}/`, + revisionDate: null, + status: "check-official-current-version", + }; +} + +export async function checkOntarioProcedureSources( + fetchImpl: typeof fetch = fetch, +) { + return Promise.all( + ONTARIO_PROCEDURE_SOURCES.map(async (source) => { + const url = new URL(source.officialUrl); + if ( + !["www.ontario.ca", "www.ontariocourts.ca"].includes( + url.hostname, + ) + ) + throw new Error("Procedure source host is not allowlisted."); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10_000); + try { + const response = await fetchImpl(url, { + method: "HEAD", + redirect: "error", + signal: controller.signal, + }); + return { + sourceId: source.id, + ok: response.ok, + checkedAt: new Date().toISOString(), + etag: response.headers.get("etag"), + lastModified: response.headers.get("last-modified"), + metadataHash: crypto + .createHash("sha256") + .update( + JSON.stringify({ + url: source.officialUrl, + etag: response.headers.get("etag"), + lastModified: + response.headers.get("last-modified"), + }), + ) + .digest("hex"), + }; + } finally { + clearTimeout(timer); + } + }), + ); +} + +export type OntarioDeadlineInput = { + profile: "ontario-civil-rule-3" | "ontario-small-claims-rule-3"; + triggerDate: string; + days: number; + serviceLocalTime?: string; + originatingProcess?: boolean; + additionalHolidays?: string[]; + courtClosures?: string[]; + calculationTimestamp?: string; +}; + +export type OntarioDeadlineResult = { + dueDate: string; + adjustedTriggerDate: string; + countedDates: string[]; + excludedDates: Array<{ date: string; reason: string }>; + assumptions: string[]; + warnings: string[]; + governingRule: string; + governingRuleUrl: string; + timeZone: "America/Toronto"; + calculatedAt: string; + requiresUserConfirmation: true; +}; + +export function calculateOntarioDeadline( + input: OntarioDeadlineInput, +): OntarioDeadlineResult { + if (!/^\d{4}-\d{2}-\d{2}$/.test(input.triggerDate)) + throw new Error("triggerDate must use YYYY-MM-DD."); + if (!Number.isInteger(input.days) || input.days < 1 || input.days > 366) + throw new Error("days must be an integer from 1 to 366."); + if ( + input.serviceLocalTime !== undefined && + !/^([01]\d|2[0-3]):[0-5]\d$/.test(input.serviceLocalTime) + ) + throw new Error("serviceLocalTime must use 24-hour HH:MM format."); + for (const value of [ + ...(input.additionalHolidays ?? []), + ...(input.courtClosures ?? []), + ]) + parseDate(value); + const trigger = parseDate(input.triggerDate); + const extra = new Set(input.additionalHolidays ?? []); + const closures = new Set(input.courtClosures ?? []); + const isHoliday = (date: Date) => + isOntarioCourtHoliday(date) || + extra.has(formatDate(date)) || + closures.has(formatDate(date)); + const excludedDates: OntarioDeadlineResult["excludedDates"] = []; + let adjustedTrigger = trigger; + const isCivil = input.profile === "ontario-civil-rule-3"; + const servedAfterFour = + isCivil && + !input.originatingProcess && + typeof input.serviceLocalTime === "string" && + input.serviceLocalTime >= "16:00"; + if (isCivil && (servedAfterFour || isHoliday(adjustedTrigger))) { + do { + adjustedTrigger = addDays(adjustedTrigger, 1); + } while (isHoliday(adjustedTrigger)); + } + + const countedDates: string[] = []; + let cursor = adjustedTrigger; + while (countedDates.length < input.days) { + cursor = addDays(cursor, 1); + if (isCivil && input.days <= 7 && isHoliday(cursor)) { + excludedDates.push({ + date: formatDate(cursor), + reason: holidayReason(cursor, extra, closures), + }); + continue; + } + countedDates.push(formatDate(cursor)); + } + while (isHoliday(cursor)) { + excludedDates.push({ + date: formatDate(cursor), + reason: `Due date moved: ${holidayReason(cursor, extra, closures)}`, + }); + cursor = addDays(cursor, 1); + } + + const civilRule = "R.R.O. 1990, Reg. 194, r. 3.01"; + const smallClaimsRule = "O. Reg. 258/98, r. 3.01"; + return { + dueDate: formatDate(cursor), + adjustedTriggerDate: formatDate(adjustedTrigger), + countedDates, + excludedDates, + assumptions: [ + "The first day is excluded and the last day is included.", + isCivil && input.days <= 7 + ? "Because the prescribed period is seven days or less, holidays are not counted." + : "Intermediate holidays are counted; a holiday due date moves to the next non-holiday.", + "Local time is America/Toronto.", + ...(servedAfterFour + ? [ + "Non-originating service after 4:00 p.m. is deemed made on the next non-holiday.", + ] + : []), + ...(input.originatingProcess + ? [ + "The after-4:00 p.m. deemed-service rule was not applied to an originating process.", + ] + : []), + ], + warnings: [ + "Confirm the governing rule, triggering event, service method, local court notices, extensions, orders, and agreements.", + "Special proclaimed holidays and unexpected court closures are included only when supplied in additionalHolidays or courtClosures.", + "This procedural calculation is not a substantive limitation-period opinion.", + ], + governingRule: isCivil ? civilRule : smallClaimsRule, + governingRuleUrl: isCivil + ? "https://www.ontario.ca/laws/regulation/900194#BK60" + : "https://www.ontario.ca/laws/regulation/980258#BK8", + timeZone: "America/Toronto", + calculatedAt: input.calculationTimestamp ?? new Date().toISOString(), + requiresUserConfirmation: true, + }; +} + +function parseDate(value: string) { + const date = new Date(`${value}T12:00:00Z`); + if (Number.isNaN(date.getTime()) || formatDate(date) !== value) + throw new Error("triggerDate is not a valid calendar date."); + return date; +} + +function formatDate(date: Date) { + return date.toISOString().slice(0, 10); +} + +function addDays(date: Date, amount: number) { + const next = new Date(date); + next.setUTCDate(next.getUTCDate() + amount); + return next; +} + +function isOntarioCourtHoliday(date: Date) { + const day = date.getUTCDay(); + if (day === 0 || day === 6) return true; + const year = date.getUTCFullYear(); + return ontarioFixedHolidays(year).has(formatDate(date)); +} + +function ontarioFixedHolidays(year: number) { + const dates = new Set(); + const add = (date: Date) => dates.add(formatDate(date)); + const addObservedMonday = (month: number, day: number) => { + const date = new Date(Date.UTC(year, month, day, 12)); + add(date); + if (date.getUTCDay() === 6) add(addDays(date, 2)); + if (date.getUTCDay() === 0) add(addDays(date, 1)); + }; + addObservedMonday(0, 1); + add(nthWeekday(year, 1, 1, 3)); + const easter = easterSunday(year); + add(addDays(easter, -2)); + add(addDays(easter, 1)); + add(lastWeekdayOnOrBefore(year, 4, 24, 1)); + addObservedMonday(6, 1); + add(nthWeekday(year, 7, 1, 1)); + add(nthWeekday(year, 8, 1, 1)); + add(nthWeekday(year, 9, 1, 2)); + addObservedMonday(10, 11); + const christmas = new Date(Date.UTC(year, 11, 25, 12)); + const boxing = new Date(Date.UTC(year, 11, 26, 12)); + add(christmas); + add(boxing); + if (christmas.getUTCDay() === 5) add(addDays(christmas, 3)); + if (christmas.getUTCDay() === 6) { + add(addDays(christmas, 2)); + add(addDays(christmas, 3)); + } + if (christmas.getUTCDay() === 0) { + add(addDays(christmas, 1)); + add(addDays(christmas, 2)); + } + return dates; +} + +function nthWeekday( + year: number, + month: number, + weekday: number, + occurrence: number, +) { + const date = new Date(Date.UTC(year, month, 1, 12)); + const offset = (weekday - date.getUTCDay() + 7) % 7; + date.setUTCDate(1 + offset + (occurrence - 1) * 7); + return date; +} + +function lastWeekdayOnOrBefore( + year: number, + month: number, + day: number, + weekday: number, +) { + const date = new Date(Date.UTC(year, month, day, 12)); + date.setUTCDate(day - ((date.getUTCDay() - weekday + 7) % 7)); + return date; +} + +function easterSunday(year: number) { + const a = year % 19; + const b = Math.floor(year / 100); + const c = year % 100; + const d = Math.floor(b / 4); + const e = b % 4; + const f = Math.floor((b + 8) / 25); + const g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4); + const k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const month = Math.floor((h + l - 7 * m + 114) / 31) - 1; + const day = ((h + l - 7 * m + 114) % 31) + 1; + return new Date(Date.UTC(year, month, day, 12)); +} + +function holidayReason(date: Date, extra: Set, closures: Set) { + const value = formatDate(date); + if (closures.has(value)) return "User-supplied court closure"; + if (extra.has(value)) return "User-supplied additional holiday"; + if (date.getUTCDay() === 0 || date.getUTCDay() === 6) return "Weekend"; + return "Ontario court holiday"; +} diff --git a/backend/src/lib/legalSources/registry.ts b/backend/src/lib/legalSources/registry.ts new file mode 100644 index 000000000..914c5be15 --- /dev/null +++ b/backend/src/lib/legalSources/registry.ts @@ -0,0 +1,30 @@ +import type { JurisdictionCode, LegalSourceKind, LegalSourceProvider } from "./types"; + +export class LegalSourceRegistry { + private readonly providers = new Map(); + + register(provider: LegalSourceProvider): this { + const { id } = provider.descriptor; + if (this.providers.has(id)) throw new Error(`Legal source provider already registered: ${id}`); + this.providers.set(id, provider); + return this; + } + + get(id: string): LegalSourceProvider { + const provider = this.providers.get(id); + if (!provider) throw new Error(`Unknown legal source provider: ${id}`); + return provider; + } + + list(filters?: { jurisdiction?: JurisdictionCode; kind?: LegalSourceKind }): LegalSourceProvider[] { + return [...this.providers.values()].filter((provider) => { + if (filters?.jurisdiction && !provider.descriptor.jurisdictions.includes(filters.jurisdiction)) return false; + if (filters?.kind && !provider.descriptor.kinds.includes(filters.kind)) return false; + return true; + }); + } + + describe() { + return this.list().map((provider) => provider.descriptor); + } +} diff --git a/backend/src/lib/legalSources/types.ts b/backend/src/lib/legalSources/types.ts new file mode 100644 index 000000000..22e67f07e --- /dev/null +++ b/backend/src/lib/legalSources/types.ts @@ -0,0 +1,176 @@ +import type { createServerSupabase } from "../supabase"; + +export type JurisdictionCode = "US" | "CA" | `CA-${string}`; +export type LegalSourceKind = + | "decision" + | "legislation" + | "regulation" + | "rule" + | "practice-direction" + | "form"; +export type VerificationState = + | "verified" + | "partial" + | "unverified" + | "unavailable"; +export type LegalSourceLanguage = "en" | "fr"; + +export type LegalSourcePassage = { + text: string; + language: LegalSourceLanguage; + paragraphStart: number | null; + paragraphEnd: number | null; + sourceUrl: string | null; + verification: VerificationState; +}; + +export type LegalSourceCoverage = { + providerId: string; + dataset: string; + jurisdiction: JurisdictionCode; + label: string; + documentCount: number | null; + firstDocumentDate: string | null; + lastDocumentDate: string | null; + checkedAt: string; +}; + +export type LegalLegislationSection = { + label: string; + heading: string | null; + text: string; + sourceUrl: string; + inForceFrom: string | null; + lastAmendedDate: string | null; +}; + +export type LegalLegislationSummary = { + providerId: string; + sourceId: string; + jurisdiction: JurisdictionCode; + kind: "legislation" | "regulation" | "rule"; + title: string; + citation: string; + language: LegalSourceLanguage; + canonicalUrl: string; + alternateLanguageUrl: string | null; + currentToDate: string | null; + lastAmendedDate: string | null; + inForceStatus: + | "in-force" + | "partly-in-force" + | "not-in-force" + | "repealed" + | "unknown"; + verification: VerificationState; +}; + +export type LegalLegislationDocument = LegalLegislationSummary & { + retrievedAt: string; + sections: LegalLegislationSection[]; + fullText: string | null; + sourceHash: string | null; + reproductionIsOfficial: false; + providerPayload: Record; +}; + +export type LegalSourceProviderDescriptor = { + id: string; + name: string; + jurisdictions: JurisdictionCode[]; + kinds: LegalSourceKind[]; + official: boolean; + fullTextStatus: "official" | "licensed" | "unofficial" | "metadata-only"; + enabledByDefault: boolean; +}; + +export type LegalDecisionSummary = { + providerId: string; + sourceId: string; + jurisdiction: JurisdictionCode; + caseName: string | null; + citation: string | null; + court: string | null; + decisionDate: string | null; + canonicalUrl: string | null; + snippet: string | null; + language: LegalSourceLanguage | null; + alternateLanguageUrl: string | null; + fullTextStatus: LegalSourceProviderDescriptor["fullTextStatus"]; + upstreamLicense: string | null; + verification: VerificationState; +}; + +export type LegalDecisionDocument = LegalDecisionSummary & { + retrievedAt: string; + fullText: string | null; + passages: LegalSourcePassage[]; + providerPayload: Record; +}; + +export type LegalCitationResult = { + input: string; + providerId: string; + status: VerificationState; + sourceId: string | null; + canonicalUrl: string | null; + providerPayload?: unknown; +}; + +export type LegalSourceContext = { + apiToken?: string | null; + db?: ReturnType; +}; + +export interface LegalSourceProvider { + readonly descriptor: LegalSourceProviderDescriptor; + health( + context?: LegalSourceContext, + ): Promise<{ ok: boolean; detail?: string }>; + searchDecisions?( + input: { + query: string; + court?: string; + jurisdiction?: JurisdictionCode; + language?: LegalSourceLanguage; + from?: string; + to?: string; + limit?: number; + offset?: number; + }, + context?: LegalSourceContext, + ): Promise; + fetchDecision?( + sourceId: string, + context?: LegalSourceContext, + ): Promise; + verifyCitations?( + citations: string[], + context?: LegalSourceContext, + ): Promise; + coverage?(context?: LegalSourceContext): Promise; + findPassages?( + document: LegalDecisionDocument, + query: string, + limit?: number, + ): LegalSourcePassage[]; + searchLegislation?( + input: { + query: string; + jurisdiction?: JurisdictionCode; + language?: LegalSourceLanguage; + kind?: "legislation" | "regulation" | "rule"; + limit?: number; + }, + context?: LegalSourceContext, + ): Promise; + fetchLegislation?( + sourceId: string, + input?: { + language?: LegalSourceLanguage; + section?: string; + versionDate?: string; + }, + context?: LegalSourceContext, + ): Promise; +} diff --git a/backend/src/lib/rossSystemWorkflows.ts b/backend/src/lib/rossSystemWorkflows.ts new file mode 100644 index 000000000..c3b2231a8 --- /dev/null +++ b/backend/src/lib/rossSystemWorkflows.ts @@ -0,0 +1,144 @@ +// Generated by scripts/build-ross-workflows.mjs. Do not edit directly. + +import type { SystemWorkflow } from "./systemWorkflows"; + +export const ROSS_SYSTEM_WORKFLOWS: SystemWorkflow[] = [ + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-ross-ontario-civil-claim-defence-issue-extraction", + "metadata": { + "title": "Ontario Civil Claim and Defence Issue Extraction (Draft — not lawyer-reviewed)", + "description": "DRAFT — not lawyer-reviewed. Extract pleaded facts, causes of action, defences, remedies, admissions, denials, and material gaps from Ontario civil pleadings.", + "type": "assistant", + "contributors": [ + { + "name": "ROSS contributors", + "organisation": "Ranade OSS", + "role": "Draft workflow author", + "linkedin": null + } + ], + "language": "English", + "version": "0.1.0-draft", + "practice": "Civil Litigation", + "jurisdictions": [ + "Canada / Ontario" + ] + }, + "skill_md": "# Ontario Civil Claim and Defence Issue Extraction\n\n## Boundary\n\nThis draft supports an Ontario professional reviewing synthetic or non-confidential pleadings. It does not give consumer advice, amend a pleading, calculate a limitation period, or decide the merits.\n\n## Instructions\n\n1. Confirm the represented party, court, region, legal as-of date, and that the claim and defence are present. If any are missing, stop and ask for them.\n2. Extract only what the pleadings state. Cite the pleading name and paragraph for every factual finding.\n3. Build a table with: issue; claim allegation; defence response; admission, denial, or no response; pleaded legal basis; requested remedy; source location; missing information.\n4. Keep allegations, admissions, evidence, and legal conclusions separate. Do not treat silence as an admission unless retrieved current authority establishes that result for this context.\n5. Retrieve the current Rules of Civil Procedure and applicable province-wide and regional practice directions through authorized legal-source tools before stating a procedural proposition. Include an exact supporting passage and canonical URL.\n6. Mark citation, passage, currency, and treatment verification independently. Never infer noting-up from ordinary search results.\n7. End with unresolved conflicts, missing pleadings or endorsements, and questions for the supervising professional. State that a human must review the result.", + "columns_config": null + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-ross-ontario-documentary-discovery-review", + "metadata": { + "title": "Ontario Documentary Discovery Review (Draft — not lawyer-reviewed)", + "description": "DRAFT — not lawyer-reviewed. Review a synthetic document set for relevance, issues, chronology, duplicates, and potential privilege flags without making privilege determinations.", + "type": "assistant", + "contributors": [ + { + "name": "ROSS contributors", + "organisation": "Ranade OSS", + "role": "Draft workflow author", + "linkedin": null + } + ], + "language": "English", + "version": "0.1.0-draft", + "practice": "Civil Litigation", + "jurisdictions": [ + "Canada / Ontario" + ] + }, + "skill_md": "# Ontario Documentary Discovery Review\n\n## Boundary\n\nThis draft organizes a synthetic or non-confidential document set. It does not make a final relevance, privilege, production, redaction, or destruction decision.\n\n## Instructions\n\n1. Confirm the represented party, pleadings or agreed issue list, court region, legal as-of date, and review protocol. Ask for missing inputs before reviewing.\n2. Preserve the source filename, document identifier, page, date, author, recipient, and document-family relationship. Never modify an original.\n3. For each document, report: responsive issue; concise factual summary; exact source location; potential duplicate or family; confidentiality indicator; potential privilege-review flag and reason; unresolved question.\n4. Use “potential privilege-review flag,” never “privileged,” unless a supervising professional supplied that determination. Do not expose flagged substance more widely than the input scope requires.\n5. Retrieve current Rules 29.1, 30, and 30.1 and applicable practice directions before stating a procedural requirement. Provide exact passages and official links.\n6. Report gaps in numbering, missing attachments, corrupt files, uncertain dates, and conflicts. Do not invent missing content.\n7. End with counts reconciled to the supplied set and a clear human decision gate for relevance, privilege, redaction, and production.", + "columns_config": null + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-ross-ontario-affidavit-fact-check", + "metadata": { + "title": "Ontario Affidavit Fact Check (Draft — not lawyer-reviewed)", + "description": "DRAFT — not lawyer-reviewed. Cross-check a draft Ontario affidavit against supplied records and identify unsupported, inconsistent, ambiguous, or hearsay-sensitive passages.", + "type": "assistant", + "contributors": [ + { + "name": "ROSS contributors", + "organisation": "Ranade OSS", + "role": "Draft workflow author", + "linkedin": null + } + ], + "language": "English", + "version": "0.1.0-draft", + "practice": "Civil Litigation", + "jurisdictions": [ + "Canada / Ontario" + ] + }, + "skill_md": "# Ontario Affidavit Fact Check\n\n## Boundary\n\nThis draft cross-checks a synthetic or non-confidential affidavit. It does not assess credibility, invent personal knowledge, decide admissibility, or approve swearing or filing.\n\n## Instructions\n\n1. Confirm the deponent, proceeding, court region, legal as-of date, draft affidavit, supporting record set, and stated basis of knowledge.\n2. Create a paragraph-by-paragraph table with: affidavit paragraph; proposition; knowledge basis stated; supporting record and pinpoint; status (supported, partially supported, inconsistent, ambiguous, or no supplied support); explanation; follow-up question.\n3. Cite both the affidavit paragraph and record location. Absence of supplied support is not proof that a statement is false.\n4. Identify internal inconsistencies, date or name mismatches, ambiguous pronouns, missing exhibit references, and statements that appear to require information-and-belief treatment. Do not rewrite facts to cure them.\n5. Retrieve current applicable rules, the Evidence Act where relevant, and the applicable practice direction before making any procedural or evidentiary observation. Quote the exact passage and link the official source.\n6. Separate factual cross-checking from legal analysis and expose every unverified proposition.\n7. End with questions for the deponent and supervising professional and an explicit human approval requirement.", + "columns_config": null + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-ross-ontario-factum-authority-record-cross-check", + "metadata": { + "title": "Ontario Factum Authority and Record Cross-Check (Draft — not lawyer-reviewed)", + "description": "DRAFT — not lawyer-reviewed. Check a draft factum's record references, quoted passages, Canadian citations, and authority support while keeping verification states separate.", + "type": "assistant", + "contributors": [ + { + "name": "ROSS contributors", + "organisation": "Ranade OSS", + "role": "Draft workflow author", + "linkedin": null + } + ], + "language": "English", + "version": "0.1.0-draft", + "practice": "Civil Litigation and Appeals", + "jurisdictions": [ + "Canada / Ontario", + "Canada / Federal" + ] + }, + "skill_md": "# Ontario Factum Authority and Record Cross-Check\n\n## Boundary\n\nThis draft checks a synthetic or non-confidential factum. It does not approve filing, invent a record reference, or claim that ordinary search is a citator.\n\n## Instructions\n\n1. Confirm the court, region, represented party, legal as-of date, draft factum, complete supplied record, and authority list.\n2. For each material proposition, create a matrix with: factum paragraph; proposition; record pinpoint and match status; cited authority; exact retrieved authority passage; citation verification; passage verification; currency verification; treatment verification; issue.\n3. Compare quotations character-for-character while allowing clearly identified ellipses or formatting changes. Flag altered meaning, missing context, unresolved record pinpoints, and authorities not retrieved.\n4. Retrieve sources only through authorized legal-source providers. Use official rules and current Court of Appeal or regional directions for formatting or procedural propositions.\n5. Keep citation syntax, passage support, source currency, and judicial treatment as separate states. Never upgrade treatment from an ordinary search result.\n6. If authority coverage is incomplete, say which court, period, or provider is missing. Do not substitute model memory.\n7. End with blockers for the supervising professional; do not label the factum filing-ready.", + "columns_config": null + }, + { + "user_id": null, + "is_system": true, + "created_at": "", + "id": "builtin-ross-ontario-small-claims-intake", + "metadata": { + "title": "Ontario Small Claims Claim and Defence Intake (Draft — not lawyer-reviewed)", + "description": "DRAFT — not lawyer-reviewed. Organize a synthetic Small Claims matter into parties, allegations, responses, remedies, documents, procedural questions, and missing facts.", + "type": "assistant", + "contributors": [ + { + "name": "ROSS contributors", + "organisation": "Ranade OSS", + "role": "Draft workflow author", + "linkedin": null + } + ], + "language": "English", + "version": "0.1.0-draft", + "practice": "Small Claims Court", + "jurisdictions": [ + "Canada / Ontario" + ] + }, + "skill_md": "# Ontario Small Claims Claim and Defence Intake\n\n## Boundary\n\nThis draft organizes a synthetic or non-confidential Ontario Small Claims matter for a professional. It does not provide consumer advice, calculate a limitation period, choose a cause of action, or file a form.\n\n## Instructions\n\n1. Confirm the represented party, court location, legal as-of date, service facts, claim or defence, and supporting records. Ask for any missing core input.\n2. Extract parties and roles, allegations, responses, material dates, amounts claimed, remedies requested, supporting documents, and factual gaps. Cite every extracted item to an input location.\n3. Produce an allegation-response-evidence matrix. Keep allegations separate from evidence and identify amounts that cannot be reconciled.\n4. Retrieve the current Rules of the Small Claims Court and applicable practice direction before stating a procedural requirement. Link to the official forms catalogue; never supply a retained form as though it were current.\n5. Do not assume a monetary limit, prescribed deadline, service date, or form version. If asked to count a supported procedural period, use the transparent deadline tool and show all inputs, exclusions, warnings, and the governing-rule link.\n6. Identify potential jurisdiction, venue, service, party-name, capacity, and document-completeness questions without deciding them.\n7. End with a missing-information list and mandatory review by the supervising Ontario lawyer or paralegal acting within permitted scope.", + "columns_config": null + } +]; + +export const ROSS_SYSTEM_WORKFLOW_IDS = new Set(ROSS_SYSTEM_WORKFLOWS.map((workflow) => workflow.id)); diff --git a/backend/src/lib/userSettings.ts b/backend/src/lib/userSettings.ts index 92ac49a15..8d2ca8abc 100644 --- a/backend/src/lib/userSettings.ts +++ b/backend/src/lib/userSettings.ts @@ -11,10 +11,33 @@ import { getUserApiKeys as getStoredUserApiKeys } from "./userApiKeys"; export type UserModelSettings = { title_model: string; tabular_model: string; + legal_research: LegalResearchSettings; + /** Legacy compatibility flag. Prefer legal_research.enabledJurisdictions. */ legal_research_us: boolean; api_keys: UserApiKeys; }; +export type LegalResearchSettings = { + enabled: boolean; + defaultCountry: "CA" | "US"; + defaultProvince: "ON" | null; + enabledJurisdictions: Array<"CA" | "CA-ON" | "US">; + enabledSourceProviders: string[]; +}; + +export const DEFAULT_LEGAL_RESEARCH_SETTINGS: LegalResearchSettings = { + enabled: true, + defaultCountry: "CA", + defaultProvince: "ON", + enabledJurisdictions: ["CA-ON", "CA", "US"], + enabledSourceProviders: [ + "a2aj-canada", + "ontario-elaws", + "justice-laws-canada", + "courtlistener-us", + ], +}; + // Title generation is a lightweight task — always routed to the cheapest model // of whichever provider the user has keys for: Gemini Flash Lite if Gemini is // available, otherwise OpenAI lite, otherwise Claude Haiku. With no user keys @@ -31,23 +54,94 @@ export async function getUserModelSettings( db?: ReturnType, ): Promise { const client = db ?? createServerSupabase(); - const { data } = await client + const generic = await client .from("user_profiles") - .select("title_model, tabular_model, legal_research_us") + .select( + "title_model, tabular_model, legal_research_enabled, default_country, default_province, enabled_jurisdictions, enabled_source_providers, legal_research_us", + ) .eq("user_id", userId) .single(); + const legacy = generic.error + ? await client + .from("user_profiles") + .select("title_model, tabular_model, legal_research_us") + .eq("user_id", userId) + .single() + : generic; + const data = legacy.data as Record | null; const api_keys = await getStoredUserApiKeys(userId, client); + const legacyUs = data?.legal_research_us !== false; + const enabledJurisdictions = normalizeJurisdictions( + data?.enabled_jurisdictions, + legacyUs, + ); + const enabledSourceProviders = normalizeProviders( + data?.enabled_source_providers, + legacyUs, + ); + const defaultCountry = data?.default_country === "US" ? "US" : "CA"; + const defaultProvince = + defaultCountry === "CA" && data?.default_province !== null + ? "ON" + : null; + return { - title_model: resolveModel(data?.title_model, resolveTitleModel(api_keys)), - tabular_model: resolveModel(data?.tabular_model, DEFAULT_TABULAR_MODEL), - legal_research_us: - (data as { legal_research_us?: boolean | null } | null) - ?.legal_research_us !== false, + title_model: resolveModel( + typeof data?.title_model === "string" ? data.title_model : null, + resolveTitleModel(api_keys), + ), + tabular_model: resolveModel( + typeof data?.tabular_model === "string" ? data.tabular_model : null, + DEFAULT_TABULAR_MODEL, + ), + legal_research: { + enabled: data?.legal_research_enabled !== false, + defaultCountry, + defaultProvince, + enabledJurisdictions, + enabledSourceProviders, + }, + legal_research_us: enabledJurisdictions.includes("US"), api_keys, }; } +function normalizeJurisdictions(value: unknown, legacyUs: boolean) { + if (!Array.isArray(value)) { + return DEFAULT_LEGAL_RESEARCH_SETTINGS.enabledJurisdictions.filter( + (jurisdiction) => jurisdiction !== "US" || legacyUs, + ); + } + const allowed = new Set< + LegalResearchSettings["enabledJurisdictions"][number] + >(["CA-ON", "CA", "US"]); + const normalized = value.filter( + (item): item is LegalResearchSettings["enabledJurisdictions"][number] => + typeof item === "string" && + allowed.has( + item as LegalResearchSettings["enabledJurisdictions"][number], + ), + ); + return [...new Set(normalized)]; +} + +function normalizeProviders(value: unknown, legacyUs: boolean) { + if (!Array.isArray(value)) { + return DEFAULT_LEGAL_RESEARCH_SETTINGS.enabledSourceProviders.filter( + (provider) => provider !== "courtlistener-us" || legacyUs, + ); + } + return [ + ...new Set( + value.filter( + (provider): provider is string => + typeof provider === "string" && provider.trim().length > 0, + ), + ), + ]; +} + export async function getUserApiKeys( userId: string, db?: ReturnType, diff --git a/backend/src/routes/caseLaw.ts b/backend/src/routes/caseLaw.ts index 4be389858..f99dc11f0 100644 --- a/backend/src/routes/caseLaw.ts +++ b/backend/src/routes/caseLaw.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import { requireAuth } from "../middleware/auth"; -import { getCourtlistenerCaseOpinions } from "../lib/courtlistener"; +import { createLegalSourceRegistry } from "../lib/legalSources"; import { createServerSupabase } from "../lib/supabase"; import { getUserModelSettings } from "../lib/userSettings"; @@ -14,6 +14,7 @@ const devLog = (...args: Parameters) => { }; const sidepanelOpinionFetches = new Map>(); +const legalSources = createLegalSourceRegistry(); function cleanClusterId(value: unknown): number | null { const numeric = @@ -51,22 +52,32 @@ caseLawRouter.post("/case-opinions", async (req, res) => { clusterId, }); } else { - fetchPromise = getCourtlistenerCaseOpinions({ - clusterId, - db, - includeFullText: true, - maxChars: 50000, - apiToken: settings.api_keys.courtlistener, - }).finally(() => { - sidepanelOpinionFetches.delete(fetchKey); - }); + const courtListener = legalSources.get("courtlistener-us"); + if (!courtListener.fetchDecision) + throw new Error( + "CourtListener decision retrieval is unavailable.", + ); + fetchPromise = courtListener + .fetchDecision(String(clusterId), { + db, + apiToken: settings.api_keys.courtlistener, + }) + .finally(() => { + sidepanelOpinionFetches.delete(fetchKey); + }); sidepanelOpinionFetches.set(fetchKey, fetchPromise); } const fetched = await fetchPromise; - const fetchedRecord = + const decisionRecord = fetched && typeof fetched === "object" && !Array.isArray(fetched) ? (fetched as Record) : {}; + const fetchedRecord = + decisionRecord.providerPayload && + typeof decisionRecord.providerPayload === "object" && + !Array.isArray(decisionRecord.providerPayload) + ? (decisionRecord.providerPayload as Record) + : {}; const opinions = Array.isArray(fetchedRecord.opinions) ? fetchedRecord.opinions : []; @@ -78,7 +89,9 @@ caseLawRouter.post("/case-opinions", async (req, res) => { return res.json({ opinions }); } catch (err) { const message = - err instanceof Error ? err.message : "Failed to fetch case opinions"; + err instanceof Error + ? err.message + : "Failed to fetch case opinions"; return res.status(502).json({ detail: message }); } }); diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 2bb3dfda6..52dd53010 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -18,9 +18,7 @@ import { type ChatMessage, } from "../lib/chat"; import { completeText } from "../lib/llm"; -import { - getUserModelSettings, -} from "../lib/userSettings"; +import { getUserModelSettings } from "../lib/userSettings"; import { checkProjectAccess } from "../lib/access"; import { safeErrorLog, safeErrorMessage } from "../lib/safeError"; @@ -35,7 +33,10 @@ const devLog = (...args: Parameters) => { const TITLE_FALLBACK = "Misc. Query"; function normalizeGeneratedTitle(raw: string): string { - const title = raw.trim().replace(/^["'`]+|["'`.,:;!?]+$/g, "").trim(); + const title = raw + .trim() + .replace(/^["'`]+|["'`.,:;!?]+$/g, "") + .trim(); if (!title) return TITLE_FALLBACK; return title.slice(0, 80); } @@ -47,7 +48,9 @@ type AccessibleChat = { project_id: string | null; } & Record; -function parseOptionalProjectId(value: unknown): +function parseOptionalProjectId( + value: unknown, +): | { ok: true; provided: boolean; projectId: string | null } | { ok: false; detail: string } { if (value === undefined) @@ -62,19 +65,20 @@ function parseOptionalProjectId(value: unknown): return { ok: true, provided: true, projectId: value.trim() }; } -function parseOptionalChatId(value: unknown): - | { ok: true; chatId: string | null } - | { ok: false; detail: string } { - if (value === undefined || value === null) return { ok: true, chatId: null }; +function parseOptionalChatId( + value: unknown, +): { ok: true; chatId: string | null } | { ok: false; detail: string } { + if (value === undefined || value === null) + return { ok: true, chatId: null }; if (typeof value !== "string" || !value.trim()) { return { ok: false, detail: "chat_id must be a non-empty string" }; } return { ok: true, chatId: value.trim() }; } -function parseChatMessages(value: unknown): - | { ok: true; messages: ChatMessage[] } - | { ok: false; detail: string } { +function parseChatMessages( + value: unknown, +): { ok: true; messages: ChatMessage[] } | { ok: false; detail: string } { if (!Array.isArray(value) || value.length === 0) { return { ok: false, detail: "messages must be a non-empty array" }; } @@ -98,9 +102,9 @@ function parseChatMessages(value: unknown): return { ok: true, messages: value as ChatMessage[] }; } -function parseOptionalModel(value: unknown): - | { ok: true; model: string | undefined } - | { ok: false; detail: string } { +function parseOptionalModel( + value: unknown, +): { ok: true; model: string | undefined } | { ok: false; detail: string } { if (value === undefined) return { ok: true, model: undefined }; if (typeof value !== "string" || !value.trim()) { return { ok: false, detail: "model must be a non-empty string" }; @@ -108,6 +112,55 @@ function parseOptionalModel(value: unknown): return { ok: true, model: value.trim() }; } +function parseLegalScope( + jurisdictions: unknown, + legalAsOfDate: unknown, +): + | { + ok: true; + jurisdictions: Array<"CA-ON" | "CA" | "US"> | null; + legalAsOfDate: string | null; + } + | { ok: false; detail: string } { + let normalizedJurisdictions: Array<"CA-ON" | "CA" | "US"> | null = null; + if (jurisdictions !== undefined) { + if (!Array.isArray(jurisdictions) || jurisdictions.length === 0) + return { + ok: false, + detail: "jurisdictions must be a non-empty array", + }; + const allowed = new Set(["CA-ON", "CA", "US"]); + if ( + jurisdictions.some( + (item) => typeof item !== "string" || !allowed.has(item), + ) + ) + return { + ok: false, + detail: "jurisdictions contains an unsupported value", + }; + normalizedJurisdictions = [ + ...new Set(jurisdictions as Array<"CA-ON" | "CA" | "US">), + ]; + } + if (legalAsOfDate !== undefined && legalAsOfDate !== null) { + if ( + typeof legalAsOfDate !== "string" || + !/^\d{4}-\d{2}-\d{2}$/.test(legalAsOfDate) || + Number.isNaN(Date.parse(`${legalAsOfDate}T00:00:00Z`)) + ) + return { + ok: false, + detail: "legal_as_of_date must use YYYY-MM-DD", + }; + } + return { + ok: true, + jurisdictions: normalizedJurisdictions, + legalAsOfDate: typeof legalAsOfDate === "string" ? legalAsOfDate : null, + }; +} + async function validateAccessibleProjectId( projectId: string | null, userId: string, @@ -181,6 +234,12 @@ chatRouter.post("/create", requireAuth, async (req, res) => { return void res.status(400).json({ detail: parsedProjectId.detail }); } const projectId = parsedProjectId.projectId; + const parsedScope = parseLegalScope( + req.body?.jurisdictions, + req.body?.legal_as_of_date, + ); + if (!parsedScope.ok) + return void res.status(400).json({ detail: parsedScope.detail }); const db = createServerSupabase(); const projectAccess = await validateAccessibleProjectId( projectId, @@ -195,7 +254,16 @@ chatRouter.post("/create", requireAuth, async (req, res) => { const { data, error } = await db .from("chats") - .insert({ user_id: userId, project_id: projectId ?? null }) + .insert({ + user_id: userId, + project_id: projectId ?? null, + ...(parsedScope.jurisdictions + ? { jurisdictions: parsedScope.jurisdictions } + : {}), + ...(parsedScope.legalAsOfDate + ? { legal_as_of_date: parsedScope.legalAsOfDate } + : {}), + }) .select("id") .single(); @@ -211,8 +279,7 @@ chatRouter.get("/:chatId", requireAuth, async (req, res) => { const db = createServerSupabase(); const chat = await getAccessibleChat(chatId, userId, userEmail, db); - if (!chat) - return void res.status(404).json({ detail: "Chat not found" }); + if (!chat) return void res.status(404).json({ detail: "Chat not found" }); const { data: messages } = await db .from("chat_messages") @@ -238,8 +305,7 @@ async function hydrateEditStatuses( if (!Array.isArray(list)) return; for (const a of list as Record[]) { if (typeof a?.edit_id === "string") editIds.add(a.edit_id); - if (typeof a?.version_id === "string") - versionIds.add(a.version_id); + if (typeof a?.version_id === "string") versionIds.add(a.version_id); } }; for (const m of messages) { @@ -387,8 +453,7 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => { const db = createServerSupabase(); const chat = await getAccessibleChat(chatId, userId, userEmail, db); - if (!chat) - return void res.status(404).json({ detail: "Chat not found" }); + if (!chat) return void res.status(404).json({ detail: "Chat not found" }); try { const { title_model, api_keys } = await getUserModelSettings( @@ -403,10 +468,7 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => { }); const title = normalizeGeneratedTitle(titleText); - await db - .from("chats") - .update({ title }) - .eq("id", chatId); + await db.from("chats").update({ title }).eq("id", chatId); res.json({ title }); } catch (err) { @@ -441,6 +503,12 @@ chatRouter.post("/", requireAuth, async (req, res) => { const askInputsResponse = parseAskInputsResponsePayload( body.ask_inputs_response, ); + const parsedScope = parseLegalScope( + body.jurisdictions, + body.legal_as_of_date, + ); + if (!parsedScope.ok) + return void res.status(400).json({ detail: parsedScope.detail }); const messages = parsedMessages.messages; const chat_id = parsedChatId.chatId; @@ -477,6 +545,19 @@ chatRouter.post("/", requireAuth, async (req, res) => { } resolvedProjectId = existingProjectId; chatTitle = existing.title; + if (parsedScope.jurisdictions || parsedScope.legalAsOfDate) { + await db + .from("chats") + .update({ + ...(parsedScope.jurisdictions + ? { jurisdictions: parsedScope.jurisdictions } + : {}), + ...(parsedScope.legalAsOfDate + ? { legal_as_of_date: parsedScope.legalAsOfDate } + : {}), + }) + .eq("id", chatId); + } } if (!chatId) { @@ -495,7 +576,16 @@ chatRouter.post("/", requireAuth, async (req, res) => { const { data: newChat, error } = await db .from("chats") - .insert({ user_id: userId, project_id: resolvedProjectId }) + .insert({ + user_id: userId, + project_id: resolvedProjectId, + ...(parsedScope.jurisdictions + ? { jurisdictions: parsedScope.jurisdictions } + : {}), + ...(parsedScope.legalAsOfDate + ? { legal_as_of_date: parsedScope.legalAsOfDate } + : {}), + }) .select("id, title") .single(); if (error || !newChat) { @@ -543,16 +633,29 @@ chatRouter.post("/", requireAuth, async (req, res) => { db, docIndex, ); - const { - api_keys: apiKeys, - legal_research_us: legalResearchUs, - } = await getUserModelSettings(userId, db); + const { api_keys: apiKeys, legal_research: legalResearch } = + await getUserModelSettings(userId, db); + const scopedLegalResearch = parsedScope.jurisdictions + ? { + ...legalResearch, + defaultCountry: parsedScope.jurisdictions.includes("US") + ? ("US" as const) + : ("CA" as const), + defaultProvince: parsedScope.jurisdictions.includes("CA-ON") + ? ("ON" as const) + : null, + enabledJurisdictions: parsedScope.jurisdictions, + } + : legalResearch; + const legalScopeExtra = parsedScope.legalAsOfDate + ? `LEGAL AS-OF DATE FOR THIS CHAT: ${parsedScope.legalAsOfDate}. Do not substitute current law without disclosure.` + : undefined; const apiMessages = buildMessages( enrichedMessages, docAvailability, + legalScopeExtra, undefined, - undefined, - legalResearchUs, + scopedLegalResearch, ); const workflowStore = await buildWorkflowStore(userId, userEmail, db); @@ -587,7 +690,7 @@ chatRouter.post("/", requireAuth, async (req, res) => { db, write, workflowStore, - includeResearchTools: legalResearchUs, + includeResearchTools: legalResearch.enabled, model, apiKeys, signal: streamAbort.signal, @@ -665,9 +768,10 @@ chatRouter.post("/", requireAuth, async (req, res) => { } console.error("[chat/stream] error:", safeErrorLog(err)); const message = safeErrorMessage(err, "Stream error"); - const errorEvents = err instanceof AssistantStreamError - ? stripTransientAssistantEvents(err.events) - : [{ type: "error" as const, message }]; + const errorEvents = + err instanceof AssistantStreamError + ? stripTransientAssistantEvents(err.events) + : [{ type: "error" as const, message }]; const errorFullText = err instanceof AssistantStreamError ? err.fullText : ""; try { @@ -700,9 +804,7 @@ chatRouter.post("/", requireAuth, async (req, res) => { console.error("[chat/stream] failed to save error", saveErr); } try { - write( - `data: ${JSON.stringify({ type: "error", message })}\n\n`, - ); + write(`data: ${JSON.stringify({ type: "error", message })}\n\n`); write("data: [DONE]\n\n"); } catch { /* ignore */ diff --git a/backend/src/routes/legalSources.ts b/backend/src/routes/legalSources.ts new file mode 100644 index 000000000..d2c66283a --- /dev/null +++ b/backend/src/routes/legalSources.ts @@ -0,0 +1,369 @@ +import { Router, type Response } from "express"; +import { z } from "zod"; +import { requireAuth } from "../middleware/auth"; +import { + ONTARIO_COURT_FORMS, + ONTARIO_PROCEDURE_SOURCES, + calculateOntarioDeadline, + createLegalSourceRegistry, + parseCanadianCitations, + renderCanadianCitation, + verifyCanadianCitations, +} from "../lib/legalSources"; +import { createServerSupabase } from "../lib/supabase"; +import { getUserModelSettings } from "../lib/userSettings"; + +export const legalSourcesRouter = Router(); +const registry = createLegalSourceRegistry(); + +const searchSchema = z.object({ + providerId: z.string().min(1).default("a2aj-canada"), + query: z.string().trim().min(2).max(500), + court: z.string().trim().max(100).optional(), + jurisdiction: z.enum(["CA", "CA-ON", "US"]).optional(), + language: z.enum(["en", "fr"]).optional(), + from: z.string().date().optional(), + to: z.string().date().optional(), + limit: z.coerce.number().int().min(1).max(50).default(10), + offset: z.coerce.number().int().min(0).max(10_000).default(0), +}); + +const citationSchema = z.object({ + providerId: z.string().min(1).default("a2aj-canada"), + citations: z.array(z.string().trim().min(2).max(250)).min(1).max(20), +}); + +const legislationSearchSchema = z.object({ + providerId: z.string().min(1).optional(), + query: z.string().trim().min(2).max(500), + jurisdiction: z.enum(["CA", "CA-ON"]).optional(), + language: z.enum(["en", "fr"]).default("en"), + kind: z.enum(["legislation", "regulation", "rule"]).optional(), + limit: z.coerce.number().int().min(1).max(50).default(10), +}); + +const canadianCitationSchema = z.object({ + text: z.string().trim().min(2).max(20_000), + profile: z.enum(["onca", "mcgill-compatible"]).default("onca"), +}); + +const ontarioDeadlineSchema = z.object({ + profile: z.enum(["ontario-civil-rule-3", "ontario-small-claims-rule-3"]), + triggerDate: z.string().date(), + days: z.number().int().min(1).max(366), + serviceLocalTime: z + .string() + .regex(/^([01]\d|2[0-3]):[0-5]\d$/) + .optional(), + originatingProcess: z.boolean().optional(), + additionalHolidays: z.array(z.string().date()).max(50).optional(), + courtClosures: z.array(z.string().date()).max(50).optional(), +}); + +legalSourcesRouter.use(requireAuth); + +legalSourcesRouter.get("/procedure/sources", (_req, res) => { + res.json({ + jurisdiction: "CA-ON", + sources: ONTARIO_PROCEDURE_SOURCES, + warning: + "Confirm the current official rule and the applicable court region before relying on a procedure source.", + }); +}); + +legalSourcesRouter.get("/procedure/forms", (_req, res) => { + res.json({ + jurisdiction: "CA-ON", + forms: ONTARIO_COURT_FORMS, + warning: + "ROSS links to official catalogues and does not retain form copies. Confirm the current official version before filing.", + }); +}); + +legalSourcesRouter.post("/procedure/deadlines/calculate", (req, res) => { + const parsed = ontarioDeadlineSchema.safeParse(req.body); + if (!parsed.success) + return res.status(400).json({ + error: "Invalid Ontario deadline calculation request.", + issues: parsed.error.issues, + }); + try { + return res.json({ calculation: calculateOntarioDeadline(parsed.data) }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Ontario deadline calculation failed."; + return res.status(400).json({ error: message }); + } +}); + +legalSourcesRouter.get("/status", async (_req, res) => { + const userId = String(res.locals.userId ?? ""); + const settings = await getUserModelSettings(userId); + const providers = await Promise.all( + registry.list().map(async (provider) => ({ + ...provider.descriptor, + health: await provider.health({ + apiToken: + provider.descriptor.id === "courtlistener-us" + ? settings.api_keys.courtlistener + : null, + }), + })), + ); + res.json({ providers }); +}); + +legalSourcesRouter.get("/coverage", async (_req, res) => { + const providers = await Promise.all( + registry.list().map(async (provider) => ({ + provider: provider.descriptor, + coverage: provider.coverage ? await provider.coverage() : [], + })), + ); + const a2ajDatasets = new Set( + providers.flatMap((entry) => + entry.provider.id === "a2aj-canada" + ? entry.coverage.map((row) => row.dataset) + : [], + ), + ); + res.json({ + providers, + knownOntarioGaps: [ + { dataset: "ONSC", label: "Ontario Superior Court of Justice" }, + { dataset: "ONCJ", label: "Ontario Court of Justice" }, + { dataset: "HRTO", label: "Human Rights Tribunal of Ontario" }, + { dataset: "ONLTB", label: "Landlord and Tenant Board" }, + ].filter(({ dataset }) => !a2ajDatasets.has(dataset)), + warning: + "Coverage is provider-reported and may change. Unlisted courts and tribunals are not represented as covered.", + }); +}); + +legalSourcesRouter.get("/decisions/search", async (req, res) => { + const parsed = searchSchema.safeParse(req.query); + if (!parsed.success) + return res.status(400).json({ + error: "Invalid legal-source search.", + issues: parsed.error.issues, + }); + try { + const provider = registry.get(parsed.data.providerId); + if (!provider.searchDecisions) + return res + .status(501) + .json({ error: "This provider cannot search decisions." }); + const results = await provider.searchDecisions( + parsed.data, + await providerContext(res, provider.descriptor.id), + ); + return res.json({ + provider: provider.descriptor, + results, + offset: parsed.data.offset, + limit: parsed.data.limit, + }); + } catch (error) { + return legalSourceError(res, error); + } +}); + +legalSourcesRouter.get("/decisions/:providerId/:sourceId", async (req, res) => { + try { + const provider = registry.get(req.params.providerId); + if (!provider.fetchDecision) + return res + .status(501) + .json({ error: "This provider cannot fetch decisions." }); + const document = await provider.fetchDecision( + req.params.sourceId, + await providerContext(res, provider.descriptor.id), + ); + return res.json({ provider: provider.descriptor, document }); + } catch (error) { + return legalSourceError(res, error); + } +}); + +legalSourcesRouter.get( + "/decisions/:providerId/:sourceId/passages", + async (req, res) => { + const query = + typeof req.query.query === "string" ? req.query.query.trim() : ""; + const limit = Math.min(10, Math.max(1, Number(req.query.limit) || 5)); + if (query.length < 2 || query.length > 500) + return res.status(400).json({ + error: "A passage query between 2 and 500 characters is required.", + }); + try { + const provider = registry.get(req.params.providerId); + if (!provider.fetchDecision) + return res + .status(501) + .json({ error: "This provider cannot fetch decisions." }); + const document = await provider.fetchDecision( + req.params.sourceId, + await providerContext(res, provider.descriptor.id), + ); + const passages = + provider.findPassages?.(document, query, limit) ?? []; + return res.json({ + provider: provider.descriptor, + sourceId: document.sourceId, + query, + passages, + }); + } catch (error) { + return legalSourceError(res, error); + } + }, +); + +legalSourcesRouter.post("/citations/verify", async (req, res) => { + const parsed = citationSchema.safeParse(req.body); + if (!parsed.success) + return res.status(400).json({ + error: "Invalid citation verification request.", + issues: parsed.error.issues, + }); + try { + const provider = registry.get(parsed.data.providerId); + if (!provider.verifyCitations) + return res + .status(501) + .json({ error: "This provider cannot verify citations." }); + const results = await provider.verifyCitations( + parsed.data.citations, + await providerContext(res, provider.descriptor.id), + ); + return res.json({ provider: provider.descriptor, results }); + } catch (error) { + return legalSourceError(res, error); + } +}); + +legalSourcesRouter.post("/citations/canadian/parse", async (req, res) => { + const parsed = canadianCitationSchema.safeParse(req.body); + if (!parsed.success) + return res.status(400).json({ + error: "Invalid Canadian citation text.", + issues: parsed.error.issues, + }); + const citations = parseCanadianCitations(parsed.data.text); + return res.json({ + profile: parsed.data.profile, + citations: citations.map((citation) => ({ + ...citation, + rendered: renderCanadianCitation(citation, { + profile: parsed.data.profile, + }), + })), + }); +}); + +legalSourcesRouter.post("/citations/canadian/verify", async (req, res) => { + const parsed = canadianCitationSchema.safeParse(req.body); + if (!parsed.success) + return res.status(400).json({ + error: "Invalid Canadian citation text.", + issues: parsed.error.issues, + }); + const citations = parseCanadianCitations(parsed.data.text); + const results = await verifyCanadianCitations(citations, registry.list()); + return res.json({ + profile: parsed.data.profile, + results, + warning: + "Parsing is not verification. A citation is upgraded only after an authorized provider returns a matching source.", + }); +}); + +legalSourcesRouter.get("/legislation/search", async (req, res) => { + const parsed = legislationSearchSchema.safeParse(req.query); + if (!parsed.success) + return res.status(400).json({ + error: "Invalid legislation search.", + issues: parsed.error.issues, + }); + try { + const providers = parsed.data.providerId + ? [registry.get(parsed.data.providerId)] + : registry + .list({ + jurisdiction: parsed.data.jurisdiction, + }) + .filter((provider) => provider.searchLegislation); + const results = ( + await Promise.all( + providers.map(async (provider) => + provider.searchLegislation + ? provider.searchLegislation(parsed.data) + : [], + ), + ) + ) + .flat() + .slice(0, parsed.data.limit); + return res.json({ + providers: providers.map((provider) => provider.descriptor), + results, + }); + } catch (error) { + return legalSourceError(res, error); + } +}); + +legalSourcesRouter.get( + "/legislation/:providerId/:sourceId", + async (req, res) => { + const language = req.query.language === "fr" ? "fr" : "en"; + const section = + typeof req.query.section === "string" + ? req.query.section.trim() + : undefined; + const versionDate = + typeof req.query.versionDate === "string" + ? req.query.versionDate.trim() + : undefined; + try { + const provider = registry.get(req.params.providerId); + if (!provider.fetchLegislation) + return res.status(501).json({ + error: "This provider cannot fetch legislation.", + }); + const document = await provider.fetchLegislation( + req.params.sourceId, + { + language, + section, + versionDate, + }, + ); + return res.json({ provider: provider.descriptor, document }); + } catch (error) { + return legalSourceError(res, error); + } + }, +); + +async function providerContext(res: Response, providerId: string) { + if (providerId !== "courtlistener-us") return undefined; + const settings = await getUserModelSettings( + String(res.locals.userId ?? ""), + ); + return { + apiToken: settings.api_keys.courtlistener, + db: createServerSupabase(), + }; +} + +function legalSourceError(res: Response, error: unknown) { + const message = + error instanceof Error ? error.message : "Legal source request failed."; + const status = message.startsWith("Unknown legal source provider") + ? 404 + : 502; + return res.status(status).json({ error: message }); +} diff --git a/backend/src/routes/projectChat.ts b/backend/src/routes/projectChat.ts index 56ea6efb5..3e8b093f1 100644 --- a/backend/src/routes/projectChat.ts +++ b/backend/src/routes/projectChat.ts @@ -18,9 +18,7 @@ import { parseAskInputsResponsePayload, type ChatMessage, } from "../lib/chat"; -import { - getUserModelSettings, -} from "../lib/userSettings"; +import { getUserModelSettings } from "../lib/userSettings"; import { checkProjectAccess } from "../lib/access"; import { safeErrorLog, safeErrorMessage } from "../lib/safeError"; @@ -34,6 +32,44 @@ When the user wants to use an existing project document as a starting point for export const projectChatRouter = Router({ mergeParams: true }); +function parseProjectLegalScope( + jurisdictions: unknown, + legalAsOfDate: unknown, +) { + if (!Array.isArray(jurisdictions) || jurisdictions.length === 0) + return { + ok: false as const, + detail: "jurisdictions must be a non-empty array", + }; + const allowed = new Set(["CA-ON", "CA", "US"]); + if ( + jurisdictions.some( + (item) => typeof item !== "string" || !allowed.has(item), + ) + ) + return { + ok: false as const, + detail: "jurisdictions contains an unsupported value", + }; + if ( + legalAsOfDate !== undefined && + (typeof legalAsOfDate !== "string" || + !/^\d{4}-\d{2}-\d{2}$/.test(legalAsOfDate) || + Number.isNaN(Date.parse(`${legalAsOfDate}T00:00:00Z`))) + ) + return { + ok: false as const, + detail: "legal_as_of_date must use YYYY-MM-DD", + }; + return { + ok: true as const, + jurisdictions: [ + ...new Set(jurisdictions as Array<"CA-ON" | "CA" | "US">), + ], + legalAsOfDate: typeof legalAsOfDate === "string" ? legalAsOfDate : null, + }; +} + // POST /projects/:projectId/chat — streaming projectChatRouter.post("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; @@ -46,18 +82,26 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { displayed_doc, attached_documents, ask_inputs_response, - } = - req.body as { - messages: ChatMessage[]; - chat_id?: string; - model?: string; - displayed_doc?: { filename: string; document_id: string }; - attached_documents?: { filename: string; document_id: string }[]; - ask_inputs_response?: unknown; - }; - const askInputsResponse = parseAskInputsResponsePayload( - ask_inputs_response, + jurisdictions, + legal_as_of_date, + } = req.body as { + messages: ChatMessage[]; + chat_id?: string; + model?: string; + displayed_doc?: { filename: string; document_id: string }; + attached_documents?: { filename: string; document_id: string }[]; + ask_inputs_response?: unknown; + jurisdictions?: Array<"CA-ON" | "CA" | "US">; + legal_as_of_date?: string; + }; + const parsedScope = parseProjectLegalScope( + jurisdictions ?? ["CA-ON", "CA"], + legal_as_of_date, ); + if (!parsedScope.ok) + return void res.status(400).json({ detail: parsedScope.detail }); + const askInputsResponse = + parseAskInputsResponsePayload(ask_inputs_response); const db = createServerSupabase(); @@ -82,13 +126,31 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { .single(); const canUse = !!existing && existing.project_id === projectId; if (!canUse) chatId = null; - else chatTitle = existing!.title; + else { + chatTitle = existing!.title; + await db + .from("chats") + .update({ + jurisdictions: parsedScope.jurisdictions, + ...(parsedScope.legalAsOfDate + ? { legal_as_of_date: parsedScope.legalAsOfDate } + : {}), + }) + .eq("id", chatId); + } } if (!chatId) { const { data: newChat, error } = await db .from("chats") - .insert({ user_id: userId, project_id: projectId }) + .insert({ + user_id: userId, + project_id: projectId, + jurisdictions: parsedScope.jurisdictions, + ...(parsedScope.legalAsOfDate + ? { legal_as_of_date: parsedScope.legalAsOfDate } + : {}), + }) .select("id, title") .single(); if (error || !newChat) @@ -153,8 +215,7 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { if (attached_documents?.length) { const slugByDocumentId = new Map(); for (const [slug, info] of Object.entries(docIndex)) { - if (info.document_id) - slugByDocumentId.set(info.document_id, slug); + if (info.document_id) slugByDocumentId.set(info.document_id, slug); } const lines = attached_documents.map((d) => { const slug = slugByDocumentId.get(d.document_id); @@ -163,16 +224,27 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { systemPromptExtra += `\n\nUSER-ATTACHED DOCUMENTS FOR THIS TURN:\nThe user has attached the following document(s) directly to their latest message. Treat these as the primary focus of the request unless their message clearly says otherwise.\n${lines.join("\n")}`; } - const { - api_keys: apiKeys, - legal_research_us: legalResearchUs, - } = await getUserModelSettings(userId, db); + const { api_keys: apiKeys, legal_research: legalResearch } = + await getUserModelSettings(userId, db); + const scopedLegalResearch = { + ...legalResearch, + defaultCountry: parsedScope.jurisdictions.includes("US") + ? ("US" as const) + : ("CA" as const), + defaultProvince: parsedScope.jurisdictions.includes("CA-ON") + ? ("ON" as const) + : null, + enabledJurisdictions: parsedScope.jurisdictions, + }; + const legalScopeExtra = parsedScope.legalAsOfDate + ? `\n\nLEGAL AS-OF DATE FOR THIS CHAT: ${parsedScope.legalAsOfDate}. Do not substitute current law without disclosure.` + : ""; const apiMessages = buildMessages( messagesForLLM, docAvailability, - systemPromptExtra, + `${systemPromptExtra}${legalScopeExtra}`, undefined, - legalResearchUs, + scopedLegalResearch, ); const workflowStore = await buildWorkflowStore(userId, userEmail, db); @@ -202,7 +274,7 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { write, extraTools: PROJECT_EXTRA_TOOLS, workflowStore, - includeResearchTools: legalResearchUs, + includeResearchTools: legalResearch.enabled, model, apiKeys, signal: streamAbort.signal, @@ -277,9 +349,10 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { } console.error("[project-chat/stream] error:", safeErrorLog(err)); const message = safeErrorMessage(err, "Stream error"); - const errorEvents = err instanceof AssistantStreamError - ? stripTransientAssistantEvents(err.events) - : [{ type: "error" as const, message }]; + const errorEvents = + err instanceof AssistantStreamError + ? stripTransientAssistantEvents(err.events) + : [{ type: "error" as const, message }]; const errorFullText = err instanceof AssistantStreamError ? err.fullText : ""; try { @@ -307,14 +380,18 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { ); } if (saveError) - console.error("[project-chat/stream] failed to save error", saveError); + console.error( + "[project-chat/stream] failed to save error", + saveError, + ); } catch (saveErr) { - console.error("[project-chat/stream] failed to save error", saveErr); + console.error( + "[project-chat/stream] failed to save error", + saveErr, + ); } try { - write( - `data: ${JSON.stringify({ type: "error", message })}\n\n`, - ); + write(`data: ${JSON.stringify({ type: "error", message })}\n\n`); write("data: [DONE]\n\n"); } catch { /* ignore */ diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 76b71811f..4a786b7bb 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -35,6 +35,16 @@ function normalizeOptionalString(value: unknown) { return trimmed.length > 0 ? trimmed : null; } +function normalizeProjectJurisdictions(value: unknown) { + if (!Array.isArray(value)) return ["CA-ON", "CA"]; + const allowed = new Set(["CA-ON", "CA", "US"]); + const normalized = value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => allowed.has(item)); + return normalized.length > 0 ? [...new Set(normalized)] : ["CA-ON", "CA"]; +} + function normalizeDocumentFilename(nextName: unknown, currentName: string) { if (typeof nextName !== "string") return null; const trimmed = nextName.trim().slice(0, 200); @@ -170,10 +180,11 @@ projectsRouter.get("/", requireAuth, async (req, res) => { projectsRouter.post("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; - const { name, cm_number, practice, shared_with } = req.body as { + const { name, cm_number, practice, jurisdictions, shared_with } = req.body as { name: string; cm_number?: string; practice?: string; + jurisdictions?: string[]; shared_with?: string[]; }; if (!name?.trim()) @@ -211,6 +222,7 @@ projectsRouter.post("/", requireAuth, async (req, res) => { name: name.trim(), cm_number: normalizeOptionalString(cm_number), practice: normalizeOptionalString(practice), + jurisdictions: normalizeProjectJurisdictions(jurisdictions), shared_with: cleanedSharedWith, }) .select("*") @@ -319,6 +331,11 @@ projectsRouter.patch("/:projectId", requireAuth, async (req, res) => { if ("practice" in req.body) { updates.practice = normalizeOptionalString(req.body.practice); } + if ("jurisdictions" in req.body) { + updates.jurisdictions = normalizeProjectJurisdictions( + req.body.jurisdictions, + ); + } if (Array.isArray(req.body.shared_with)) { // Normalise: lowercase + dedupe + drop empties. const normalizedUserEmail = userEmail?.trim().toLowerCase(); diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index ca77f5952..3bb473b24 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -56,6 +56,11 @@ type UserProfileRow = { tabular_model: string; mfa_on_login: boolean | null; legal_research_us: boolean | null; + legal_research_enabled?: boolean | null; + default_country?: string | null; + default_province?: string | null; + enabled_jurisdictions?: string[] | null; + enabled_source_providers?: string[] | null; }; function errorMessage(error: unknown): string { @@ -104,11 +109,14 @@ function shortHash(value: string) { : null; } -function mcpOAuthPopupHtml(payload: { - success: boolean; - connectorId?: string; - detail?: string; -}, nonce: string) { +function mcpOAuthPopupHtml( + payload: { + success: boolean; + connectorId?: string; + detail?: string; + }, + nonce: string, +) { const targetOrigin = new URL(frontendUrl()).origin; const targetUrl = frontendUrl(); const message = JSON.stringify({ @@ -160,6 +168,8 @@ function mcpOAuthPopupCsp(nonce: string) { ].join("; "); } +const PROFILE_SELECT_GENERIC = + "display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model, mfa_on_login, legal_research_us, legal_research_enabled, default_country, default_province, enabled_jurisdictions, enabled_source_providers"; const PROFILE_SELECT = "display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model, mfa_on_login, legal_research_us"; const PROFILE_SELECT_NO_LEGAL = @@ -178,15 +188,24 @@ function isMissingProfileColumn(error: unknown, column: string): boolean { return record.code === "42703" && message.includes(column); } -// Loads a profile while tolerating older databases that lack the -// legal_research_us column. Tries the full select first, then falls back to -// the legacy cascade (which also handles missing title_model / mfa_on_login) -// and defaults the feature flag to enabled. +// Loads a profile while tolerating older Mike databases. The generic Ontario +// settings are tried first, followed by the released U.S. flag and then the +// earlier model/MFA compatibility cascade. async function selectProfile( db: ReturnType, userId: string, mode: "maybe" | "single", ) { + const genericQuery = db + .from("user_profiles") + .select(PROFILE_SELECT_GENERIC) + .eq("user_id", userId); + const generic = + mode === "single" + ? await genericQuery.single() + : await genericQuery.maybeSingle(); + if (!generic.error) return generic; + const fullQuery = db .from("user_profiles") .select(PROFILE_SELECT) @@ -283,6 +302,15 @@ function serializeProfile(row: UserProfileRow, apiKeyStatus?: ApiKeyStatus) { : apiKeyStatus?.claude ? CLAUDE_LOW_MODELS[0] : DEFAULT_TITLE_MODEL; + const legacyUs = row.legal_research_us !== false; + const enabledJurisdictions = normalizeEnabledJurisdictions( + row.enabled_jurisdictions, + legacyUs, + ); + const enabledSourceProviders = normalizeEnabledProviders( + row.enabled_source_providers, + legacyUs, + ); return { displayName: row.display_name, organisation: row.organisation, @@ -293,11 +321,52 @@ function serializeProfile(row: UserProfileRow, apiKeyStatus?: ApiKeyStatus) { titleModel: resolveModel(row.title_model, titleFallback), tabularModel: resolveModel(row.tabular_model, DEFAULT_TABULAR_MODEL), mfaOnLogin: row.mfa_on_login === true, - legalResearchUs: row.legal_research_us !== false, + legalResearch: { + enabled: row.legal_research_enabled !== false, + defaultCountry: row.default_country === "US" ? "US" : "CA", + defaultProvince: + row.default_country === "US" + ? null + : row.default_province === null + ? null + : "ON", + enabledJurisdictions, + enabledSourceProviders, + }, + legalResearchUs: enabledJurisdictions.includes("US"), ...(apiKeyStatus ? { apiKeyStatus } : {}), }; } +const DEFAULT_CANADIAN_PROVIDERS = [ + "a2aj-canada", + "ontario-elaws", + "justice-laws-canada", +]; + +function normalizeEnabledJurisdictions( + value: string[] | null | undefined, + legacyUs: boolean, +) { + const fallback = ["CA-ON", "CA", ...(legacyUs ? ["US"] : [])]; + if (!Array.isArray(value)) return fallback; + const allowed = new Set(["CA-ON", "CA", "US"]); + return [...new Set(value.filter((item) => allowed.has(item)))]; +} + +function normalizeEnabledProviders( + value: string[] | null | undefined, + legacyUs: boolean, +) { + if (!Array.isArray(value)) { + return [ + ...DEFAULT_CANADIAN_PROVIDERS, + ...(legacyUs ? ["courtlistener-us"] : []), + ]; + } + return [...new Set(value.filter((provider) => provider.trim()))]; +} + function validateProfilePayload(body: unknown): | { ok: true; @@ -307,6 +376,11 @@ function validateProfilePayload(body: unknown): title_model?: string; tabular_model?: string; legal_research_us?: boolean; + legal_research_enabled?: boolean; + default_country?: "CA" | "US"; + default_province?: "ON" | null; + enabled_jurisdictions?: string[]; + enabled_source_providers?: string[]; updated_at: string; }; } @@ -321,6 +395,7 @@ function validateProfilePayload(body: unknown): "organisation", "titleModel", "tabularModel", + "legalResearch", "legalResearchUs", ]); const invalidField = Object.keys(raw).find( @@ -339,6 +414,11 @@ function validateProfilePayload(body: unknown): title_model?: string; tabular_model?: string; legal_research_us?: boolean; + legal_research_enabled?: boolean; + default_country?: "CA" | "US"; + default_province?: "ON" | null; + enabled_jurisdictions?: string[]; + enabled_source_providers?: string[]; updated_at: string; } = { updated_at: new Date().toISOString() }; @@ -394,9 +474,113 @@ function validateProfilePayload(body: unknown): update.legal_research_us = raw.legalResearchUs; } + if ("legalResearch" in raw) { + const parsed = validateLegalResearchSettings(raw.legalResearch); + if (!parsed.ok) return parsed; + Object.assign(update, parsed.update, { + legal_research_us: + parsed.update.enabled_jurisdictions.includes("US"), + }); + } + return { ok: true, update }; } +function validateLegalResearchSettings(value: unknown): + | { + ok: true; + update: { + legal_research_enabled: boolean; + default_country: "CA" | "US"; + default_province: "ON" | null; + enabled_jurisdictions: string[]; + enabled_source_providers: string[]; + }; + } + | { ok: false; detail: string } { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { ok: false, detail: "legalResearch must be an object" }; + } + const raw = value as Record; + const allowedFields = new Set([ + "enabled", + "defaultCountry", + "defaultProvince", + "enabledJurisdictions", + "enabledSourceProviders", + ]); + const invalidField = Object.keys(raw).find( + (key) => !allowedFields.has(key), + ); + if (invalidField) + return { + ok: false, + detail: `Unsupported legalResearch field: ${invalidField}`, + }; + if (typeof raw.enabled !== "boolean") + return { ok: false, detail: "legalResearch.enabled must be a boolean" }; + if (raw.defaultCountry !== "CA" && raw.defaultCountry !== "US") + return { + ok: false, + detail: "legalResearch.defaultCountry must be CA or US", + }; + if (raw.defaultProvince !== null && raw.defaultProvince !== "ON") + return { + ok: false, + detail: "legalResearch.defaultProvince must be ON or null", + }; + const jurisdictionValues = Array.isArray(raw.enabledJurisdictions) + ? raw.enabledJurisdictions + : null; + if ( + !jurisdictionValues || + jurisdictionValues.some( + (item) => !["CA-ON", "CA", "US"].includes(String(item)), + ) + ) + return { + ok: false, + detail: "legalResearch.enabledJurisdictions contains an unsupported value", + }; + const providerValues = Array.isArray(raw.enabledSourceProviders) + ? raw.enabledSourceProviders + : null; + const allowedProviders = new Set([ + "a2aj-canada", + "ontario-elaws", + "justice-laws-canada", + "courtlistener-us", + "canlii-licensed", + ]); + if ( + !providerValues || + providerValues.some( + (item) => + typeof item !== "string" || + !allowedProviders.has(item), + ) + ) + return { + ok: false, + detail: "legalResearch.enabledSourceProviders contains an unsupported value", + }; + const enabledJurisdictions = [...new Set(jurisdictionValues.map(String))]; + const enabledSourceProviders = [ + ...new Set(providerValues.map((item) => String(item))), + ]; + return { + ok: true, + update: { + legal_research_enabled: raw.enabled, + default_country: raw.defaultCountry, + default_province: + raw.defaultCountry === "US" ? null : raw.defaultProvince, + enabled_jurisdictions: enabledJurisdictions, + enabled_source_providers: enabledSourceProviders, + }, + }; +} + function readBooleanBodyField( body: unknown, field: string, diff --git a/backend/src/routes/workflows.ts b/backend/src/routes/workflows.ts index 62b28d4c8..215e04294 100644 --- a/backend/src/routes/workflows.ts +++ b/backend/src/routes/workflows.ts @@ -1,14 +1,28 @@ -import { Router, type NextFunction, type Request, type Response } from "express"; +import { + Router, + type NextFunction, + type Request, + type Response, +} from "express"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; import { - SYSTEM_WORKFLOW_IDS, - SYSTEM_WORKFLOWS, + SYSTEM_WORKFLOW_IDS as MIKE_SYSTEM_WORKFLOW_IDS, + SYSTEM_WORKFLOWS as MIKE_SYSTEM_WORKFLOWS, type SystemWorkflow, } from "../lib/systemWorkflows"; +import { + ROSS_SYSTEM_WORKFLOW_IDS, + ROSS_SYSTEM_WORKFLOWS, +} from "../lib/rossSystemWorkflows"; import { findMissingUserEmails } from "../lib/userLookup"; export const workflowsRouter = Router(); +const SYSTEM_WORKFLOWS = [...MIKE_SYSTEM_WORKFLOWS, ...ROSS_SYSTEM_WORKFLOWS]; +const SYSTEM_WORKFLOW_IDS = new Set([ + ...MIKE_SYSTEM_WORKFLOW_IDS, + ...ROSS_SYSTEM_WORKFLOW_IDS, +]); type Db = ReturnType; const isDev = process.env.NODE_ENV !== "production"; @@ -82,18 +96,16 @@ const DEFAULT_WORKFLOW_CONTRIBUTOR: WorkflowContributor = { linkedin: null, }; const DEFAULT_WORKFLOW_LANGUAGE = "English"; -const DEFAULT_WORKFLOW_PRACTICE = "General Transactions"; -const DEFAULT_WORKFLOW_JURISDICTIONS = ["General"]; +const DEFAULT_WORKFLOW_PRACTICE = "Civil Litigation"; +const DEFAULT_WORKFLOW_JURISDICTIONS = ["Canada / Ontario"]; const WORKFLOW_CONTRIBUTIONS_ENABLED = process.env.WORKFLOW_CONTRIBUTIONS_ENABLED === "true"; -type WorkflowAccess = - | { - workflow: WorkflowRecord; - allowEdit: boolean; - isOwner: boolean; - } - | null; +type WorkflowAccess = { + workflow: WorkflowRecord; + allowEdit: boolean; + isOwner: boolean; +} | null; type AsyncRoute = (req: Request, res: Response) => Promise; @@ -105,7 +117,11 @@ function asyncRoute(handler: AsyncRoute) { function withWorkflowAccess( workflow: T, - access: { allowEdit: boolean; isOwner: boolean; sharedByName?: string | null }, + access: { + allowEdit: boolean; + isOwner: boolean; + sharedByName?: string | null; + }, ) { return { ...workflow, @@ -136,15 +152,16 @@ function workflowTypeFrom(value: unknown): WorkflowType { return value === "tabular" ? "tabular" : "assistant"; } -function metadataFromWorkflowRecord(workflow: WorkflowRecord): WorkflowMetadata { +function metadataFromWorkflowRecord( + workflow: WorkflowRecord, +): WorkflowMetadata { return { title: workflow.title ?? "", description: null, type: workflowTypeFrom(workflow.type), - contributors: - normalizeContributors(workflow.contributors) ?? [ - DEFAULT_WORKFLOW_CONTRIBUTOR, - ], + contributors: normalizeContributors(workflow.contributors) ?? [ + DEFAULT_WORKFLOW_CONTRIBUTOR, + ], language: workflow.language ?? DEFAULT_WORKFLOW_LANGUAGE, version: workflow.version ?? null, practice: workflow.practice ?? DEFAULT_WORKFLOW_PRACTICE, @@ -240,7 +257,11 @@ async function resolveWorkflowAccess( .maybeSingle(); if (!share) return null; - return { workflow: workflowRecord, allowEdit: !!share.allow_edit, isOwner: false }; + return { + workflow: workflowRecord, + allowEdit: !!share.allow_edit, + isOwner: false, + }; } function toOpenSourceSubmissionSummary( @@ -269,7 +290,9 @@ async function getLatestOpenSourceSubmission( .limit(1) .maybeSingle(); if (error) throw error; - return data ? toOpenSourceSubmissionSummary(data as OpenSourceSubmissionRow) : null; + return data + ? toOpenSourceSubmissionSummary(data as OpenSourceSubmissionRow) + : null; } function buildOpenSourceSnapshot( @@ -297,7 +320,8 @@ function validateOpenSourceWorkflow(workflow: WorkflowRecord): string | null { : "Assistant workflows need instructions before they can be opened source."; } if (workflow.type === "tabular") { - return Array.isArray(workflow.columns_config) && workflow.columns_config.length > 0 + return Array.isArray(workflow.columns_config) && + workflow.columns_config.length > 0 ? null : "Tabular workflows need at least one column before they can be opened source."; } @@ -305,105 +329,114 @@ function validateOpenSourceWorkflow(workflow: WorkflowRecord): string | null { } // GET /workflows -workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => { - const userId = res.locals.userId as string; - const userEmail = res.locals.userEmail as string | undefined; - const { type } = req.query as { type?: string }; - const db = createServerSupabase(); - const workflowType = typeof type === "string" && type ? type : null; - - const { data, error } = await db.rpc("get_workflows_overview", { - p_user_id: userId, - p_user_email: userEmail ?? null, - p_type: workflowType, - }); - if (error) { - return void res.status(500).json({ detail: error.message }); - } +workflowsRouter.get( + "/", + requireAuth, + asyncRoute(async (req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const { type } = req.query as { type?: string }; + const db = createServerSupabase(); + const workflowType = typeof type === "string" && type ? type : null; + + const { data, error } = await db.rpc("get_workflows_overview", { + p_user_id: userId, + p_user_email: userEmail ?? null, + p_type: workflowType, + }); + if (error) { + return void res.status(500).json({ detail: error.message }); + } - const systemWorkflows = SYSTEM_WORKFLOWS.filter( - (workflow) => !workflowType || workflow.metadata.type === workflowType, - ).map(withSystemWorkflowAccess); - const databaseWorkflows = ((data ?? []) as WorkflowRecord[]).filter( - (workflow) => !SYSTEM_WORKFLOW_IDS.has(workflow.id), - ).map(withDatabaseWorkflow); + const systemWorkflows = SYSTEM_WORKFLOWS.filter( + (workflow) => !workflowType || workflow.metadata.type === workflowType, + ).map(withSystemWorkflowAccess); + const databaseWorkflows = ((data ?? []) as WorkflowRecord[]) + .filter((workflow) => !SYSTEM_WORKFLOW_IDS.has(workflow.id)) + .map(withDatabaseWorkflow); - res.json([...systemWorkflows, ...databaseWorkflows]); -})); + res.json([...systemWorkflows, ...databaseWorkflows]); + }), +); // POST /workflows -workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => { - const userId = res.locals.userId as string; - const { - metadata, - skill_md, - columns_config, - } = req.body as { - metadata?: Partial; - skill_md?: string; - columns_config?: unknown; - }; - const title = metadata?.title; - const type = metadata?.type; - if (!title?.trim()) - return void res.status(400).json({ detail: "metadata.title is required" }); - if (type !== "assistant" && type !== "tabular") - return void res - .status(400) - .json({ detail: "metadata.type must be 'assistant' or 'tabular'" }); +workflowsRouter.post( + "/", + requireAuth, + asyncRoute(async (req, res) => { + const userId = res.locals.userId as string; + const { metadata, skill_md, columns_config } = req.body as { + metadata?: Partial; + skill_md?: string; + columns_config?: unknown; + }; + const title = metadata?.title; + const type = metadata?.type; + if (!title?.trim()) + return void res + .status(400) + .json({ detail: "metadata.title is required" }); + if (type !== "assistant" && type !== "tabular") + return void res.status(400).json({ + detail: "metadata.type must be 'assistant' or 'tabular'", + }); - const db = createServerSupabase(); - devLog("[workflows/create] request", { - userId, - title: title.trim(), - type, - hasSkill: typeof skill_md === "string" && skill_md.length > 0, - columnCount: Array.isArray(columns_config) ? columns_config.length : null, - language: - normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE, - practice: metadata?.practice ?? null, - jurisdictions: - normalizeJurisdictions(metadata?.jurisdictions) ?? - DEFAULT_WORKFLOW_JURISDICTIONS, - }); - const { data, error } = await db - .from("workflows") - .insert({ - user_id: userId, + const db = createServerSupabase(); + devLog("[workflows/create] request", { + userId, title: title.trim(), type, - prompt_md: skill_md ?? null, - columns_config: columns_config ?? null, + hasSkill: typeof skill_md === "string" && skill_md.length > 0, + columnCount: Array.isArray(columns_config) ? columns_config.length : null, language: - normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE, - practice: - normalizeOptionalString(metadata?.practice) ?? DEFAULT_WORKFLOW_PRACTICE, + normalizeOptionalString(metadata?.language) ?? + DEFAULT_WORKFLOW_LANGUAGE, + practice: metadata?.practice ?? null, jurisdictions: normalizeJurisdictions(metadata?.jurisdictions) ?? DEFAULT_WORKFLOW_JURISDICTIONS, - }) - .select("*") - .single(); - if (error) { - devLog("[workflows/create] insert error", { - userId, - title: title.trim(), - type, - code: error.code, - message: error.message, - details: error.details, - hint: error.hint, }); - return void res.status(500).json({ detail: error.message }); - } - devLog("[workflows/create] inserted", { - id: data?.id, - user_id: data?.user_id, - title: data?.title, - type: data?.type, - }); - res.status(201).json(withDatabaseWorkflow(data as WorkflowRecord)); -})); + const { data, error } = await db + .from("workflows") + .insert({ + user_id: userId, + title: title.trim(), + type, + prompt_md: skill_md ?? null, + columns_config: columns_config ?? null, + language: + normalizeOptionalString(metadata?.language) ?? + DEFAULT_WORKFLOW_LANGUAGE, + practice: + normalizeOptionalString(metadata?.practice) ?? + DEFAULT_WORKFLOW_PRACTICE, + jurisdictions: + normalizeJurisdictions(metadata?.jurisdictions) ?? + DEFAULT_WORKFLOW_JURISDICTIONS, + }) + .select("*") + .single(); + if (error) { + devLog("[workflows/create] insert error", { + userId, + title: title.trim(), + type, + code: error.code, + message: error.message, + details: error.details, + hint: error.hint, + }); + return void res.status(500).json({ detail: error.message }); + } + devLog("[workflows/create] inserted", { + id: data?.id, + user_id: data?.user_id, + title: data?.title, + type: data?.type, + }); + res.status(201).json(withDatabaseWorkflow(data as WorkflowRecord)); + }), +); async function handleWorkflowUpdate(req: Request, res: Response) { const userId = res.locals.userId as string; @@ -448,328 +481,395 @@ async function handleWorkflowUpdate(req: Request, res: Response) { } // PUT /workflows/:workflowId -workflowsRouter.put("/:workflowId", requireAuth, asyncRoute(handleWorkflowUpdate)); +workflowsRouter.put( + "/:workflowId", + requireAuth, + asyncRoute(handleWorkflowUpdate), +); // PATCH /workflows/:workflowId -workflowsRouter.patch("/:workflowId", requireAuth, asyncRoute(handleWorkflowUpdate)); +workflowsRouter.patch( + "/:workflowId", + requireAuth, + asyncRoute(handleWorkflowUpdate), +); // DELETE /workflows/:workflowId -workflowsRouter.delete("/:workflowId", requireAuth, asyncRoute(async (req, res) => { - const userId = res.locals.userId as string; - const { workflowId } = req.params; - const systemWorkflow = SYSTEM_WORKFLOWS.find( - (workflow) => workflow.id === workflowId, - ); - if (systemWorkflow) { - return void res.json(withSystemWorkflowAccess(systemWorkflow)); - } +workflowsRouter.delete( + "/:workflowId", + requireAuth, + asyncRoute(async (req, res) => { + const userId = res.locals.userId as string; + const { workflowId } = req.params; + const systemWorkflow = SYSTEM_WORKFLOWS.find( + (workflow) => workflow.id === workflowId, + ); + if (systemWorkflow) { + return void res.json(withSystemWorkflowAccess(systemWorkflow)); + } - const db = createServerSupabase(); - const { error } = await db - .from("workflows") - .delete() - .eq("id", workflowId) - .eq("user_id", userId); - if (error) return void res.status(500).json({ detail: error.message }); - res.status(204).send(); -})); + const db = createServerSupabase(); + const { error } = await db + .from("workflows") + .delete() + .eq("id", workflowId) + .eq("user_id", userId); + if (error) return void res.status(500).json({ detail: error.message }); + res.status(204).send(); + }), +); // GET /workflows/hidden -workflowsRouter.get("/hidden", requireAuth, asyncRoute(async (req, res) => { - const userId = res.locals.userId as string; - const db = createServerSupabase(); - const { data, error } = await db - .from("hidden_workflows") - .select("workflow_id") - .eq("user_id", userId); - if (error) return void res.status(500).json({ detail: error.message }); - res.json((data ?? []).map((r) => r.workflow_id)); -})); +workflowsRouter.get( + "/hidden", + requireAuth, + asyncRoute(async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const { data, error } = await db + .from("hidden_workflows") + .select("workflow_id") + .eq("user_id", userId); + if (error) return void res.status(500).json({ detail: error.message }); + res.json((data ?? []).map((r) => r.workflow_id)); + }), +); // POST /workflows/hidden -workflowsRouter.post("/hidden", requireAuth, asyncRoute(async (req, res) => { - const userId = res.locals.userId as string; - const { workflow_id } = req.body as { workflow_id: string }; - if (!workflow_id?.trim()) - return void res.status(400).json({ detail: "workflow_id is required" }); - const db = createServerSupabase(); - const { error } = await db - .from("hidden_workflows") - .upsert({ user_id: userId, workflow_id }, { onConflict: "user_id,workflow_id" }); - if (error) return void res.status(500).json({ detail: error.message }); - res.status(204).send(); -})); +workflowsRouter.post( + "/hidden", + requireAuth, + asyncRoute(async (req, res) => { + const userId = res.locals.userId as string; + const { workflow_id } = req.body as { workflow_id: string }; + if (!workflow_id?.trim()) + return void res.status(400).json({ detail: "workflow_id is required" }); + const db = createServerSupabase(); + const { error } = await db + .from("hidden_workflows") + .upsert( + { user_id: userId, workflow_id }, + { onConflict: "user_id,workflow_id" }, + ); + if (error) return void res.status(500).json({ detail: error.message }); + res.status(204).send(); + }), +); // DELETE /workflows/hidden/:workflowId -workflowsRouter.delete("/hidden/:workflowId", requireAuth, asyncRoute(async (req, res) => { - const userId = res.locals.userId as string; - const { workflowId } = req.params; - const db = createServerSupabase(); - const { error } = await db - .from("hidden_workflows") - .delete() - .eq("user_id", userId) - .eq("workflow_id", workflowId); - if (error) return void res.status(500).json({ detail: error.message }); - res.status(204).send(); -})); +workflowsRouter.delete( + "/hidden/:workflowId", + requireAuth, + asyncRoute(async (req, res) => { + const userId = res.locals.userId as string; + const { workflowId } = req.params; + const db = createServerSupabase(); + const { error } = await db + .from("hidden_workflows") + .delete() + .eq("user_id", userId) + .eq("workflow_id", workflowId); + if (error) return void res.status(500).json({ detail: error.message }); + res.status(204).send(); + }), +); // POST /workflows/:workflowId/open-source -workflowsRouter.post("/:workflowId/open-source", requireAuth, asyncRoute(async (req, res) => { - if (!WORKFLOW_CONTRIBUTIONS_ENABLED) { - return void res.status(404).json({ detail: "Workflow contributions are disabled" }); - } - - const userId = res.locals.userId as string; - const userEmail = res.locals.userEmail as string | undefined; - const { workflowId } = req.params; - const openSourceBody = req.body as { - contributor_mode?: unknown; - contributor?: unknown; - }; - const requestedContributorMode = - openSourceBody.contributor_mode === "named" - ? "named" - : "anonymous"; - const db = createServerSupabase(); +workflowsRouter.post( + "/:workflowId/open-source", + requireAuth, + asyncRoute(async (req, res) => { + if (!WORKFLOW_CONTRIBUTIONS_ENABLED) { + return void res + .status(404) + .json({ detail: "Workflow contributions are disabled" }); + } - const { data: workflow, error: workflowError } = await db - .from("workflows") - .select("*") - .eq("id", workflowId) - .eq("user_id", userId) - .maybeSingle(); - if (workflowError) { - return void res.status(500).json({ detail: workflowError.message }); - } - if (!workflow) { - return void res - .status(404) - .json({ detail: "Workflow not found or not open-sourceable" }); - } + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const { workflowId } = req.params; + const openSourceBody = req.body as { + contributor_mode?: unknown; + contributor?: unknown; + }; + const requestedContributorMode = + openSourceBody.contributor_mode === "named" ? "named" : "anonymous"; + const db = createServerSupabase(); + + const { data: workflow, error: workflowError } = await db + .from("workflows") + .select("*") + .eq("id", workflowId) + .eq("user_id", userId) + .maybeSingle(); + if (workflowError) { + return void res.status(500).json({ detail: workflowError.message }); + } + if (!workflow) { + return void res + .status(404) + .json({ detail: "Workflow not found or not open-sourceable" }); + } - const workflowRecord = workflow as WorkflowRecord; - const validationError = validateOpenSourceWorkflow(workflowRecord); - if (validationError) { - return void res.status(400).json({ detail: validationError }); - } + const workflowRecord = workflow as WorkflowRecord; + const validationError = validateOpenSourceWorkflow(workflowRecord); + if (validationError) { + return void res.status(400).json({ detail: validationError }); + } - const { data: profile } = await db - .from("user_profiles") - .select("display_name") - .eq("user_id", userId) - .maybeSingle(); - const submitterName = - typeof profile?.display_name === "string" && profile.display_name.trim() - ? profile.display_name.trim() - : null; - const submittedContributor = - normalizeContributors([openSourceBody.contributor])?.[0] ?? - contributorFromName(submitterName || userEmail); - const publicContributors = - requestedContributorMode === "named" - ? [submittedContributor] - : [DEFAULT_WORKFLOW_CONTRIBUTOR]; - const now = new Date().toISOString(); - const snapshot = buildOpenSourceSnapshot( - workflowRecord, - publicContributors, - requestedContributorMode, - ); + const { data: profile } = await db + .from("user_profiles") + .select("display_name") + .eq("user_id", userId) + .maybeSingle(); + const submitterName = + typeof profile?.display_name === "string" && profile.display_name.trim() + ? profile.display_name.trim() + : null; + const submittedContributor = + normalizeContributors([openSourceBody.contributor])?.[0] ?? + contributorFromName(submitterName || userEmail); + const publicContributors = + requestedContributorMode === "named" + ? [submittedContributor] + : [DEFAULT_WORKFLOW_CONTRIBUTOR]; + const now = new Date().toISOString(); + const snapshot = buildOpenSourceSnapshot( + workflowRecord, + publicContributors, + requestedContributorMode, + ); + + const { data: pendingSubmission, error: pendingError } = await db + .from("workflow_open_source_submissions") + .select("*") + .eq("workflow_id", workflowId) + .eq("submitted_by_user_id", userId) + .eq("status", "pending") + .maybeSingle(); + if (pendingError) { + return void res.status(500).json({ detail: pendingError.message }); + } - const { data: pendingSubmission, error: pendingError } = await db - .from("workflow_open_source_submissions") - .select("*") - .eq("workflow_id", workflowId) - .eq("submitted_by_user_id", userId) - .eq("status", "pending") - .maybeSingle(); - if (pendingError) { - return void res.status(500).json({ detail: pendingError.message }); - } + if (pendingSubmission) { + const { data: updated, error: updateError } = await db + .from("workflow_open_source_submissions") + .update({ + submitter_email: userEmail ?? null, + submitter_name: + requestedContributorMode === "named" ? submitterName : null, + contributor_mode: requestedContributorMode, + snapshot, + updated_at: now, + }) + .eq("id", pendingSubmission.id) + .select("id, status, submitted_at, updated_at, reviewed_at") + .single(); + if (updateError || !updated) { + return void res.status(500).json({ + detail: updateError?.message ?? "Failed to update submission", + }); + } + return void res.json({ + ...toOpenSourceSubmissionSummary(updated as OpenSourceSubmissionRow), + mode: "updated", + }); + } - if (pendingSubmission) { - const { data: updated, error: updateError } = await db + const { data: created, error: createError } = await db .from("workflow_open_source_submissions") - .update({ + .insert({ + workflow_id: workflowId, + submitted_by_user_id: userId, submitter_email: userEmail ?? null, submitter_name: requestedContributorMode === "named" ? submitterName : null, contributor_mode: requestedContributorMode, + status: "pending", snapshot, + submitted_at: now, updated_at: now, }) - .eq("id", pendingSubmission.id) .select("id, status, submitted_at, updated_at, reviewed_at") .single(); - if (updateError || !updated) { + if (createError || !created) { return void res.status(500).json({ - detail: updateError?.message ?? "Failed to update submission", + detail: createError?.message ?? "Failed to create submission", }); } - return void res.json({ - ...toOpenSourceSubmissionSummary(updated as OpenSourceSubmissionRow), - mode: "updated", - }); - } - const { data: created, error: createError } = await db - .from("workflow_open_source_submissions") - .insert({ - workflow_id: workflowId, - submitted_by_user_id: userId, - submitter_email: userEmail ?? null, - submitter_name: - requestedContributorMode === "named" ? submitterName : null, - contributor_mode: requestedContributorMode, - status: "pending", - snapshot, - submitted_at: now, - updated_at: now, - }) - .select("id, status, submitted_at, updated_at, reviewed_at") - .single(); - if (createError || !created) { - return void res.status(500).json({ - detail: createError?.message ?? "Failed to create submission", + res.status(201).json({ + ...toOpenSourceSubmissionSummary(created as OpenSourceSubmissionRow), + mode: "created", }); - } - - res.status(201).json({ - ...toOpenSourceSubmissionSummary(created as OpenSourceSubmissionRow), - mode: "created", - }); -})); + }), +); // GET /workflows/:workflowId -workflowsRouter.get("/:workflowId", requireAuth, asyncRoute(async (req, res) => { - const userId = res.locals.userId as string; - const userEmail = res.locals.userEmail as string | undefined; - const { workflowId } = req.params; - const systemWorkflow = SYSTEM_WORKFLOWS.find( - (workflow) => workflow.id === workflowId, - ); - if (systemWorkflow) { - return void res.json(withSystemWorkflowAccess(systemWorkflow)); - } +workflowsRouter.get( + "/:workflowId", + requireAuth, + asyncRoute(async (req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const { workflowId } = req.params; + const systemWorkflow = SYSTEM_WORKFLOWS.find( + (workflow) => workflow.id === workflowId, + ); + if (systemWorkflow) { + return void res.json(withSystemWorkflowAccess(systemWorkflow)); + } - const db = createServerSupabase(); - const access = await resolveWorkflowAccess(workflowId, userId, userEmail, db); - if (!access) - return void res.status(404).json({ detail: "Workflow not found" }); - const openSourceSubmission = access.isOwner - ? await getLatestOpenSourceSubmission(db, workflowId, userId) - : null; - res.json( - withOpenSourceSubmission( - withWorkflowAccess(withDatabaseWorkflow(access.workflow), { - allowEdit: access.allowEdit, - isOwner: access.isOwner, - }), - openSourceSubmission, - ), - ); -})); + const db = createServerSupabase(); + const access = await resolveWorkflowAccess( + workflowId, + userId, + userEmail, + db, + ); + if (!access) + return void res.status(404).json({ detail: "Workflow not found" }); + const openSourceSubmission = access.isOwner + ? await getLatestOpenSourceSubmission(db, workflowId, userId) + : null; + res.json( + withOpenSourceSubmission( + withWorkflowAccess(withDatabaseWorkflow(access.workflow), { + allowEdit: access.allowEdit, + isOwner: access.isOwner, + }), + openSourceSubmission, + ), + ); + }), +); // GET /workflows/:workflowId/shares -workflowsRouter.get("/:workflowId/shares", requireAuth, asyncRoute(async (req, res) => { - const userId = res.locals.userId as string; - const { workflowId } = req.params; - const db = createServerSupabase(); - - const { data: wf } = await db - .from("workflows") - .select("id") - .eq("id", workflowId) - .eq("user_id", userId) - .single(); - if (!wf) return void res.status(404).json({ detail: "Workflow not found or not editable" }); - - const { data: shares, error } = await db - .from("workflow_shares") - .select("id, shared_with_email, allow_edit, created_at") - .eq("workflow_id", workflowId) - .order("created_at", { ascending: true }); - if (error) return void res.status(500).json({ detail: error.message }); - - res.json(shares ?? []); -})); +workflowsRouter.get( + "/:workflowId/shares", + requireAuth, + asyncRoute(async (req, res) => { + const userId = res.locals.userId as string; + const { workflowId } = req.params; + const db = createServerSupabase(); + + const { data: wf } = await db + .from("workflows") + .select("id") + .eq("id", workflowId) + .eq("user_id", userId) + .single(); + if (!wf) + return void res + .status(404) + .json({ detail: "Workflow not found or not editable" }); + + const { data: shares, error } = await db + .from("workflow_shares") + .select("id, shared_with_email, allow_edit, created_at") + .eq("workflow_id", workflowId) + .order("created_at", { ascending: true }); + if (error) return void res.status(500).json({ detail: error.message }); + + res.json(shares ?? []); + }), +); // DELETE /workflows/:workflowId/shares/:shareId -workflowsRouter.delete("/:workflowId/shares/:shareId", requireAuth, asyncRoute(async (req, res) => { - const userId = res.locals.userId as string; - const { workflowId, shareId } = req.params; - const db = createServerSupabase(); - - const { data: wf } = await db - .from("workflows") - .select("id") - .eq("id", workflowId) - .eq("user_id", userId) - .single(); - if (!wf) return void res.status(404).json({ detail: "Workflow not found" }); - - await db.from("workflow_shares").delete().eq("id", shareId).eq("workflow_id", workflowId); - res.status(204).send(); -})); +workflowsRouter.delete( + "/:workflowId/shares/:shareId", + requireAuth, + asyncRoute(async (req, res) => { + const userId = res.locals.userId as string; + const { workflowId, shareId } = req.params; + const db = createServerSupabase(); + + const { data: wf } = await db + .from("workflows") + .select("id") + .eq("id", workflowId) + .eq("user_id", userId) + .single(); + if (!wf) return void res.status(404).json({ detail: "Workflow not found" }); + + await db + .from("workflow_shares") + .delete() + .eq("id", shareId) + .eq("workflow_id", workflowId); + res.status(204).send(); + }), +); // POST /workflows/:workflowId/share -workflowsRouter.post("/:workflowId/share", requireAuth, asyncRoute(async (req, res) => { - const userId = res.locals.userId as string; - const userEmail = res.locals.userEmail as string | undefined; - const { workflowId } = req.params; - const { emails, allow_edit } = req.body as { emails: string[]; allow_edit: boolean }; - - if (!emails?.length) return void res.status(400).json({ detail: "emails is required" }); - const normalizedEmails = [ - ...new Set( - emails - .map((email) => email.trim().toLowerCase()) - .filter(Boolean), - ), - ]; - if (normalizedEmails.length === 0) { - return void res.status(400).json({ detail: "emails is required" }); - } - const normalizedUserEmail = userEmail?.trim().toLowerCase(); - if (normalizedUserEmail && normalizedEmails.includes(normalizedUserEmail)) { - return void res - .status(400) - .json({ detail: "You cannot share a workflow with yourself." }); - } +workflowsRouter.post( + "/:workflowId/share", + requireAuth, + asyncRoute(async (req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const { workflowId } = req.params; + const { emails, allow_edit } = req.body as { + emails: string[]; + allow_edit: boolean; + }; + + if (!emails?.length) + return void res.status(400).json({ detail: "emails is required" }); + const normalizedEmails = [ + ...new Set( + emails.map((email) => email.trim().toLowerCase()).filter(Boolean), + ), + ]; + if (normalizedEmails.length === 0) { + return void res.status(400).json({ detail: "emails is required" }); + } + const normalizedUserEmail = userEmail?.trim().toLowerCase(); + if (normalizedUserEmail && normalizedEmails.includes(normalizedUserEmail)) { + return void res + .status(400) + .json({ detail: "You cannot share a workflow with yourself." }); + } - const db = createServerSupabase(); - const missingSharedUsers = await findMissingUserEmails(db, normalizedEmails); - if (missingSharedUsers.length > 0) { - return void res.status(400).json({ - detail: `${missingSharedUsers[0]} does not belong to a Mike user.`, - }); - } + const db = createServerSupabase(); + const missingSharedUsers = await findMissingUserEmails( + db, + normalizedEmails, + ); + if (missingSharedUsers.length > 0) { + return void res.status(400).json({ + detail: `${missingSharedUsers[0]} does not belong to a Mike user.`, + }); + } - // Verify ownership - const { data: wf } = await db - .from("workflows") - .select("id") - .eq("id", workflowId) - .eq("user_id", userId) - .single(); - if (!wf) return void res.status(404).json({ detail: "Workflow not found or not editable" }); - - const rows = normalizedEmails.map((email: string) => ({ - workflow_id: workflowId, - shared_by_user_id: userId, - shared_with_email: email, - allow_edit: allow_edit ?? false, - })); - // Upsert on (workflow_id, shared_with_email) so re-sharing to the same - // person updates the existing row instead of stacking duplicates. - const { error } = await db - .from("workflow_shares") - .upsert(rows, { onConflict: "workflow_id,shared_with_email" }); - if (error) return void res.status(500).json({ detail: error.message }); + // Verify ownership + const { data: wf } = await db + .from("workflows") + .select("id") + .eq("id", workflowId) + .eq("user_id", userId) + .single(); + if (!wf) + return void res + .status(404) + .json({ detail: "Workflow not found or not editable" }); - res.status(204).send(); -})); + const rows = normalizedEmails.map((email: string) => ({ + workflow_id: workflowId, + shared_by_user_id: userId, + shared_with_email: email, + allow_edit: allow_edit ?? false, + })); + // Upsert on (workflow_id, shared_with_email) so re-sharing to the same + // person updates the existing row instead of stacking duplicates. + const { error } = await db + .from("workflow_shares") + .upsert(rows, { onConflict: "workflow_id,shared_with_email" }); + if (error) return void res.status(500).json({ detail: error.message }); + + res.status(204).send(); + }), +); workflowsRouter.use( (err: unknown, _req: Request, res: Response, next: NextFunction) => { diff --git a/docs/legal-sources/a2aj-canadian-provider.md b/docs/legal-sources/a2aj-canadian-provider.md new file mode 100644 index 000000000..3f3978d22 --- /dev/null +++ b/docs/legal-sources/a2aj-canadian-provider.md @@ -0,0 +1,54 @@ +# A2AJ Canadian decision provider + +Status: implemented for Delivery A (ROSS-060) +Last source review: 2026-07-16 + +ROSS uses the public A2AJ Canadian Legal Data REST API as an additive source of +Canadian decisions. It does not replace the inherited CourtListener provider. +No API key is required by A2AJ at the time of this review. + +## Supported operations + +- Search decisions through `/search`, with bounded result size, pagination, + language, date, and dataset filters. +- Fetch a decision by citation through `/fetch`. +- Read English or French metadata and unofficial text where supplied. +- Find query-grounded passages within retrieved text. +- Verify that a citation matches a retrieved A2AJ record. +- Read live provider coverage through `/coverage`. + +The client applies a 15-second timeout, at most two retries for HTTP 429 or 5xx +responses, `Retry-After` handling, response-schema validation, and a circuit +breaker after repeated failures. Provider coverage is cached for 15 minutes so +the status interface does not consume unnecessary upstream capacity. + +## Authority and verification boundary + +A2AJ text is always labelled `unofficial`. ROSS retains the per-document +`upstream_license`, retrieval metadata, English/French official-source URLs, +and provider payload. A successful metadata or citation match is not treated as +proof that every passage is authoritative. Users are directed to the linked +official court source for authoritative verification. + +The provider currently maps Ontario Court of Appeal decisions to `CA-ON` and +supported federal courts to `CA`. ROSS reads live coverage rather than claiming +that A2AJ covers all Canadian or Ontario courts. The coverage endpoint names +known Ontario gaps, including ONSC, ONCJ, HRTO, and LTB when A2AJ does not report +those datasets. + +Court decisions can include sensitive personal information or be subject to a +publication ban. Retrieval does not grant permission to republish, retain, or +use a decision contrary to applicable law or its upstream licence. + +## Primary references + +- [A2AJ REST API documentation](https://api.a2aj.ca/docs) +- [A2AJ Canadian Legal Data coverage and access](https://a2aj.ca/data/) +- [A2AJ Canadian case-law dataset card](https://huggingface.co/datasets/a2aj/canadian-case-law/blob/main/README.md) + +## Test strategy + +Automated tests use only visibly synthetic mocked records. They verify bounded +query construction, retries, circuit breaking, Ontario metadata, official-link +retention, upstream-licence retention, coverage mapping, and passage grounding. +No test requires the live A2AJ service. diff --git a/docs/legal-sources/canadian-citations.md b/docs/legal-sources/canadian-citations.md new file mode 100644 index 000000000..d0e1117a8 --- /dev/null +++ b/docs/legal-sources/canadian-citations.md @@ -0,0 +1,54 @@ +# Canadian citation engine + +Status: implemented foundation for Delivery A (ROSS-090) +Last source review: 2026-07-16 + +ROSS parses and normalizes the following initial citation families: + +- Canadian neutral case citations, including SCC, federal, and Ontario courts; +- CanLII citations with the deciding court or tribunal identifier; +- common S.C.R., O.R., and D.L.R. reporter forms; +- Ontario and federal revised and annual statute citations; +- Ontario regulations, R.R.O. regulations, SOR, and DORS instruments; +- paragraph, page, section, and rule pinpoints and ranges; +- common English and French Ontario regulation forms. + +## Verification boundary + +Parsing proves only that text matches a supported citation grammar. Every +parsed citation begins as `unverified`. ROSS separately tracks: + +- citation verification; +- passage verification; +- legislation currency verification; and +- subsequent-treatment verification. + +A state is upgraded only after an authorized provider returns a matching +source. A case citation can be checked through A2AJ or a future licensed +provider. A statute or regulation can be checked against the matching official +e-Laws or Justice Laws provider. A citation match does not automatically verify +the quoted passage, currency, or treatment. + +## Rendering + +The Ontario profile follows the Court of Appeal for Ontario guide: prefer a +neutral citation, use `at para.` or `at paras.` for numbered decisions, and use +section/rule pinpoints for legislation. The `mcgill-compatible` profile is an +explicit compatibility target and currently emits the same supported primary +law forms. Case-name italics remain a presentation-layer responsibility so the +same structured citation can be rendered in HTML, Markdown, DOCX, or plain +text without embedding markup in its canonical value. + +## Primary references + +- [Court of Appeal for Ontario citation guide](https://www.ontariocourts.ca/coa/how-to-proceed-court/practice-directions-guidelines/reference-guide-citation/) +- [Ontario Superior Court civil practice direction](https://www.ontariocourts.ca/scj/areas-of-law/civil-court/civil-pd/) +- [CanLII RefLex citation explanation](https://www.canlii.org/info/reflex.html) + +## Limitations + +This foundation does not attempt to parse every historical reporter, loose-leaf +service, secondary source, docket number, or local court variation. Unsupported +or malformed text remains unparsed and therefore unverified. Additional +patterns require positive, negative, bilingual, and collision tests before +activation. diff --git a/docs/legal-sources/licensed-provider-onboarding.md b/docs/legal-sources/licensed-provider-onboarding.md new file mode 100644 index 000000000..db942554a --- /dev/null +++ b/docs/legal-sources/licensed-provider-onboarding.md @@ -0,0 +1,47 @@ +# Licensed legal-source provider onboarding + +This checklist applies to CanLII and any commercial legal-data provider. The +connector remains disabled until every applicable item is complete. + +## Legal and product approval + +- [ ] Identify the contracting ROSS organization and authorized users. +- [ ] Obtain an executed agreement and record its non-secret contract ID. +- [ ] Record allowed operations: metadata search, citation lookup, citator, + and/or full-text retrieval. +- [ ] Record jurisdiction, document-type, language, and use-purpose limits. +- [ ] Record retention, caching, reproduction, redistribution, and deletion + terms for metadata and full text separately. +- [ ] Confirm whether model input, retrieval augmentation, and generated output + are permitted. +- [ ] Complete privacy, publication-ban, and security review. +- [ ] Obtain product-owner and legal sign-off. + +## Technical approval + +- [ ] Implement a contract-specific API transport; web scraping is prohibited. +- [ ] Restrict the base URL to the approved HTTPS API host. +- [ ] Store credentials in the deployment secret manager, never the database, + logs, browser, repository, status response, or audit payload. +- [ ] Map every operation through `LicensedConnectorGate.authorize`. +- [ ] Add organization and user entitlement checks. +- [ ] Enforce retention/deletion and redistribution policy in storage/export. +- [ ] Add rate limits, timeout, retry, circuit-breaker, and provider health. +- [ ] Audit allowed and denied operations without query or document content. +- [ ] Add synthetic contract, denial, redaction, retention, and revocation tests. +- [ ] Document shutdown and credential-rotation procedures. + +## Activation + +- [ ] Populate environment configuration in staging only. +- [ ] Verify that the connector remains absent from unauthorized accounts. +- [ ] Run contract-specific acceptance tests with approved non-confidential + queries. +- [ ] Obtain final legal/security/product activation approval. +- [ ] Activate production using a change record and rollback plan. + +Current CanLII terms state that automated or large-scale retrieval should use +original sources or another authorized channel and prohibit systematic +programmatic downloading. ROSS therefore provides no CanLII web scraper. + +Primary reference: [CanLII Terms of Use](https://www.canlii.org/info/terms.html) diff --git a/docs/legal-sources/official-legislation.md b/docs/legal-sources/official-legislation.md new file mode 100644 index 000000000..7f4ba3a2c --- /dev/null +++ b/docs/legal-sources/official-legislation.md @@ -0,0 +1,61 @@ +# Official Ontario and federal legislation + +Status: implemented foundation for Delivery A (ROSS-070) +Last source review: 2026-07-16 + +ROSS has two additive official-source providers: + +- `ontario-elaws` links to and retrieves allowlisted pages from Ontario e-Laws. +- `justice-laws-canada` parses XML published by the Department of Justice in + `justicecanada/laws-lois-xml` and links each result to the Justice Laws + Website. + +Both providers expose an intentionally curated initial index of common Ontario +and federal Acts, regulations, and court rules. Search is performed against +that local index. ROSS does not crawl or scrape a government search interface. + +## Verification model + +The source website and publisher are official, but text parsed and displayed by +ROSS is a reproduction. Every returned document therefore includes: + +- the official canonical URL; +- English/French source links where configured; +- current-to and last-amended dates when present in the source; +- retrieval time and a SHA-256 hash of the retrieved source; +- section-level source links; +- `reproductionIsOfficial: false`. + +This follows the federal reproduction rule: ROSS must exercise due diligence +and must not represent its reproduction as an official version. Users should +open the canonical government page before relying on the text. + +## Safe retrieval + +Remote retrieval is limited to HTTPS and an exact hostname allowlist. Responses +have a 20-second timeout and a 15 MB size limit. Unknown identifiers fail +closed. Historical-version requests also fail closed until an explicit official +archived version is selected; ROSS never silently substitutes the current law +for a requested historical date. + +## Initial indexed materials + +Ontario includes the Courts of Justice Act, Rules of Civil Procedure, Rules of +the Small Claims Court, Limitations Act, Evidence Act, Family Law Act, Law +Society Act, and Succession Law Reform Act. + +Federal materials include the Criminal Code, Divorce Act, Canada Evidence Act, +Federal Courts Act, Bankruptcy and Insolvency Act, Federal Courts Rules, and +Federal Child Support Guidelines. + +The index is a product boundary, not a coverage claim. Missing material must be +shown as unsupported until it is explicitly added and tested. + +## Primary references + +- [Ontario e-Laws](https://www.ontario.ca/laws) +- [Ontario announcement describing e-Laws official status](https://news.ontario.ca/en/release/533/e-laws-becomes-an-official-source-of-law) +- [Justice Laws FAQ](https://laws-lois.justice.gc.ca/eng/faq/) +- [Justice Laws official-status note](https://laws-lois.justice.gc.ca/eng/importantnote/) +- [Justice Canada consolidated XML repository](https://github.com/justicecanada/laws-lois-xml) +- [Justice Laws stable-link guide](https://laws.justice.gc.ca/eng/LinkingGuide/) diff --git a/docs/legal-sources/ontario-procedure.md b/docs/legal-sources/ontario-procedure.md new file mode 100644 index 000000000..ac50a62a4 --- /dev/null +++ b/docs/legal-sources/ontario-procedure.md @@ -0,0 +1,27 @@ +# Ontario procedure sources + +ROSS-120 adds a conservative Ontario procedure foundation without treating generated output as legal advice or filing authority. + +## Official-source registry + +The registry points to current official pages for the Rules of Civil Procedure, Rules of the Small Claims Court, Superior Court practice directions, Court of Appeal general practice direction, and the official civil and Small Claims forms catalogues. Practice-direction users must identify the applicable court and region; ROSS does not infer a region from an incomplete matter description. + +Court forms are link-only. ROSS records a form number, title, official catalogue URL, and a `check-official-current-version` status. It does not retain a potentially stale editable or PDF copy. + +An operations check may make `HEAD` requests only to `www.ontario.ca` and `www.ontariocourts.ca`. The returned ETag, Last-Modified value, reachability, timestamp, and metadata hash can be stored in `legal_source_version_checks`. A changed hash is a review signal, not proof of a substantive legal change. + +## Deadline calculator boundary + +The deterministic calculator supports only the counting conventions in Ontario Civil Rule 3.01 and Small Claims Rule 3.01. It reports every counted and excluded date, the adjusted trigger date, governing-rule link, assumptions, warnings, and calculation timestamp. + +It does not select the triggering event or prescribed period, calculate limitation periods, resolve service disputes, account for an unprovided special holiday or closure, or override an order or agreement. Its result always requires user confirmation against the current official rule, applicable practice direction, service method, local notice, order, and agreement. + +Unexpected court closures and specially proclaimed holidays must be supplied explicitly. Date calculations use `America/Toronto` as the labelled local timezone and date-only arithmetic to avoid daylight-saving drift. + +## Authenticated API + +- `GET /legal-sources/procedure/sources` +- `GET /legal-sources/procedure/forms` +- `POST /legal-sources/procedure/deadlines/calculate` + +All three routes sit behind the existing ROSS authentication middleware. diff --git a/docs/legal-sources/provider-neutral-core.md b/docs/legal-sources/provider-neutral-core.md new file mode 100644 index 000000000..fc7108654 --- /dev/null +++ b/docs/legal-sources/provider-neutral-core.md @@ -0,0 +1,17 @@ +# Provider-neutral legal-source core + +ROSS legal research now begins with normalized provider descriptors, decision +summaries, fetched documents, citation results, jurisdiction codes, source +kinds, and verification states. + +CourtListener remains available as `courtlistener-us`. Its inherited API and +bulk-data functions are wrapped by the provider interface, and the existing +`/case-law/case-opinions` response remains compatible. The registry can filter +providers by jurisdiction and source kind without treating the U.S. provider +as the application-wide legal model. + +The authenticated `GET /legal-sources/status` endpoint reports configured +providers and health without returning credentials. Canadian providers will be +registered here in subsequent Delivery A milestones. + +No Canadian source is claimed to be live in this foundation checkpoint. diff --git a/docs/operations/local-staging-topology.md b/docs/operations/local-staging-topology.md new file mode 100644 index 000000000..4dff0eace --- /dev/null +++ b/docs/operations/local-staging-topology.md @@ -0,0 +1,43 @@ +# ROSS local and staging topology + +ROSS keeps the inherited frontend and backend independently runnable while the +public website remains a separate application. + +## Local services + +| Service | Default URL | Configuration | +|---|---|---| +| Public website | `http://localhost:4173` | `NEXT_PUBLIC_ROSS_APP_URL`, `NEXT_PUBLIC_ROSS_WEBSITE_URL` | +| Authenticated app | `http://localhost:3000` | `NEXT_PUBLIC_API_BASE_URL`, `NEXT_PUBLIC_ROSS_WEBSITE_URL` | +| API | `http://localhost:3001` | `CORS_ALLOWED_ORIGINS`, `ROSS_ENV`, provider and storage settings | + +Start each application with its existing development command. The public site +links to the authenticated app; the app links back to the public site; and the +API accepts browser requests only from the exact comma-separated origins in +`CORS_ALLOWED_ORIGINS`. + +## Staging boundary + +- Use `ROSS_ENV=staging` and distinct authentication, database, storage, + provider, email, and encryption credentials. +- Set website, app, and API URLs explicitly. Do not use `.invalid`, localhost, + or production credentials. +- Allow only the staging app origin through API CORS. +- Keep staging invitation-only and use synthetic or non-confidential fixtures. +- Configure auth callbacks for signup, email confirmation, password reset, MFA, + and logout before exercising cross-application journeys. +- Run the root verification gate plus the topology and preserved-Mike contracts + before promoting a staging revision. + +## Production guard + +The API refuses to start in `ROSS_ENV=production` when core auth, download, +storage, or allowed-origin settings are absent, placeholders, or local URLs. +Provider credentials remain optional when that provider is disabled or an +approved bulk source is configured. + +## Remaining deployment evidence + +An isolated staging deployment and its browser-auth journey require hosted +authentication and infrastructure decisions. Those are retained as acceptance +evidence for Delivery A and are not claimed by the local topology foundation. diff --git a/docs/ross-060-verification.md b/docs/ross-060-verification.md new file mode 100644 index 000000000..16b79d747 --- /dev/null +++ b/docs/ross-060-verification.md @@ -0,0 +1,31 @@ +# ROSS-060 verification + +Milestone: A2AJ Canadian provider +Delivery: A — Core Ontario product + +## Implemented + +- Provider-neutral A2AJ API client and registry adapter. +- Validated search, fetch, coverage, citation-verification, and passage routes. +- Bilingual case metadata and official-source links. +- Unofficial-text, verification, retrieval, and upstream-licence metadata. +- Live provider-reported coverage with explicit Ontario gaps. +- Timeout, retry, rate-limit, health-cache, and circuit-breaker behaviour. +- Synthetic provider, failure, and compatibility tests. + +## Verification commands + +```sh +npm run test:legal-sources --prefix backend +npm run build --prefix backend +npm test +``` + +All commands passed locally. A live-provider smoke test remains an explicit +staging task and is not part of the deterministic local suite. + +## Preserved boundary + +CourtListener remains registered as `courtlistener-us`. The inherited +`/case-law/case-opinions` response remains compatible. A2AJ is registered as +`a2aj-canada`; its text is never represented as official. diff --git a/docs/ross-070-verification.md b/docs/ross-070-verification.md new file mode 100644 index 000000000..2949e67a7 --- /dev/null +++ b/docs/ross-070-verification.md @@ -0,0 +1,35 @@ +# ROSS-070 verification + +Milestone: Official Ontario and federal legislation +Delivery: A — Core Ontario product + +## Implemented foundation + +- Provider-neutral legislation, regulation, rule, section, currency, language, + verification, and version fields. +- Ontario e-Laws and Department of Justice provider adapters. +- Curated Ontario/federal search registry with English/French canonical links. +- Official HTML/XML retrieval with strict host, timeout, and size boundaries. +- Federal XML and Ontario text parsing, section filtering, source hashes, and + current-to/last-amended metadata. +- Explicit reproduction status and historical-version fail-closed behaviour. +- Authenticated legislation search and fetch routes. +- Synthetic parser, currency, safety, and section tests. + +## Remaining work inside Delivery A + +- Add reviewed historical-version selectors rather than infer a version. +- Persist scheduled snapshots and emit change/staleness events. +- Expand the curated registry only with reviewed official identifiers. +- Connect the legislation response to the Canadian authority interface. + +## Verification commands + +```sh +npm run test:legal-sources --prefix backend +npm run build --prefix backend +npm test +``` + +The deterministic suite uses synthetic data and does not depend on government +websites being available during CI. diff --git a/docs/ross-080-verification.md b/docs/ross-080-verification.md new file mode 100644 index 000000000..d338c27fa --- /dev/null +++ b/docs/ross-080-verification.md @@ -0,0 +1,21 @@ +# ROSS-080 verification + +Milestone: Licensed CanLII/commercial connector framework +Delivery: A — Core Ontario product + +## Implemented + +- Disabled-by-default `canlii-licensed` provider descriptor. +- Contract, organization, credential, transport, operation, retention, + full-text, and redistribution entitlement gates. +- Exact approved API-host validation; no CanLII website scraper. +- Allowed/denied audit-event hook without credential or content fields. +- Credential-safe provider status. +- Synthetic denial, activation, secret-redaction, and full-text tests. +- Reusable onboarding and activation checklist. + +## Boundary + +This milestone does not claim a CanLII agreement or activate a CanLII API. The +provider cannot perform search or retrieval until a separately reviewed +contract-specific transport implements only the authorized operations. diff --git a/docs/ross-090-verification.md b/docs/ross-090-verification.md new file mode 100644 index 000000000..25dce1b27 --- /dev/null +++ b/docs/ross-090-verification.md @@ -0,0 +1,21 @@ +# ROSS-090 verification + +Milestone: Canadian citation engine +Delivery: A — Core Ontario product + +## Implemented + +- Canadian case, reporter, statute, regulation, and pinpoint parsers. +- Normalization, deterministic canonical IDs, and exact deduplication. +- Ontario and McGill-compatible primary-law rendering profiles. +- Separate citation, passage, currency, and treatment verification states. +- Provider-backed case and official-legislation verification. +- Authenticated parse and verify routes. +- Positive, malformed, bilingual, historical, range, deduplication, and + verification-boundary tests using synthetic data. + +## Safety property + +No parsed or generated citation is marked verified solely because it matches a +regular expression. Verification requires a matching result from a configured +authorized provider. diff --git a/docs/ross-100-verification.md b/docs/ross-100-verification.md new file mode 100644 index 000000000..8b9177a5a --- /dev/null +++ b/docs/ross-100-verification.md @@ -0,0 +1,27 @@ +# ROSS-100 verification + +Milestone: Ontario jurisdiction, prompts, and account settings + +## Implemented + +- Forward-only migration from the inherited `legal_research_us` flag to + provider-neutral settings, while retaining the legacy flag and U.S. feature. +- Ontario and applicable federal Canadian law as the new-user default. +- Account controls for Ontario, federal Canada, and preserved U.S. research. +- Jurisdiction metadata and controls for projects, chats, and workflows. +- Optional legal as-of date storage for chats. +- Ontario-first research and drafting instructions with ambiguity, coverage, + verification, temporal, bilingual, Canadian spelling, CAD, and date rules. +- Ontario practice-area vocabulary while retaining upstream practice choices. + +## Compatibility boundary + +CourtListener and the inherited U.S. setting remain available. Older databases +can still be read through the legacy-profile fallback, but deployments must run +the ROSS-100 migration before writing generic settings. + +## Product boundary + +Jurisdiction selection controls source availability; it does not prove source +coverage. The assistant must state the exact unavailable court, tribunal, date, +form, or regional direction instead of silently answering from model memory. diff --git a/docs/ross-110-verification.md b/docs/ross-110-verification.md new file mode 100644 index 000000000..c846ade78 --- /dev/null +++ b/docs/ross-110-verification.md @@ -0,0 +1,30 @@ +# ROSS-110 verification + +Milestone: Canadian authority interface + +## Implemented + +- Provider-neutral tools to search, fetch, find within, and verify legal + sources while retaining the inherited CourtListener tools. +- Server enforcement of the user's enabled jurisdictions and providers. +- Metadata-only search results followed by an explicit source fetch and exact + passage retrieval step. +- Stream events for source searches and fetched authorities. +- Inspectable authority interface for decisions, legislation, regulations, + rules, and later provider kinds. +- Official/provider links, court, jurisdiction, decision date, language, + current-to date, last-amended date, retrieval timestamp, and provider. +- Separate visible states for citation, passage, currency, and treatment. +- Keyboard-accessible native details controls, buttons, links, and panel tabs. + +## Verification boundary + +Search results are metadata and cannot verify a legal proposition. ROSS must +retrieve the exact supporting passage during the answer. An absent treatment +warning is never represented as proof that an authority remains good law. + +## Coverage boundary + +The A2AJ adapter emits a known-gap warning for requested Ontario courts and +tribunals outside its published coverage. Disabled or unauthorized providers +cannot be selected merely by naming them in a model tool call. diff --git a/docs/ross-120-verification.md b/docs/ross-120-verification.md new file mode 100644 index 000000000..4e538bb8c --- /dev/null +++ b/docs/ross-120-verification.md @@ -0,0 +1,20 @@ +# ROSS-120 verification + +## Scope + +ROSS-120 establishes the Ontario rules, practice-direction, current-form, source-change, and transparent deadline-calculation foundation. + +## Automated checks + +- Legal-source unit tests verify allowlisted official sources, link-only forms, synthetic metadata checks, both supported Rule 3.01 counting profiles, observed holidays, after-4 p.m. deemed service, user-provided closures, and invalid-input rejection. +- The baseline contract verifies official-source boundaries, form-currentness warnings, authenticated deadline routing, audit-table isolation, and the explicit limitation-period exclusion. +- Backend TypeScript compilation verifies the route and module contract. + +## Human checks before production + +1. An Ontario lawyer or paralegal must validate each supported deadline scenario and warning. +2. Operations must schedule the source metadata check and route changes to a human legal-content review queue. +3. Product owners must confirm the current regional practice direction for every workflow that depends on one. +4. Security must apply the migration with a service-role-only writer and confirm no browser role can read or write the audit table. + +ROSS-120 is review-ready infrastructure. It is not a representation that a generated deadline, form, rule, or practice direction is current or correct for a particular matter. diff --git a/docs/ross-140-verification.md b/docs/ross-140-verification.md new file mode 100644 index 000000000..b7bfb3b3e --- /dev/null +++ b/docs/ross-140-verification.md @@ -0,0 +1,20 @@ +# ROSS-140 verification + +## Delivered + +- Five Ontario workflow drafts: civil pleadings, documentary discovery, affidavit fact-checking, factum authority/record cross-checking, and Small Claims intake. +- A validated in-repository source catalogue and deterministic generator. +- Additive backend registration that retains all inherited Mike workflows. +- Public catalogue entries with governed metadata and authenticated-app deep links. +- Synthetic fixtures and explicit independent-review gates. + +## Automated verification + +- `npm run test:workflow-sources` rejects stale generated files, non-official primary-source hosts, missing governance fields, approved status without review, and malformed workflow instructions. +- Baseline contracts verify exactly five drafts, null review records, additive Mike/ROSS registration, public catalogue wiring, and synthetic fixture paths. +- Website route tests verify the catalogue, a real workflow detail page, draft status, review checks, and app deep link. +- Backend and website builds type-check the generated representations. + +## External blocker + +These drafts have not been reviewed by an independent Ontario lawyer. ROSS must not describe them as lawyer-reviewed, production-approved, or filing-ready until the governance record and evaluation gate are complete. diff --git a/docs/workflows/ontario-workflow-governance.md b/docs/workflows/ontario-workflow-governance.md new file mode 100644 index 000000000..39023b52b --- /dev/null +++ b/docs/workflows/ontario-workflow-governance.md @@ -0,0 +1,27 @@ +# Ontario workflow governance + +ROSS-140 adds five in-repository Ontario workflow drafts without changing or replacing any inherited Mike workflow. The build script validates the governed source catalogue and generates additive backend and public-website representations. + +## Status model + +Every initial entry is `draft-awaiting-lawyer-review`, carries a `0.1.0-draft` version, and has a null reviewer and review date. The authenticated application also displays “Draft — not lawyer-reviewed” in the title and description. A software contributor must not fill the reviewer fields or remove the draft label without a recorded independent review by an Ontario lawyer with suitable subject-matter experience. + +## Required review record + +The reviewer should record their name, professional status, scope of review, review date, source as-of date, issues found, changes required, benchmark results, and approval or rejection. A review applies only to the identified workflow version. Any substantive prompt, source, rule, output, or boundary change requires a new version and review. + +## Evaluation material + +Each workflow has a deliberately synthetic fixture. These fixtures exercise issue extraction, discovery traceability and potential privilege flags, affidavit discrepancies, unverified citations, and incomplete Small Claims intake. They contain no real people, matters, authorities, or client information. + +## Release gate + +Before a workflow can be represented as approved: + +1. Run the source generator and all repository checks. +2. Have the independent reviewer validate the prompt against current official sources and the applicable professional scope. +3. Evaluate the synthetic fixture plus adversarial cases for invented facts, invented citations, missing-source behavior, unsafe deadline claims, and confidentiality-boundary behavior. +4. Record the reviewer and review date, bump the version, and publish the review record. +5. Re-run the full preservation and product gate. + +Until then, the public catalogue is a transparent preview and the authenticated workflow is for controlled evaluation only. diff --git a/frontend/.env.local.example b/frontend/.env.local.example index c0ceb7144..91fef604c 100644 --- a/frontend/.env.local.example +++ b/frontend/.env.local.example @@ -1,3 +1,5 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=your-supabase-anon-key NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 +NEXT_PUBLIC_ROSS_APP_URL=http://localhost:3000 +NEXT_PUBLIC_ROSS_WEBSITE_URL=http://localhost:4173 diff --git a/frontend/src/app/(pages)/account/features/page.tsx b/frontend/src/app/(pages)/account/features/page.tsx index 4ecfa4e0b..eb1bec2b5 100644 --- a/frontend/src/app/(pages)/account/features/page.tsx +++ b/frontend/src/app/(pages)/account/features/page.tsx @@ -1,18 +1,36 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Check } from "lucide-react"; import { useUserProfile } from "@/app/contexts/UserProfileContext"; +import type { LegalResearchSettings } from "@/app/lib/mikeApi"; import { AccountSection } from "../AccountSection"; +const JURISDICTIONS = [ + { + id: "CA-ON" as const, + label: "Ontario, Canada", + description: + "Ontario decisions, e-Laws, rules and applicable federal law.", + }, + { + id: "CA" as const, + label: "Federal — Canada", + description: "Supreme Court, Federal Courts and Justice Laws sources.", + }, + { + id: "US" as const, + label: "United States", + description: "Preserved Mike case-law research through CourtListener.", + }, +]; + export default function FeaturesPage() { - const { profile, updateLegalResearchUs } = useUserProfile(); + const { profile, updateLegalResearch } = useUserProfile(); + const [draft, setDraft] = useState(null); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [saveError, setSaveError] = useState(null); - const [draftLegalResearchUs, setDraftLegalResearchUs] = useState< - boolean | null - >(null); const savedTimerRef = useRef | null>(null); useEffect(() => { @@ -21,87 +39,177 @@ export default function FeaturesPage() { }; }, []); - const persistedLegalResearchUs = profile?.legalResearchUs ?? true; - const usEnabled = draftLegalResearchUs ?? persistedLegalResearchUs; - const hasChanges = - draftLegalResearchUs !== null && - draftLegalResearchUs !== persistedLegalResearchUs; + const persisted = profile?.legalResearch ?? null; + const settings = draft ?? persisted; + const hasChanges = useMemo( + () => !!draft && JSON.stringify(draft) !== JSON.stringify(persisted), + [draft, persisted], + ); - const handleUpdateLegalResearch = async () => { - if (saving) return; + const updateDraft = (next: LegalResearchSettings) => { + setDraft(next); setSaved(false); setSaveError(null); + }; + + const toggleJurisdiction = ( + jurisdiction: LegalResearchSettings["enabledJurisdictions"][number], + ) => { + if (!settings) return; + const enabled = settings.enabledJurisdictions.includes(jurisdiction); + const enabledJurisdictions = enabled + ? settings.enabledJurisdictions.filter( + (item) => item !== jurisdiction, + ) + : [...settings.enabledJurisdictions, jurisdiction]; + const provider = jurisdiction === "US" ? "courtlistener-us" : null; + const enabledSourceProviders = provider + ? enabled + ? settings.enabledSourceProviders.filter( + (item) => item !== provider, + ) + : [...new Set([...settings.enabledSourceProviders, provider])] + : settings.enabledSourceProviders; + updateDraft({ + ...settings, + enabledJurisdictions, + enabledSourceProviders, + }); + }; + + const handleSave = async () => { + if (!settings || saving || !hasChanges) return; setSaving(true); - const ok = await updateLegalResearchUs(usEnabled); + setSaveError(null); + const ok = await updateLegalResearch(settings); setSaving(false); - if (ok) { - setDraftLegalResearchUs(null); - setSaved(true); - if (savedTimerRef.current) clearTimeout(savedTimerRef.current); - savedTimerRef.current = setTimeout(() => setSaved(false), 1600); - } else { + if (!ok) { setSaveError("Could not update. Try again."); + return; } + setDraft(null); + setSaved(true); + if (savedTimerRef.current) clearTimeout(savedTimerRef.current); + savedTimerRef.current = setTimeout(() => setSaved(false), 1600); }; return (
-
-

- Legal Research -

-
+

+ Legal research +

-
-
+
+

- Jurisdiction + Default jurisdiction

-

- Choose which jurisdictions the assistant can - research. When a jurisdiction is enabled, its - case-law research tools are available in chat. +

+ ROSS starts with Ontario and applicable federal + Canadian law. It asks when the governing + jurisdiction is unclear.

-
-
- - + {(["CA", "US"] as const).map((country) => ( + + ))} +
-
+ +
+

+ Enabled jurisdictions +

+

+ Enabling a jurisdiction makes only its + configured legal-source providers available. + Coverage limitations are still shown in results. +

+
+ {JURISDICTIONS.map((item) => { + const checked = + settings?.enabledJurisdictions.includes( + item.id, + ) ?? false; + return ( +
+ + +
+ ); + })} +
+
+ +

{saveError ?? ""}

+ )} +
+ ) : null} + + + ); +} + export function DocFindBlock({ filename, query, diff --git a/frontend/src/app/components/assistant/message/eventUtils.ts b/frontend/src/app/components/assistant/message/eventUtils.ts index 520e4188a..8a8a8f02a 100644 --- a/frontend/src/app/components/assistant/message/eventUtils.ts +++ b/frontend/src/app/components/assistant/message/eventUtils.ts @@ -28,6 +28,11 @@ export function toolCallLabel(name: string): string { if (name === "courtlistener_read_case") return "Reading case..."; if (name === "courtlistener_verify_citations") return "Verifying citations..."; + if (name === "search_legal_sources") return "Searching legal sources..."; + if (name === "fetch_legal_source") return "Fetching legal authority..."; + if (name === "find_in_legal_source") + return "Retrieving supporting passages..."; + if (name === "verify_legal_citations") return "Verifying citations..."; if (name.startsWith("mcp_")) return "Using connector..."; return name ? `Running ${name}...` : "Working..."; } diff --git a/frontend/src/app/components/projects/NewProjectModal.tsx b/frontend/src/app/components/projects/NewProjectModal.tsx index e592500b5..b187595ab 100644 --- a/frontend/src/app/components/projects/NewProjectModal.tsx +++ b/frontend/src/app/components/projects/NewProjectModal.tsx @@ -1,7 +1,7 @@ "use client"; import { useRef, useState } from "react"; -import { Upload, User, X } from "lucide-react"; +import { Check, Upload, User, X } from "lucide-react"; import { addDocumentToProject, createProject, @@ -29,6 +29,9 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) { const [name, setName] = useState(""); const [cmNumber, setCmNumber] = useState(""); const [practice, setPractice] = useState(""); + const [jurisdictions, setJurisdictions] = useState< + Array<"CA-ON" | "CA" | "US"> + >(["CA-ON", "CA"]); const [sharedUsers, setSharedUsers] = useState([]); const [selectedDocIds, setSelectedDocIds] = useState>(new Set()); const [pendingFiles, setPendingFiles] = useState([]); @@ -74,6 +77,7 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) { practice.trim() && practice.trim() !== "Other" ? practice.trim() : undefined, + jurisdictions, ownEmail ? sharedUsers .map((user) => user.email) @@ -99,6 +103,7 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) { setName(""); setCmNumber(""); setPractice(""); + setJurisdictions(["CA-ON", "CA"]); setSharedUsers([]); setSelectedDocIds(new Set()); setPendingFiles([]); @@ -248,6 +253,51 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) { />
+
+ Jurisdictions +
+ {( + [ + ["CA-ON", "Ontario, Canada"], + ["CA", "Federal — Canada"], + ["US", "United States"], + ] as const + ).map(([code, label]) => { + const checked = jurisdictions.includes(code); + return ( + + ); + })} +
+
+
Share with diff --git a/frontend/src/app/components/shared/types.ts b/frontend/src/app/components/shared/types.ts index b07b4c514..0f6a831b1 100644 --- a/frontend/src/app/components/shared/types.ts +++ b/frontend/src/app/components/shared/types.ts @@ -19,6 +19,7 @@ export interface Project { name: string; cm_number: string | null; practice: string | null; + jurisdictions?: Array<"CA-ON" | "CA" | "US">; shared_with: string[]; created_at: string; updated_at: string; @@ -66,6 +67,8 @@ export interface Chat { user_id: string; creator_display_name?: string | null; title: string | null; + jurisdictions?: Array<"CA-ON" | "CA" | "US">; + legal_as_of_date?: string | null; created_at: string; } @@ -255,6 +258,26 @@ export type AssistantEvent = error?: string; isStreaming?: boolean; } + | { + type: "legal_source_search"; + provider_id: string | null; + provider_name: string | null; + query: string; + result_count: number; + coverage_warning?: string; + error?: string; + isStreaming?: boolean; + } + | { + type: "legal_authority"; + action: "fetched" | "passages" | "verified"; + provider_id: string | null; + provider_name: string | null; + authority?: LegalAuthoritySummary; + passage_count?: number; + error?: string; + isStreaming?: boolean; + } | { type: "case_citation"; cluster_id: number | null; @@ -288,6 +311,38 @@ export type AssistantEvent = } | { type: "content"; text: string; isStreaming?: boolean }; +export type LegalAuthoritySummary = { + providerId: string; + providerName: string; + official: boolean; + fullTextStatus: "official" | "licensed" | "unofficial" | "metadata-only"; + sourceId: string | null; + kind: string; + title: string | null; + citation: string | null; + court: string | null; + jurisdiction: string | null; + decisionDate: string | null; + currentToDate: string | null; + lastAmendedDate: string | null; + retrievedAt: string | null; + language: string | null; + canonicalUrl: string | null; + alternateLanguageUrl: string | null; + verification: "verified" | "partial" | "unverified" | "unavailable"; + reproductionIsOfficial: boolean | null; + passages: Array<{ + text: string; + language?: string; + paragraphStart?: number | null; + paragraphEnd?: number | null; + section?: string; + heading?: string | null; + sourceUrl?: string | null; + verification?: string; + }>; +}; + export type CaseCitationQuote = { opinionId: number | null; type: string | null; @@ -302,6 +357,8 @@ export interface Message { files?: { filename: string; document_id?: string }[]; workflow?: { id: string; title: string }; model?: string; + jurisdictions?: Array<"CA-ON" | "CA" | "US">; + legalAsOfDate?: string; citations?: Citation[]; citationStatus?: "started" | "partial" | "final"; events?: AssistantEvent[]; diff --git a/frontend/src/app/components/workflows/NewWorkflowModal.tsx b/frontend/src/app/components/workflows/NewWorkflowModal.tsx index 8f7a955eb..deb67ac92 100644 --- a/frontend/src/app/components/workflows/NewWorkflowModal.tsx +++ b/frontend/src/app/components/workflows/NewWorkflowModal.tsx @@ -12,8 +12,8 @@ import { ModalSelect } from "../modals/ModalSelect"; import { ModalTextInput } from "../modals/ModalTextInput"; const DEFAULT_LANGUAGE = "English"; -const DEFAULT_PRACTICE = "General Transactions"; -const DEFAULT_JURISDICTION = "General"; +const DEFAULT_PRACTICE = "Civil Litigation"; +const DEFAULT_JURISDICTION = "Canada / Ontario"; const LANGUAGE_OPTIONS = [ "English", "Chinese", @@ -57,6 +57,8 @@ const LANGUAGE_OPTIONS = [ "Other", ] as const; const JURISDICTION_OPTIONS = [ + "Canada / Ontario", + "Federal — Canada", "General", "United States", "England and Wales", diff --git a/frontend/src/app/components/workflows/practices.ts b/frontend/src/app/components/workflows/practices.ts index 3ecb5ab4b..91397cf91 100644 --- a/frontend/src/app/components/workflows/practices.ts +++ b/frontend/src/app/components/workflows/practices.ts @@ -1,4 +1,21 @@ export const PRACTICE_OPTIONS = [ + "Civil Litigation", + "Small Claims", + "Administrative Law", + "Employment and Labour", + "Human Rights", + "Residential Tenancies", + "Family", + "Criminal", + "Estates", + "Corporate and Commercial", + "Privacy", + "Intellectual Property", + "Entertainment and Media", + "Municipal", + "Professional Regulation", + "Bankruptcy and Insolvency", + "Immigration", "General Transactions", "Corporate", "Finance", diff --git a/frontend/src/app/contexts/UserProfileContext.tsx b/frontend/src/app/contexts/UserProfileContext.tsx index 42d168aa8..95329570d 100644 --- a/frontend/src/app/contexts/UserProfileContext.tsx +++ b/frontend/src/app/contexts/UserProfileContext.tsx @@ -12,6 +12,7 @@ import { useAuth } from "@/app/contexts/AuthContext"; import { type ApiKeyState, type ApiKeyProvider, + type LegalResearchSettings, type UserProfile as ApiUserProfile, getUserProfile, isMfaRequiredError, @@ -30,6 +31,7 @@ interface UserProfile { titleModel: string; tabularModel: string; mfaOnLogin: boolean; + legalResearch: LegalResearchSettings; legalResearchUs: boolean; apiKeys: ApiKeyState; } @@ -44,6 +46,7 @@ interface UserProfileContextType { value: string, ) => Promise; updateMfaOnLogin: (enabled: boolean) => Promise; + updateLegalResearch: (settings: LegalResearchSettings) => Promise; updateLegalResearchUs: (enabled: boolean) => Promise; updateApiKey: ( provider: ApiKeyProvider, @@ -120,6 +123,18 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { titleModel: "gemini-3.1-flash-lite-preview", tabularModel: "gemini-3-flash-preview", mfaOnLogin: false, + legalResearch: { + enabled: true, + defaultCountry: "CA", + defaultProvince: "ON", + enabledJurisdictions: ["CA-ON", "CA", "US"], + enabledSourceProviders: [ + "a2aj-canada", + "ontario-elaws", + "justice-laws-canada", + "courtlistener-us", + ], + }, legalResearchUs: true, apiKeys: emptyApiKeys(), }); @@ -230,6 +245,24 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { [user], ); + const updateLegalResearch = useCallback( + async (settings: LegalResearchSettings): Promise => { + if (!user) return false; + try { + const updated = await updateUserProfile({ + legalResearch: settings, + }); + setProfile((prev) => + prev ? { ...prev, ...toProfile(updated) } : null, + ); + return true; + } catch { + return false; + } + }, + [user], + ); + const updateApiKey = useCallback( async ( provider: ApiKeyProvider, @@ -290,6 +323,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { updateOrganisation, updateModelPreference, updateMfaOnLogin, + updateLegalResearch, updateLegalResearchUs, updateApiKey, reloadProfile, diff --git a/frontend/src/app/hooks/useAssistantChat.ts b/frontend/src/app/hooks/useAssistantChat.ts index 62a008581..29a30a254 100644 --- a/frontend/src/app/hooks/useAssistantChat.ts +++ b/frontend/src/app/hooks/useAssistantChat.ts @@ -357,6 +357,8 @@ export function useAssistantChat({ attached_documents: attachedDocs.length > 0 ? attachedDocs : undefined, ask_inputs_response: opts?.askInputsResponse, + jurisdictions: message.jurisdictions, + legal_as_of_date: message.legalAsOfDate, signal: controller.signal, }) : streamChat({ @@ -364,6 +366,8 @@ export function useAssistantChat({ chat_id: chatId, model, ask_inputs_response: opts?.askInputsResponse, + jurisdictions: message.jurisdictions, + legal_as_of_date: message.legalAsOfDate, signal: controller.signal, })); @@ -598,6 +602,64 @@ export function useAssistantChat({ continue; } + if (data.type === "legal_source_search") { + pushEvent({ + type: "legal_source_search", + provider_id: + typeof data.provider_id === "string" + ? (data.provider_id as string) + : null, + provider_name: + typeof data.provider_name === "string" + ? (data.provider_name as string) + : null, + query: typeof data.query === "string" ? data.query : "", + result_count: + typeof data.result_count === "number" ? data.result_count : 0, + coverage_warning: + typeof data.coverage_warning === "string" + ? data.coverage_warning + : undefined, + error: + typeof data.error === "string" ? data.error : undefined, + }); + pushThinkingPlaceholder(); + continue; + } + + if (data.type === "legal_authority") { + pushEvent({ + type: "legal_authority", + action: + data.action === "passages" || data.action === "verified" + ? data.action + : "fetched", + provider_id: + typeof data.provider_id === "string" + ? data.provider_id + : null, + provider_name: + typeof data.provider_name === "string" + ? data.provider_name + : null, + authority: + data.authority && typeof data.authority === "object" + ? (data.authority as Extract< + AssistantEvent, + { type: "legal_authority" } + >["authority"]) + : undefined, + passage_count: + typeof data.passage_count === "number" + ? data.passage_count + : undefined, + error: + typeof data.error === "string" ? data.error : undefined, + }); + pushThinkingPlaceholder(); + continue; + } + if (data.type === "mcp_tool_start") { pushEvent({ type: "mcp_tool_call", diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index a1fb42b95..8ef851927 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -170,12 +170,19 @@ export async function createProject( name: string, cm_number?: string, practice?: string, + jurisdictions?: Array<"CA-ON" | "CA" | "US">, shared_with?: string[], ): Promise { return apiRequest("/projects", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, cm_number, practice, shared_with }), + body: JSON.stringify({ + name, + cm_number, + practice, + jurisdictions, + shared_with, + }), }); } @@ -226,10 +233,20 @@ export interface UserProfile { titleModel: string; tabularModel: string; mfaOnLogin: boolean; + legalResearch: LegalResearchSettings; + /** Legacy compatibility alias for enabledJurisdictions.includes("US"). */ legalResearchUs: boolean; apiKeyStatus: ApiKeyStatus; } +export interface LegalResearchSettings { + enabled: boolean; + defaultCountry: "CA" | "US"; + defaultProvince: "ON" | null; + enabledJurisdictions: Array<"CA-ON" | "CA" | "US">; + enabledSourceProviders: string[]; +} + export interface UserLookupResult { exists: boolean; email: string; @@ -253,6 +270,7 @@ export async function updateUserProfile(payload: { organisation?: string | null; titleModel?: string; tabularModel?: string; + legalResearch?: LegalResearchSettings; legalResearchUs?: boolean; }): Promise { return apiRequest("/user/profile", { @@ -745,6 +763,8 @@ export async function downloadDocumentsZip( export async function createChat(payload?: { project_id?: string; + jurisdictions?: Array<"CA-ON" | "CA" | "US">; + legal_as_of_date?: string; }): Promise<{ id: string }> { return apiRequest<{ id: string }>("/chat/create", { method: "POST", @@ -853,6 +873,8 @@ export async function streamChat(payload: { chat_id?: string; project_id?: string; model?: string; + jurisdictions?: Array<"CA-ON" | "CA" | "US">; + legal_as_of_date?: string; ask_inputs_response?: { responses: ( | { @@ -898,6 +920,8 @@ export async function streamProjectChat(payload: { messages: StreamChatMessage[]; chat_id?: string; model?: string; + jurisdictions?: Array<"CA-ON" | "CA" | "US">; + legal_as_of_date?: string; displayed_doc?: { filename: string; document_id: string }; attached_documents?: { filename: string; document_id: string }[]; ask_inputs_response?: { diff --git a/frontend/src/app/lib/rossBrand.ts b/frontend/src/app/lib/rossBrand.ts index eca1d1ccc..15dd19481 100644 --- a/frontend/src/app/lib/rossBrand.ts +++ b/frontend/src/app/lib/rossBrand.ts @@ -8,8 +8,8 @@ export const rossBrand = { tagline: product.tagline, description: product.description, betaLabel: product.betaLabel, - appUrl: urls.app, - websiteUrl: urls.website, - termsUrl: `${urls.website}/terms`, - privacyUrl: `${urls.website}/privacy`, + appUrl: process.env.NEXT_PUBLIC_ROSS_APP_URL ?? urls.app, + websiteUrl: process.env.NEXT_PUBLIC_ROSS_WEBSITE_URL ?? urls.website, + termsUrl: `${process.env.NEXT_PUBLIC_ROSS_WEBSITE_URL ?? urls.website}/terms`, + privacyUrl: `${process.env.NEXT_PUBLIC_ROSS_WEBSITE_URL ?? urls.website}/privacy`, } as const; diff --git a/package.json b/package.json index 02da58f7c..b7c5a7749 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,13 @@ }, "scripts": { "install:all": "npm ci --prefix backend && npm ci --prefix frontend && npm ci --prefix website", - "test": "npm run test:baseline", + "test": "npm run test:workflow-sources && npm run test:baseline && npm run test:legal-sources --prefix backend", + "test:workflow-sources": "node scripts/build-ross-workflows.mjs --check", "test:baseline": "node --test tests/baseline/*.test.mjs", "test:e2e": "node --test tests/e2e/*.test.mjs", "test:website": "npm run test:routes --prefix website", - "build": "npm run build:backend && npm run build:frontend && npm run build:website", + "build": "npm run build:ross-workflows && npm run build:backend && npm run build:frontend && npm run build:website", + "build:ross-workflows": "node scripts/build-ross-workflows.mjs", "build:backend": "npm run build --prefix backend", "build:frontend": "node scripts/build-frontend.mjs", "build:website": "npm run build --prefix website", @@ -20,7 +22,7 @@ "lint:baseline": "node scripts/lint-baseline.mjs", "lint:strict": "npm run lint --prefix frontend", "lint:website": "npm run lint --prefix website", - "check": "npm run test:baseline && npm run build && npm run lint && npm run test:website" + "check": "npm test && npm run build && npm run lint && npm run test:website" }, "license": "AGPL-3.0-only" } diff --git a/scripts/build-ross-workflows.mjs b/scripts/build-ross-workflows.mjs new file mode 100644 index 000000000..b97aade7d --- /dev/null +++ b/scripts/build-ross-workflows.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const sourceDir = resolve(root, "workflows/ontario"); +const cataloguePath = resolve(sourceDir, "catalogue.json"); +const backendPath = resolve(root, "backend/src/lib/rossSystemWorkflows.ts"); +const websitePath = resolve(root, "website/app/generated-ontario-workflows.ts"); +const checkOnly = process.argv.includes("--check"); +const allowedSourceHosts = new Set(["www.ontario.ca", "www.ontariocourts.ca"]); + +const catalogue = JSON.parse(readFileSync(cataloguePath, "utf8")); +if (!Array.isArray(catalogue) || catalogue.length !== 5) + throw new Error( + "The ROSS-140 catalogue must contain exactly five MVP workflows.", + ); + +const seen = new Set(); +const normalized = catalogue.map((entry) => { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.slug)) + throw new Error(`Invalid workflow slug: ${entry.slug}`); + if (seen.has(entry.slug)) + throw new Error(`Duplicate workflow slug: ${entry.slug}`); + seen.add(entry.slug); + for (const field of [ + "title", + "description", + "practice", + "version", + "status", + "sourceCurrency", + "output", + "syntheticFixture", + "skillFile", + ]) + if (typeof entry[field] !== "string" || !entry[field].trim()) + throw new Error( + `${entry.slug}.${field} must be a non-empty string.`, + ); + for (const field of [ + "jurisdictions", + "intendedUsers", + "excludedUses", + "requiredInputs", + "reviewChecklist", + ]) + if (!Array.isArray(entry[field]) || entry[field].length === 0) + throw new Error( + `${entry.slug}.${field} must be a non-empty array.`, + ); + if (entry.status !== "draft-awaiting-lawyer-review") + throw new Error(`${entry.slug} cannot be published as approved.`); + if (entry.reviewer !== null || entry.reviewDate !== null) + throw new Error( + `${entry.slug} has not completed external legal review.`, + ); + if ( + !Array.isArray(entry.primarySources) || + entry.primarySources.length === 0 + ) + throw new Error(`${entry.slug}.primarySources must not be empty.`); + for (const source of entry.primarySources) { + const url = new URL(source.url); + if (!allowedSourceHosts.has(url.hostname)) + throw new Error( + `${entry.slug} uses a non-official primary-source host.`, + ); + } + const instructions = readFileSync( + resolve(sourceDir, entry.skillFile), + "utf8", + ).trim(); + if ( + !instructions.includes("## Boundary") || + !instructions.includes("## Instructions") + ) + throw new Error( + `${entry.skillFile} must contain Boundary and Instructions sections.`, + ); + return { + ...entry, + id: `builtin-ross-ontario-${entry.slug}`, + instructions, + }; +}); + +const backendWorkflows = normalized.map((entry) => ({ + user_id: null, + is_system: true, + created_at: "", + id: entry.id, + metadata: { + title: `${entry.title} (Draft — not lawyer-reviewed)`, + description: `DRAFT — not lawyer-reviewed. ${entry.description}`, + type: "assistant", + contributors: [ + { + name: "ROSS contributors", + organisation: "Ranade OSS", + role: "Draft workflow author", + linkedin: null, + }, + ], + language: "English", + version: entry.version, + practice: entry.practice, + jurisdictions: entry.jurisdictions, + }, + skill_md: entry.instructions, + columns_config: null, +})); + +const publicWorkflows = normalized.map( + ({ instructions: _instructions, skillFile: _skillFile, ...entry }) => ({ + ...entry, + appPath: `/workflows/assistant/${entry.id}`, + }), +); + +const backendText = `// Generated by scripts/build-ross-workflows.mjs. Do not edit directly.\n\nimport type { SystemWorkflow } from "./systemWorkflows";\n\nexport const ROSS_SYSTEM_WORKFLOWS: SystemWorkflow[] = ${JSON.stringify(backendWorkflows, null, 4)};\n\nexport const ROSS_SYSTEM_WORKFLOW_IDS = new Set(ROSS_SYSTEM_WORKFLOWS.map((workflow) => workflow.id));\n`; +const websiteText = `// Generated by scripts/build-ross-workflows.mjs. Do not edit directly.\n\nexport type OntarioWorkflowCatalogueEntry = {\n slug: string;\n id: string;\n title: string;\n description: string;\n practice: string;\n jurisdictions: string[];\n version: string;\n status: "draft-awaiting-lawyer-review";\n intendedUsers: string[];\n excludedUses: string[];\n requiredInputs: string[];\n primarySources: { label: string; url: string }[];\n sourceCurrency: string;\n output: string;\n reviewChecklist: string[];\n reviewer: null;\n reviewDate: null;\n syntheticFixture: string;\n appPath: string;\n};\n\nexport const ONTARIO_WORKFLOW_CATALOGUE: OntarioWorkflowCatalogueEntry[] = ${JSON.stringify(publicWorkflows, null, 2)};\n`; + +function emit(path, content) { + if (checkOnly) { + if (readFileSync(path, "utf8") !== content) + throw new Error( + `${path.slice(root.length + 1)} is stale. Run npm run build:ross-workflows.`, + ); + return; + } + writeFileSync(path, content); +} + +emit(backendPath, backendText); +emit(websitePath, websiteText); +console.log( + `${checkOnly ? "Verified" : "Generated"} ${normalized.length} Ontario workflow drafts.`, +); diff --git a/tests/baseline/ross-topology-and-sources.test.mjs b/tests/baseline/ross-topology-and-sources.test.mjs new file mode 100644 index 000000000..ba2f7e6d5 --- /dev/null +++ b/tests/baseline/ross-topology-and-sources.test.mjs @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const read = (path) => readFileSync(resolve(root, path), "utf8"); + +test("website, app, and API URLs are environment configurable", () => { + assert.match(read("website/app/site-config.ts"), /NEXT_PUBLIC_ROSS_APP_URL/); + assert.match( + read("frontend/src/app/lib/rossBrand.ts"), + /NEXT_PUBLIC_ROSS_WEBSITE_URL/, + ); + assert.match( + read("frontend/.env.local.example"), + /NEXT_PUBLIC_API_BASE_URL=http:\/\/localhost:3001/, + ); + assert.match( + read("website/.env.example"), + /NEXT_PUBLIC_ROSS_APP_URL=http:\/\/localhost:3000/, + ); +}); + +test("the API validates its environment and exact CORS origins", () => { + const runtime = read("backend/src/config/runtime.ts"); + const server = read("backend/src/index.ts"); + assert.match(runtime, /ROSS_ENV/); + assert.match(runtime, /CORS_ALLOWED_ORIGINS/); + assert.match(runtime, /Production CORS origins cannot use localhost/); + assert.match(server, /runtime\.allowedOrigins\.includes\(origin\)/); + assert.doesNotMatch(server, /origin: process\.env\.FRONTEND_URL/); +}); + +test("the legal-source model is provider-neutral", () => { + const types = read("backend/src/lib/legalSources/types.ts"); + const registry = read("backend/src/lib/legalSources/registry.ts"); + assert.match(types, /interface LegalSourceProvider/); + assert.match(types, /JurisdictionCode/); + assert.match(types, /LegalDecisionSummary/); + assert.match(types, /VerificationState/); + assert.match(registry, /filters\?\.jurisdiction/); + assert.match(registry, /filters\?\.kind/); +}); + +test("CourtListener is preserved behind the generic provider", () => { + const adapter = read("backend/src/lib/legalSources/courtlistenerProvider.ts"); + const route = read("backend/src/routes/caseLaw.ts"); + assert.match(adapter, /id: "courtlistener-us"/); + assert.match(adapter, /searchCourtlistenerCaseLaw/); + assert.match(adapter, /getCourtlistenerCaseOpinions/); + assert.match(adapter, /verifyCourtlistenerCitations/); + assert.match(route, /legalSources\.get\("courtlistener-us"\)/); + assert.match(route, /courtListener\s*\.fetchDecision/); + assert.match(route, /providerPayload/); +}); + +test("provider status is authenticated and credential-safe", () => { + const route = read("backend/src/routes/legalSources.ts"); + assert.match(route, /legalSourcesRouter\.use\(requireAuth\)/); + assert.match(route, /provider\.health/); + assert.doesNotMatch(route, /res\.json\([^)]*apiToken/s); +}); + +test("A2AJ is an additive Canadian provider with explicit source limitations", () => { + const client = read("backend/src/lib/legalSources/a2ajClient.ts"); + const provider = read("backend/src/lib/legalSources/a2ajProvider.ts"); + const route = read("backend/src/routes/legalSources.ts"); + assert.match(client, /https:\/\/api\.a2aj\.ca/); + assert.match(client, /circuit breaker is open/); + assert.match(provider, /id: "a2aj-canada"/); + assert.match(provider, /fullTextStatus: "unofficial"/); + assert.match(provider, /upstreamLicense/); + assert.match(route, /knownOntarioGaps/); + assert.match(route, /decisions\/:providerId\/:sourceId\/passages/); +}); + +test("official legislation providers retain authority, currency, and reproduction boundaries", () => { + const provider = read("backend/src/lib/legalSources/officialLegislation.ts"); + const types = read("backend/src/lib/legalSources/types.ts"); + const route = read("backend/src/routes/legalSources.ts"); + assert.match(provider, /id: "ontario-elaws"/); + assert.match(provider, /id: "justice-laws-canada"/); + assert.match( + provider, + /raw\.githubusercontent\.com\/justicecanada\/laws-lois-xml/, + ); + assert.match(provider, /allowedHosts/); + assert.match(provider, /historical-version retrieval/); + assert.match(types, /reproductionIsOfficial: false/); + assert.match(types, /currentToDate/); + assert.match(route, /legislation\/:providerId\/:sourceId/); +}); + +test("licensed CanLII access is disabled, entitlement-gated, and non-scraping", () => { + const connector = read("backend/src/lib/legalSources/licensedConnector.ts"); + const environment = read("backend/.env.example"); + assert.match(connector, /id: "canlii-licensed"/); + assert.match(connector, /enabledByDefault: false/); + assert.match(connector, /does not scrape CanLII/); + assert.match(connector, /LicensedConnectorGate/); + assert.match(connector, /CANLII_FULL_TEXT_ENTITLED/); + assert.match(connector, /url\.hostname === "api\.canlii\.org"/); + assert.doesNotMatch(connector, /fetch\(/); + assert.match(environment, /CANLII_CONNECTOR_ENABLED=false/); +}); + +test("Canadian citations separate parsing from provider-backed verification", () => { + const engine = read("backend/src/lib/legalSources/canadianCitations.ts"); + const route = read("backend/src/routes/legalSources.ts"); + assert.match(engine, /parseCanadianCitations/); + assert.match(engine, /renderCanadianCitation/); + assert.match(engine, /verifyCanadianCitations/); + assert.match(engine, /citationVerification: "unverified"/); + assert.match(engine, /passageVerification/); + assert.match(engine, /currencyVerification/); + assert.match(engine, /treatmentVerification/); + assert.match(route, /citations\/canadian\/parse/); + assert.match(route, /citations\/canadian\/verify/); +}); + +test("Ontario defaults are additive and the legacy U.S. feature remains available", () => { + const migration = read( + "backend/migrations/20260716_01_ontario_jurisdiction_settings.sql", + ); + const settings = read("backend/src/lib/userSettings.ts"); + const prompt = read("backend/src/lib/chat/prompts.ts"); + const account = read("frontend/src/app/(pages)/account/features/page.tsx"); + const chat = read("frontend/src/app/components/assistant/ChatInput.tsx"); + assert.match(migration, /default_country text not null default 'CA'/i); + assert.match(migration, /legal_research_us column remains/i); + assert.match(settings, /defaultCountry: "CA"/); + assert.match(settings, /"CA-ON", "CA", "US"/); + assert.match(settings, /courtlistener-us/); + assert.match(prompt, /Default jurisdiction:/); + assert.match(prompt, /retrieve the exact supporting passage/i); + assert.match(prompt, /Do not silently substitute model memory/i); + assert.match(account, /Ontario, Canada/); + assert.match(account, /Federal — Canada/); + assert.match(account, /United States/); + assert.match(chat, /Governing jurisdiction/); +}); + +test("Canadian authority research requires an inspectable retrieved passage", () => { + const tools = read("backend/src/lib/chat/tools/legalSourceTools.ts"); + const dispatcher = read("backend/src/lib/chat/tools/toolDispatcher.ts"); + const panel = read( + "frontend/src/app/components/assistant/LegalAuthorityPanel.tsx", + ); + assert.match(tools, /search_legal_sources/); + assert.match(tools, /fetch_legal_source/); + assert.match(tools, /find_in_legal_source/); + assert.match(tools, /verify_legal_citations/); + assert.match(tools, /exact passage/i); + assert.match(dispatcher, /enabledSourceProviders/); + assert.match(dispatcher, /cleanLegalAuthority/); + assert.match(panel, /Citation verified/); + assert.match(panel, /Passage verified/); + assert.match(panel, /Currency not available/); + assert.match(panel, /Treatment not available/); + assert.match(panel, /No exact passage was retrieved/); +}); + +test("Ontario procedure sources, forms, and deadlines fail safely", () => { + const procedure = read("backend/src/lib/legalSources/ontarioProcedure.ts"); + const route = read("backend/src/routes/legalSources.ts"); + const migration = read( + "backend/migrations/20260716_02_ontario_procedure_sources.sql", + ); + assert.match(procedure, /www\.ontario\.ca/); + assert.match(procedure, /www\.ontariocourts\.ca/); + assert.match(procedure, /copyingPolicy: "link-only"/); + assert.match(procedure, /check-official-current-version/); + assert.match( + procedure, + /This procedural calculation is not a substantive limitation-period opinion/, + ); + assert.match(procedure, /requiresUserConfirmation: true/); + assert.match(route, /procedure\/deadlines\/calculate/); + assert.match(route, /ontarioDeadlineSchema\.safeParse/); + assert.match(migration, /enable row level security/i); + assert.match(migration, /revoke all.*anon, authenticated/i); +}); + +test("Ontario workflow drafts are additive, generated, public, and review-gated", () => { + const generator = read("scripts/build-ross-workflows.mjs"); + const source = JSON.parse(read("workflows/ontario/catalogue.json")); + const routes = read("backend/src/routes/workflows.ts"); + const generated = read("backend/src/lib/rossSystemWorkflows.ts"); + const publicRoute = read("website/app/[...slug]/page.tsx"); + assert.equal(source.length, 5); + assert.ok( + source.every((entry) => entry.status === "draft-awaiting-lawyer-review"), + ); + assert.ok( + source.every( + (entry) => entry.reviewer === null && entry.reviewDate === null, + ), + ); + assert.ok( + source.every((entry) => + entry.syntheticFixture.startsWith("tests/fixtures/workflows/"), + ), + ); + assert.match(generator, /allowedSourceHosts/); + assert.match(generator, /DRAFT — not lawyer-reviewed/); + assert.match(routes, /\.\.\.MIKE_SYSTEM_WORKFLOWS/); + assert.match(routes, /\.\.\.ROSS_SYSTEM_WORKFLOWS/); + assert.match(generated, /builtin-ross-ontario-small-claims-intake/); + assert.match(publicRoute, /ONTARIO_WORKFLOW_CATALOGUE/); + assert.match(publicRoute, /Open in ROSS/); +}); diff --git a/tests/fixtures/legal-sources/a2aj-onca-synthetic.json b/tests/fixtures/legal-sources/a2aj-onca-synthetic.json new file mode 100644 index 000000000..e1830cff9 --- /dev/null +++ b/tests/fixtures/legal-sources/a2aj-onca-synthetic.json @@ -0,0 +1,10 @@ +{ + "synthetic": true, + "dataset": "ONCA", + "citation_en": "2025 ONCA 999", + "name_en": "Synthetic Applicant v. Synthetic Respondent", + "document_date_en": "2025-03-04T00:00:00", + "url_en": "https://example.invalid/official/2025-onca-999", + "unofficial_text_en": "[1] This is a SYNTHETIC decision.\n\n[2] The synthetic housing issue is allowed.", + "upstream_license": "SYNTHETIC TEST LICENCE" +} diff --git a/tests/fixtures/workflows/ontario-affidavit-synthetic.md b/tests/fixtures/workflows/ontario-affidavit-synthetic.md new file mode 100644 index 000000000..e9308d65c --- /dev/null +++ b/tests/fixtures/workflows/ontario-affidavit-synthetic.md @@ -0,0 +1,9 @@ +# SYNTHETIC Ontario affidavit fixture + +Testing only. The deponent and events are fictional. + +1. I am Avery Example, a synthetic operations manager of Alpha Test Corporation. +2. I sent the fictional specification sheet on January 6, 2026. +3. The attached synthetic email is marked Exhibit A. + +Synthetic record note: Exhibit A is dated January 7, 2026 and identifies Blair Test as the sender. This deliberate mismatch is an evaluation case, not a real allegation. diff --git a/tests/fixtures/workflows/ontario-civil-pleadings-synthetic.md b/tests/fixtures/workflows/ontario-civil-pleadings-synthetic.md new file mode 100644 index 000000000..595989b9b --- /dev/null +++ b/tests/fixtures/workflows/ontario-civil-pleadings-synthetic.md @@ -0,0 +1,13 @@ +# SYNTHETIC Ontario civil pleadings fixture + +Testing only. This document contains no real person, client, matter, or confidential information. + +## Synthetic claim + +1. Alpha Test Corporation alleges that Beta Example Limited failed to deliver 100 fictional blue widgets by January 15, 2026. +2. Alpha claims a synthetic invoice amount of CAD 12,345 and fictional consequential loss of CAD 2,000. + +## Synthetic defence + +1. Beta admits the fictional contract but denies a January 15 delivery date. +2. Beta alleges that delivery was conditional on Alpha supplying a synthetic specification sheet and says it was never supplied. diff --git a/tests/fixtures/workflows/ontario-discovery-synthetic.md b/tests/fixtures/workflows/ontario-discovery-synthetic.md new file mode 100644 index 000000000..ad0980003 --- /dev/null +++ b/tests/fixtures/workflows/ontario-discovery-synthetic.md @@ -0,0 +1,8 @@ +# SYNTHETIC Ontario discovery fixture + +Testing only. No real or confidential information appears here. + +- DOC-001: fictional January 5, 2026 email from Avery Example to Blair Test asking for the blue-widget specification. +- DOC-002: exact synthetic duplicate of DOC-001. +- DOC-003: fictional January 12, 2026 internal note. It contains the phrase “ask test counsel” solely to exercise a potential privilege-review flag; it is not labelled privileged. +- DOC-004: fictional invoice for CAD 12,345 with no attachment. diff --git a/tests/fixtures/workflows/ontario-factum-synthetic.md b/tests/fixtures/workflows/ontario-factum-synthetic.md new file mode 100644 index 000000000..aac54b6ab --- /dev/null +++ b/tests/fixtures/workflows/ontario-factum-synthetic.md @@ -0,0 +1,7 @@ +# SYNTHETIC Ontario factum fixture + +Testing only. No real case, citation, quotation, record, or client information is used. + +Paragraph 1 states that a fictional delivery date was January 15, 2026 and cites Synthetic Record page 12. + +Paragraph 2 cites the intentionally synthetic neutral citation `2026 ONCA 9999` for a fictional proposition. The citation must remain unverified unless a provider returns a matching source; the fixture does not represent that such a decision exists. diff --git a/tests/fixtures/workflows/ontario-small-claims-synthetic.md b/tests/fixtures/workflows/ontario-small-claims-synthetic.md new file mode 100644 index 000000000..9b8f12903 --- /dev/null +++ b/tests/fixtures/workflows/ontario-small-claims-synthetic.md @@ -0,0 +1,5 @@ +# SYNTHETIC Ontario Small Claims fixture + +Testing only. No real party, claim, amount, service event, or client information appears here. + +The fictional claimant, Alpha Test, says Beta Example did not pay a CAD 1,234 synthetic invoice dated February 1, 2026. Beta says the fictional service was incomplete. The fixture deliberately omits the service date, court location, official form version, and legal as-of date so the workflow must request them. diff --git a/website/.env.example b/website/.env.example new file mode 100644 index 000000000..19e659d25 --- /dev/null +++ b/website/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_ROSS_WEBSITE_URL=http://localhost:4173 +NEXT_PUBLIC_ROSS_APP_URL=http://localhost:3000 diff --git a/website/.gitignore b/website/.gitignore index c413892c9..e6f9241a4 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -29,6 +29,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/website/app/[...slug]/page.tsx b/website/app/[...slug]/page.tsx index c990c9d33..d7864d88f 100644 --- a/website/app/[...slug]/page.tsx +++ b/website/app/[...slug]/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; +import { ONTARIO_WORKFLOW_CATALOGUE } from "../generated-ontario-workflows"; import { publicPages } from "../page-content"; import { SiteShell } from "../site-shell"; import { siteConfig } from "../site-config"; @@ -7,17 +8,29 @@ import { siteConfig } from "../site-config"; type RouteProps = { params: Promise<{ slug: string[] }> }; function resolvePage(slug: string[]) { - if (slug[0] === "workflows" && slug.length === 2) return publicPages.workflows; + if (slug[0] === "workflows" && slug.length === 2) + return ONTARIO_WORKFLOW_CATALOGUE.some((entry) => entry.slug === slug[1]) + ? publicPages.workflows + : undefined; if (slug[0] === "updates" && slug.length === 2) return publicPages.updates; if (slug.length !== 1) return undefined; return publicPages[slug[0]]; } -export async function generateMetadata({ params }: RouteProps): Promise { +export async function generateMetadata({ + params, +}: RouteProps): Promise { const { slug } = await params; const page = resolvePage(slug); if (!page) return {}; - return { title: page.title, description: page.summary }; + const workflow = + slug[0] === "workflows" + ? ONTARIO_WORKFLOW_CATALOGUE.find((entry) => entry.slug === slug[1]) + : undefined; + return { + title: workflow?.title ?? page.title, + description: workflow?.description ?? page.summary, + }; } export default async function PublicPageRoute({ params }: RouteProps) { @@ -26,6 +39,10 @@ export default async function PublicPageRoute({ params }: RouteProps) { if (!page) notFound(); const isWorkflow = slug[0] === "workflows" && slug.length === 2; + const isWorkflowCatalogue = slug[0] === "workflows" && slug.length === 1; + const workflow = isWorkflow + ? ONTARIO_WORKFLOW_CATALOGUE.find((entry) => entry.slug === slug[1]) + : undefined; const isUpdate = slug[0] === "updates" && slug.length === 2; return ( @@ -33,20 +50,137 @@ export default async function PublicPageRoute({ params }: RouteProps) {

{page.eyebrow}

-

{isWorkflow ? "Workflow preview" : isUpdate ? "Update preview" : page.title}

-

{page.summary}

-
Status

{page.status}

+

+ {workflow?.title ?? (isUpdate ? "Update preview" : page.title)} +

+

+ {workflow?.description ?? page.summary} +

+
+ Status +

+ {workflow + ? "Draft awaiting independent Ontario lawyer review." + : page.status} +

+
- {isWorkflow &&

Catalogue entry

{slug[1].replaceAll("-", " ")}

No public Ontario workflow with this slug is approved yet. Future entries will include jurisdiction, practice area, version, contributor, review date, sources, limitations, and a launch-in-app action.

} - {isUpdate &&

Update entry

{slug[1].replaceAll("-", " ")}

No published update exists at this placeholder route. Material updates will be versioned and dated.

} - {!isWorkflow && !isUpdate && page.sections.map((section, index) =>

{String(index + 1).padStart(2, "0")}

{section.title}

{section.body}

)} + {isWorkflowCatalogue && + ONTARIO_WORKFLOW_CATALOGUE.map((entry, index) => ( +
+

+ {String(index + 1).padStart(2, "0")} · {entry.version} +

+

+ {entry.title} +

+

{entry.description}

+

+ {entry.practice} ·{" "} + {entry.jurisdictions.join(", ")} +

+
+ ))} + {workflow && ( + <> +
+

Scope

+

Inputs and output

+

+ Required:{" "} + {workflow.requiredInputs.join("; ")} +

+

+ Output: {workflow.output} +

+
+
+

Boundaries

+

Excluded uses

+

{workflow.excludedUses.join("; ")}

+
+
+

Sources

+

Primary authority

+ {workflow.primarySources.map((source) => ( +

+ {source.label} +

+ ))} +

{workflow.sourceCurrency}

+
+
+

Review

+

Human checks

+

{workflow.reviewChecklist.join("; ")}

+
+
+

Governance

+

Not yet approved

+

+ Reviewer: not assigned. Review date: not set. Synthetic + fixture: {workflow.syntheticFixture}. +

+
+
+

Application

+

Open the draft

+

Authentication and beta access are required.

+ + Open in ROSS + +
+ + )} + {isUpdate && ( +
+

Update entry

+

{slug[1].replaceAll("-", " ")}

+

+ No published update exists at this placeholder route. Material + updates will be versioned and dated. +

+
+ )} + {!isWorkflow && + !isUpdate && + page.sections.map((section, index) => ( +
+

+ {String(index + 1).padStart(2, "0")} +

+

{section.title}

+

{section.body}

+
+ ))}
-

Content governance

Review status is part of the content.

+
+

Content governance

+

Review status is part of the content.

+

{page.review}

-

Inspect the work

Follow ROSS in the open.

+
+
+
+

Inspect the work

+

Follow ROSS in the open.

+
+ +
+
); diff --git a/website/app/generated-ontario-workflows.ts b/website/app/generated-ontario-workflows.ts new file mode 100644 index 000000000..2d5b90666 --- /dev/null +++ b/website/app/generated-ontario-workflows.ts @@ -0,0 +1,301 @@ +// Generated by scripts/build-ross-workflows.mjs. Do not edit directly. + +export type OntarioWorkflowCatalogueEntry = { + slug: string; + id: string; + title: string; + description: string; + practice: string; + jurisdictions: string[]; + version: string; + status: "draft-awaiting-lawyer-review"; + intendedUsers: string[]; + excludedUses: string[]; + requiredInputs: string[]; + primarySources: { label: string; url: string }[]; + sourceCurrency: string; + output: string; + reviewChecklist: string[]; + reviewer: null; + reviewDate: null; + syntheticFixture: string; + appPath: string; +}; + +export const ONTARIO_WORKFLOW_CATALOGUE: OntarioWorkflowCatalogueEntry[] = [ + { + "slug": "civil-claim-defence-issue-extraction", + "title": "Ontario Civil Claim and Defence Issue Extraction", + "description": "Extract pleaded facts, causes of action, defences, remedies, admissions, denials, and material gaps from Ontario civil pleadings.", + "practice": "Civil Litigation", + "jurisdictions": [ + "Canada / Ontario" + ], + "version": "0.1.0-draft", + "status": "draft-awaiting-lawyer-review", + "intendedUsers": [ + "Ontario lawyers", + "Ontario paralegals acting within their permitted scope" + ], + "excludedUses": [ + "Consumer legal advice", + "Autonomous pleading amendment", + "Limitation-period calculation", + "Use with confidential material during the controlled beta" + ], + "requiredInputs": [ + "Claim and defence", + "Represented party", + "Court file and region", + "Legal as-of date", + "Any relevant orders or endorsements" + ], + "primarySources": [ + { + "label": "Rules of Civil Procedure", + "url": "https://www.ontario.ca/laws/regulation/900194" + }, + { + "label": "Superior Court practice directions", + "url": "https://www.ontariocourts.ca/scj/practice-directions/" + } + ], + "sourceCurrency": "Retrieve and verify the official rule and applicable regional direction at run time.", + "output": "A source-linked issue table and a separate missing-information list.", + "reviewChecklist": [ + "Every finding cites an uploaded pleading location", + "Legal propositions use retrieved primary authority", + "Admissions and allegations remain distinct", + "No deadline or limitation opinion is inferred", + "Regional direction is identified or the gap is visible" + ], + "reviewer": null, + "reviewDate": null, + "syntheticFixture": "tests/fixtures/workflows/ontario-civil-pleadings-synthetic.md", + "id": "builtin-ross-ontario-civil-claim-defence-issue-extraction", + "appPath": "/workflows/assistant/builtin-ross-ontario-civil-claim-defence-issue-extraction" + }, + { + "slug": "documentary-discovery-review", + "title": "Ontario Documentary Discovery Review", + "description": "Review a synthetic document set for relevance, issues, chronology, duplicates, and potential privilege flags without making privilege determinations.", + "practice": "Civil Litigation", + "jurisdictions": [ + "Canada / Ontario" + ], + "version": "0.1.0-draft", + "status": "draft-awaiting-lawyer-review", + "intendedUsers": [ + "Ontario lawyers", + "Ontario paralegals acting within their permitted scope" + ], + "excludedUses": [ + "Final privilege determination", + "Autonomous production decision", + "Destruction or alteration of originals", + "Use with confidential material during the controlled beta" + ], + "requiredInputs": [ + "Synthetic document set", + "Pleadings or issue list", + "Represented party", + "Court file and region", + "Legal as-of date", + "Review protocol" + ], + "primarySources": [ + { + "label": "Rules of Civil Procedure", + "url": "https://www.ontario.ca/laws/regulation/900194" + }, + { + "label": "Superior Court practice directions", + "url": "https://www.ontariocourts.ca/scj/practice-directions/" + } + ], + "sourceCurrency": "Verify Rules 29.1, 30, and 30.1 and applicable directions at run time.", + "output": "A review table with source locations, relevance rationale, privilege-review flags, and unresolved questions.", + "reviewChecklist": [ + "No privilege conclusion is automated", + "Each entry identifies its source document", + "Duplicates and families remain traceable", + "Review scope and date are recorded", + "Human production decision is explicit" + ], + "reviewer": null, + "reviewDate": null, + "syntheticFixture": "tests/fixtures/workflows/ontario-discovery-synthetic.md", + "id": "builtin-ross-ontario-documentary-discovery-review", + "appPath": "/workflows/assistant/builtin-ross-ontario-documentary-discovery-review" + }, + { + "slug": "affidavit-fact-check", + "title": "Ontario Affidavit Fact Check", + "description": "Cross-check a draft Ontario affidavit against supplied records and identify unsupported, inconsistent, ambiguous, or hearsay-sensitive passages.", + "practice": "Civil Litigation", + "jurisdictions": [ + "Canada / Ontario" + ], + "version": "0.1.0-draft", + "status": "draft-awaiting-lawyer-review", + "intendedUsers": [ + "Ontario lawyers", + "Ontario paralegals acting within their permitted scope" + ], + "excludedUses": [ + "Assessing witness credibility", + "Inventing personal knowledge", + "Final admissibility opinion", + "Use with confidential material during the controlled beta" + ], + "requiredInputs": [ + "Draft affidavit", + "Synthetic supporting records", + "Deponent identity and stated knowledge basis", + "Court and region", + "Legal as-of date" + ], + "primarySources": [ + { + "label": "Rules of Civil Procedure", + "url": "https://www.ontario.ca/laws/regulation/900194" + }, + { + "label": "Ontario Evidence Act", + "url": "https://www.ontario.ca/laws/statute/90e23" + }, + { + "label": "Superior Court practice directions", + "url": "https://www.ontariocourts.ca/scj/practice-directions/" + } + ], + "sourceCurrency": "Retrieve relevant official provisions and applicable directions at run time.", + "output": "A paragraph-by-paragraph evidence map, discrepancy list, and questions for the deponent and supervising professional.", + "reviewChecklist": [ + "Every factual comparison cites both locations", + "Absence of evidence is not treated as contradiction", + "Personal knowledge and information-and-belief are separated", + "No credibility conclusion is generated", + "Exhibit references are checked" + ], + "reviewer": null, + "reviewDate": null, + "syntheticFixture": "tests/fixtures/workflows/ontario-affidavit-synthetic.md", + "id": "builtin-ross-ontario-affidavit-fact-check", + "appPath": "/workflows/assistant/builtin-ross-ontario-affidavit-fact-check" + }, + { + "slug": "factum-authority-record-cross-check", + "title": "Ontario Factum Authority and Record Cross-Check", + "description": "Check a draft factum's record references, quoted passages, Canadian citations, and authority support while keeping verification states separate.", + "practice": "Civil Litigation and Appeals", + "jurisdictions": [ + "Canada / Ontario", + "Canada / Federal" + ], + "version": "0.1.0-draft", + "status": "draft-awaiting-lawyer-review", + "intendedUsers": [ + "Ontario lawyers", + "Ontario paralegals acting within their permitted scope" + ], + "excludedUses": [ + "Automated noting-up conclusion", + "Inventing record references", + "Final filing approval", + "Use with confidential material during the controlled beta" + ], + "requiredInputs": [ + "Draft factum", + "Synthetic appeal book or record", + "Book of authorities or authority list", + "Court and region", + "Legal as-of date" + ], + "primarySources": [ + { + "label": "Rules of Civil Procedure", + "url": "https://www.ontario.ca/laws/regulation/900194" + }, + { + "label": "Court of Appeal general practice direction", + "url": "https://www.ontariocourts.ca/coa/how-to-proceed-court/general/" + }, + { + "label": "Court of Appeal civil practice direction", + "url": "https://www.ontariocourts.ca/coa/how-to-proceed-court/practice-directions-guidelines/practice-direction-civil/" + } + ], + "sourceCurrency": "Verify current court directions and retrieve each authority at run time; ordinary search does not establish treatment.", + "output": "A proposition-to-record-and-authority matrix with separate citation, passage, currency, and treatment states.", + "reviewChecklist": [ + "Quoted text matches retrieved passages", + "Record pinpoints resolve", + "Citation and passage verification remain separate", + "Currency and treatment gaps are visible", + "No unsupported proposition is marked verified" + ], + "reviewer": null, + "reviewDate": null, + "syntheticFixture": "tests/fixtures/workflows/ontario-factum-synthetic.md", + "id": "builtin-ross-ontario-factum-authority-record-cross-check", + "appPath": "/workflows/assistant/builtin-ross-ontario-factum-authority-record-cross-check" + }, + { + "slug": "small-claims-intake", + "title": "Ontario Small Claims Claim and Defence Intake", + "description": "Organize a synthetic Small Claims matter into parties, allegations, responses, remedies, documents, procedural questions, and missing facts.", + "practice": "Small Claims Court", + "jurisdictions": [ + "Canada / Ontario" + ], + "version": "0.1.0-draft", + "status": "draft-awaiting-lawyer-review", + "intendedUsers": [ + "Ontario lawyers", + "Ontario paralegals acting within their permitted scope" + ], + "excludedUses": [ + "Consumer-facing legal advice", + "Automatic form filing", + "Limitation-period calculation", + "Use with confidential material during the controlled beta" + ], + "requiredInputs": [ + "Claim, defence, or intake narrative", + "Represented party", + "Synthetic supporting records", + "Court location", + "Legal as-of date", + "Known service information" + ], + "primarySources": [ + { + "label": "Rules of the Small Claims Court", + "url": "https://www.ontario.ca/laws/regulation/980258" + }, + { + "label": "Official Small Claims forms", + "url": "https://www.ontariocourts.ca/scj/filing-procedures/rules/small-claims/" + }, + { + "label": "Superior Court practice directions", + "url": "https://www.ontariocourts.ca/scj/practice-directions/" + } + ], + "sourceCurrency": "Verify the current rules, official form version, monetary jurisdiction, and applicable direction at run time.", + "output": "An intake summary, allegation-response matrix, document list, source-linked procedural questions, and missing-information list.", + "reviewChecklist": [ + "Allegations and evidence remain distinct", + "Current official forms are linked rather than copied", + "No deadline or monetary-limit value is assumed", + "Court location and service facts are confirmed", + "Professional-scope warning remains visible" + ], + "reviewer": null, + "reviewDate": null, + "syntheticFixture": "tests/fixtures/workflows/ontario-small-claims-synthetic.md", + "id": "builtin-ross-ontario-small-claims-intake", + "appPath": "/workflows/assistant/builtin-ross-ontario-small-claims-intake" + } +]; diff --git a/website/app/globals.css b/website/app/globals.css index d51c7ab61..3e8007af4 100644 --- a/website/app/globals.css +++ b/website/app/globals.css @@ -20,189 +20,895 @@ --shadow: 0 26px 70px rgba(16, 42, 67, 0.12); } -* { box-sizing: border-box; } -html { scroll-behavior: smooth; } -body { margin: 0; background: var(--white); color: var(--ink); font-family: var(--font-sans); line-height: 1.6; } -a { color: inherit; } -button, a { -webkit-tap-highlight-color: transparent; } -img { display: block; max-width: 100%; } - -.skip-link { position: fixed; left: 1rem; top: -5rem; z-index: 100; padding: .75rem 1rem; background: var(--white); color: var(--navy); border: 3px solid var(--teal); border-radius: var(--radius-sm); font-weight: 800; } -.skip-link:focus { top: 1rem; } -:focus-visible { outline: 3px solid var(--gold); outline-offset: 4px; } - -.section-wrap { width: min(1180px, calc(100% - 48px)); margin-inline: auto; } -.beta-banner { padding: 8px 24px; background: var(--navy-deep); color: #fff; font-size: 12px; font-weight: 750; letter-spacing: .07em; text-align: center; text-transform: uppercase; } -.site-header { position: sticky; top: 0; z-index: 40; background: rgba(255,255,255,.94); border-bottom: 1px solid rgba(203,216,223,.75); backdrop-filter: blur(16px); } -.header-inner { min-height: 78px; display: flex; align-items: center; gap: 32px; } -.brand { display: inline-flex; align-items: center; gap: 11px; text-decoration: none; font-weight: 900; font-size: 20px; letter-spacing: .04em; line-height: 1; } -.brand img { border-radius: 9px; } -.brand span { display: grid; gap: 5px; } -.brand small { font-size: 8px; font-weight: 800; letter-spacing: .22em; color: var(--teal-dark); text-transform: uppercase; } -.site-header nav { display: flex; align-items: center; gap: 26px; margin-left: auto; } -.site-header nav a, .github-link, .login-link { color: #314d60; text-decoration: none; font-size: 14px; font-weight: 700; } -.site-header nav a:hover, .github-link:hover, .login-link:hover { color: var(--teal-dark); } -.header-actions { display: flex; align-items: center; gap: 18px; } - -.button { min-height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 12px 20px; border: 1px solid transparent; border-radius: 999px; font-weight: 800; text-decoration: none; cursor: pointer; } -.button.primary, .small-button { background: var(--teal-dark); color: #fff; box-shadow: 0 10px 28px rgba(15,139,141,.2); } -.button.primary:hover, .small-button:hover { background: #045d5f; transform: translateY(-1px); } -.button.secondary { background: #fff; border-color: var(--line); color: var(--navy); } -.button.secondary:hover { border-color: var(--navy); } -.small-button { min-height: 40px; padding: 8px 16px; font-size: 13px; } - -.hero { min-height: 690px; padding-block: 88px 78px; display: grid; grid-template-columns: 1.05fr .95fr; gap: 74px; align-items: center; } -.status-pill { width: fit-content; display: flex; align-items: center; gap: 9px; margin: 0 0 24px; padding: 8px 13px; border: 1px solid #b9d9d9; border-radius: 999px; background: #effafa; color: #175d60; font-size: 12px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; } -.status-pill span { width: 8px; height: 8px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 0 4px rgba(15,139,141,.12); } -h1, h2, h3, p { text-wrap: pretty; } -.hero h1 { max-width: 690px; margin: 0; font-family: var(--font-display); font-size: clamp(52px, 6vw, 82px); font-weight: 500; line-height: .98; letter-spacing: -.045em; } -.hero h1 em { color: var(--teal-dark); font-weight: 500; } -.hero-lede { max-width: 650px; margin: 28px 0 0; color: var(--muted); font-size: 19px; line-height: 1.72; } -.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 32px; } -.hero-note { max-width: 620px; margin: 22px 0 0; padding-left: 16px; border-left: 3px solid var(--gold); color: #5a6d79; font-size: 13px; } - -.source-window { overflow: hidden; border: 1px solid #c6d7de; border-radius: var(--radius-lg); background: #fff; box-shadow: var(--shadow); transform: rotate(1.2deg); } -.window-bar { min-height: 48px; display: flex; align-items: center; gap: 7px; padding: 0 17px; border-bottom: 1px solid var(--line); background: var(--ice); } -.window-bar span { width: 9px; height: 9px; border-radius: 50%; background: #bfd0d8; } -.window-bar span:first-child { background: #d9ad4b; } -.window-bar p { margin: 0 0 0 auto; color: #718591; font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } -.source-body { padding: 38px; } -.eyebrow { margin: 0 0 13px; color: var(--teal-dark); font-size: 12px; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; } -.source-body h2 { margin: 0; font-family: var(--font-display); font-size: 35px; line-height: 1.12; font-weight: 500; letter-spacing: -.025em; } -.source-list { display: grid; gap: 0; margin: 28px 0; padding: 0; list-style: none; border-top: 1px solid var(--line); } -.source-list li { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; align-items: center; padding: 16px 0; border-bottom: 1px solid var(--line); color: #314d60; font-size: 13px; font-weight: 700; } -.source-list strong { grid-column: 2; width: fit-content; padding: 3px 8px; border-radius: 999px; background: var(--ice); color: #687d89; font-size: 9px; letter-spacing: .1em; text-transform: uppercase; } -.source-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--gold); } -.source-dot.inherited { background: var(--teal); } -.text-link, .card-link { color: var(--teal-dark); font-weight: 850; text-underline-offset: 4px; } - -.trust-strip { border-block: 1px solid var(--line); background: var(--ice); } -.trust-grid { display: grid; grid-template-columns: repeat(4, 1fr); } -.trust-grid p { margin: 0; padding: 25px 28px; border-right: 1px solid var(--line); color: #617682; font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } -.trust-grid p:first-child { border-left: 1px solid var(--line); } -.trust-grid span { display: block; margin-bottom: 5px; color: var(--teal-dark); } -.trust-grid strong { color: var(--navy); font-size: 14px; letter-spacing: 0; text-transform: none; } - -.split-section { padding-block: 120px 90px; display: grid; grid-template-columns: 1fr .78fr; gap: 110px; align-items: start; } -.split-section h2, .section-heading h2, .deployment-copy h2, .cta-inner h2, .page-hero h1, .review-panel h2, .page-cta h2 { margin: 0; font-family: var(--font-display); font-weight: 500; letter-spacing: -.035em; line-height: 1.08; } -.split-section h2 { max-width: 720px; font-size: clamp(40px, 4.6vw, 62px); } -.principle-copy { padding-top: 26px; } -.principle-copy p { margin: 0 0 24px; color: var(--muted); font-size: 17px; line-height: 1.8; } - -.feature-grid { padding-bottom: 120px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; } -.section-heading { grid-column: 1 / -1; max-width: 720px; margin-bottom: 30px; } -.section-heading h2 { font-size: clamp(38px, 4vw, 54px); } -.feature-card { min-height: 420px; display: flex; flex-direction: column; padding: 32px; border-radius: var(--radius-md); } -.feature-card h3 { margin: 42px 0 16px; font-family: var(--font-display); font-size: 31px; font-weight: 500; line-height: 1.12; } -.feature-card p { font-size: 15px; } -.feature-card .card-number { margin: 0; font-size: 11px; font-weight: 900; letter-spacing: .12em; } -.feature-card ul { display: grid; gap: 8px; margin: 22px 0 0; padding: 0; list-style: none; font-size: 12px; font-weight: 750; } -.feature-card li::before { content: "—"; margin-right: 8px; color: var(--gold); } -.navy-card { background: var(--navy); color: #fff; } -.navy-card p { color: #c4d3dc; } -.teal-card { background: var(--teal-dark); color: #fff; } -.teal-card p { color: #d4eded; } -.light-card { border: 1px solid var(--line); background: var(--ice); } -.light-card p { color: var(--muted); } -.card-link { margin-top: auto; color: inherit; } - -.deployment-section { margin-bottom: 120px; padding: 70px; display: grid; grid-template-columns: .88fr 1.12fr; gap: 70px; border-radius: var(--radius-lg); background: #eaf5f3; } -.deployment-copy h2 { font-size: clamp(38px, 4vw, 54px); } -.deployment-copy > p:last-child { color: var(--muted); } -.deployment-options { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } -.deployment-options article { display: flex; flex-direction: column; min-height: 310px; padding: 28px; border: 1px solid #c6dedc; border-radius: var(--radius-md); background: rgba(255,255,255,.8); } -.deployment-options h3 { margin: 20px 0 10px; font-family: var(--font-display); font-size: 29px; font-weight: 500; } -.deployment-options p { color: var(--muted); font-size: 14px; } -.deployment-options a { margin-top: auto; color: var(--teal-dark); font-size: 13px; font-weight: 850; } -.option-label { margin: 0; color: var(--teal-dark) !important; font-size: 10px !important; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; } - -.final-cta, .page-cta { background: var(--navy); color: #fff; } -.cta-inner, .page-cta .section-wrap { padding-block: 80px; display: flex; align-items: center; justify-content: space-between; gap: 60px; } -.cta-inner > div:first-child { max-width: 710px; } -.cta-inner h2, .page-cta h2 { font-size: clamp(38px, 4.3vw, 58px); } -.cta-inner p:not(.eyebrow) { color: #c7d5dd; } -.final-cta .eyebrow, .page-cta .eyebrow { color: #72cfd0; } -.light-button { background: #fff; color: var(--navy); } -.outline-button { border-color: #78909e; color: #fff; } - -.site-footer { padding-top: 75px; background: var(--navy-deep); color: #fff; } -.footer-grid { display: grid; grid-template-columns: 1.6fr repeat(4, 1fr); gap: 40px; } -.brand.inverse small { color: #72cfd0; } -.footer-brand > p { max-width: 290px; color: #bdcbd3; } -.footer-boundary { font-size: 12px; } -.footer-group { display: flex; flex-direction: column; gap: 10px; } -.footer-group h2 { margin: 4px 0 10px; color: #75cfd0; font-size: 11px; letter-spacing: .14em; text-transform: uppercase; } -.footer-group a { color: #d5dfe4; font-size: 13px; text-decoration: none; } -.footer-group a:hover { color: #fff; text-decoration: underline; } -.footer-bottom { margin-top: 60px; padding-block: 22px; display: flex; justify-content: space-between; gap: 40px; border-top: 1px solid #2f485b; color: #91a5b1; font-size: 10px; } -.footer-bottom p { max-width: 620px; margin: 0; } - -.page-main { background: var(--white); } -.page-hero { padding-block: 95px 70px; } -.page-hero h1 { max-width: 880px; font-size: clamp(54px, 7vw, 88px); } -.page-summary { max-width: 760px; margin: 28px 0 34px; color: var(--muted); font-size: 20px; line-height: 1.7; } -.status-panel { max-width: 760px; display: grid; grid-template-columns: 100px 1fr; gap: 18px; padding: 18px 20px; border: 1px solid #bcd7d6; border-radius: var(--radius-sm); background: #f0f9f8; } -.status-panel span { color: var(--teal-dark); font-size: 10px; font-weight: 900; letter-spacing: .12em; text-transform: uppercase; } -.status-panel p { margin: 0; color: #385d62; font-size: 13px; } -.page-sections { padding-block: 15px 100px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; } -.page-sections article { min-height: 250px; padding: 30px; border-top: 3px solid var(--teal); background: var(--ice); } -.section-index { margin: 0; color: var(--teal-dark) !important; font-size: 11px !important; font-weight: 900; letter-spacing: .12em; } -.page-sections h2 { margin: 35px 0 13px; font-family: var(--font-display); font-size: 29px; font-weight: 500; line-height: 1.15; } -.page-sections p { color: var(--muted); font-size: 14px; } -.review-panel { margin-bottom: 100px; padding: 48px; display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; border: 1px solid var(--line); border-radius: var(--radius-md); } -.review-panel h2 { font-size: 38px; } -.review-panel > p { color: var(--muted); } -.page-cta .section-wrap { padding-block: 60px; } -.page-cta h2 { font-size: 44px; } -.center-page { min-height: 70vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px; text-align: center; } -.center-page h1 { max-width: 760px; margin: 0; font-family: var(--font-display); font-size: 54px; font-weight: 500; } -.center-page > p:not(.eyebrow) { max-width: 620px; margin: 18px 0 28px; color: var(--muted); } +* { + box-sizing: border-box; +} +html { + scroll-behavior: smooth; +} +body { + margin: 0; + background: var(--white); + color: var(--ink); + font-family: var(--font-sans); + line-height: 1.6; +} +a { + color: inherit; +} +button, +a { + -webkit-tap-highlight-color: transparent; +} +img { + display: block; + max-width: 100%; +} + +.skip-link { + position: fixed; + left: 1rem; + top: -5rem; + z-index: 100; + padding: 0.75rem 1rem; + background: var(--white); + color: var(--navy); + border: 3px solid var(--teal); + border-radius: var(--radius-sm); + font-weight: 800; +} +.skip-link:focus { + top: 1rem; +} +:focus-visible { + outline: 3px solid var(--gold); + outline-offset: 4px; +} + +.section-wrap { + width: min(1180px, calc(100% - 48px)); + margin-inline: auto; +} +.beta-banner { + padding: 8px 24px; + background: var(--navy-deep); + color: #fff; + font-size: 12px; + font-weight: 750; + letter-spacing: 0.07em; + text-align: center; + text-transform: uppercase; +} +.site-header { + position: sticky; + top: 0; + z-index: 40; + background: rgba(255, 255, 255, 0.94); + border-bottom: 1px solid rgba(203, 216, 223, 0.75); + backdrop-filter: blur(16px); +} +.header-inner { + min-height: 78px; + display: flex; + align-items: center; + gap: 32px; +} +.brand { + display: inline-flex; + align-items: center; + gap: 11px; + text-decoration: none; + font-weight: 900; + font-size: 20px; + letter-spacing: 0.04em; + line-height: 1; +} +.brand img { + border-radius: 9px; +} +.brand span { + display: grid; + gap: 5px; +} +.brand small { + font-size: 8px; + font-weight: 800; + letter-spacing: 0.22em; + color: var(--teal-dark); + text-transform: uppercase; +} +.site-header nav { + display: flex; + align-items: center; + gap: 26px; + margin-left: auto; +} +.site-header nav a, +.github-link, +.login-link { + color: #314d60; + text-decoration: none; + font-size: 14px; + font-weight: 700; +} +.site-header nav a:hover, +.github-link:hover, +.login-link:hover { + color: var(--teal-dark); +} +.header-actions { + display: flex; + align-items: center; + gap: 18px; +} + +.button { + min-height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 20px; + border: 1px solid transparent; + border-radius: 999px; + font-weight: 800; + text-decoration: none; + cursor: pointer; +} +.button.primary, +.small-button { + background: var(--teal-dark); + color: #fff; + box-shadow: 0 10px 28px rgba(15, 139, 141, 0.2); +} +.button.primary:hover, +.small-button:hover { + background: #045d5f; + transform: translateY(-1px); +} +.button.secondary { + background: #fff; + border-color: var(--line); + color: var(--navy); +} +.button.secondary:hover { + border-color: var(--navy); +} +.small-button { + min-height: 40px; + padding: 8px 16px; + font-size: 13px; +} + +.hero { + min-height: 690px; + padding-block: 88px 78px; + display: grid; + grid-template-columns: 1.05fr 0.95fr; + gap: 74px; + align-items: center; +} +.status-pill { + width: fit-content; + display: flex; + align-items: center; + gap: 9px; + margin: 0 0 24px; + padding: 8px 13px; + border: 1px solid #b9d9d9; + border-radius: 999px; + background: #effafa; + color: #175d60; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.status-pill span { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--teal); + box-shadow: 0 0 0 4px rgba(15, 139, 141, 0.12); +} +h1, +h2, +h3, +p { + text-wrap: pretty; +} +.hero h1 { + max-width: 690px; + margin: 0; + font-family: var(--font-display); + font-size: clamp(52px, 6vw, 82px); + font-weight: 500; + line-height: 0.98; + letter-spacing: -0.045em; +} +.hero h1 em { + color: var(--teal-dark); + font-weight: 500; +} +.hero-lede { + max-width: 650px; + margin: 28px 0 0; + color: var(--muted); + font-size: 19px; + line-height: 1.72; +} +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 32px; +} +.hero-note { + max-width: 620px; + margin: 22px 0 0; + padding-left: 16px; + border-left: 3px solid var(--gold); + color: #5a6d79; + font-size: 13px; +} + +.source-window { + overflow: hidden; + border: 1px solid #c6d7de; + border-radius: var(--radius-lg); + background: #fff; + box-shadow: var(--shadow); + transform: rotate(1.2deg); +} +.window-bar { + min-height: 48px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 17px; + border-bottom: 1px solid var(--line); + background: var(--ice); +} +.window-bar span { + width: 9px; + height: 9px; + border-radius: 50%; + background: #bfd0d8; +} +.window-bar span:first-child { + background: #d9ad4b; +} +.window-bar p { + margin: 0 0 0 auto; + color: #718591; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.source-body { + padding: 38px; +} +.eyebrow { + margin: 0 0 13px; + color: var(--teal-dark); + font-size: 12px; + font-weight: 900; + letter-spacing: 0.16em; + text-transform: uppercase; +} +.source-body h2 { + margin: 0; + font-family: var(--font-display); + font-size: 35px; + line-height: 1.12; + font-weight: 500; + letter-spacing: -0.025em; +} +.source-list { + display: grid; + gap: 0; + margin: 28px 0; + padding: 0; + list-style: none; + border-top: 1px solid var(--line); +} +.source-list li { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 10px; + align-items: center; + padding: 16px 0; + border-bottom: 1px solid var(--line); + color: #314d60; + font-size: 13px; + font-weight: 700; +} +.source-list strong { + grid-column: 2; + width: fit-content; + padding: 3px 8px; + border-radius: 999px; + background: var(--ice); + color: #687d89; + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.source-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--gold); +} +.source-dot.inherited { + background: var(--teal); +} +.text-link, +.card-link { + color: var(--teal-dark); + font-weight: 850; + text-underline-offset: 4px; +} + +.trust-strip { + border-block: 1px solid var(--line); + background: var(--ice); +} +.trust-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); +} +.trust-grid p { + margin: 0; + padding: 25px 28px; + border-right: 1px solid var(--line); + color: #617682; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.trust-grid p:first-child { + border-left: 1px solid var(--line); +} +.trust-grid span { + display: block; + margin-bottom: 5px; + color: var(--teal-dark); +} +.trust-grid strong { + color: var(--navy); + font-size: 14px; + letter-spacing: 0; + text-transform: none; +} + +.split-section { + padding-block: 120px 90px; + display: grid; + grid-template-columns: 1fr 0.78fr; + gap: 110px; + align-items: start; +} +.split-section h2, +.section-heading h2, +.deployment-copy h2, +.cta-inner h2, +.page-hero h1, +.review-panel h2, +.page-cta h2 { + margin: 0; + font-family: var(--font-display); + font-weight: 500; + letter-spacing: -0.035em; + line-height: 1.08; +} +.split-section h2 { + max-width: 720px; + font-size: clamp(40px, 4.6vw, 62px); +} +.principle-copy { + padding-top: 26px; +} +.principle-copy p { + margin: 0 0 24px; + color: var(--muted); + font-size: 17px; + line-height: 1.8; +} + +.feature-grid { + padding-bottom: 120px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 18px; +} +.section-heading { + grid-column: 1 / -1; + max-width: 720px; + margin-bottom: 30px; +} +.section-heading h2 { + font-size: clamp(38px, 4vw, 54px); +} +.feature-card { + min-height: 420px; + display: flex; + flex-direction: column; + padding: 32px; + border-radius: var(--radius-md); +} +.feature-card h3 { + margin: 42px 0 16px; + font-family: var(--font-display); + font-size: 31px; + font-weight: 500; + line-height: 1.12; +} +.feature-card p { + font-size: 15px; +} +.feature-card .card-number { + margin: 0; + font-size: 11px; + font-weight: 900; + letter-spacing: 0.12em; +} +.feature-card ul { + display: grid; + gap: 8px; + margin: 22px 0 0; + padding: 0; + list-style: none; + font-size: 12px; + font-weight: 750; +} +.feature-card li::before { + content: "—"; + margin-right: 8px; + color: var(--gold); +} +.navy-card { + background: var(--navy); + color: #fff; +} +.navy-card p { + color: #c4d3dc; +} +.teal-card { + background: var(--teal-dark); + color: #fff; +} +.teal-card p { + color: #d4eded; +} +.light-card { + border: 1px solid var(--line); + background: var(--ice); +} +.light-card p { + color: var(--muted); +} +.card-link { + margin-top: auto; + color: inherit; +} + +.deployment-section { + margin-bottom: 120px; + padding: 70px; + display: grid; + grid-template-columns: 0.88fr 1.12fr; + gap: 70px; + border-radius: var(--radius-lg); + background: #eaf5f3; +} +.deployment-copy h2 { + font-size: clamp(38px, 4vw, 54px); +} +.deployment-copy > p:last-child { + color: var(--muted); +} +.deployment-options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} +.deployment-options article { + display: flex; + flex-direction: column; + min-height: 310px; + padding: 28px; + border: 1px solid #c6dedc; + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.8); +} +.deployment-options h3 { + margin: 20px 0 10px; + font-family: var(--font-display); + font-size: 29px; + font-weight: 500; +} +.deployment-options p { + color: var(--muted); + font-size: 14px; +} +.deployment-options a { + margin-top: auto; + color: var(--teal-dark); + font-size: 13px; + font-weight: 850; +} +.option-label { + margin: 0; + color: var(--teal-dark) !important; + font-size: 10px !important; + font-weight: 900; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.final-cta, +.page-cta { + background: var(--navy); + color: #fff; +} +.cta-inner, +.page-cta .section-wrap { + padding-block: 80px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 60px; +} +.cta-inner > div:first-child { + max-width: 710px; +} +.cta-inner h2, +.page-cta h2 { + font-size: clamp(38px, 4.3vw, 58px); +} +.cta-inner p:not(.eyebrow) { + color: #c7d5dd; +} +.final-cta .eyebrow, +.page-cta .eyebrow { + color: #72cfd0; +} +.light-button { + background: #fff; + color: var(--navy); +} +.outline-button { + border-color: #78909e; + color: #fff; +} + +.site-footer { + padding-top: 75px; + background: var(--navy-deep); + color: #fff; +} +.footer-grid { + display: grid; + grid-template-columns: 1.6fr repeat(4, 1fr); + gap: 40px; +} +.brand.inverse small { + color: #72cfd0; +} +.footer-brand > p { + max-width: 290px; + color: #bdcbd3; +} +.footer-boundary { + font-size: 12px; +} +.footer-group { + display: flex; + flex-direction: column; + gap: 10px; +} +.footer-group h2 { + margin: 4px 0 10px; + color: #75cfd0; + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; +} +.footer-group a { + color: #d5dfe4; + font-size: 13px; + text-decoration: none; +} +.footer-group a:hover { + color: #fff; + text-decoration: underline; +} +.footer-bottom { + margin-top: 60px; + padding-block: 22px; + display: flex; + justify-content: space-between; + gap: 40px; + border-top: 1px solid #2f485b; + color: #91a5b1; + font-size: 10px; +} +.footer-bottom p { + max-width: 620px; + margin: 0; +} + +.page-main { + background: var(--white); +} +.page-hero { + padding-block: 95px 70px; +} +.page-hero h1 { + max-width: 880px; + font-size: clamp(54px, 7vw, 88px); +} +.page-summary { + max-width: 760px; + margin: 28px 0 34px; + color: var(--muted); + font-size: 20px; + line-height: 1.7; +} +.status-panel { + max-width: 760px; + display: grid; + grid-template-columns: 100px 1fr; + gap: 18px; + padding: 18px 20px; + border: 1px solid #bcd7d6; + border-radius: var(--radius-sm); + background: #f0f9f8; +} +.status-panel span { + color: var(--teal-dark); + font-size: 10px; + font-weight: 900; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.status-panel p { + margin: 0; + color: #385d62; + font-size: 13px; +} +.page-sections { + padding-block: 15px 100px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 18px; +} +.page-sections article { + min-height: 250px; + padding: 30px; + border-top: 3px solid var(--teal); + background: var(--ice); +} +.section-index { + margin: 0; + color: var(--teal-dark) !important; + font-size: 11px !important; + font-weight: 900; + letter-spacing: 0.12em; +} +.page-sections h2 { + margin: 35px 0 13px; + font-family: var(--font-display); + font-size: 29px; + font-weight: 500; + line-height: 1.15; +} +.page-sections h2 a { + color: inherit; + text-decoration-color: var(--teal); + text-underline-offset: 5px; +} +.page-sections p { + color: var(--muted); + font-size: 14px; +} +.review-panel { + margin-bottom: 100px; + padding: 48px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 60px; + align-items: center; + border: 1px solid var(--line); + border-radius: var(--radius-md); +} +.review-panel h2 { + font-size: 38px; +} +.review-panel > p { + color: var(--muted); +} +.page-cta .section-wrap { + padding-block: 60px; +} +.page-cta h2 { + font-size: 44px; +} +.center-page { + min-height: 70vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px; + text-align: center; +} +.center-page h1 { + max-width: 760px; + margin: 0; + font-family: var(--font-display); + font-size: 54px; + font-weight: 500; +} +.center-page > p:not(.eyebrow) { + max-width: 620px; + margin: 18px 0 28px; + color: var(--muted); +} @media (max-width: 1040px) { - .site-header nav { display: none; } - .github-link { display: none; } - .header-actions { margin-left: auto; } - .hero { grid-template-columns: 1fr; padding-top: 64px; } - .source-window { max-width: 680px; transform: none; } - .split-section, .deployment-section { grid-template-columns: 1fr; gap: 40px; } - .feature-grid { grid-template-columns: 1fr 1fr; } - .section-heading { grid-column: 1 / -1; } - .feature-card:last-child { grid-column: 1 / -1; min-height: 300px; } - .footer-grid { grid-template-columns: 2fr repeat(2, 1fr); } - .footer-brand { grid-row: span 2; } + .site-header nav { + display: none; + } + .github-link { + display: none; + } + .header-actions { + margin-left: auto; + } + .hero { + grid-template-columns: 1fr; + padding-top: 64px; + } + .source-window { + max-width: 680px; + transform: none; + } + .split-section, + .deployment-section { + grid-template-columns: 1fr; + gap: 40px; + } + .feature-grid { + grid-template-columns: 1fr 1fr; + } + .section-heading { + grid-column: 1 / -1; + } + .feature-card:last-child { + grid-column: 1 / -1; + min-height: 300px; + } + .footer-grid { + grid-template-columns: 2fr repeat(2, 1fr); + } + .footer-brand { + grid-row: span 2; + } } @media (max-width: 720px) { - .section-wrap { width: min(100% - 30px, 1180px); } - .beta-banner { font-size: 9px; } - .header-inner { min-height: 68px; } - .header-actions .login-link { display: none; } - .small-button { font-size: 11px; } - .hero { min-height: auto; padding-block: 52px; gap: 46px; } - .hero h1 { font-size: clamp(45px, 14vw, 64px); } - .hero-lede { font-size: 16px; } - .source-body { padding: 26px; } - .trust-grid { grid-template-columns: 1fr 1fr; } - .trust-grid p { border-bottom: 1px solid var(--line); } - .split-section { padding-block: 80px 65px; gap: 20px; } - .feature-grid, .deployment-options, .page-sections { grid-template-columns: 1fr; } - .feature-card:last-child { grid-column: auto; } - .deployment-section { width: 100%; margin-bottom: 80px; padding: 55px 24px; border-radius: 0; } - .cta-inner, .page-cta .section-wrap { align-items: flex-start; flex-direction: column; gap: 15px; } - .footer-grid { grid-template-columns: 1fr 1fr; } - .footer-brand { grid-column: 1 / -1; grid-row: auto; margin-bottom: 20px; } - .footer-bottom { flex-direction: column; } - .page-hero { padding-block: 68px 45px; } - .page-hero h1 { font-size: 50px; } - .status-panel { grid-template-columns: 1fr; } - .review-panel { padding: 30px; grid-template-columns: 1fr; gap: 18px; } + .section-wrap { + width: min(100% - 30px, 1180px); + } + .beta-banner { + font-size: 9px; + } + .header-inner { + min-height: 68px; + } + .header-actions .login-link { + display: none; + } + .small-button { + font-size: 11px; + } + .hero { + min-height: auto; + padding-block: 52px; + gap: 46px; + } + .hero h1 { + font-size: clamp(45px, 14vw, 64px); + } + .hero-lede { + font-size: 16px; + } + .source-body { + padding: 26px; + } + .trust-grid { + grid-template-columns: 1fr 1fr; + } + .trust-grid p { + border-bottom: 1px solid var(--line); + } + .split-section { + padding-block: 80px 65px; + gap: 20px; + } + .feature-grid, + .deployment-options, + .page-sections { + grid-template-columns: 1fr; + } + .feature-card:last-child { + grid-column: auto; + } + .deployment-section { + width: 100%; + margin-bottom: 80px; + padding: 55px 24px; + border-radius: 0; + } + .cta-inner, + .page-cta .section-wrap { + align-items: flex-start; + flex-direction: column; + gap: 15px; + } + .footer-grid { + grid-template-columns: 1fr 1fr; + } + .footer-brand { + grid-column: 1 / -1; + grid-row: auto; + margin-bottom: 20px; + } + .footer-bottom { + flex-direction: column; + } + .page-hero { + padding-block: 68px 45px; + } + .page-hero h1 { + font-size: 50px; + } + .status-panel { + grid-template-columns: 1fr; + } + .review-panel { + padding: 30px; + grid-template-columns: 1fr; + gap: 18px; + } } @media (prefers-reduced-motion: reduce) { - *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .001ms !important; animation-duration: .001ms !important; animation-iteration-count: 1 !important; } + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.001ms !important; + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + } } @media (forced-colors: active) { - .button, .status-pill, .status-panel, .feature-card, .source-window { border: 2px solid ButtonText; } - .source-dot, .status-pill span { forced-color-adjust: none; background: Highlight; } + .button, + .status-pill, + .status-panel, + .feature-card, + .source-window { + border: 2px solid ButtonText; + } + .source-dot, + .status-pill span { + forced-color-adjust: none; + background: Highlight; + } } diff --git a/website/app/page-content.ts b/website/app/page-content.ts index a4543cb61..4138eb892 100644 --- a/website/app/page-content.ts +++ b/website/app/page-content.ts @@ -8,21 +8,325 @@ export type PublicPage = { }; export const publicPages: Record = { - ontario: { title: "Ontario and Canadian capability", eyebrow: "Jurisdiction plan", summary: "ROSS will add verified Ontario and binding federal primary law without removing inherited U.S. research.", status: "Source integrations are planned, not yet live.", review: "Legal-content owner: TBD · Review required before coverage claims", sections: [{ title: "Planned foundation", body: "Provider-neutral records will separate jurisdiction, court, authority level, document type, source version, provider, canonical URL, retrieval time, and verification state." }, { title: "Authorized sources", body: "The approved direction is A2AJ, official government and court sources, and negotiated or licensed providers. ROSS will not scrape CanLII contrary to its terms or without permission." }, { title: "Visible gaps", body: "Coverage for ONSC, ONCJ, Small Claims Court, tribunals, historical material, noting-up, and source currency must be measured and displayed before being represented as supported." }] }, - features: { title: "A complete legal workspace", eyebrow: "Preserved Mike capabilities", summary: "ROSS keeps the inherited assistant, project, document, tabular-review, workflow, sharing, connector, and self-hosting surfaces under regression protection.", status: "Ontario-specific defaults and research are future packages.", review: "Product owner: TBD · Last reviewed 2026-07-15", sections: [{ title: "Work with matters and documents", body: "Organize projects, upload and preview documents, draft and edit, track document versions, and export generated work." }, { title: "Structured review", body: "Use assistant workflows, reusable workflows, and tabular review while retaining citations and matter context." }, { title: "Deploy on your terms", body: "The AGPL codebase supports self-hosting. Each deployer remains responsible for security, privacy, providers, storage, legal content, backups, and operations." }] }, - workflows: { title: "Ontario workflow catalogue", eyebrow: "Public catalogue", summary: "Only approved, non-confidential, versioned workflows with an identified contributor and review record will appear here.", status: "No Ontario workflows are approved for public use yet.", review: "Workflow owner: TBD · Catalogue review pending", sections: [{ title: "Catalogue requirements", body: "Each workflow will state jurisdiction, practice area, version, contributor, review date, sources, limitations, and a synthetic example." }, { title: "Current availability", body: "The inherited workflow engine is preserved. Ontario workflow content will be added only after substantive review and evaluation." }] }, - coverage: { title: "Source coverage", eyebrow: "Machine-readable status planned", summary: "Coverage must describe what ROSS can retrieve and verify without implying completeness, official status, or currentness that the evidence does not support.", status: "Foundation only — coverage registry not implemented.", review: "Legal-content and technical owners: TBD · Review before source launch", sections: [{ title: "Decisions", body: "CourtListener remains available for optional U.S. research. A2AJ, official sources, and licensed providers are the approved Ontario and Canadian direction." }, { title: "Legislation", body: "Ontario e-Laws and federal Justice Laws sources are planned with version, currency, consolidation, amendment, and canonical-link metadata." }, { title: "Decisions and noting-up", body: "Decision availability and citator coverage vary by court and provider. Missing coverage must be explicit, and noting-up must never be inferred from ordinary search results." }] }, - "open-source": { title: "Open source and self-hosting", eyebrow: "AGPL-3.0", summary: "ROSS is an independently developed modified fork of Mike. Its source and architecture decisions are public.", status: "Self-hosting documentation and production runbooks remain in development.", review: "Release owner: TBD · Licence review required before public service", sections: [{ title: "Corresponding source", body: "Every ROSS network deployment must provide a working link to the corresponding source and preserve applicable copyright and licence notices." }, { title: "Deployment responsibility", body: "Self-hosters choose and operate authentication, database, storage, model providers, email, conversion, monitoring, backups, retention, and incident response." }, { title: "Upstream attribution", body: "ROSS preserves Mike functionality and credits the upstream project without implying endorsement or affiliation." }] }, - security: { title: "Security", eyebrow: "Controls before claims", summary: "ROSS is not yet approved for real confidential or privileged client material in an operator-hosted service.", status: "Threat model, privacy assessment, retention schedule, vendor inventory, and independent review remain open.", review: "Security owner: TBD · Technical and legal review required", sections: [{ title: "Current beta boundary", body: "Development, demos, previews, and hosted beta use synthetic or non-confidential materials only. This applies to documents, prompts, logs, screenshots, support, and analytics." }, { title: "Architecture direction", body: "The inherited application uses authenticated API, database, object storage, model-provider, conversion, and email services. Production regions and vendors are not yet approved." }, { title: "Report safely", body: "Do not open public vulnerability details. Private GitHub vulnerability reporting is the current security channel." }] }, - privacy: { title: "Privacy notice — placeholder", eyebrow: "Legal review required", summary: "This scaffold does not collect form submissions, run analytics, authenticate visitors, or load application data.", status: "Not a production privacy notice.", review: "Privacy owner and operator: TBD · Effective date not set", sections: [{ title: "Public website", body: "The scaffold serves public content only. Hosting-level request logs may still exist and must be documented before publication." }, { title: "Hosted application", body: "The planned invitation-only beta is limited to synthetic or non-confidential material. A complete data inventory and retention schedule are required before launch." }] }, - terms: { title: "Terms of service — placeholder", eyebrow: "Legal review required", summary: "No hosted-service terms have been approved and this page does not create a production service offering.", status: "Operator, service scope, jurisdiction, support, suspension, liability, and change terms remain open.", review: "Operator and legal reviewer: TBD", sections: [{ title: "Current effect", body: "The repository is available under its open-source licence. Separate terms will be required for any operator-hosted service." }, { title: "Professional responsibility", body: "Future terms must preserve the user’s responsibility to verify sources, protect information, supervise work, and exercise independent professional judgment." }] }, - "acceptable-use": { title: "Acceptable use — placeholder", eyebrow: "Controlled beta", summary: "ROSS is intended for lawful professional work by approved beta participants using synthetic or non-confidential materials.", status: "Enforcement controls and final policy remain open.", review: "Operator, security, and legal reviewers: TBD", sections: [{ title: "Prohibited during beta", body: "Do not submit privileged, confidential, regulated, proprietary, or real client material; attempt unauthorized access; misrepresent output as verified authority; or use the service for consumer legal advice." }, { title: "Report concerns", body: "Security issues belong in private vulnerability reporting. Other concerns will use the contact workflow after it is implemented." }] }, - accessibility: { title: "Accessibility", eyebrow: "WCAG 2.2 AA target", summary: "New ROSS public surfaces target accessible semantics, keyboard operation, visible focus, contrast, zoom, and reduced-motion support.", status: "Formal conformance has not been audited or claimed.", review: "Accessibility owner: TBD · Manual review pending", sections: [{ title: "Built into the scaffold", body: "The site includes a skip link, semantic landmarks, labelled navigation, keyboard-visible focus, responsive layouts, and reduced-motion handling." }, { title: "Feedback", body: "A production accessibility feedback channel will be published after the operator and contact system are approved." }] }, - contact: { title: "Contact ROSS", eyebrow: "Channels pending", summary: "Support, privacy, security, licensing, partnership, and general-inquiry routes will remain separated.", status: "No contact form or production mailbox is active in this scaffold.", review: "Operator and support owner: TBD", sections: [{ title: "Security", body: "Use the repository’s private vulnerability reporting for security-sensitive details. Do not place vulnerability details in a public issue." }, { title: "Other enquiries", body: "Placeholder addresses are intentionally non-deliverable until the operator, retention, routing, spam protection, and privacy notice are approved." }] }, - about: { title: "About ROSS", eyebrow: "Ranade OSS", summary: "ROSS is an Ontario-focused, open-source legal workspace in development and a modified fork of Mike.", status: "The legal operator and accountable owners remain TBD.", review: "Product owner: TBD · Name and trademark review open", sections: [{ title: "Purpose", body: "The project aims to preserve a useful open-source legal workspace while adding authorized, traceable Ontario and Canadian legal sources and Canadian drafting defaults." }, { title: "Independence", body: "ROSS is not affiliated with or endorsed by governments, courts, CanLII, CourtListener, A2AJ, source providers, or the upstream Mike maintainers." }, { title: "Contributors", body: "Contributor records and governance will be published as the project develops." }] }, - docs: { title: "Documentation", eyebrow: "Versioned project records", summary: "Architecture decisions, product boundaries, verification reports, and contributor guidance live with the source code.", status: "Deployment and user documentation are still being developed.", review: "Technical owner: TBD", sections: [{ title: "Start with the repository", body: "The README covers the inherited application. The docs directory records the baseline, product boundary, architecture decisions, brand rules, and verification results." }] }, - updates: { title: "Project updates", eyebrow: "Changelog scaffold", summary: "Future product, security, source-coverage, and legal-content updates will carry effective and review dates.", status: "No public update feed is active yet.", review: "Release owner: TBD", sections: [{ title: "Publication policy", body: "Material changes should distinguish software releases from source, prompt, workflow, policy, and coverage changes." }] }, - status: { title: "Service status", eyebrow: "No production service", summary: "ROSS does not yet operate a production public website or client-material application.", status: "Development scaffold only.", review: "Operations owner: TBD", sections: [{ title: "Future status reporting", body: "Production status will be independently hosted and cover website, application, API, legal-source ingestion, and material incidents." }] }, - subprocessors: { title: "Subprocessors", eyebrow: "Vendor selection pending", summary: "No production hosting, analytics, support, email, monitoring, or model-provider configuration has been approved for the ROSS hosted service.", status: "Inventory pending.", review: "Privacy owner: TBD", sections: [{ title: "Required disclosure", body: "Before launch, the inventory must identify purpose, data categories, region, retention, access, contractual basis, and review date for each service." }] }, - "responsible-ai": { title: "Responsible AI and verification", eyebrow: "Human review is required", summary: "Model output is not authority. ROSS is designed to connect propositions to inspectable sources and make uncertainty visible.", status: "Ontario benchmark and release thresholds are not yet implemented.", review: "Legal-content and evaluation owners: TBD", sections: [{ title: "Verification", body: "A source link alone does not prove that the cited passage supports the proposition, remains current, or has not been limited. Those are distinct checks." }, { title: "Evaluation", body: "The Ontario release gate will measure source support, jurisdiction, temporal validity, citation accuracy, coverage gaps, refusal behaviour, and preservation of inherited functions." }] }, + ontario: { + title: "Ontario and Canadian capability", + eyebrow: "Jurisdiction plan", + summary: + "ROSS will add verified Ontario and binding federal primary law without removing inherited U.S. research.", + status: "Source integrations are planned, not yet live.", + review: "Legal-content owner: TBD · Review required before coverage claims", + sections: [ + { + title: "Planned foundation", + body: "Provider-neutral records will separate jurisdiction, court, authority level, document type, source version, provider, canonical URL, retrieval time, and verification state.", + }, + { + title: "Authorized sources", + body: "The approved direction is A2AJ, official government and court sources, and negotiated or licensed providers. ROSS will not scrape CanLII contrary to its terms or without permission.", + }, + { + title: "Visible gaps", + body: "Coverage for ONSC, ONCJ, Small Claims Court, tribunals, historical material, noting-up, and source currency must be measured and displayed before being represented as supported.", + }, + ], + }, + features: { + title: "A complete legal workspace", + eyebrow: "Preserved Mike capabilities", + summary: + "ROSS keeps the inherited assistant, project, document, tabular-review, workflow, sharing, connector, and self-hosting surfaces under regression protection.", + status: "Ontario-specific defaults and research are future packages.", + review: "Product owner: TBD · Last reviewed 2026-07-15", + sections: [ + { + title: "Work with matters and documents", + body: "Organize projects, upload and preview documents, draft and edit, track document versions, and export generated work.", + }, + { + title: "Structured review", + body: "Use assistant workflows, reusable workflows, and tabular review while retaining citations and matter context.", + }, + { + title: "Deploy on your terms", + body: "The AGPL codebase supports self-hosting. Each deployer remains responsible for security, privacy, providers, storage, legal content, backups, and operations.", + }, + ], + }, + workflows: { + title: "Ontario workflow catalogue", + eyebrow: "Public catalogue", + summary: + "Five versioned Ontario workflow drafts are available for transparent review while the inherited Mike workflow library remains intact.", + status: + "Drafts await independent Ontario lawyer review and are not approved for production use.", + review: + "Workflow owner: TBD · Reviewer and review date intentionally unset", + sections: [ + { + title: "Catalogue requirements", + body: "Each workflow states jurisdiction, practice area, version, intended user, required inputs, primary sources, exclusions, review checklist, and synthetic benchmark fixture.", + }, + { + title: "Current availability", + body: "Draft catalogue entries can be inspected publicly and opened in the authenticated application. They remain limited to synthetic or non-confidential material until the controlled-beta gates are met.", + }, + ], + }, + coverage: { + title: "Source coverage", + eyebrow: "Machine-readable status planned", + summary: + "Coverage must describe what ROSS can retrieve and verify without implying completeness, official status, or currentness that the evidence does not support.", + status: "Foundation only — coverage registry not implemented.", + review: + "Legal-content and technical owners: TBD · Review before source launch", + sections: [ + { + title: "Decisions", + body: "CourtListener remains available for optional U.S. research. A2AJ, official sources, and licensed providers are the approved Ontario and Canadian direction.", + }, + { + title: "Legislation", + body: "Ontario e-Laws and federal Justice Laws sources are planned with version, currency, consolidation, amendment, and canonical-link metadata.", + }, + { + title: "Decisions and noting-up", + body: "Decision availability and citator coverage vary by court and provider. Missing coverage must be explicit, and noting-up must never be inferred from ordinary search results.", + }, + ], + }, + "open-source": { + title: "Open source and self-hosting", + eyebrow: "AGPL-3.0", + summary: + "ROSS is an independently developed modified fork of Mike. Its source and architecture decisions are public.", + status: + "Self-hosting documentation and production runbooks remain in development.", + review: + "Release owner: TBD · Licence review required before public service", + sections: [ + { + title: "Corresponding source", + body: "Every ROSS network deployment must provide a working link to the corresponding source and preserve applicable copyright and licence notices.", + }, + { + title: "Deployment responsibility", + body: "Self-hosters choose and operate authentication, database, storage, model providers, email, conversion, monitoring, backups, retention, and incident response.", + }, + { + title: "Upstream attribution", + body: "ROSS preserves Mike functionality and credits the upstream project without implying endorsement or affiliation.", + }, + ], + }, + security: { + title: "Security", + eyebrow: "Controls before claims", + summary: + "ROSS is not yet approved for real confidential or privileged client material in an operator-hosted service.", + status: + "Threat model, privacy assessment, retention schedule, vendor inventory, and independent review remain open.", + review: "Security owner: TBD · Technical and legal review required", + sections: [ + { + title: "Current beta boundary", + body: "Development, demos, previews, and hosted beta use synthetic or non-confidential materials only. This applies to documents, prompts, logs, screenshots, support, and analytics.", + }, + { + title: "Architecture direction", + body: "The inherited application uses authenticated API, database, object storage, model-provider, conversion, and email services. Production regions and vendors are not yet approved.", + }, + { + title: "Report safely", + body: "Do not open public vulnerability details. Private GitHub vulnerability reporting is the current security channel.", + }, + ], + }, + privacy: { + title: "Privacy notice — placeholder", + eyebrow: "Legal review required", + summary: + "This scaffold does not collect form submissions, run analytics, authenticate visitors, or load application data.", + status: "Not a production privacy notice.", + review: "Privacy owner and operator: TBD · Effective date not set", + sections: [ + { + title: "Public website", + body: "The scaffold serves public content only. Hosting-level request logs may still exist and must be documented before publication.", + }, + { + title: "Hosted application", + body: "The planned invitation-only beta is limited to synthetic or non-confidential material. A complete data inventory and retention schedule are required before launch.", + }, + ], + }, + terms: { + title: "Terms of service — placeholder", + eyebrow: "Legal review required", + summary: + "No hosted-service terms have been approved and this page does not create a production service offering.", + status: + "Operator, service scope, jurisdiction, support, suspension, liability, and change terms remain open.", + review: "Operator and legal reviewer: TBD", + sections: [ + { + title: "Current effect", + body: "The repository is available under its open-source licence. Separate terms will be required for any operator-hosted service.", + }, + { + title: "Professional responsibility", + body: "Future terms must preserve the user’s responsibility to verify sources, protect information, supervise work, and exercise independent professional judgment.", + }, + ], + }, + "acceptable-use": { + title: "Acceptable use — placeholder", + eyebrow: "Controlled beta", + summary: + "ROSS is intended for lawful professional work by approved beta participants using synthetic or non-confidential materials.", + status: "Enforcement controls and final policy remain open.", + review: "Operator, security, and legal reviewers: TBD", + sections: [ + { + title: "Prohibited during beta", + body: "Do not submit privileged, confidential, regulated, proprietary, or real client material; attempt unauthorized access; misrepresent output as verified authority; or use the service for consumer legal advice.", + }, + { + title: "Report concerns", + body: "Security issues belong in private vulnerability reporting. Other concerns will use the contact workflow after it is implemented.", + }, + ], + }, + accessibility: { + title: "Accessibility", + eyebrow: "WCAG 2.2 AA target", + summary: + "New ROSS public surfaces target accessible semantics, keyboard operation, visible focus, contrast, zoom, and reduced-motion support.", + status: "Formal conformance has not been audited or claimed.", + review: "Accessibility owner: TBD · Manual review pending", + sections: [ + { + title: "Built into the scaffold", + body: "The site includes a skip link, semantic landmarks, labelled navigation, keyboard-visible focus, responsive layouts, and reduced-motion handling.", + }, + { + title: "Feedback", + body: "A production accessibility feedback channel will be published after the operator and contact system are approved.", + }, + ], + }, + contact: { + title: "Contact ROSS", + eyebrow: "Channels pending", + summary: + "Support, privacy, security, licensing, partnership, and general-inquiry routes will remain separated.", + status: "No contact form or production mailbox is active in this scaffold.", + review: "Operator and support owner: TBD", + sections: [ + { + title: "Security", + body: "Use the repository’s private vulnerability reporting for security-sensitive details. Do not place vulnerability details in a public issue.", + }, + { + title: "Other enquiries", + body: "Placeholder addresses are intentionally non-deliverable until the operator, retention, routing, spam protection, and privacy notice are approved.", + }, + ], + }, + about: { + title: "About ROSS", + eyebrow: "Ranade OSS", + summary: + "ROSS is an Ontario-focused, open-source legal workspace in development and a modified fork of Mike.", + status: "The legal operator and accountable owners remain TBD.", + review: "Product owner: TBD · Name and trademark review open", + sections: [ + { + title: "Purpose", + body: "The project aims to preserve a useful open-source legal workspace while adding authorized, traceable Ontario and Canadian legal sources and Canadian drafting defaults.", + }, + { + title: "Independence", + body: "ROSS is not affiliated with or endorsed by governments, courts, CanLII, CourtListener, A2AJ, source providers, or the upstream Mike maintainers.", + }, + { + title: "Contributors", + body: "Contributor records and governance will be published as the project develops.", + }, + ], + }, + docs: { + title: "Documentation", + eyebrow: "Versioned project records", + summary: + "Architecture decisions, product boundaries, verification reports, and contributor guidance live with the source code.", + status: "Deployment and user documentation are still being developed.", + review: "Technical owner: TBD", + sections: [ + { + title: "Start with the repository", + body: "The README covers the inherited application. The docs directory records the baseline, product boundary, architecture decisions, brand rules, and verification results.", + }, + ], + }, + updates: { + title: "Project updates", + eyebrow: "Changelog scaffold", + summary: + "Future product, security, source-coverage, and legal-content updates will carry effective and review dates.", + status: "No public update feed is active yet.", + review: "Release owner: TBD", + sections: [ + { + title: "Publication policy", + body: "Material changes should distinguish software releases from source, prompt, workflow, policy, and coverage changes.", + }, + ], + }, + status: { + title: "Service status", + eyebrow: "No production service", + summary: + "ROSS does not yet operate a production public website or client-material application.", + status: "Development scaffold only.", + review: "Operations owner: TBD", + sections: [ + { + title: "Future status reporting", + body: "Production status will be independently hosted and cover website, application, API, legal-source ingestion, and material incidents.", + }, + ], + }, + subprocessors: { + title: "Subprocessors", + eyebrow: "Vendor selection pending", + summary: + "No production hosting, analytics, support, email, monitoring, or model-provider configuration has been approved for the ROSS hosted service.", + status: "Inventory pending.", + review: "Privacy owner: TBD", + sections: [ + { + title: "Required disclosure", + body: "Before launch, the inventory must identify purpose, data categories, region, retention, access, contractual basis, and review date for each service.", + }, + ], + }, + "responsible-ai": { + title: "Responsible AI and verification", + eyebrow: "Human review is required", + summary: + "Model output is not authority. ROSS is designed to connect propositions to inspectable sources and make uncertainty visible.", + status: "Ontario benchmark and release thresholds are not yet implemented.", + review: "Legal-content and evaluation owners: TBD", + sections: [ + { + title: "Verification", + body: "A source link alone does not prove that the cited passage supports the proposition, remains current, or has not been limited. Those are distinct checks.", + }, + { + title: "Evaluation", + body: "The Ontario release gate will measure source support, jurisdiction, temporal validity, citation accuracy, coverage gaps, refusal behaviour, and preservation of inherited functions.", + }, + ], + }, }; diff --git a/website/app/site-config.ts b/website/app/site-config.ts index e7f70bb9f..517509b1a 100644 --- a/website/app/site-config.ts +++ b/website/app/site-config.ts @@ -3,8 +3,8 @@ import brand from "../../config/ross-brand.json"; export const siteConfig = { name: brand.product.name, expandedName: brand.product.expandedName, - websiteUrl: brand.urls.website, - appUrl: brand.urls.app, + websiteUrl: process.env.NEXT_PUBLIC_ROSS_WEBSITE_URL ?? brand.urls.website, + appUrl: process.env.NEXT_PUBLIC_ROSS_APP_URL ?? brand.urls.app, sourceUrl: brand.urls.source, upstreamUrl: brand.urls.upstreamSource, supportUrl: brand.urls.support, diff --git a/website/tests/rendered-html.test.mjs b/website/tests/rendered-html.test.mjs index 42e5940d0..0c9910128 100644 --- a/website/tests/rendered-html.test.mjs +++ b/website/tests/rendered-html.test.mjs @@ -1,19 +1,27 @@ import assert from "node:assert/strict"; import test from "node:test"; -const developmentPreviewMeta = /]*\bname=["']codex-preview["'])(?=[^>]*\bcontent=["']development["'])[^>]*>/i; +const developmentPreviewMeta = + /]*\bname=["']codex-preview["'])(?=[^>]*\bcontent=["']development["'])[^>]*>/i; async function getWorker() { const workerUrl = new URL("../dist/server/index.js", import.meta.url); - workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}-${Math.random()}`); + workerUrl.searchParams.set( + "test", + `${process.pid}-${Date.now()}-${Math.random()}`, + ); return (await import(workerUrl.href)).default; } async function render(path) { const worker = await getWorker(); const response = await worker.fetch( - new Request(`http://localhost${path}`, { headers: { accept: "text/html" } }), - { ASSETS: { fetch: async () => new Response("Not found", { status: 404 }) } }, + new Request(`http://localhost${path}`, { + headers: { accept: "text/html" }, + }), + { + ASSETS: { fetch: async () => new Response("Not found", { status: 404 }) }, + }, { waitUntil() {}, passThroughOnException() {} }, ); return { response, html: await response.text() }; @@ -28,15 +36,32 @@ test("landing page renders the accurate beta and source boundary", async () => { assert.match(html, /synthetic or non-confidential materials only/i); assert.match(html, /Ontario source integrations are not live yet/i); assert.match(html, /href="\/coverage"/); - assert.match(html, /href="https:\/\/github\.com\/ranade-oss\/ROSS-RanadeOSS"/); + assert.match( + html, + /href="https:\/\/github\.com\/ranade-oss\/ROSS-RanadeOSS"/, + ); }); test("required public routes render without authentication", async () => { const routes = [ - "/ontario", "/features", "/workflows", "/workflows/example-workflow", - "/open-source", "/security", "/privacy", "/terms", "/acceptable-use", - "/accessibility", "/contact", "/about", "/docs", "/updates", - "/updates/foundation", "/coverage", "/status", "/subprocessors", + "/ontario", + "/features", + "/workflows", + "/workflows/civil-claim-defence-issue-extraction", + "/open-source", + "/security", + "/privacy", + "/terms", + "/acceptable-use", + "/accessibility", + "/contact", + "/about", + "/docs", + "/updates", + "/updates/foundation", + "/coverage", + "/status", + "/subprocessors", "/responsible-ai", ]; @@ -49,6 +74,23 @@ test("required public routes render without authentication", async () => { } }); +test("Ontario workflow catalogue exposes governed drafts and app deep links", async () => { + const catalogue = await render("/workflows"); + assert.match(catalogue.html, /five versioned Ontario workflow drafts/i); + assert.match(catalogue.html, /Ontario Documentary Discovery Review/); + const entry = await render("/workflows/factum-authority-record-cross-check"); + assert.equal(entry.response.status, 200); + assert.match(entry.html, /Draft awaiting independent Ontario lawyer review/i); + assert.match( + entry.html, + /builtin-ross-ontario-factum-authority-record-cross-check/, + ); + assert.match( + entry.html, + /Citation and passage verification remain separate/i, + ); +}); + test("unknown routes return the custom not-found page", async () => { const { response, html } = await render("/not-a-real-ross-page"); assert.equal(response.status, 404); diff --git a/workflows/ontario/affidavit-fact-check.md b/workflows/ontario/affidavit-fact-check.md new file mode 100644 index 000000000..9e2e41663 --- /dev/null +++ b/workflows/ontario/affidavit-fact-check.md @@ -0,0 +1,15 @@ +# Ontario Affidavit Fact Check + +## Boundary + +This draft cross-checks a synthetic or non-confidential affidavit. It does not assess credibility, invent personal knowledge, decide admissibility, or approve swearing or filing. + +## Instructions + +1. Confirm the deponent, proceeding, court region, legal as-of date, draft affidavit, supporting record set, and stated basis of knowledge. +2. Create a paragraph-by-paragraph table with: affidavit paragraph; proposition; knowledge basis stated; supporting record and pinpoint; status (supported, partially supported, inconsistent, ambiguous, or no supplied support); explanation; follow-up question. +3. Cite both the affidavit paragraph and record location. Absence of supplied support is not proof that a statement is false. +4. Identify internal inconsistencies, date or name mismatches, ambiguous pronouns, missing exhibit references, and statements that appear to require information-and-belief treatment. Do not rewrite facts to cure them. +5. Retrieve current applicable rules, the Evidence Act where relevant, and the applicable practice direction before making any procedural or evidentiary observation. Quote the exact passage and link the official source. +6. Separate factual cross-checking from legal analysis and expose every unverified proposition. +7. End with questions for the deponent and supervising professional and an explicit human approval requirement. diff --git a/workflows/ontario/catalogue.json b/workflows/ontario/catalogue.json new file mode 100644 index 000000000..21abe8e9d --- /dev/null +++ b/workflows/ontario/catalogue.json @@ -0,0 +1,261 @@ +[ + { + "slug": "civil-claim-defence-issue-extraction", + "title": "Ontario Civil Claim and Defence Issue Extraction", + "description": "Extract pleaded facts, causes of action, defences, remedies, admissions, denials, and material gaps from Ontario civil pleadings.", + "practice": "Civil Litigation", + "jurisdictions": ["Canada / Ontario"], + "version": "0.1.0-draft", + "status": "draft-awaiting-lawyer-review", + "intendedUsers": [ + "Ontario lawyers", + "Ontario paralegals acting within their permitted scope" + ], + "excludedUses": [ + "Consumer legal advice", + "Autonomous pleading amendment", + "Limitation-period calculation", + "Use with confidential material during the controlled beta" + ], + "requiredInputs": [ + "Claim and defence", + "Represented party", + "Court file and region", + "Legal as-of date", + "Any relevant orders or endorsements" + ], + "primarySources": [ + { + "label": "Rules of Civil Procedure", + "url": "https://www.ontario.ca/laws/regulation/900194" + }, + { + "label": "Superior Court practice directions", + "url": "https://www.ontariocourts.ca/scj/practice-directions/" + } + ], + "sourceCurrency": "Retrieve and verify the official rule and applicable regional direction at run time.", + "output": "A source-linked issue table and a separate missing-information list.", + "reviewChecklist": [ + "Every finding cites an uploaded pleading location", + "Legal propositions use retrieved primary authority", + "Admissions and allegations remain distinct", + "No deadline or limitation opinion is inferred", + "Regional direction is identified or the gap is visible" + ], + "reviewer": null, + "reviewDate": null, + "syntheticFixture": "tests/fixtures/workflows/ontario-civil-pleadings-synthetic.md", + "skillFile": "civil-claim-defence-issue-extraction.md" + }, + { + "slug": "documentary-discovery-review", + "title": "Ontario Documentary Discovery Review", + "description": "Review a synthetic document set for relevance, issues, chronology, duplicates, and potential privilege flags without making privilege determinations.", + "practice": "Civil Litigation", + "jurisdictions": ["Canada / Ontario"], + "version": "0.1.0-draft", + "status": "draft-awaiting-lawyer-review", + "intendedUsers": [ + "Ontario lawyers", + "Ontario paralegals acting within their permitted scope" + ], + "excludedUses": [ + "Final privilege determination", + "Autonomous production decision", + "Destruction or alteration of originals", + "Use with confidential material during the controlled beta" + ], + "requiredInputs": [ + "Synthetic document set", + "Pleadings or issue list", + "Represented party", + "Court file and region", + "Legal as-of date", + "Review protocol" + ], + "primarySources": [ + { + "label": "Rules of Civil Procedure", + "url": "https://www.ontario.ca/laws/regulation/900194" + }, + { + "label": "Superior Court practice directions", + "url": "https://www.ontariocourts.ca/scj/practice-directions/" + } + ], + "sourceCurrency": "Verify Rules 29.1, 30, and 30.1 and applicable directions at run time.", + "output": "A review table with source locations, relevance rationale, privilege-review flags, and unresolved questions.", + "reviewChecklist": [ + "No privilege conclusion is automated", + "Each entry identifies its source document", + "Duplicates and families remain traceable", + "Review scope and date are recorded", + "Human production decision is explicit" + ], + "reviewer": null, + "reviewDate": null, + "syntheticFixture": "tests/fixtures/workflows/ontario-discovery-synthetic.md", + "skillFile": "documentary-discovery-review.md" + }, + { + "slug": "affidavit-fact-check", + "title": "Ontario Affidavit Fact Check", + "description": "Cross-check a draft Ontario affidavit against supplied records and identify unsupported, inconsistent, ambiguous, or hearsay-sensitive passages.", + "practice": "Civil Litigation", + "jurisdictions": ["Canada / Ontario"], + "version": "0.1.0-draft", + "status": "draft-awaiting-lawyer-review", + "intendedUsers": [ + "Ontario lawyers", + "Ontario paralegals acting within their permitted scope" + ], + "excludedUses": [ + "Assessing witness credibility", + "Inventing personal knowledge", + "Final admissibility opinion", + "Use with confidential material during the controlled beta" + ], + "requiredInputs": [ + "Draft affidavit", + "Synthetic supporting records", + "Deponent identity and stated knowledge basis", + "Court and region", + "Legal as-of date" + ], + "primarySources": [ + { + "label": "Rules of Civil Procedure", + "url": "https://www.ontario.ca/laws/regulation/900194" + }, + { + "label": "Ontario Evidence Act", + "url": "https://www.ontario.ca/laws/statute/90e23" + }, + { + "label": "Superior Court practice directions", + "url": "https://www.ontariocourts.ca/scj/practice-directions/" + } + ], + "sourceCurrency": "Retrieve relevant official provisions and applicable directions at run time.", + "output": "A paragraph-by-paragraph evidence map, discrepancy list, and questions for the deponent and supervising professional.", + "reviewChecklist": [ + "Every factual comparison cites both locations", + "Absence of evidence is not treated as contradiction", + "Personal knowledge and information-and-belief are separated", + "No credibility conclusion is generated", + "Exhibit references are checked" + ], + "reviewer": null, + "reviewDate": null, + "syntheticFixture": "tests/fixtures/workflows/ontario-affidavit-synthetic.md", + "skillFile": "affidavit-fact-check.md" + }, + { + "slug": "factum-authority-record-cross-check", + "title": "Ontario Factum Authority and Record Cross-Check", + "description": "Check a draft factum's record references, quoted passages, Canadian citations, and authority support while keeping verification states separate.", + "practice": "Civil Litigation and Appeals", + "jurisdictions": ["Canada / Ontario", "Canada / Federal"], + "version": "0.1.0-draft", + "status": "draft-awaiting-lawyer-review", + "intendedUsers": [ + "Ontario lawyers", + "Ontario paralegals acting within their permitted scope" + ], + "excludedUses": [ + "Automated noting-up conclusion", + "Inventing record references", + "Final filing approval", + "Use with confidential material during the controlled beta" + ], + "requiredInputs": [ + "Draft factum", + "Synthetic appeal book or record", + "Book of authorities or authority list", + "Court and region", + "Legal as-of date" + ], + "primarySources": [ + { + "label": "Rules of Civil Procedure", + "url": "https://www.ontario.ca/laws/regulation/900194" + }, + { + "label": "Court of Appeal general practice direction", + "url": "https://www.ontariocourts.ca/coa/how-to-proceed-court/general/" + }, + { + "label": "Court of Appeal civil practice direction", + "url": "https://www.ontariocourts.ca/coa/how-to-proceed-court/practice-directions-guidelines/practice-direction-civil/" + } + ], + "sourceCurrency": "Verify current court directions and retrieve each authority at run time; ordinary search does not establish treatment.", + "output": "A proposition-to-record-and-authority matrix with separate citation, passage, currency, and treatment states.", + "reviewChecklist": [ + "Quoted text matches retrieved passages", + "Record pinpoints resolve", + "Citation and passage verification remain separate", + "Currency and treatment gaps are visible", + "No unsupported proposition is marked verified" + ], + "reviewer": null, + "reviewDate": null, + "syntheticFixture": "tests/fixtures/workflows/ontario-factum-synthetic.md", + "skillFile": "factum-authority-record-cross-check.md" + }, + { + "slug": "small-claims-intake", + "title": "Ontario Small Claims Claim and Defence Intake", + "description": "Organize a synthetic Small Claims matter into parties, allegations, responses, remedies, documents, procedural questions, and missing facts.", + "practice": "Small Claims Court", + "jurisdictions": ["Canada / Ontario"], + "version": "0.1.0-draft", + "status": "draft-awaiting-lawyer-review", + "intendedUsers": [ + "Ontario lawyers", + "Ontario paralegals acting within their permitted scope" + ], + "excludedUses": [ + "Consumer-facing legal advice", + "Automatic form filing", + "Limitation-period calculation", + "Use with confidential material during the controlled beta" + ], + "requiredInputs": [ + "Claim, defence, or intake narrative", + "Represented party", + "Synthetic supporting records", + "Court location", + "Legal as-of date", + "Known service information" + ], + "primarySources": [ + { + "label": "Rules of the Small Claims Court", + "url": "https://www.ontario.ca/laws/regulation/980258" + }, + { + "label": "Official Small Claims forms", + "url": "https://www.ontariocourts.ca/scj/filing-procedures/rules/small-claims/" + }, + { + "label": "Superior Court practice directions", + "url": "https://www.ontariocourts.ca/scj/practice-directions/" + } + ], + "sourceCurrency": "Verify the current rules, official form version, monetary jurisdiction, and applicable direction at run time.", + "output": "An intake summary, allegation-response matrix, document list, source-linked procedural questions, and missing-information list.", + "reviewChecklist": [ + "Allegations and evidence remain distinct", + "Current official forms are linked rather than copied", + "No deadline or monetary-limit value is assumed", + "Court location and service facts are confirmed", + "Professional-scope warning remains visible" + ], + "reviewer": null, + "reviewDate": null, + "syntheticFixture": "tests/fixtures/workflows/ontario-small-claims-synthetic.md", + "skillFile": "small-claims-intake.md" + } +] diff --git a/workflows/ontario/civil-claim-defence-issue-extraction.md b/workflows/ontario/civil-claim-defence-issue-extraction.md new file mode 100644 index 000000000..c8985c25a --- /dev/null +++ b/workflows/ontario/civil-claim-defence-issue-extraction.md @@ -0,0 +1,15 @@ +# Ontario Civil Claim and Defence Issue Extraction + +## Boundary + +This draft supports an Ontario professional reviewing synthetic or non-confidential pleadings. It does not give consumer advice, amend a pleading, calculate a limitation period, or decide the merits. + +## Instructions + +1. Confirm the represented party, court, region, legal as-of date, and that the claim and defence are present. If any are missing, stop and ask for them. +2. Extract only what the pleadings state. Cite the pleading name and paragraph for every factual finding. +3. Build a table with: issue; claim allegation; defence response; admission, denial, or no response; pleaded legal basis; requested remedy; source location; missing information. +4. Keep allegations, admissions, evidence, and legal conclusions separate. Do not treat silence as an admission unless retrieved current authority establishes that result for this context. +5. Retrieve the current Rules of Civil Procedure and applicable province-wide and regional practice directions through authorized legal-source tools before stating a procedural proposition. Include an exact supporting passage and canonical URL. +6. Mark citation, passage, currency, and treatment verification independently. Never infer noting-up from ordinary search results. +7. End with unresolved conflicts, missing pleadings or endorsements, and questions for the supervising professional. State that a human must review the result. diff --git a/workflows/ontario/documentary-discovery-review.md b/workflows/ontario/documentary-discovery-review.md new file mode 100644 index 000000000..9f52bbee9 --- /dev/null +++ b/workflows/ontario/documentary-discovery-review.md @@ -0,0 +1,15 @@ +# Ontario Documentary Discovery Review + +## Boundary + +This draft organizes a synthetic or non-confidential document set. It does not make a final relevance, privilege, production, redaction, or destruction decision. + +## Instructions + +1. Confirm the represented party, pleadings or agreed issue list, court region, legal as-of date, and review protocol. Ask for missing inputs before reviewing. +2. Preserve the source filename, document identifier, page, date, author, recipient, and document-family relationship. Never modify an original. +3. For each document, report: responsive issue; concise factual summary; exact source location; potential duplicate or family; confidentiality indicator; potential privilege-review flag and reason; unresolved question. +4. Use “potential privilege-review flag,” never “privileged,” unless a supervising professional supplied that determination. Do not expose flagged substance more widely than the input scope requires. +5. Retrieve current Rules 29.1, 30, and 30.1 and applicable practice directions before stating a procedural requirement. Provide exact passages and official links. +6. Report gaps in numbering, missing attachments, corrupt files, uncertain dates, and conflicts. Do not invent missing content. +7. End with counts reconciled to the supplied set and a clear human decision gate for relevance, privilege, redaction, and production. diff --git a/workflows/ontario/factum-authority-record-cross-check.md b/workflows/ontario/factum-authority-record-cross-check.md new file mode 100644 index 000000000..7e9e40e7c --- /dev/null +++ b/workflows/ontario/factum-authority-record-cross-check.md @@ -0,0 +1,15 @@ +# Ontario Factum Authority and Record Cross-Check + +## Boundary + +This draft checks a synthetic or non-confidential factum. It does not approve filing, invent a record reference, or claim that ordinary search is a citator. + +## Instructions + +1. Confirm the court, region, represented party, legal as-of date, draft factum, complete supplied record, and authority list. +2. For each material proposition, create a matrix with: factum paragraph; proposition; record pinpoint and match status; cited authority; exact retrieved authority passage; citation verification; passage verification; currency verification; treatment verification; issue. +3. Compare quotations character-for-character while allowing clearly identified ellipses or formatting changes. Flag altered meaning, missing context, unresolved record pinpoints, and authorities not retrieved. +4. Retrieve sources only through authorized legal-source providers. Use official rules and current Court of Appeal or regional directions for formatting or procedural propositions. +5. Keep citation syntax, passage support, source currency, and judicial treatment as separate states. Never upgrade treatment from an ordinary search result. +6. If authority coverage is incomplete, say which court, period, or provider is missing. Do not substitute model memory. +7. End with blockers for the supervising professional; do not label the factum filing-ready. diff --git a/workflows/ontario/small-claims-intake.md b/workflows/ontario/small-claims-intake.md new file mode 100644 index 000000000..2c3c79fcc --- /dev/null +++ b/workflows/ontario/small-claims-intake.md @@ -0,0 +1,15 @@ +# Ontario Small Claims Claim and Defence Intake + +## Boundary + +This draft organizes a synthetic or non-confidential Ontario Small Claims matter for a professional. It does not provide consumer advice, calculate a limitation period, choose a cause of action, or file a form. + +## Instructions + +1. Confirm the represented party, court location, legal as-of date, service facts, claim or defence, and supporting records. Ask for any missing core input. +2. Extract parties and roles, allegations, responses, material dates, amounts claimed, remedies requested, supporting documents, and factual gaps. Cite every extracted item to an input location. +3. Produce an allegation-response-evidence matrix. Keep allegations separate from evidence and identify amounts that cannot be reconciled. +4. Retrieve the current Rules of the Small Claims Court and applicable practice direction before stating a procedural requirement. Link to the official forms catalogue; never supply a retained form as though it were current. +5. Do not assume a monetary limit, prescribed deadline, service date, or form version. If asked to count a supported procedural period, use the transparent deadline tool and show all inputs, exclusions, warnings, and the governing-rule link. +6. Identify potential jurisdiction, venue, service, party-name, capacity, and document-completeness questions without deciding them. +7. End with a missing-information list and mandatory review by the supervising Ontario lawyer or paralegal acting within permitted scope.