Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/branch-review-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -555,3 +555,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD
| 2026-07-15 | PR #677 / claude/cleanup-branches-worktrees-mxov4x | a93db73a29ca6a19619a6592e2e30ca9ea2f8218 | active PR review and remediation | All three actionable review findings are fixed: every branch namespace is parsed from the ledger table, only an exact completed cleanup review at the current HEAD suppresses repeat work, pending deletions remain actionable, and GitHub provider provenance is accurate. No additional high-confidence defect remains in the changed scope. | GitHub unresolved-thread inventory (0 remaining); hosted required CI green; exact-head focused Vitest `tests/repo-hygiene.test.ts` (9/9); `node scripts/sweep-branch-ledger.mjs --no-fetch --json`; `git diff --check`. No Supabase, OpenAI, or other live-service checks. |
| 2026-07-15 | PR #656 / claude/specifiers-v2-design-r55baf | 58ce935758c31672a0a051c5b3e6b888a7d8d153 | full DSM/ICD specifier catalogue and clinical-content gate review | No remaining high-confidence code defect after the review sequence corrected source provenance, verified-content wording, search ranking/deduplication, empty-state behavior, and neutral mixed-source labelling. The 494 unverified definitions remain withheld from display and ranking. Residual risk is the PR-declared qualified-clinician and TGA classification review before broader clinical deployment. | GitHub review-thread inventory (0 unresolved); exact-head hosted required CI, build, critical UI, UI regression, coverage, static, and security checks green. No live Supabase/OpenAI or provider-backed clinical workflow run. |
| 2026-07-15 | PR #679 / claude/therapy-compass-pages-rz0m5l | f5ca25f8b6b14f41e63f708933fe4bb311994795 | Therapy Compass production promotion and review-followup | All five review findings are fixed on the reviewed head: run-enabled shared-composer links stay on the Therapy Compass route, production data loads outside `/mockups`, deep links seed the in-tool search, and same-route query changes remount the provider. No additional high-confidence defect remains in the changed scope. | GitHub review-thread inventory; exact-head hosted build, critical UI smoke, UI regression, unit coverage, static, security, and image checks green; focused Vitest 28/28; TypeScript; `git diff --check`. No live Supabase/OpenAI checks run. |
| 2026-07-15 | PR #680 / claude/rag-scalability-review-x0s55l | d842f3fd + reviewed follow-up fixes | privacy, public-catalog throttling, ingestion-recovery, and merge-readiness review | Audit remediation wave 1 plus review follow-ups. Confirmed and fixed: mixed-owner authenticated document lists returned owner-internal nested label/summary fields for public documents (now fetched with ownership-specific projections plus response redaction); the `[id]` detail route likewise now redacts summary provenance (`source_chunk_ids`/`source_image_ids`/`model`) for non-owners; anonymous catalog rate limiting now covers the `[slug]` DETAIL routes as well as the list routes (removed the `shouldResolvePublicCatalogAccess` bypass and its dead helpers); and ingestion recovery keeps an open pending/freshly-processing job while superseding only failed/stale siblings, with both recovery scripts (`recover-ingestion-queue.ts`, `reindex.ts`) passing all queried statuses to the planner so a failed+fresh-processing document cannot collide on `ingestion_jobs_one_open_per_document_uidx`. Residual (low, deferred): base document metadata is still returned on non-owned public list rows. | GitHub review-thread and exact-head inspection; Next.js route-handler guide; `npm run verify:cheap` (lint, TypeScript, full Vitest) green after merging origin/main; focused route + ingestion-recovery Vitest; Prettier; `git diff --check`. No live Supabase/OpenAI/provider checks run. |
4 changes: 2 additions & 2 deletions scripts/recover-ingestion-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ function parseArgs(argv: string[]) {
async function main() {
const [
{ env, requireServerEnv },
{ buildIngestionRecoveryPlan },
{ buildIngestionRecoveryPlan, INGESTION_RECOVERY_JOB_STATUSES },
{ createAdminClient },
{ assertSupabaseHealthy, probeSupabaseHealth },
] = await Promise.all([
Expand All @@ -105,7 +105,7 @@ async function main() {
const { data, error } = await supabase
.from("ingestion_jobs")
.select("id,document_id,status,locked_at,documents(status,page_count,chunk_count)")
.in("status", ["pending", "processing", "failed"])
.in("status", [...INGESTION_RECOVERY_JOB_STATUSES])
.order("created_at", { ascending: true });

if (error) throw supabaseStageError("load open ingestion jobs", error);
Expand Down
35 changes: 13 additions & 22 deletions scripts/reindex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ async function safeCount(label: string, countPromise: PromiseLike<CountResult>)
async function main() {
const [
{ env, requireServerEnv },
{ buildIngestionRecoveryPlan, isFreshProcessingJob },
{ buildIngestionRecoveryPlan, INGESTION_RECOVERY_JOB_STATUSES, isFreshProcessingJob },
{ hasIncompleteDocumentsWithoutOpenJobs, isReindexQueueClear },
{ createAdminClient },
{ assertSupabaseHealthy, probeSupabaseHealth },
Expand Down Expand Up @@ -229,7 +229,7 @@ async function main() {
const { data, error } = await supabase
.from("ingestion_jobs")
.select("id,document_id,status,locked_at,documents(status,page_count,chunk_count)")
.in("status", ["processing", "failed"])
.in("status", [...INGESTION_RECOVERY_JOB_STATUSES])
Comment thread
BigSimmo marked this conversation as resolved.
.order("created_at", { ascending: true });

if (error) {
Expand All @@ -246,26 +246,17 @@ async function main() {
locked_at: string | null;
documents: RecoveryDocument | RecoveryDocument[] | null;
};
const staleAfterCutoff = new Date(staleCutoff);
const jobs = (data ?? [])
.map((job: RawJobRow) => ({
...job,
documents: Array.isArray(job.documents)
? (job.documents[0] as RecoveryDocument | undefined)
: (job.documents as RecoveryDocument | undefined),
}))
.filter((job) => {
if (job.status === "failed") {
return true;
}
if (job.status !== "processing") {
return false;
}
if (job.locked_at === null) {
return true;
}
return new Date(job.locked_at) <= staleAfterCutoff;
});
// Pass every queried job (pending/processing/failed) to the planner and let its
// active-vs-recoverable classification decide. Filtering out fresh `processing` jobs here
// hid legitimate in-flight owners from the planner: a document with a failed row plus a
// fresh processing retry would then requeue the failed row and collide with the still-open
// processing sibling on ingestion_jobs_one_open_per_document_uidx (Audit I2/E2 follow-up).
const jobs = (data ?? []).map((job: RawJobRow) => ({
...job,
documents: Array.isArray(job.documents)
? (job.documents[0] as RecoveryDocument | undefined)
: (job.documents as RecoveryDocument | undefined),
}));
hasActiveProcessingJobs = (data ?? [])
.map((job: RawJobRow) => ({
...job,
Expand Down
27 changes: 4 additions & 23 deletions src/app/api/differentials/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { getDifferentialDetailContext, getDifferentialRecord, getPresentationWor
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { safeErrorLogDetails } from "@/lib/privacy";
import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access";
import { publicAccessContext } from "@/lib/public-api-access";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
import { parseRequestQuery } from "@/lib/validation/query";
Expand Down Expand Up @@ -69,28 +69,9 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
});
}

if (!shouldResolvePublicCatalogAccess(request)) {
const snapshot = loadDifferentialSnapshot();
const governance = deriveGovernanceFromSnapshot(snapshot);
if (kind === "presentation") {
const workflow = getPresentationWorkflow(normalizedSlug);
if (!workflow) return notFoundResponse(normalizedSlug);
return differentialResponse({
workflow,
governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
publicAccess: true,
});
}
const record = getDifferentialRecord(normalizedSlug);
if (!record) return notFoundResponse(normalizedSlug);
return differentialResponse({
record,
detailContext: getDifferentialDetailContext(record),
governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
publicAccess: true,
});
}

// Anonymous callers still resolve access + rate limit before we serve the seed detail:
// publicAccessContext skips the Supabase auth round-trip when there's no session cookie/bearer,
// but every caller must pass the registry limiter (M4/C1 — no anonymous bypass).
const supabase = createAdminClient();
const access = await publicAccessContext(request, supabase);

Expand Down
23 changes: 4 additions & 19 deletions src/app/api/differentials/presentations/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { getDifferentialRecord, getPresentationWorkflow } from "@/lib/differenti
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { safeErrorLogDetails } from "@/lib/privacy";
import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access";
import { publicAccessContext } from "@/lib/public-api-access";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";

Expand Down Expand Up @@ -58,24 +58,9 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
});
}

if (!shouldResolvePublicCatalogAccess(request)) {
const snapshot = loadDifferentialSnapshot();
const workflow = getPresentationWorkflow(normalizedSlug);
if (!workflow) return notFoundResponse(normalizedSlug);
const governance = deriveGovernanceFromSnapshot(snapshot);
const candidates = workflow.candidates.flatMap((candidate) => {
const record = getDifferentialRecord(candidate.slug);
if (!record) return [];
return [{ ...candidate, record }];
});
return differentialResponse({
workflow,
candidates,
governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
publicAccess: true,
});
}

// Anonymous callers still resolve access + rate limit before we serve the seed detail:
// publicAccessContext skips the Supabase auth round-trip when there's no session cookie/bearer,
// but every caller must pass the registry limiter (M4/C1 — no anonymous bypass).
const supabase = createAdminClient();
const access = await publicAccessContext(request, supabase);

Expand Down
12 changes: 4 additions & 8 deletions src/app/api/differentials/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
} from "@/lib/differentials";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access";
import { publicAccessContext } from "@/lib/public-api-access";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
import { parseRequestQuery, queryInteger } from "@/lib/validation/query";
Expand Down Expand Up @@ -88,13 +88,9 @@ export async function GET(request: Request) {
});
}

if (!shouldResolvePublicCatalogAccess(request)) {
return differentialResponse({
...publicDifferentialPayload(kind, q, limit),
publicAccess: true,
});
}

// Anonymous callers still resolve access + rate limit: publicAccessContext skips the
// Supabase auth round-trip for requests with no session cookie/bearer, but every caller
// (authenticated or not) must pass the registry limiter before we serve the full catalog.
const supabase = createAdminClient();
const access = await publicAccessContext(request, supabase);

Expand Down
25 changes: 18 additions & 7 deletions src/app/api/documents/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { invalidateRagCachesForDocumentMutation } from "@/lib/rag";
import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access";
import { callerOwnsDocumentRow, enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access";
import { writeAuditLog } from "@/lib/audit";
import { parseJsonBody } from "@/lib/validation/body";
import { parseRouteParams } from "@/lib/validation/params";
Expand Down Expand Up @@ -296,6 +296,13 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
if (error) throw new Error(error.message);
if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 });

// withOwnerReadScope also returns PUBLIC (owner_id IS NULL) documents to an authenticated
// caller. The owner-only projection (raw metadata, storage_path/content_hash, index health,
// image storage paths) must be gated on OWNERSHIP, not merely on being authenticated, so an
// authed non-owner viewing a shared public document gets the same redacted view as an
// anonymous caller (S1/D1).
const isOwner = callerOwnsDocumentRow(document, access.ownerId);

const chunkId = detailQuery.chunk ?? null;
const requestedPage = Math.min(detailQuery.page, Math.max(1, document.page_count ?? 1));
const pageLimit = detailQuery.pageLimit;
Expand Down Expand Up @@ -390,21 +397,25 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
"import_batch_id",
"error_message",
"metadata",
// Summary provenance: only present on document_summaries rows (fetched with select("*")).
// A non-owner viewing a public document's summary must not see the owner's chunk/image
// source IDs or the generation model, matching the list route's PUBLIC_SUMMARY projection.
"source_chunk_ids",
"source_image_ids",
"model",
]);
return Object.fromEntries(Object.entries(row).filter(([key]) => !internalKeys.has(key)));
};
const publicRows = <T extends Record<string, unknown>>(rows: T[]) =>
access.authenticated ? rows : rows.map(omitPublicInternalFields);
const responseDocument = access.authenticated
? document
: omitPublicInternalFields(document as Record<string, unknown>);
isOwner ? rows : rows.map(omitPublicInternalFields);
const responseDocument = isOwner ? document : omitPublicInternalFields(document as Record<string, unknown>);

return NextResponse.json({
document: {
...responseDocument,
labels: publicRows((labelsResult.data ?? []) as Record<string, unknown>[]),
summary:
access.authenticated || !summaryResult.data
isOwner || !summaryResult.data
? (summaryResult.data ?? null)
: omitPublicInternalFields(summaryResult.data as Record<string, unknown>),
Comment thread
BigSimmo marked this conversation as resolved.
},
Expand All @@ -430,7 +441,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
hasAfter: Boolean(document.chunk_count && chunkRangeEnd + 1 < document.chunk_count),
selectedChunkId: selectedChunk?.id ?? null,
},
...(access.authenticated
...(isOwner
? {
indexHealth: {
extractionQuality: safeMetadata(document.metadata).extraction_quality ?? null,
Expand Down
11 changes: 10 additions & 1 deletion src/app/api/documents/bulk/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { z } from "zod";
import { normalizeDocumentLabelForStorage } from "@/lib/document-tags";
import { isDemoMode } from "@/lib/env";
import { jsonError, PublicApiError } from "@/lib/http";
import { logger } from "@/lib/logger";
import { safeErrorLogDetails } from "@/lib/privacy";
import { invalidateRagCachesForOwner } from "@/lib/rag";
import { createAdminClient } from "@/lib/supabase/admin";
import type { Json, TablesUpdate } from "@/lib/supabase/database.types";
Expand Down Expand Up @@ -207,10 +209,17 @@ export async function POST(request: Request) {
if (updateError) throw new Error(updateError.message);
results.push({ documentId: document.id, updated: true });
} catch (error) {
// Never surface the raw DB error text (constraint names, column details) to the client;
// log the sanitized detail server-side and return a generic per-document failure (S10/D5).
logger.error("Bulk document edit failed for a document", {
ownerId: user.id,
documentId: document.id,
...safeErrorLogDetails(error),
});
results.push({
documentId: document.id,
updated: false,
error: error instanceof Error ? error.message : "Bulk edit failed.",
error: "Bulk edit failed for this document.",
});
}
}
Expand Down
Loading