From f8bf0412d99cfa994032b560ecc46b2cc458946c Mon Sep 17 00:00:00 2001 From: monkey1sai <26239865+monkey1sai@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:27:39 +0800 Subject: [PATCH 1/2] feat(a4): persist session-bound search issues --- bim-review-coordinator/README.md | 11 +- bim-review-coordinator/src/app.ts | 7 + .../src/routes/a4IssueRoutes.ts | 365 +++++++++++ .../src/routes/a4SearchRoutes.ts | 25 +- .../governance-issue-from-a4-session.test.ts | 338 ++++++++++ .../governance-search-for-session.test.ts | 15 +- governance-service/README.md | 11 + governance-service/app.py | 6 + governance-service/issues/__init__.py | 16 +- governance-service/issues/a4_api.py | 278 ++++++++ governance-service/issues/api.py | 10 +- governance-service/issues/store.py | 258 +++++++- governance-service/search/api.py | 30 +- governance-service/search/engine.py | 346 ++++++++-- governance-service/search/internal_auth.py | 28 + governance-service/search/proofs.py | 315 +++++++++- governance-service/tests/test_a4_issues.py | 591 ++++++++++++++++++ .../tests/test_search_handoff_api.py | 12 +- governance-service/tests/test_search_model.py | 417 +++++++++++- .../a4-semantic-search-model-qa/tasks.md | 12 +- 20 files changed, 2956 insertions(+), 135 deletions(-) create mode 100644 bim-review-coordinator/src/routes/a4IssueRoutes.ts create mode 100644 bim-review-coordinator/tests/governance-issue-from-a4-session.test.ts create mode 100644 governance-service/issues/a4_api.py create mode 100644 governance-service/search/internal_auth.py create mode 100644 governance-service/tests/test_a4_issues.py diff --git a/bim-review-coordinator/README.md b/bim-review-coordinator/README.md index cf4bf7ffd..36aae7eff 100644 --- a/bim-review-coordinator/README.md +++ b/bim-review-coordinator/README.md @@ -88,6 +88,7 @@ POST /api/internal/review-sessions/{session_id}/stage-binding-confirmations POST /api/governance/search/model/for-session/{session_id} POST /api/governance/search/model/for-session/{session_id}/partial-confirmation POST /api/governance/search/model/for-ifc-ready/{job_id} +POST /api/governance/issues/from-a4-search/for-session/{session_id} ``` The canonical A4 search route authenticates the caller first, requires the @@ -101,7 +102,15 @@ lab-only `ifc_ready_table_only` compatibility route until user auth carries tenant/project authorization; it never forwards a mapping or session proof context. -Trusted A4 forwarding requires a non-empty server-only +The scoped A4 Issue route accepts one confirmed row/draft per request. It +reauthenticates the current session principal and primary lease, requires the +exact production model/artifact/binding and verified mapping capability, then +adds non-overridable trusted context before forwarding. Browser actor/source, +session, lease, proof-digest, and trusted-context fields are rejected. The +currently mounted local-dev lease remains `lab_unverified`, so mutation stays +fail-closed until an authentic shared lease capability is available. + +Trusted A4 forwarding requires a 16–4096 character printable-ASCII server-only `A4_INTERNAL_CONTEXT_TOKEN` shared with governance-service and either an exact loopback `GOVERNANCE_API_BASE` or an exact origin listed by `A4_TRUSTED_GOVERNANCE_ORIGINS`. The host-kit deployment injects only its diff --git a/bim-review-coordinator/src/app.ts b/bim-review-coordinator/src/app.ts index e706a4c0c..421ec29df 100644 --- a/bim-review-coordinator/src/app.ts +++ b/bim-review-coordinator/src/app.ts @@ -71,6 +71,7 @@ import { type A4SearchPrincipalResolution, type A4SearchSessionResolution as A4SearchRouteSessionResolution, } from "./routes/a4SearchRoutes.js"; +import { registerA4IssueRoutes } from "./routes/a4IssueRoutes.js"; import { registerA4HandoffRoutes, type A4SearchSessionResolution, @@ -3820,6 +3821,12 @@ export function createCoordinatorApp( resolveIfcReadyContext: resolveA4SearchIfcReadyContext, }); + registerA4IssueRoutes(app, { + isSafeSessionId, + authenticatePrincipal: authenticateA4SearchPrincipal, + resolveSessionContext: resolveA4SearchSessionContext, + }); + registerGovernanceProxy(app, { isSafeSessionId, isSafeIfcReadyJobId, diff --git a/bim-review-coordinator/src/routes/a4IssueRoutes.ts b/bim-review-coordinator/src/routes/a4IssueRoutes.ts new file mode 100644 index 000000000..0e5a4ae19 --- /dev/null +++ b/bim-review-coordinator/src/routes/a4IssueRoutes.ts @@ -0,0 +1,365 @@ +import type { Express, Request, Response } from "express"; + +import { + forwardTrustedA4, + type A4SearchPrincipal, + type A4SearchPrincipalResolution, + type A4SearchSessionContext, + type A4SearchSessionResolution, +} from "./a4SearchRoutes.js"; + +export interface A4IssueRouteDeps { + isSafeSessionId?: (sessionId: string) => boolean; + authenticatePrincipal?: ( + headers: Record, + ) => A4SearchPrincipalResolution; + resolveSessionContext?: ( + sessionId: string, + principal: A4SearchPrincipal, + ) => A4SearchSessionResolution; + trustedGovernanceOrigins?: string[]; + a4InternalContextToken?: string; + governanceTimeoutMs?: number; +} + +type A4IssueDraft = { + title: string; + description?: string | null; + severity: "low" | "medium" | "high" | "critical"; + assignee?: string | null; + ifc_guid: string; + usd_prim_path?: string | null; + evidence_proof: string; + a4_evidence_snapshot: Record; +}; + +type SanitizedDraft = + | { ok: true; value: A4IssueDraft } + | { ok: false; authority: boolean; detail: string }; + +const ISSUE_DRAFT_KEYS = new Set([ + "title", + "description", + "severity", + "assignee", + "ifc_guid", + "usd_prim_path", + "evidence_proof", + "a4_evidence_snapshot", +]); +const BROWSER_AUTHORITY_KEYS = new Set([ + "user_id", + "actor", + "principal", + "principal_ref", + "session_id", + "review_session_id", + "source_type", + "source_ref", + "model_version_id", + "primary_artifact_id", + "active_binding_revision", + "mapping_provenance", + "primary_lease_capability", + "auth_scope", + "lease_id", + "lease_token", + "viewer_lease_id", + "viewer_lease_token", + "a4_trusted_context", + "proof_id", + "snapshot_hash", + "proof_digest", + "creation_request_hash", +]); +const BROWSER_AUTHORITY_HEADERS = new Set(["x-actor", "x-operator"]); +const PROOF_PATTERN = /^a4p\.[A-Za-z0-9_-]{1,64}\.[A-Za-z0-9_-]{16,96}\.[0-9a-f]{64}$/; +const IFC_GUID_PATTERN = /^[A-Za-z0-9_$-]{1,64}$/; +const USD_PRIM_PATTERN = /^\/(?:[A-Za-z_][A-Za-z0-9_]*)+(?:\/[A-Za-z_][A-Za-z0-9_]*)*$/; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function collectExactStringEchoes(value: unknown, output: string[] = []): string[] { + if (typeof value === "string") { + output.push(value); + return output; + } + if (Array.isArray(value)) { + for (const item of value) collectExactStringEchoes(item, output); + return output; + } + if (isRecord(value)) { + for (const item of Object.values(value)) collectExactStringEchoes(item, output); + } + return output; +} + +function normalizedRequestHeaders(request: Request): Record { + const headers: Record = {}; + for (const [name, value] of Object.entries(request.headers)) { + headers[name.toLowerCase()] = Array.isArray(value) ? value[0] : value; + } + return headers; +} + +function hasBrowserAuthorityHeader(headers: Record): boolean { + return [...BROWSER_AUTHORITY_HEADERS].some((name) => { + const value = headers[name]; + return typeof value === "string" && value.trim().length > 0; + }); +} + +function optionalText( + body: Record, + key: "description" | "assignee" | "usd_prim_path", + maxLength: number, +): boolean { + const value = body[key]; + return value === undefined || value === null || (typeof value === "string" && value.length <= maxLength); +} + +function sanitizeIssueDraft(body: unknown): SanitizedDraft { + if (!isRecord(body)) { + return { ok: false, authority: false, detail: "A4 Issue draft must be a JSON object." }; + } + const keys = Object.keys(body); + if (keys.some((key) => BROWSER_AUTHORITY_KEYS.has(key.toLowerCase()))) { + return { ok: false, authority: true, detail: "Browser identity or authority fields are not accepted." }; + } + if (keys.some((key) => !ISSUE_DRAFT_KEYS.has(key))) { + return { ok: false, authority: false, detail: "A4 Issue draft contains unsupported fields." }; + } + if ( + typeof body.title !== "string" + || body.title.trim().length === 0 + || body.title.length > 500 + || !optionalText(body, "description", 4_000) + || !optionalText(body, "assignee", 256) + || !optionalText(body, "usd_prim_path", 2_048) + || (body.severity !== "low" && body.severity !== "medium" + && body.severity !== "high" && body.severity !== "critical") + || typeof body.ifc_guid !== "string" + || !IFC_GUID_PATTERN.test(body.ifc_guid) + || typeof body.evidence_proof !== "string" + || !PROOF_PATTERN.test(body.evidence_proof) + || !isRecord(body.a4_evidence_snapshot) + ) { + return { ok: false, authority: false, detail: "A4 Issue draft is invalid." }; + } + if ( + typeof body.usd_prim_path === "string" + && !USD_PRIM_PATTERN.test(body.usd_prim_path) + ) { + return { ok: false, authority: false, detail: "A4 Issue USD prim path is invalid." }; + } + return { ok: true, value: body as A4IssueDraft }; +} + +function authenticate( + request: Request, + response: Response, + deps: A4IssueRouteDeps, +): A4SearchPrincipal | null { + if (!deps.authenticatePrincipal) { + response.status(503).json({ + error_code: "a4_authentication_unavailable", + detail: "A4 authentication is unavailable.", + }); + return null; + } + const headers = normalizedRequestHeaders(request); + let resolution: A4SearchPrincipalResolution; + try { + resolution = deps.authenticatePrincipal(headers); + } catch { + response.status(503).json({ + error_code: "a4_authentication_unavailable", + detail: "A4 authentication is unavailable.", + }); + return null; + } + if (!resolution.ok) { + response.status(resolution.status).json({ + error_code: resolution.error_code, + detail: resolution.detail, + }); + return null; + } + if ( + typeof resolution.principal.principal_ref !== "string" + || resolution.principal.principal_ref.length === 0 + || resolution.principal.principal_ref.length > 160 + || (resolution.principal.auth_scope !== "production" && resolution.principal.auth_scope !== "lab") + ) { + response.status(503).json({ + error_code: "a4_authentication_unavailable", + detail: "A4 authentication is unavailable.", + }); + return null; + } + if (hasBrowserAuthorityHeader(headers)) { + response.status(403).json({ + error_code: "a4_browser_authority_forbidden", + detail: "Browser identity headers cannot establish A4 authority.", + }); + return null; + } + return resolution.principal; +} + +function resolveSession( + response: Response, + deps: A4IssueRouteDeps, + sessionId: string, + principal: A4SearchPrincipal, +): A4SearchSessionContext | null { + if (!deps.resolveSessionContext) { + response.status(503).json({ + error_code: "a4_trusted_context_unavailable", + detail: "A4 session authorization is unavailable.", + }); + return null; + } + let resolution: A4SearchSessionResolution; + try { + resolution = deps.resolveSessionContext(sessionId, principal); + } catch { + response.status(503).json({ + error_code: "a4_trusted_context_unavailable", + detail: "A4 session authorization is unavailable.", + }); + return null; + } + if (!resolution.ok) { + response.status(resolution.status).json({ + error_code: resolution.error_code, + detail: resolution.detail, + }); + return null; + } + return resolution.context; +} + +function trustedIssueContext( + sessionId: string, + principal: A4SearchPrincipal, + context: A4SearchSessionContext, +): Record | null { + if ( + principal.auth_scope !== "production" + || context.review_session_id !== sessionId + || !context.model_version_id + || !context.primary_artifact_id + || !context.active_binding_revision + || context.mapping_provenance !== "server_resolved" + || !context.element_mapping_path + || context.primary_lease_capability !== "verified" + ) return null; + return { + scope: "session_table_only", + review_session_id: sessionId, + principal_ref: principal.principal_ref, + primary_artifact_id: context.primary_artifact_id, + active_binding_revision: context.active_binding_revision, + model_version_id: context.model_version_id, + auth_scope: "production", + mapping_provenance: "server_resolved", + primary_lease_capability: "verified", + }; +} + +function snapshotMatchesCurrentBinding( + draft: A4IssueDraft, + trusted: Record, +): boolean { + const snapshot = draft.a4_evidence_snapshot; + const binding = snapshot.session_binding; + const row = snapshot.row; + if (!isRecord(binding) || !isRecord(row)) return false; + const expected = { + review_session_id: trusted.review_session_id, + principal_ref: trusted.principal_ref, + primary_artifact_id: trusted.primary_artifact_id, + active_binding_revision: trusted.active_binding_revision, + model_version_id: trusted.model_version_id, + mapping_provenance: "server_resolved", + primary_lease_capability: "verified", + auth_scope: "production", + session_id: trusted.review_session_id, + principal: trusted.principal_ref, + model_artifact: trusted.primary_artifact_id, + }; + if ( + snapshot.model_version_id !== trusted.model_version_id + || Object.entries(expected).some(([key, value]) => binding[key] !== value) + || row.ifc_guid !== draft.ifc_guid + ) return false; + const acceptedPrim = row.accepted_usd_prim; + const snapshotPrim = row.usd_prim_path; + const draftPrim = draft.usd_prim_path ?? null; + return (acceptedPrim ?? null) === draftPrim && (snapshotPrim ?? null) === draftPrim; +} + +export function registerA4IssueRoutes(app: Express, deps: A4IssueRouteDeps): void { + app.post("/api/governance/issues/from-a4-search/for-session/:sessionId", (request, response) => { + const sessionId = request.params.sessionId; + if (!deps.isSafeSessionId?.(sessionId)) { + response.status(400).json({ error_code: "invalid_session_id", detail: "Invalid review session id." }); + return; + } + const principal = authenticate(request, response, deps); + if (!principal) return; + if (principal.auth_scope !== "production") { + response.status(503).json({ + error_code: "a4_issue_authority_unavailable", + detail: "Production A4 Issue authority is unavailable.", + }); + return; + } + const draft = sanitizeIssueDraft(request.body); + if (!draft.ok) { + response.status(draft.authority ? 403 : 400).json({ + error_code: draft.authority ? "a4_browser_authority_forbidden" : "invalid_a4_issue_draft", + detail: draft.detail, + }); + return; + } + const context = resolveSession(response, deps, sessionId, principal); + if (!context) return; + const trusted = trustedIssueContext(sessionId, principal, context); + if (!trusted) { + response.status(503).json({ + error_code: "a4_issue_authority_unavailable", + detail: "Current A4 Issue session authority is unavailable.", + }); + return; + } + if (!snapshotMatchesCurrentBinding(draft.value, trusted)) { + response.status(403).json({ + error_code: "a4_issue_binding_mismatch", + detail: "A4 Issue evidence does not match the current authorized session.", + }); + return; + } + void forwardTrustedA4( + response, + deps, + "/api/internal/a4/issues/from-search", + "deterministic", + { ...draft.value, a4_trusted_context: trusted }, + [context.ifc_source_path, context.element_mapping_path ?? ""], + [ + ...collectExactStringEchoes(draft.value), + draft.value.title.normalize("NFC").trim(), + ...(draft.value.description + ? [draft.value.description.normalize("NFC")] + : []), + ...(draft.value.assignee + ? [draft.value.assignee.normalize("NFC").trim()] + : []), + ], + ); + }); +} diff --git a/bim-review-coordinator/src/routes/a4SearchRoutes.ts b/bim-review-coordinator/src/routes/a4SearchRoutes.ts index f89e51781..758e605f4 100644 --- a/bim-review-coordinator/src/routes/a4SearchRoutes.ts +++ b/bim-review-coordinator/src/routes/a4SearchRoutes.ts @@ -66,7 +66,7 @@ type A4SearchControls = { retry_of_query_id?: string; }; -type GovernanceTimeoutBudget = "deterministic" | "model"; +export type GovernanceTimeoutBudget = "deterministic" | "model"; type Sanitized = | { ok: true; value: T } @@ -307,7 +307,9 @@ function internalToken(deps: A4SearchRouteDeps): string | null { // A short shared value is both weak authority and unsafe to use as a // substring-based response leak sentinel (for example, token "a" would // match almost every JSON response). Fail closed on either condition. - return token.length >= 16 && token.length <= 4_096 ? token : null; + return token.length >= 16 && token.length <= 4_096 && /^[\x21-\x7E]+$/.test(token) + ? token + : null; } function governanceTimeout(deps: A4SearchRouteDeps, budget: GovernanceTimeoutBudget): number { @@ -365,6 +367,7 @@ async function readBoundedJson(upstream: globalThis.Response): Promise function responseContainsForbiddenData( value: unknown, forbiddenStrings: string[], + allowedEchoStrings = new Set(), seen = new Set(), parentKey = "", ): boolean { @@ -372,6 +375,10 @@ function responseContainsForbiddenData( if (forbiddenStrings.some((candidate) => candidate.length > 0 && value.includes(candidate))) { return true; } + // A session-authorized mutation may safely echo an exact browser draft + // string. Treat only exact values as provenance-aware echoes; any extra + // upstream text still goes through the path/credential leak checks. + if (allowedEchoStrings.has(value)) return false; const normalizedKey = parentKey.toLowerCase(); if (USD_PRIM_PATH_KEYS.has(normalizedKey) && /^\/(?:[A-Za-z_][A-Za-z0-9_]*)+(?:\/[A-Za-z_][A-Za-z0-9_]*)*$/.test(value)) { return false; @@ -383,12 +390,17 @@ function responseContainsForbiddenData( if (!value || typeof value !== "object" || seen.has(value)) return false; seen.add(value); if (Array.isArray(value)) { - return value.some((item) => responseContainsForbiddenData(item, forbiddenStrings, seen)); + return value.some((item) => responseContainsForbiddenData( + item, + forbiddenStrings, + allowedEchoStrings, + seen, + )); } return Object.entries(value as Record).some(([key, nested]) => FORBIDDEN_UPSTREAM_RESPONSE_KEYS.has(key.toLowerCase()) || FORBIDDEN_CREDENTIAL_KEY_PATTERN.test(key) - || responseContainsForbiddenData(nested, forbiddenStrings, seen, key), + || responseContainsForbiddenData(nested, forbiddenStrings, allowedEchoStrings, seen, key), ); } @@ -399,13 +411,14 @@ function sendGovernanceUnavailable(response: Response): void { }); } -async function forwardTrustedA4( +export async function forwardTrustedA4( response: Response, deps: A4SearchRouteDeps, upstreamPath: string, timeoutBudget: GovernanceTimeoutBudget, body: Record, serverPaths: string[], + allowedResponseEchoes: string[] = [], ): Promise { const base = governanceBaseUrl(deps); const token = internalToken(deps); @@ -429,7 +442,7 @@ async function forwardTrustedA4( }); const payload = await readBoundedJson(upstream); const forbiddenStrings = [...serverPaths, token, base.toString()]; - if (responseContainsForbiddenData(payload, forbiddenStrings)) { + if (responseContainsForbiddenData(payload, forbiddenStrings, new Set(allowedResponseEchoes))) { sendGovernanceUnavailable(response); return; } diff --git a/bim-review-coordinator/tests/governance-issue-from-a4-session.test.ts b/bim-review-coordinator/tests/governance-issue-from-a4-session.test.ts new file mode 100644 index 000000000..d228973fa --- /dev/null +++ b/bim-review-coordinator/tests/governance-issue-from-a4-session.test.ts @@ -0,0 +1,338 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; + +import express from "express"; +import request from "supertest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + registerA4IssueRoutes, + type A4IssueRouteDeps, +} from "../src/routes/a4IssueRoutes.js"; + +type RecordedRequest = { + url: string; + headers: http.IncomingHttpHeaders; + body: Record; +}; + +const activeServers: http.Server[] = []; +const originalBase = process.env.GOVERNANCE_API_BASE; +const originalInternalToken = process.env.A4_INTERNAL_CONTEXT_TOKEN; + +beforeEach(() => { + process.env.A4_INTERNAL_CONTEXT_TOKEN = "test-a4-internal-context-token"; +}); + +afterEach(async () => { + while (activeServers.length > 0) { + const server = activeServers.pop(); + await new Promise((resolve) => server?.close(() => resolve())); + } + if (originalBase === undefined) delete process.env.GOVERNANCE_API_BASE; + else process.env.GOVERNANCE_API_BASE = originalBase; + if (originalInternalToken === undefined) delete process.env.A4_INTERNAL_CONTEXT_TOKEN; + else process.env.A4_INTERNAL_CONTEXT_TOKEN = originalInternalToken; +}); + +async function startGovernanceStub(options: { status?: number; body?: unknown } = {}) { + const calls: RecordedRequest[] = []; + const server = http.createServer((incoming, outgoing) => { + const chunks: Buffer[] = []; + incoming.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + incoming.on("end", () => { + calls.push({ + url: incoming.url ?? "/", + headers: incoming.headers, + body: JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record, + }); + const payload = JSON.stringify(options.body ?? { + issue: { id: "iss_a4", source_type: "a4_search" }, + replayed: false, + }); + outgoing.writeHead(options.status ?? 201, { "Content-Type": "application/json" }); + outgoing.end(payload); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + activeServers.push(server); + return { + calls, + baseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + }; +} + +const sessionId = "review_session_a4test001"; +const principalRef = "production_principal_a4"; + +function sessionContext() { + return { + ifc_source_path: "C:\\server-only\\model.ifc", + element_mapping_path: "C:\\server-only\\element_mapping.json", + model_version_id: "a4_fixture_v1", + review_session_id: sessionId, + primary_artifact_id: "artifact_a4", + active_binding_revision: "binding_a4_1", + mapping_provenance: "server_resolved" as const, + primary_lease_capability: "verified" as const, + }; +} + +function routeApp(overrides: Partial = {}) { + const app = express(); + app.use(express.json()); + registerA4IssueRoutes(app, { + isSafeSessionId: (value) => /^review_session_[A-Za-z0-9_-]+$/.test(value), + authenticatePrincipal: () => ({ + ok: true, + principal: { principal_ref: principalRef, auth_scope: "production" }, + }), + resolveSessionContext: () => ({ ok: true, context: sessionContext() }), + a4InternalContextToken: "test-a4-internal-context-token", + ...overrides, + }); + return app; +} + +function evidenceSnapshot(overrides: Record = {}) { + return { + schema_version: "a4-proof-v1", + query_id: "a4q_issue_fixture_0001", + query: "IfcDoor", + normalized_filters: { ifc_classes: ["IfcDoor"] }, + interpretation: { + source: "deterministic_grammar", + degraded_to_deterministic: false, + unresolved_terms: [], + }, + row: { + ifc_guid: "0A4DoorLow000000000001", + ifc_class: "IfcDoor", + matched_properties: {}, + predicate_trace: ["IfcDoor"], + accepted_usd_prim: "/World/Doors/Low", + usd_prim_path: "/World/Doors/Low", + mapping_observed: true, + }, + model_version_id: "a4_fixture_v1", + session_binding: { + review_session_id: sessionId, + principal_ref: principalRef, + primary_artifact_id: "artifact_a4", + active_binding_revision: "binding_a4_1", + model_version_id: "a4_fixture_v1", + mapping_provenance: "server_resolved", + primary_lease_capability: "verified", + auth_scope: "production", + session_id: sessionId, + principal: principalRef, + model_artifact: "artifact_a4", + }, + mapping_digest: "a".repeat(64), + ...overrides, + }; +} + +function issuePayload(overrides: Record = {}) { + return { + title: "4F 防火門 FireRating 不足", + description: "人工確認", + severity: "high", + assignee: "reviewer-a4", + ifc_guid: "0A4DoorLow000000000001", + usd_prim_path: "/World/Doors/Low", + evidence_proof: `a4p.a4_test_kid.proof_id_fixture_0001.${"b".repeat(64)}`, + a4_evidence_snapshot: evidenceSnapshot(), + ...overrides, + }; +} + +describe("session-scoped A4 Issue route", () => { + it("forwards one exact draft with non-overridable trusted current context", async () => { + const governance = await startGovernanceStub(); + process.env.GOVERNANCE_API_BASE = governance.baseUrl; + const payload = issuePayload(); + + const response = await request(routeApp()) + .post(`/api/governance/issues/from-a4-search/for-session/${sessionId}`) + .send(payload); + + expect(response.status).toBe(201); + expect(governance.calls).toHaveLength(1); + expect(governance.calls[0].url).toBe("/api/internal/a4/issues/from-search"); + expect(governance.calls[0].headers["x-a4-internal-token"]).toBe( + "test-a4-internal-context-token", + ); + expect(governance.calls[0].body).toEqual({ + ...payload, + a4_trusted_context: { + scope: "session_table_only", + review_session_id: sessionId, + principal_ref: principalRef, + primary_artifact_id: "artifact_a4", + active_binding_revision: "binding_a4_1", + model_version_id: "a4_fixture_v1", + auth_scope: "production", + mapping_provenance: "server_resolved", + primary_lease_capability: "verified", + }, + }); + }); + + it("allows exact path-like draft echoes without hiding a committed Issue", async () => { + const title = "/Door defect"; + const description = "Inspect user note C:\\model"; + const snapshot = evidenceSnapshot({ query: "/Door query" }); + const governance = await startGovernanceStub({ + body: { + issue: { + id: "iss_a4_pathlike_draft", + source_type: "a4_search", + title, + description, + a4_evidence_snapshot: snapshot, + }, + replayed: false, + }, + }); + process.env.GOVERNANCE_API_BASE = governance.baseUrl; + + const response = await request(routeApp()) + .post(`/api/governance/issues/from-a4-search/for-session/${sessionId}`) + .send(issuePayload({ title, description, a4_evidence_snapshot: snapshot })); + + expect(response.status).toBe(201); + expect(response.body.issue.title).toBe(title); + expect(response.body.issue.description).toBe(description); + expect(response.body.issue.a4_evidence_snapshot.query).toBe("/Door query"); + expect(governance.calls).toHaveLength(1); + }); + + it("rejects missing auth and session resolution failures before upstream persistence", async () => { + const governance = await startGovernanceStub(); + process.env.GOVERNANCE_API_BASE = governance.baseUrl; + const unauthenticated = await request(routeApp({ + authenticatePrincipal: () => ({ + ok: false, + status: 401, + error_code: "a4_authentication_required", + detail: "Authentication is required.", + }), + })) + .post(`/api/governance/issues/from-a4-search/for-session/${sessionId}`) + .send(issuePayload()); + expect(unauthenticated.status).toBe(401); + + const inactive = await request(routeApp({ + resolveSessionContext: () => ({ + ok: false, + status: 409, + error_code: "a4_session_inactive", + detail: "A4 review session is not active.", + }), + })) + .post(`/api/governance/issues/from-a4-search/for-session/${sessionId}`) + .send(issuePayload()); + expect(inactive.status).toBe(409); + expect(governance.calls).toHaveLength(0); + }); + + it("fails closed for lab identity, unavailable mapping, or unverified primary lease", async () => { + const governance = await startGovernanceStub(); + process.env.GOVERNANCE_API_BASE = governance.baseUrl; + const lab = await request(routeApp({ + authenticatePrincipal: () => ({ + ok: true, + principal: { principal_ref: principalRef, auth_scope: "lab" }, + }), + })) + .post(`/api/governance/issues/from-a4-search/for-session/${sessionId}`) + .send(issuePayload()); + expect(lab.status).toBe(503); + expect(lab.body.error_code).toBe("a4_issue_authority_unavailable"); + + const unverified = await request(routeApp({ + resolveSessionContext: () => ({ + ok: true, + context: { ...sessionContext(), primary_lease_capability: "lab_unverified" }, + }), + })) + .post(`/api/governance/issues/from-a4-search/for-session/${sessionId}`) + .send(issuePayload()); + expect(unverified.status).toBe(503); + + const unmapped = await request(routeApp({ + resolveSessionContext: () => ({ + ok: true, + context: { ...sessionContext(), mapping_provenance: "unavailable" }, + }), + })) + .post(`/api/governance/issues/from-a4-search/for-session/${sessionId}`) + .send(issuePayload()); + expect(unmapped.status).toBe(503); + + const nonAsciiToken = await request(routeApp({ + a4InternalContextToken: "test-a4-internal-token-非", + })) + .post(`/api/governance/issues/from-a4-search/for-session/${sessionId}`) + .send(issuePayload()); + expect(nonAsciiToken.status).toBe(503); + expect(nonAsciiToken.body.error_code).toBe("a4_trusted_context_unavailable"); + expect(governance.calls).toHaveLength(0); + }); + + it("rejects body/header authority and cross-session evidence without upstream calls", async () => { + const governance = await startGovernanceStub(); + process.env.GOVERNANCE_API_BASE = governance.baseUrl; + const bodyAuthority = await request(routeApp()) + .post(`/api/governance/issues/from-a4-search/for-session/${sessionId}`) + .send(issuePayload({ source_type: "a4_search" })); + expect(bodyAuthority.status).toBe(403); + expect(bodyAuthority.body.error_code).toBe("a4_browser_authority_forbidden"); + + const headerAuthority = await request(routeApp()) + .post(`/api/governance/issues/from-a4-search/for-session/${sessionId}`) + .set("X-Actor", "admin") + .send(issuePayload()); + expect(headerAuthority.status).toBe(403); + + const crossSession = await request(routeApp()) + .post(`/api/governance/issues/from-a4-search/for-session/${sessionId}`) + .send(issuePayload({ + a4_evidence_snapshot: evidenceSnapshot({ + session_binding: { + ...(evidenceSnapshot().session_binding as Record), + review_session_id: "review_session_other", + session_id: "review_session_other", + }, + }), + })); + expect(crossSession.status).toBe(403); + expect(crossSession.body.error_code).toBe("a4_issue_binding_mismatch"); + expect(governance.calls).toHaveLength(0); + }); + + it("preserves safe governance errors but replaces path-bearing responses", async () => { + const safe = await startGovernanceStub({ + status: 409, + body: { detail: { code: "a4_proof_expired", retryable: true } }, + }); + process.env.GOVERNANCE_API_BASE = safe.baseUrl; + const expired = await request(routeApp()) + .post(`/api/governance/issues/from-a4-search/for-session/${sessionId}`) + .send(issuePayload()); + expect(expired.status).toBe(409); + expect(expired.body.detail.code).toBe("a4_proof_expired"); + + const leaking = await startGovernanceStub({ + status: 422, + body: { detail: "C:\\governance-private\\model.ifc" }, + }); + process.env.GOVERNANCE_API_BASE = leaking.baseUrl; + const sanitized = await request(routeApp()) + .post(`/api/governance/issues/from-a4-search/for-session/${sessionId}`) + .send(issuePayload()); + expect(sanitized.status).toBe(502); + expect(JSON.stringify(sanitized.body)).not.toContain("governance-private"); + }); +}); diff --git a/bim-review-coordinator/tests/governance-search-for-session.test.ts b/bim-review-coordinator/tests/governance-search-for-session.test.ts index c77e8aff7..876701ecd 100644 --- a/bim-review-coordinator/tests/governance-search-for-session.test.ts +++ b/bim-review-coordinator/tests/governance-search-for-session.test.ts @@ -692,7 +692,7 @@ describe("A4 search route contract", () => { }); describe("createCoordinatorApp A4 search integration", () => { - it("mounts the A4 router before all three legacy search routes", async () => { + it("mounts the scoped A4 search and Issue routers before the generic proxy", async () => { const governance = await startGovernanceStub(); const fixture = seedCoordinatorFixture(governance.baseUrl); @@ -705,6 +705,9 @@ describe("createCoordinatorApp A4 search integration", () => { const ifcReady = await request(fixture.app.app) .post(`/api/governance/search/model/for-ifc-ready/${fixture.ifcReadyJobId}`) .send({ query: "IfcDoor" }); + const issue = await request(fixture.app.app) + .post(`/api/governance/issues/from-a4-search/for-session/${fixture.sessionId}`) + .send({}); expect(generic.status).toBe(404); expect(generic.body.error_code).toBe("a4_generic_search_disabled"); @@ -712,6 +715,8 @@ describe("createCoordinatorApp A4 search integration", () => { expect(session.body.error_code).toBe("a4_authentication_required"); expect(ifcReady.status).toBe(401); expect(ifcReady.body.error_code).toBe("a4_authentication_required"); + expect(issue.status).toBe(401); + expect(issue.body.error_code).toBe("a4_authentication_required"); expect(governance.calls).toHaveLength(0); }); @@ -760,6 +765,14 @@ describe("createCoordinatorApp A4 search integration", () => { expect(JSON.stringify(ready.body)).not.toContain(fixture.sourcePath); expect(JSON.stringify(ready.body)).not.toContain(fixture.mappingPath); + const issueMutation = await request(fixture.app.app) + .post(`/api/governance/issues/from-a4-search/for-session/${fixture.sessionId}`) + .set("X-User-Token", owner) + .send({}); + expect(issueMutation.status).toBe(503); + expect(issueMutation.body.error_code).toBe("a4_issue_authority_unavailable"); + expect(governance.calls).toHaveLength(1); + const stolenLease = await request(fixture.app.app) .post(`/api/governance/search/model/for-session/${fixture.sessionId}`) .set("X-User-Token", "different-user") diff --git a/governance-service/README.md b/governance-service/README.md index 749340642..13c13f908 100644 --- a/governance-service/README.md +++ b/governance-service/README.md @@ -48,10 +48,21 @@ POST /api/rule-runs {ifc_source_path, rule_set?, model_ver GET /api/rule-runs/{id} status / score / summary GET /api/rule-runs/{id}/results?status=failed 失敗構件(ifc_guid, usd_prim_path, message) GET /api/rule-runs/{id}/export?fmt=excel openpyxl xlsx(fmt=bcf -> 501 p15) +POST /api/internal/a4/issues/from-search coordinator-only;signed row proof -> atomic confirmed Issue ``` `element_mapping_path` 可選;提供時 join `ifc_guid -> usd_prim_path`(未對映留 `null`,fake/smoke mapping 一律不視為覆蓋率)。 +A4 Issue route 只接受 16–4096 字元 printable-ASCII server-only internal token 與 trusted current +session/principal context 的單列 request。首次 consume 原子保存 Issue、immutable +snapshot、unique proof ID 與三個 replay digests;exact replay 回原 Issue,generic +manual/rule/diff Issue 不會被 fabricated A4 provenance 回填。 +在 session-authorized lifecycle route 落地前,generic Issue list/detail/transition +不列出也不揭露 A4 Issue;trusted internal create response 是目前唯一可讀完整 A4 +immutable evidence 的 API boundary。 +Proof snapshot 中的 finite float 與超過 JavaScript safe range 的 integer 會以 +exact decimal string 傳輸,避免 Python/Node JSON round-trip 改變 signed bytes。 + ## 執行 / 測試 ```bash diff --git a/governance-service/app.py b/governance-service/app.py index 9ecf84059..8809aaf70 100644 --- a/governance-service/app.py +++ b/governance-service/app.py @@ -61,6 +61,12 @@ app.include_router(issue_router) +# A4 confirmed-row Issue creation is a coordinator-only trusted-context route; +# it is separate from generic/manual/rule/diff Issue semantics. +from issues.a4_api import router as a4_issue_router # noqa: E402 + +app.include_router(a4_issue_router) + # BCF 匯出(issue → BCF 2.1 .bcfzip):匯出模組執行期只用 stdlib、不 import bcf-client; # ifctester 會在環境 transitive 安裝 bcf-client(GPLv3),匯出產物不含其程式碼。 from bcf.api import router as bcf_router # noqa: E402 diff --git a/governance-service/issues/__init__.py b/governance-service/issues/__init__.py index 27e026af0..ef7a01819 100644 --- a/governance-service/issues/__init__.py +++ b/governance-service/issues/__init__.py @@ -1,6 +1,18 @@ """Governance issue tracking package(issue 生命週期 + audit + BCF-aligned 來源綁定)。""" from __future__ import annotations -from .store import ISSUE_STATUSES, IssueStore, TransitionError +from .store import ( + ISSUE_STATUSES, + A4IssueReplayConflict, + A4IssueUnauthorized, + IssueStore, + TransitionError, +) -__all__ = ["ISSUE_STATUSES", "IssueStore", "TransitionError"] +__all__ = [ + "ISSUE_STATUSES", + "A4IssueReplayConflict", + "A4IssueUnauthorized", + "IssueStore", + "TransitionError", +] diff --git a/governance-service/issues/a4_api.py b/governance-service/issues/a4_api.py new file mode 100644 index 000000000..88fb83554 --- /dev/null +++ b/governance-service/issues/a4_api.py @@ -0,0 +1,278 @@ +"""Coordinator-only A4 confirmed-row Issue creation boundary.""" +from __future__ import annotations + +import hashlib +import hmac +import re +import unicodedata +from typing import Any, Literal, Optional + +from fastapi import APIRouter, Header, HTTPException, Response +from pydantic import BaseModel, ConfigDict, Field + +from search.proofs import ( + ProofExpired, + ProofUnavailable, + canonical_json, + canonicalize_proof_snapshot, + parse_proof_token, + proof_digest as digest_proof, + proof_registry, +) +from search.internal_auth import internal_context_token, internal_token_matches + +from .api import _get_store +from .store import A4IssueReplayConflict, A4IssueUnauthorized + +router = APIRouter() + +_HEX_64_RE = re.compile(r"^[0-9a-f]{64}$") +_PROOF_TOKEN_PATTERN = r"^a4p\.[A-Za-z0-9_-]{1,64}\.[A-Za-z0-9_-]{16,96}\.[0-9a-f]{64}$" + + +class TrustedA4IssueContext(BaseModel): + """Current coordinator authority; browsers cannot establish this object.""" + + model_config = ConfigDict(extra="forbid") + + scope: Literal["session_table_only"] + review_session_id: str = Field(..., min_length=1, max_length=160) + principal_ref: str = Field(..., min_length=1, max_length=160) + primary_artifact_id: str = Field(..., min_length=1, max_length=160) + active_binding_revision: str = Field(..., min_length=1, max_length=160) + model_version_id: str = Field(..., min_length=1, max_length=256) + auth_scope: Literal["production"] + mapping_provenance: Literal["server_resolved"] + primary_lease_capability: Literal["verified"] + + +class InternalA4IssueCreateBody(BaseModel): + model_config = ConfigDict(extra="forbid") + + title: str = Field(..., min_length=1, max_length=500) + description: Optional[str] = Field(default=None, max_length=4_000) + severity: Literal["low", "medium", "high", "critical"] = "medium" + assignee: Optional[str] = Field(default=None, max_length=256) + ifc_guid: str = Field(..., min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_$-]+$") + usd_prim_path: Optional[str] = Field(default=None, max_length=2_048) + evidence_proof: str = Field(..., min_length=87, max_length=230, pattern=_PROOF_TOKEN_PATTERN) + a4_evidence_snapshot: dict[str, Any] + a4_trusted_context: TrustedA4IssueContext + + +def _error(status_code: int, code: str, **details: Any) -> HTTPException: + return HTTPException(status_code=status_code, detail={"code": code, **details}) + + +def _normalize_required_text(value: str, *, code: str) -> str: + normalized = unicodedata.normalize("NFC", value).strip() + if not normalized: + raise _error(422, code) + return normalized + + +def _normalize_optional_text(value: Optional[str], *, strip: bool = False) -> Optional[str]: + if value is None: + return None + normalized = unicodedata.normalize("NFC", value) + if strip: + normalized = normalized.strip() + return normalized or None + return normalized + + +def _require_snapshot_structure(snapshot: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + filters = snapshot.get("normalized_filters") + interpretation = snapshot.get("interpretation") + row = snapshot.get("row") + if ( + not isinstance(snapshot.get("query"), str) + or not snapshot["query"].strip() + or not isinstance(filters, dict) + or not isinstance(interpretation, dict) + or not isinstance(interpretation.get("source"), str) + or interpretation.get("complete") is not True + or interpretation.get("completion_scope") != "complete_table" + or interpretation.get("partial_execution") is not False + or interpretation.get("scan_complete") is not True + or interpretation.get("truncated") is not False + or not isinstance(interpretation.get("degraded_to_deterministic"), bool) + or not isinstance(interpretation.get("unresolved_terms"), list) + or not all(isinstance(item, str) for item in interpretation["unresolved_terms"]) + or not isinstance(row, dict) + or not isinstance(row.get("ifc_guid"), str) + or not row["ifc_guid"] + or not isinstance(row.get("ifc_class"), str) + or not row["ifc_class"] + or not isinstance(row.get("matched_properties"), dict) + or not isinstance(row.get("predicate_trace"), list) + or not all(isinstance(item, str) for item in row["predicate_trace"]) + or not isinstance(row.get("mapping_observed"), bool) + or not isinstance(snapshot.get("mapping_digest"), str) + or not _HEX_64_RE.fullmatch(snapshot["mapping_digest"]) + ): + raise _error(422, "a4_evidence_snapshot_invalid") + return row, interpretation + + +def _require_current_binding( + snapshot: dict[str, Any], + context: TrustedA4IssueContext, +) -> dict[str, Any]: + binding = snapshot.get("session_binding") + if not isinstance(binding, dict): + raise _error(422, "a4_evidence_snapshot_invalid") + expected = { + "review_session_id": context.review_session_id, + "principal_ref": context.principal_ref, + "primary_artifact_id": context.primary_artifact_id, + "active_binding_revision": context.active_binding_revision, + "model_version_id": context.model_version_id, + "mapping_provenance": "server_resolved", + "primary_lease_capability": "verified", + "auth_scope": "production", + "session_id": context.review_session_id, + "principal": context.principal_ref, + "model_artifact": context.primary_artifact_id, + } + if snapshot.get("model_version_id") != context.model_version_id or any( + binding.get(name) != value for name, value in expected.items() + ): + raise _error(403, "a4_issue_unauthorized") + return binding + + +def _canonical_issue_snapshot( + body: InternalA4IssueCreateBody, +) -> dict[str, Any]: + snapshot = canonicalize_proof_snapshot(body.a4_evidence_snapshot) + if snapshot is None: + raise _error(422, "a4_evidence_snapshot_invalid") + row, _interpretation = _require_snapshot_structure(snapshot) + _require_current_binding(snapshot, body.a4_trusted_context) + + accepted_prim = row.get("accepted_usd_prim") + row_prim = row.get("usd_prim_path") + if row["ifc_guid"] != body.ifc_guid: + raise _error(422, "a4_issue_row_mismatch") + if row["mapping_observed"]: + if ( + not isinstance(accepted_prim, str) + or not accepted_prim + or row_prim != accepted_prim + or body.usd_prim_path != accepted_prim + ): + raise _error(422, "a4_issue_mapping_mismatch") + elif accepted_prim is not None or row_prim is not None or body.usd_prim_path is not None: + raise _error(422, "a4_issue_mapping_mismatch") + return snapshot + + +def _creation_fields(body: InternalA4IssueCreateBody) -> dict[str, Any]: + return { + "title": _normalize_required_text(body.title, code="a4_issue_title_invalid"), + "description": _normalize_optional_text(body.description), + "severity": body.severity, + "assignee": _normalize_optional_text(body.assignee, strip=True), + "ifc_guid": unicodedata.normalize("NFC", body.ifc_guid), + "usd_prim_path": _normalize_optional_text(body.usd_prim_path), + } + + +@router.post("/api/internal/a4/issues/from-search") +def create_issue_from_a4_search( + body: InternalA4IssueCreateBody, + response: Response, + internal_token: Optional[str] = Header(default=None, alias="X-A4-Internal-Token"), +) -> dict[str, Any]: + """Consume one signed A4 row after coordinator reauthorization.""" + configured_token = internal_context_token() + if configured_token is None: + raise _error(503, "a4_internal_context_unavailable") + if not internal_token or not internal_token_matches(internal_token, configured_token): + raise _error(401, "a4_internal_context_unauthorized") + + snapshot = _canonical_issue_snapshot(body) + fields = _creation_fields(body) + reference = parse_proof_token(body.evidence_proof) + if reference is None: + raise _error(422, "a4_proof_invalid") + + snapshot_bytes = canonical_json(snapshot) + snapshot_hash = hashlib.sha256(snapshot_bytes).hexdigest() + proof_hash = digest_proof(body.evidence_proof) + context = body.a4_trusted_context + creation_request_hash = hashlib.sha256( + canonical_json( + { + **fields, + "model_version_id": context.model_version_id, + "primary_artifact_id": context.primary_artifact_id, + "active_binding_revision": context.active_binding_revision, + "snapshot_hash": snapshot_hash, + "proof_digest": proof_hash, + } + ) + ).hexdigest() + + replay_args = { + "proof_id": reference.proof_id, + "review_session_id": context.review_session_id, + "principal_ref": context.principal_ref, + "snapshot_hash": snapshot_hash, + "proof_digest": proof_hash, + "creation_request_hash": creation_request_hash, + } + store = _get_store() + try: + existing = store.find_a4_issue_replay(**replay_args) + except A4IssueUnauthorized as exc: + raise _error(403, "a4_issue_unauthorized") from exc + except A4IssueReplayConflict as exc: + raise _error(409, "a4_issue_replay_conflict") from exc + if existing is not None: + response.status_code = 200 + return {"issue": existing, "replayed": True} + + try: + verified = proof_registry.verify(body.evidence_proof, snapshot=snapshot) + except ProofExpired as exc: + raise _error( + 409, + "a4_proof_expired", + retryable=True, + recovery="rerun_query", + draft_preserved=True, + ) from exc + except ProofUnavailable as exc: + raise _error(409, "a4_proof_invalid") from exc + + snapshot_matches = hmac.compare_digest(verified.snapshot_hash, snapshot_hash) + proof_matches = hmac.compare_digest(verified.proof_digest, proof_hash) + bytes_match = hmac.compare_digest(canonical_json(verified.snapshot), snapshot_bytes) + if ( + verified.proof_id != reference.proof_id + or not snapshot_matches + or not proof_matches + or not bytes_match + ): + raise _error(409, "a4_proof_invalid") + + try: + issue, replayed = store.create_a4_issue( + **fields, + model_version_id=context.model_version_id, + primary_artifact_id=context.primary_artifact_id, + active_binding_revision=context.active_binding_revision, + query_id=snapshot["query_id"], + schema_version=snapshot["schema_version"], + evidence_snapshot_json=snapshot_bytes.decode("utf-8"), + **replay_args, + ) + except A4IssueUnauthorized as exc: + raise _error(403, "a4_issue_unauthorized") from exc + except A4IssueReplayConflict as exc: + raise _error(409, "a4_issue_replay_conflict") from exc + + response.status_code = 200 if replayed else 201 + return {"issue": issue, "replayed": replayed} diff --git a/governance-service/issues/api.py b/governance-service/issues/api.py index 4fe52c4b8..ffd52c5f7 100644 --- a/governance-service/issues/api.py +++ b/governance-service/issues/api.py @@ -65,15 +65,21 @@ def list_issues(status: Optional[str] = Query(None), severity: Optional[str] = Q @router.get("/api/issues/{issue_id}") def get_issue(issue_id: str): issue = _get_store().get_issue(issue_id) - if not issue: + if not issue or issue.get("source_type") == "a4_search": raise HTTPException(status_code=404, detail="issue not found") return {"issue": issue, "events": _get_store().get_events(issue_id)} @router.post("/api/issues/{issue_id}/transition") def transition_issue(issue_id: str, body: TransitionBody): + store = _get_store() + current = store.get_issue(issue_id) + if current is None or current.get("source_type") == "a4_search": + # Do not expose whether an A4 Issue exists through the generic, + # non-session-authorized route. + raise HTTPException(status_code=404, detail="issue not found") try: - return _get_store().transition(issue_id, body.to_status, body.note) + return store.transition(issue_id, body.to_status, body.note) except KeyError: raise HTTPException(status_code=404, detail="issue not found") except TransitionError as exc: diff --git a/governance-service/issues/store.py b/governance-service/issues/store.py index 0c2a8fae8..f2fcd97da 100644 --- a/governance-service/issues/store.py +++ b/governance-service/issues/store.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +import hmac import json import os import sqlite3 @@ -36,7 +37,7 @@ ifc_guid TEXT, usd_prim_path TEXT, model_version_id TEXT, - source_type TEXT, -- 'manual' | 'rule_result' | 'diff_item' + source_type TEXT, -- 'manual' | 'rule_result' | 'diff_item' | 'a4_search' source_ref TEXT, created_at TEXT, updated_at TEXT @@ -52,6 +53,22 @@ ); CREATE INDEX IF NOT EXISTS idx_issue_events_issue ON issue_events(issue_id); CREATE INDEX IF NOT EXISTS idx_issues_mv ON issues(model_version_id); +CREATE TABLE IF NOT EXISTS a4_issue_evidence( + issue_id TEXT PRIMARY KEY, + schema_version TEXT NOT NULL, + evidence_snapshot TEXT NOT NULL, + review_session_id TEXT NOT NULL, + principal_ref TEXT NOT NULL, + primary_artifact_id TEXT NOT NULL, + active_binding_revision TEXT NOT NULL, + proof_id TEXT NOT NULL UNIQUE, + snapshot_hash TEXT NOT NULL, + proof_digest TEXT NOT NULL, + creation_request_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(issue_id) REFERENCES issues(id) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_a4_issue_evidence_proof ON a4_issue_evidence(proof_id); """ @@ -67,6 +84,14 @@ class TransitionError(ValueError): pass +class A4IssueReplayConflict(ValueError): + """A consumed proof ID was replayed with different immutable bytes.""" + + +class A4IssueUnauthorized(PermissionError): + """Current trusted session/principal does not own the consumed proof.""" + + class IssueStore: def __init__(self, db_path: str): self.db_path = db_path @@ -81,6 +106,209 @@ def _conn(self) -> sqlite3.Connection: conn.row_factory = sqlite3.Row return conn + @staticmethod + def _attach_a4_evidence(issue: dict, evidence: sqlite3.Row | None) -> dict: + if evidence is None: + return issue + issue.update( + { + "primary_artifact_id": evidence["primary_artifact_id"], + "active_binding_revision": evidence["active_binding_revision"], + "a4_evidence_snapshot": json.loads(evidence["evidence_snapshot"]), + "a4_proof_id": evidence["proof_id"], + "snapshot_hash": evidence["snapshot_hash"], + "proof_digest": evidence["proof_digest"], + "creation_request_hash": evidence["creation_request_hash"], + } + ) + return issue + + def _a4_record_by_proof(self, conn: sqlite3.Connection, proof_id: str): + evidence = conn.execute( + "SELECT * FROM a4_issue_evidence WHERE proof_id=?", + (proof_id,), + ).fetchone() + if evidence is None: + return None + issue_row = conn.execute("SELECT * FROM issues WHERE id=?", (evidence["issue_id"],)).fetchone() + if issue_row is None: + # The two rows are inserted atomically. Treat corruption as a hard + # failure rather than fabricating an Issue or accepting a replay. + raise RuntimeError("A4 Issue evidence has no owning Issue") + return { + "issue": self._attach_a4_evidence(dict(issue_row), evidence), + "review_session_id": evidence["review_session_id"], + "principal_ref": evidence["principal_ref"], + "snapshot_hash": evidence["snapshot_hash"], + "proof_digest": evidence["proof_digest"], + "creation_request_hash": evidence["creation_request_hash"], + } + + @staticmethod + def _validated_a4_replay( + record: dict, + *, + review_session_id: str, + principal_ref: str, + snapshot_hash: str, + proof_digest: str, + creation_request_hash: str, + ) -> dict: + # Authorization deliberately precedes digest comparisons so an + # authenticated but different session cannot use replay responses as a + # proof-existence oracle. + if ( + record["review_session_id"] != review_session_id + or record["principal_ref"] != principal_ref + ): + raise A4IssueUnauthorized("A4 Issue replay is not authorized") + + # Evaluate all three comparisons before combining their results. This + # avoids a data-dependent short circuit across immutable replay fields. + snapshot_matches = hmac.compare_digest(record["snapshot_hash"], snapshot_hash) + proof_matches = hmac.compare_digest(record["proof_digest"], proof_digest) + request_matches = hmac.compare_digest( + record["creation_request_hash"], creation_request_hash + ) + if not (snapshot_matches and proof_matches and request_matches): + raise A4IssueReplayConflict("A4 Issue replay conflicts with the stored request") + return record["issue"] + + def find_a4_issue_replay( + self, + *, + proof_id: str, + review_session_id: str, + principal_ref: str, + snapshot_hash: str, + proof_digest: str, + creation_request_hash: str, + ) -> dict | None: + """Return an exact consumed-proof replay without consulting live keys.""" + with self._conn() as conn: + record = self._a4_record_by_proof(conn, proof_id) + if record is None: + return None + return self._validated_a4_replay( + record, + review_session_id=review_session_id, + principal_ref=principal_ref, + snapshot_hash=snapshot_hash, + proof_digest=proof_digest, + creation_request_hash=creation_request_hash, + ) + + def create_a4_issue( + self, + *, + title: str, + description: str | None, + severity: str, + assignee: str | None, + ifc_guid: str, + usd_prim_path: str | None, + model_version_id: str, + primary_artifact_id: str, + active_binding_revision: str, + query_id: str, + schema_version: str, + evidence_snapshot_json: str, + review_session_id: str, + principal_ref: str, + proof_id: str, + snapshot_hash: str, + proof_digest: str, + creation_request_hash: str, + ) -> tuple[dict, bool]: + """Atomically create one confirmed A4 Issue or return its exact replay.""" + issue_id = _new_id("iss") + now = _now() + conn = self._conn() + conn.isolation_level = None + try: + conn.execute("PRAGMA busy_timeout=5000") + conn.execute("BEGIN IMMEDIATE") + existing = self._a4_record_by_proof(conn, proof_id) + if existing is not None: + issue = self._validated_a4_replay( + existing, + review_session_id=review_session_id, + principal_ref=principal_ref, + snapshot_hash=snapshot_hash, + proof_digest=proof_digest, + creation_request_hash=creation_request_hash, + ) + conn.execute("COMMIT") + return issue, True + + conn.execute( + "INSERT INTO issues(id, kind, title, description, status, severity, assignee, ifc_guid," + " usd_prim_path, model_version_id, source_type, source_ref, created_at, updated_at)" + " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + issue_id, + "issue", + title, + description, + "open", + severity, + assignee, + ifc_guid, + usd_prim_path, + model_version_id, + "a4_search", + query_id, + now, + now, + ), + ) + conn.execute( + "INSERT INTO a4_issue_evidence(issue_id, schema_version, evidence_snapshot," + " review_session_id, principal_ref, primary_artifact_id, active_binding_revision," + " proof_id, snapshot_hash, proof_digest, creation_request_hash, created_at)" + " VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", + ( + issue_id, + schema_version, + evidence_snapshot_json, + review_session_id, + principal_ref, + primary_artifact_id, + active_binding_revision, + proof_id, + snapshot_hash, + proof_digest, + creation_request_hash, + now, + ), + ) + conn.execute( + "INSERT INTO issue_events(id, issue_id, event_type, from_status, to_status, note, created_at)" + " VALUES(?,?,?,?,?,?,?)", + ( + _new_id("ev"), + issue_id, + "created", + None, + "open", + f"source=a4_search;query_id={query_id}", + now, + ), + ) + conn.execute("COMMIT") + except Exception: + try: + conn.execute("ROLLBACK") + except Exception: + pass + raise + finally: + conn.close() + issue = self.get_a4_issue(issue_id) + if issue is None: + raise RuntimeError("A4 Issue transaction committed without a readable Issue") + return issue, False + def create_issue( self, title, @@ -168,16 +396,40 @@ def create_issues_batch(self, items: list[dict]) -> dict: def get_issue(self, issue_id: str): with self._conn() as conn: row = conn.execute("SELECT * FROM issues WHERE id=?", (issue_id,)).fetchone() - return dict(row) if row else None + return dict(row) if row is not None else None + + def get_a4_issue(self, issue_id: str): + """Read A4 evidence only for the trusted internal creation boundary.""" + with self._conn() as conn: + row = conn.execute("SELECT * FROM issues WHERE id=?", (issue_id,)).fetchone() + if row is None: + return None + evidence = conn.execute( + "SELECT * FROM a4_issue_evidence WHERE issue_id=?", + (issue_id,), + ).fetchone() + return self._attach_a4_evidence(dict(row), evidence) def get_events(self, issue_id: str) -> list[dict]: with self._conn() as conn: return [dict(r) for r in conn.execute( "SELECT * FROM issue_events WHERE issue_id=? ORDER BY created_at", (issue_id,)).fetchall()] - def list_issues(self, status=None, severity=None, model_version_id=None, kind=None) -> list[dict]: + def list_issues( + self, + status=None, + severity=None, + model_version_id=None, + kind=None, + *, + include_a4: bool = False, + ) -> list[dict]: query = "SELECT * FROM issues WHERE 1=1" args: list = [] + if not include_a4: + # The generic issue surface is not session-authorized. Keep A4 + # records non-enumerable until a trusted lifecycle route exists. + query += " AND (source_type IS NULL OR source_type <> 'a4_search')" for col, val in (("status", status), ("severity", severity), ("model_version_id", model_version_id), ("kind", kind)): if val: query += f" AND {col}=?" diff --git a/governance-service/search/api.py b/governance-service/search/api.py index 7985ba44d..b1351a473 100644 --- a/governance-service/search/api.py +++ b/governance-service/search/api.py @@ -1,8 +1,6 @@ """A4 search REST — hung under governance-service; browser reaches via coordinator proxy.""" from __future__ import annotations -import os -import secrets from typing import Annotated, Any, Literal, Optional from fastapi import APIRouter, Header, HTTPException @@ -11,6 +9,7 @@ from .engine import PartialFallbackUnavailable, SearchRequest, confirm_partial_fallback, run_model_search from .handoff import ProofAuthority, ProofRejected, verify_handoff_evidence +from .internal_auth import internal_context_token, internal_token_matches from .llm_client import load_llm_config from .proofs import ProofExpired, ProofUnavailable, proof_registry @@ -113,21 +112,6 @@ def verify(self, token: str, *, now: Optional[float] = None): _handoff_proof_authority: Optional[ProofAuthority] = _ProofRegistryHandoffAuthority() -def _internal_context_token() -> Optional[str]: - token = os.getenv("A4_INTERNAL_CONTEXT_TOKEN", "").strip() - return token or None - - -def _internal_token_matches(candidate: str, configured: str) -> bool: - """Compare the opaque internal token and fail closed on non-ASCII input.""" - try: - candidate_bytes = candidate.encode("ascii") - configured_bytes = configured.encode("ascii") - except UnicodeEncodeError: - return False - return secrets.compare_digest(candidate_bytes, configured_bytes) - - def _require_matching_session_model_version(body: InternalModelSearchBody) -> None: """A session proof may never bind a different model than the scanned model.""" context = body.a4_trusted_context @@ -207,10 +191,10 @@ def search_model_with_trusted_context( internal_token: Optional[str] = Header(default=None, alias="X-A4-Internal-Token"), ) -> dict[str, Any]: """Accept coordinator provenance only over the configured internal channel.""" - configured_token = _internal_context_token() + configured_token = internal_context_token() if configured_token is None: raise HTTPException(status_code=503, detail={"code": "a4_internal_context_unavailable"}) - if not internal_token or not _internal_token_matches(internal_token, configured_token): + if not internal_token or not internal_token_matches(internal_token, configured_token): raise HTTPException(status_code=401, detail={"code": "a4_internal_context_unauthorized"}) _require_matching_session_model_version(body) return _run_search(body, body.a4_trusted_context.model_dump(exclude_none=True)) @@ -222,10 +206,10 @@ def confirm_model_search_partial( internal_token: Optional[str] = Header(default=None, alias="X-A4-Internal-Token"), ) -> dict[str, Any]: """Run only the exact short-lived session-bound candidate after confirmation.""" - configured_token = _internal_context_token() + configured_token = internal_context_token() if configured_token is None: raise HTTPException(status_code=503, detail={"code": "a4_internal_context_unavailable"}) - if not internal_token or not _internal_token_matches(internal_token, configured_token): + if not internal_token or not internal_token_matches(internal_token, configured_token): raise HTTPException(status_code=401, detail={"code": "a4_internal_context_unauthorized"}) try: return confirm_partial_fallback( @@ -244,10 +228,10 @@ def verify_a4_handoff( internal_token: Optional[str] = Header(default=None, alias="X-A4-Internal-Token"), ): """Verify an atomic proof set; never persist intent or return proof/query data.""" - configured_token = _internal_context_token() + configured_token = internal_context_token() if configured_token is None: raise HTTPException(status_code=503, detail={"code": "a4_internal_context_unavailable"}) - if not internal_token or not _internal_token_matches(internal_token, configured_token): + if not internal_token or not internal_token_matches(internal_token, configured_token): raise HTTPException(status_code=401, detail={"code": "a4_internal_context_unauthorized"}) authority = _handoff_proof_authority if authority is None: diff --git a/governance-service/search/engine.py b/governance-service/search/engine.py index f86513d42..afee85f79 100644 --- a/governance-service/search/engine.py +++ b/governance-service/search/engine.py @@ -32,6 +32,19 @@ MAX_A4_SEARCH_WALL_TIME_SECONDS = 10.0 PARTIAL_FALLBACK_TTL_SECONDS = 300.0 PARTIAL_FALLBACK_MAX_ENTRIES = 256 +MAX_A4_PROOF_ROWS_PER_RESPONSE = 128 +MAX_A4_PROOF_ATTEMPTS_PER_RESPONSE = 128 +MAX_A4_PROOF_RESPONSE_BYTES = 512 * 1024 +# Keep governance safely below the coordinator's 2 MiB hard response cap. The +# margin covers serializer/envelope differences and small future additive fields. +MAX_A4_SEARCH_RESPONSE_BYTES = 1_792 * 1024 +MAX_A4_SEARCH_RESPONSE_MARGIN_BYTES = 16 * 1024 +MAX_A4_FILTER_ITEMS_PER_FIELD = 64 +MAX_A4_TABLE_TEXT = 1_024 +MAX_A4_NUMERIC_TEXT = 256 +MAX_SAFE_JSON_INTEGER = (1 << 53) - 1 +MAX_A4_INTEGER_BITS = 851 +_USD_PRIM_RE = re.compile(r"^/(?:[A-Za-z_][A-Za-z0-9_]*)+(?:/[A-Za-z_][A-Za-z0-9_]*)*$") @dataclass @@ -56,6 +69,7 @@ class _FilterResolution: semantic_error_code: Optional[str] = None semantic_retryable: bool = False partial_candidate: bool = False + degraded_to_deterministic: bool = False class PartialFallbackUnavailable(ValueError): @@ -71,6 +85,7 @@ class _PartialFallbackRecord: query_digest: str binding: dict[str, str] binding_digest: str + degraded_to_deterministic: bool expires_at: float @@ -100,6 +115,7 @@ def mint( evidence_refs: list[dict[str, Any]], query_id: str, binding: dict[str, str], + degraded_to_deterministic: bool, ) -> tuple[str, str]: now = time.time() expires_at = now + self.ttl_seconds @@ -114,6 +130,7 @@ def mint( binding_digest=hashlib.sha256( json.dumps(binding, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") ).hexdigest(), + degraded_to_deterministic=degraded_to_deterministic, expires_at=expires_at, ) with self._lock: @@ -204,9 +221,15 @@ def _numeric(value: Any) -> Optional[float]: if value is None or isinstance(value, bool): return None if isinstance(value, (int, float)): - numeric = float(value) + try: + numeric = float(value) + except (OverflowError, ValueError): + return None return numeric if math.isfinite(numeric) else None - match = re.search(r"-?\d+(?:\.\d+)?", str(value).strip()) + if not isinstance(value, str) or len(value) > MAX_A4_NUMERIC_TEXT: + return None + text = value.strip() + match = re.search(r"-?\d+(?:\.\d+)?", text) if not match: return None try: @@ -216,6 +239,46 @@ def _numeric(value: Any) -> Optional[float]: return numeric if math.isfinite(numeric) else None +def _table_scalar(value: Any) -> Any: + """Project one bounded, JSON-wire-stable value into a table row.""" + if value is None or isinstance(value, bool): + return value + if isinstance(value, int): + if abs(value) <= MAX_SAFE_JSON_INTEGER: + return value + if value.bit_length() > MAX_A4_INTEGER_BITS: + return None + text = str(value) + return text if len(text.lstrip("-")) <= MAX_A4_NUMERIC_TEXT else None + if isinstance(value, float): + return value if math.isfinite(value) else None + if isinstance(value, str): + return value if len(value) <= MAX_A4_TABLE_TEXT else None + return None + + +def _trace_scalar(value: Any) -> str: + safe = _table_scalar(value) + if safe is None and value is not None: + return "" + text = str(safe) + return text if len(text) <= 160 else "" + + +def _safe_row_guid(value: Any) -> Optional[str]: + return value if isinstance(value, str) and 0 < len(value) <= 64 else None + + +def _safe_prim_path(value: Any) -> Optional[str]: + return ( + value + if isinstance(value, str) + and len(value) <= 2_048 + and _USD_PRIM_RE.fullmatch(value) + else None + ) + + def _prop_match(properties: dict[str, Any], predicate: PropertyFilter) -> tuple[bool, Any, str]: if predicate.name not in properties: return False, None, f"property_missing:{predicate.name}" @@ -230,7 +293,7 @@ def _prop_match(properties: dict[str, Any], predicate: PropertyFilter) -> tuple[ ">=": numeric >= predicate.value, "==": numeric == predicate.value, }.get(predicate.op, False) - return matches, actual, f"{predicate.name}{predicate.op}{predicate.value} actual={actual}" + return matches, actual, f"{predicate.name}{predicate.op}{predicate.value} actual={_trace_scalar(actual)}" def _storey_match(storey: Optional[str], tokens: list[str]) -> bool: @@ -347,7 +410,12 @@ def _resolve_filters(request: SearchRequest) -> _FilterResolution: "fallback": "after_incomplete_llm", } ) - return _FilterResolution(filters=deterministic, evidence=evidence, partial_candidate=True) + return _FilterResolution( + filters=deterministic, + evidence=evidence, + partial_candidate=True, + degraded_to_deterministic=True, + ) return _FilterResolution( filters=semantic, evidence=evidence, @@ -364,7 +432,12 @@ def _resolve_filters(request: SearchRequest) -> _FilterResolution: "fallback": "after_llm_error", } ) - return _FilterResolution(filters=deterministic, evidence=evidence, partial_candidate=True) + return _FilterResolution( + filters=deterministic, + evidence=evidence, + partial_candidate=True, + degraded_to_deterministic=True, + ) return _FilterResolution( filters=_semantic_failure_filters(request.query, exc.code), evidence=evidence, @@ -477,11 +550,20 @@ def _issue_row_proof( filters: InterpretedFilters, base_response: dict[str, Any], row: dict[str, Any], -) -> Optional[dict[str, str]]: + *, + degraded_to_deterministic: bool, +) -> Optional[dict[str, Any]]: """Mint a small immutable proof only for a complete trusted result row.""" binding = _proof_binding(request) guid = row.get("ifc_guid") - if binding is None or not isinstance(guid, str) or not guid: + if ( + binding is None + or not isinstance(guid, str) + or not guid + or row.get("property_values_omitted") + or (filters.name_contains and row.get("name_omitted")) + or (filters.storey_tokens and row.get("storey_omitted")) + ): return None accepted_prim = row.get("usd_prim_path") if not isinstance(accepted_prim, str): @@ -528,7 +610,7 @@ def _issue_row_proof( "partial_execution": False, "scan_complete": True, "truncated": False, - "degraded_to_deterministic": False, + "degraded_to_deterministic": degraded_to_deterministic, "unresolved_terms": list(filters.unresolved_terms[:64]), }, "row": { @@ -627,6 +709,55 @@ def _is_deadline_exceeded(started_at: float) -> bool: return time.monotonic() - started_at > MAX_A4_SEARCH_WALL_TIME_SECONDS +def _success_response( + *, + base_response: dict[str, Any], + results: list[dict[str, Any]], + candidate_count: int, + scanned: int, + matched: int, + partial_execution: bool, + degraded_to_deterministic: bool, + proof_count: int, + proof_limited: bool, +) -> dict[str, Any]: + truncated = matched > len(results) + mapped = sum(1 for row in results if row.get("mapping_observed") is True) + unmapped = len(results) - mapped + return { + **base_response, + "status": "ok", + "results": results, + "stats": { + "total": candidate_count, + "scanned": scanned, + "matched": matched, + "not_matched": scanned - matched, + "returned": len(results), + "mapped": mapped, + "unmapped": unmapped, + "truncated": truncated, + "total_is_lower_bound": False, + "scan_complete": True, + "proof_eligible_returned": proof_count, + "proof_limited": proof_limited, + }, + "completion_scope": "partial_table_only" if partial_execution else ("complete_table" if not truncated else "truncated_table"), + "partial_execution_confirmed": partial_execution, + "partial_confirmation_available": False, + "partial_fallback_id": None, + "degraded_to_deterministic": degraded_to_deterministic, + "proof_eligible": proof_count > 0, + "issue_eligible": proof_count > 0, + "highlight_eligible": any(row.get("highlight_eligible") is True for row in results), + "next_step": ( + "narrow_query_or_reduce_result_limit" + if truncated + else (None if results else "broaden_filters_or_check_model_content") + ), + } + + def _execute_search( request: SearchRequest, filters: InterpretedFilters, @@ -634,7 +765,18 @@ def _execute_search( base_response: dict[str, Any], *, partial_execution: bool, + degraded_to_deterministic: bool, ) -> dict[str, Any]: + if any( + len(items) > MAX_A4_FILTER_ITEMS_PER_FIELD + for items in ( + filters.ifc_classes, + filters.storey_tokens, + filters.property_filters, + filters.name_contains, + ) + ): + return _resource_limit_response(base_response, "filter_budget_exhausted") try: if os.path.getsize(request.ifc_source_path) > MAX_A4_IFC_BYTES: return _resource_limit_response(base_response, "ifc_source_too_large") @@ -705,8 +847,6 @@ def _execute_search( limit = max(1, min(int(request.limit or 200), 1000)) results: list[dict[str, Any]] = [] matched = 0 - mapped = 0 - unmapped = 0 scanned = 0 for element in candidates: if _is_deadline_exceeded(started_at): @@ -723,6 +863,8 @@ def _execute_search( ifc_class = element.is_a() storey = _storey_name(element) properties = _psets_flat(element) + safe_name = _table_scalar(name) + safe_storey = _table_scalar(storey) traces: list[str] = [] matches = True @@ -730,13 +872,16 @@ def _execute_search( matches = False traces.append(f"class_mismatch:{ifc_class}") if matches and filters.storey_tokens: - if _storey_match(storey, filters.storey_tokens): - traces.append(f"storey_match:{storey}") + if isinstance(safe_storey, str) and _storey_match( + safe_storey, + filters.storey_tokens, + ): + traces.append(f"storey_match:{_trace_scalar(safe_storey)}") else: matches = False - traces.append(f"storey_mismatch:{storey}") + traces.append(f"storey_mismatch:{_trace_scalar(safe_storey)}") if matches and filters.name_contains: - normalized_name = (name or "").lower() + normalized_name = safe_name.lower() if isinstance(safe_name, str) else "" for fragment in filters.name_contains: if fragment.lower() in normalized_name: traces.append(f"name_contains:{fragment}") @@ -755,23 +900,29 @@ def _execute_search( continue matched += 1 - prim_path = mapping.get(guid) if guid else None + safe_guid = _safe_row_guid(guid) + prim_path = _safe_prim_path(mapping.get(guid)) if guid else None if len(results) < limit: - if prim_path: - mapped += 1 - else: - unmapped += 1 + row_properties: dict[str, Any] = {} + omitted_properties: list[str] = [] + for predicate in filters.property_filters: + actual = properties.get(predicate.name) + projected = _table_scalar(actual) + row_properties[predicate.name] = projected + if actual is not None and projected is None: + omitted_properties.append(predicate.name) results.append( { - "ifc_guid": guid, + "ifc_guid": safe_guid, "usd_prim_path": prim_path, "mapping_observed": bool(prim_path), "ifc_class": ifc_class, - "name": name, - "storey": storey, - "properties": {predicate.name: properties.get(predicate.name) for predicate in filters.property_filters} - if filters.property_filters - else {}, + "name": safe_name, + "name_omitted": name is not None and safe_name is None, + "storey": safe_storey, + "storey_omitted": storey is not None and safe_storey is None, + "properties": row_properties, + "property_values_omitted": omitted_properties, # This is a query-predicate result, never a compliance # verdict. Keep the wire value explicit for every UI. "match_status": "matched_query", @@ -785,46 +936,112 @@ def _execute_search( } ) + # Bound the complete response, not just proof attachments. IFC names, + # property evidence, and predicate traces are all variable-size legitimate + # data. Reduce returned rows honestly while preserving full scan counts. + response_wire_limit = MAX_A4_SEARCH_RESPONSE_BYTES - MAX_A4_SEARCH_RESPONSE_MARGIN_BYTES + + def unproved_response_for(candidate_results: list[dict[str, Any]]) -> dict[str, Any]: + return _success_response( + base_response=base_response, + results=candidate_results, + candidate_count=len(candidates), + scanned=scanned, + matched=matched, + partial_execution=partial_execution, + degraded_to_deterministic=degraded_to_deterministic, + proof_count=0, + proof_limited=True, + ) + + unproved_response = unproved_response_for(results) + if len(canonical_json(unproved_response)) > response_wire_limit: + low = 0 + high = len(results) + while low < high: + midpoint = (low + high + 1) // 2 + candidate_response = unproved_response_for(results[:midpoint]) + if len(canonical_json(candidate_response)) <= response_wire_limit: + low = midpoint + else: + high = midpoint - 1 + del results[low:] + unproved_response = unproved_response_for(results) + if len(canonical_json(unproved_response)) > response_wire_limit: + return _resource_limit_response( + base_response, + "response_budget_exhausted", + candidate_count_lower_bound=len(candidates), + scanned=scanned, + matched=matched, + ) + truncated = matched > len(results) proof_count = 0 + proof_attempts = 0 + proof_response_bytes = 0 + proof_limited = False + unproved_response_bytes = len(canonical_json(unproved_response)) + proof_wire_limit = max( + 0, + min( + MAX_A4_PROOF_RESPONSE_BYTES, + response_wire_limit - unproved_response_bytes, + ), + ) if not partial_execution and not truncated: for row in results: - proof = _issue_row_proof(request, filters, base_response, row) + if ( + proof_count >= MAX_A4_PROOF_ROWS_PER_RESPONSE + or proof_attempts >= MAX_A4_PROOF_ATTEMPTS_PER_RESPONSE + ): + proof_limited = True + break + proof_attempts += 1 + proof = _issue_row_proof( + request, + filters, + base_response, + row, + degraded_to_deterministic=degraded_to_deterministic, + ) if proof is None: continue + attachment_bytes = len( + canonical_json( + { + "evidence_proof": proof["evidence_proof"], + "a4_evidence_snapshot": proof["a4_evidence_snapshot"], + "proof_expires_at": proof["expires_at"], + } + ) + ) + if proof_response_bytes + attachment_bytes > proof_wire_limit: + proof_registry.discard(proof["proof_id"]) + proof_limited = True + break row["evidence_proof"] = proof["evidence_proof"] + row["a4_evidence_snapshot"] = proof["a4_evidence_snapshot"] row["proof_expires_at"] = proof["expires_at"] row["proof_eligible"] = True row["issue_eligible"] = True row["action_eligible"] = bool(row.get("mapping_observed")) row["highlight_eligible"] = bool(row.get("mapping_observed")) proof_count += 1 - return { - **base_response, - "status": "ok", - "results": results, - "stats": { - "total": len(candidates), - "scanned": scanned, - "matched": matched, - "not_matched": scanned - matched, - "returned": len(results), - "mapped": mapped, - "unmapped": unmapped, - "truncated": truncated, - "total_is_lower_bound": False, - "scan_complete": True, - }, - "completion_scope": "partial_table_only" if partial_execution else ("complete_table" if not truncated else "truncated_table"), - "partial_execution_confirmed": partial_execution, - "partial_confirmation_available": False, - "partial_fallback_id": None, - "degraded_to_deterministic": partial_execution, - "proof_eligible": proof_count > 0, - "issue_eligible": proof_count > 0, - "highlight_eligible": any(row.get("highlight_eligible") is True for row in results), - "next_step": None if results else "broaden_filters_or_check_model_content", - } + proof_response_bytes += attachment_bytes + if _proof_binding(request) is not None and proof_count < len(results): + proof_limited = True + return _success_response( + base_response=base_response, + results=results, + candidate_count=len(candidates), + scanned=scanned, + matched=matched, + partial_execution=partial_execution, + degraded_to_deterministic=degraded_to_deterministic, + proof_count=proof_count, + proof_limited=proof_limited, + ) def run_model_search(request: SearchRequest) -> dict[str, Any]: @@ -867,6 +1084,7 @@ def run_model_search(request: SearchRequest) -> dict[str, Any]: evidence_refs=evidence_refs, query_id=query_id, binding=binding, + degraded_to_deterministic=resolution.degraded_to_deterministic, ) except PartialFallbackUnavailable: return { @@ -877,7 +1095,7 @@ def run_model_search(request: SearchRequest) -> dict[str, Any]: "partial_execution_confirmed": False, "partial_confirmation_available": False, "partial_fallback_id": None, - "degraded_to_deterministic": True, + "degraded_to_deterministic": resolution.degraded_to_deterministic, "next_step": "retry_authenticated_query_later", } return { @@ -888,7 +1106,7 @@ def run_model_search(request: SearchRequest) -> dict[str, Any]: "partial_confirmation_available": True, "partial_fallback_id": partial_fallback_id, "partial_fallback_expires_at": expires_at, - "degraded_to_deterministic": True, + "degraded_to_deterministic": resolution.degraded_to_deterministic, "next_step": "confirm_exact_partial_filters", } # Exact, bound partial confirmation can only be minted by the coordinator @@ -901,11 +1119,18 @@ def run_model_search(request: SearchRequest) -> dict[str, Any]: "partial_execution_confirmed": False, "partial_confirmation_available": False, "partial_fallback_id": None, - "degraded_to_deterministic": resolution.partial_candidate, + "degraded_to_deterministic": resolution.degraded_to_deterministic, "next_step": "run_through_authenticated_session_confirmation", } - return _execute_search(request, filters, evidence_refs, base_response, partial_execution=False) + return _execute_search( + request, + filters, + evidence_refs, + base_response, + partial_execution=False, + degraded_to_deterministic=resolution.degraded_to_deterministic, + ) def confirm_partial_fallback(partial_fallback_id: str, trusted_a4_context: dict[str, Any]) -> dict[str, Any]: @@ -925,4 +1150,11 @@ def confirm_partial_fallback(partial_fallback_id: str, trusted_a4_context: dict[ evidence_refs = copy.deepcopy(record.evidence_refs) evidence_refs.append({"kind": "partial_confirmation", "mode": "exact_bound_candidate"}) base_response = _base_response(request, record.filters, evidence_refs) - return _execute_search(request, record.filters, evidence_refs, base_response, partial_execution=True) + return _execute_search( + request, + record.filters, + evidence_refs, + base_response, + partial_execution=True, + degraded_to_deterministic=record.degraded_to_deterministic, + ) diff --git a/governance-service/search/internal_auth.py b/governance-service/search/internal_auth.py new file mode 100644 index 000000000..0a8a6c50d --- /dev/null +++ b/governance-service/search/internal_auth.py @@ -0,0 +1,28 @@ +"""Shared fail-closed authentication for coordinator-only A4 routes.""" +from __future__ import annotations + +import os +import secrets +from typing import Optional + + +def internal_context_token() -> Optional[str]: + token = os.getenv("A4_INTERNAL_CONTEXT_TOKEN", "").strip() + if not token or len(token) < 16 or len(token) > 4_096: + return None + try: + encoded = token.encode("ascii") + except UnicodeEncodeError: + return None + if any(value < 0x21 or value > 0x7E for value in encoded): + return None + return token + + +def internal_token_matches(candidate: str, configured: str) -> bool: + try: + candidate_bytes = candidate.encode("ascii") + configured_bytes = configured.encode("ascii") + except UnicodeEncodeError: + return False + return secrets.compare_digest(candidate_bytes, configured_bytes) diff --git a/governance-service/search/proofs.py b/governance-service/search/proofs.py index 1e6cebaf0..00e5004e5 100644 --- a/governance-service/search/proofs.py +++ b/governance-service/search/proofs.py @@ -1,11 +1,15 @@ """Short-lived, opaque A4 row proofs. -Proofs are deliberately process-local rather than a query-history store. A -browser sees only an opaque id plus MAC; the immutable snapshot remains on the -governance side until expiry or one successful A4 Issue transaction. +Live proof records are deliberately process-local rather than a query-history +store. A browser receives an opaque id plus MAC and the bounded path-free +snapshot that the MAC binds. Authenticated expiry/hash claims let a retained +active/previous key verify that submitted snapshot after a normal restart. +Only a human-confirmed Issue may persist the snapshot. """ from __future__ import annotations +import base64 +import binascii import hashlib import hmac import json @@ -24,16 +28,24 @@ DEFAULT_PROOF_TTL_SECONDS = 300.0 MAX_PROOF_TTL_SECONDS = 900.0 MAX_PROOF_RECORDS = 2_048 +MAX_PROOF_RECORDS_PER_BINDING = 256 +MAX_PROOF_RECORDS_PER_PRINCIPAL = 512 MAX_PROOF_SNAPSHOT_BYTES = 64 * 1024 MAX_PROOF_SNAPSHOT_DEPTH = 8 MAX_PROOF_SNAPSHOT_ITEMS = 128 MAX_PROOF_SNAPSHOT_TEXT = 4_096 +MAX_SAFE_JSON_INTEGER = (1 << 53) - 1 +MAX_PROOF_INTEGER_DECIMAL_DIGITS = 256 +MAX_PROOF_INTEGER_BITS = 851 _KID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") # Dots make the three variable-length fields unambiguous. Both ``kid`` and a # URL-safe opaque identifier may contain underscores, so an underscore-delimited # token would admit ambiguous parses during verification. _TOKEN_RE = re.compile(r"^a4p\.([A-Za-z0-9_-]{1,64})\.([A-Za-z0-9_-]{16,96})\.([0-9a-f]{64})$") _QUERY_ID_RE = re.compile(r"^a4q_[A-Za-z0-9_-]{12,64}$") +_EMBEDDED_PROOF_ID_VERSION = 1 +_EMBEDDED_PROOF_NONCE_BYTES = 18 +_EMBEDDED_PROOF_ID_BYTES = 1 + 8 + 32 + _EMBEDDED_PROOF_NONCE_BYTES _SNAPSHOT_KEYS = { "schema_version", "query_id", @@ -102,11 +114,54 @@ def canonical_json(value: Any) -> bytes: ).encode("utf-8") +def _wire_stable_snapshot_value(value: Any) -> Any: + """Normalize proof values that JavaScript JSON cannot round-trip exactly. + + The proof snapshot crosses Python -> JSON -> Node -> JSON -> Python before + Issue creation. JSON numbers do not preserve Python's float spelling or + integers outside JavaScript's safe range, so proof-only numeric evidence is + represented as an exact string. Booleans remain booleans because ``bool`` + is an ``int`` subclass in Python. + """ + if value is None or isinstance(value, bool): + return value + if isinstance(value, int): + if abs(value) <= MAX_SAFE_JSON_INTEGER: + return value + if value.bit_length() > MAX_PROOF_INTEGER_BITS: + raise ValueError("integer exceeds proof snapshot budget") + text = str(value) + if len(text.lstrip("-")) > MAX_PROOF_INTEGER_DECIMAL_DIGITS: + raise ValueError("integer exceeds proof snapshot budget") + return text + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError("non-finite JSON value") + return repr(value) + if isinstance(value, str): + return unicodedata.normalize("NFC", value) + if isinstance(value, list): + return [_wire_stable_snapshot_value(item) for item in value] + if isinstance(value, dict): + normalized: dict[str, Any] = {} + for raw_key, item in value.items(): + if not isinstance(raw_key, str): + raise ValueError("JSON object key must be a string") + key = unicodedata.normalize("NFC", raw_key) + if key in normalized: + raise ValueError("duplicate canonical JSON key") + normalized[key] = _wire_stable_snapshot_value(item) + return normalized + raise ValueError("unsupported proof snapshot value") + + def _is_bounded_snapshot_value(value: Any, *, depth: int = 0) -> bool: if depth > MAX_PROOF_SNAPSHOT_DEPTH: return False - if value is None or isinstance(value, (bool, int)): + if value is None or isinstance(value, bool): return True + if isinstance(value, int): + return value.bit_length() <= MAX_PROOF_INTEGER_BITS if isinstance(value, float): return math.isfinite(value) if isinstance(value, str): @@ -155,7 +210,8 @@ def _canonical_snapshot(snapshot: dict[str, Any]) -> Optional[dict[str, Any]]: ): return None try: - encoded = canonical_json(snapshot) + wire_stable = _wire_stable_snapshot_value(snapshot) + encoded = canonical_json(wire_stable) except (TypeError, ValueError): return None if len(encoded) > MAX_PROOF_SNAPSHOT_BYTES: @@ -163,6 +219,16 @@ def _canonical_snapshot(snapshot: dict[str, Any]) -> Optional[dict[str, Any]]: return json.loads(encoded) +def canonicalize_proof_snapshot(snapshot: dict[str, Any]) -> Optional[dict[str, Any]]: + """Return the bounded canonical A4 snapshot accepted by proof issuance. + + A4 Issue creation receives the same immutable snapshot alongside the opaque + proof. Keeping this validator public lets that boundary hash exactly the + contract that was signed without exposing registry state or signing keys. + """ + return _canonical_snapshot(snapshot) + + def _safe_ttl_seconds() -> Optional[float]: raw = os.getenv("A4_PROOF_TTL_SECONDS", "").strip() if not raw: @@ -228,10 +294,61 @@ class ProofTokenReference: proof_id: str +@dataclass(frozen=True) +class _EmbeddedProofClaims: + expires_at_epoch: float + snapshot_hash: str + + @dataclass class _ProofRecord: verified: VerifiedProof signature: str + binding_digest: str + principal_digest: str + + +def _proof_quota_digests(snapshot: dict[str, Any]) -> Optional[tuple[str, str]]: + binding = snapshot.get("session_binding") + if not isinstance(binding, dict): + return None + review_session_id = binding.get("review_session_id") + legacy_session_id = binding.get("session_id") + principal_ref = binding.get("principal_ref") + legacy_principal = binding.get("principal") + if ( + isinstance(review_session_id, str) + and isinstance(legacy_session_id, str) + and review_session_id != legacy_session_id + ): + return None + if ( + isinstance(principal_ref, str) + and isinstance(legacy_principal, str) + and principal_ref != legacy_principal + ): + return None + if not isinstance(review_session_id, str): + review_session_id = legacy_session_id + if not isinstance(principal_ref, str): + principal_ref = legacy_principal + if ( + not isinstance(review_session_id, str) + or not review_session_id + or not isinstance(principal_ref, str) + or not principal_ref + ): + return None + binding_digest = hashlib.sha256( + canonical_json( + { + "review_session_id": review_session_id, + "principal_ref": principal_ref, + } + ) + ).hexdigest() + principal_digest = hashlib.sha256(principal_ref.encode("utf-8")).hexdigest() + return binding_digest, principal_digest def _signature_claims(*, kid: str, proof_id: str, expires_at_epoch: float, snapshot_hash: str) -> bytes: @@ -249,6 +366,40 @@ def _sign(key: bytes, claims: bytes) -> str: return hmac.new(key, claims, hashlib.sha256).hexdigest() +def _encode_proof_id(*, expires_at_epoch: float, snapshot_hash: str) -> str: + """Encode restart-safe public claims inside the still-opaque proof id. + + The random nonce preserves proof-id uniqueness. Expiry and snapshot hash are + authenticated by the outer MAC, so an Issue can verify a browser-submitted + snapshot after a normal key-rotation restart without persisting query rows. + """ + raw = ( + bytes([_EMBEDDED_PROOF_ID_VERSION]) + + int(expires_at_epoch).to_bytes(8, "big") + + bytes.fromhex(snapshot_hash) + + secrets.token_bytes(_EMBEDDED_PROOF_NONCE_BYTES) + ) + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _decode_proof_id(proof_id: str) -> Optional[_EmbeddedProofClaims]: + padding = "=" * (-len(proof_id) % 4) + try: + raw = base64.b64decode(proof_id + padding, altchars=b"-_", validate=True) + except (binascii.Error, ValueError): + return None + if len(raw) != _EMBEDDED_PROOF_ID_BYTES or raw[0] != _EMBEDDED_PROOF_ID_VERSION: + return None + expires_at_epoch = float(int.from_bytes(raw[1:9], "big")) + snapshot_hash = raw[9:41].hex() + if expires_at_epoch <= 0: + return None + return _EmbeddedProofClaims( + expires_at_epoch=expires_at_epoch, + snapshot_hash=snapshot_hash, + ) + + def parse_proof_token(token: str) -> Optional[ProofTokenReference]: """Parse an opaque envelope without treating it as authenticated evidence.""" match = _TOKEN_RE.fullmatch(token or "") @@ -264,32 +415,74 @@ def proof_digest(token: str) -> str: class ProofRegistry: - def __init__(self, *, max_records: int = MAX_PROOF_RECORDS): + def __init__( + self, + *, + max_records: int = MAX_PROOF_RECORDS, + max_records_per_binding: int = MAX_PROOF_RECORDS_PER_BINDING, + max_records_per_principal: int = MAX_PROOF_RECORDS_PER_PRINCIPAL, + ): self.max_records = max_records + self.max_records_per_binding = max_records_per_binding + self.max_records_per_principal = max_records_per_principal self._records: dict[str, _ProofRecord] = {} + self._binding_counts: dict[str, int] = {} + self._principal_counts: dict[str, int] = {} self._expired_ids: dict[str, float] = {} self._lock = threading.Lock() + @staticmethod + def _decrement_count(counts: dict[str, int], key: str) -> None: + remaining = counts.get(key, 0) - 1 + if remaining > 0: + counts[key] = remaining + else: + counts.pop(key, None) + + def _remove_record(self, proof_id: str) -> Optional[_ProofRecord]: + record = self._records.pop(proof_id, None) + if record is not None: + self._decrement_count(self._binding_counts, record.binding_digest) + self._decrement_count(self._principal_counts, record.principal_digest) + return record + def _purge_expired(self, now: float) -> None: for proof_id, record in list(self._records.items()): if record.verified.expires_at_epoch <= now: - self._records.pop(proof_id, None) + self._remove_record(proof_id) self._expired_ids[proof_id] = now # This remains a short-lived availability hint, not proof/query history. for proof_id, recorded_at in list(self._expired_ids.items()): if recorded_at + MAX_PROOF_TTL_SECONDS <= now: self._expired_ids.pop(proof_id, None) - def issue(self, snapshot: dict[str, Any]) -> Optional[dict[str, str]]: + def issue(self, snapshot: dict[str, Any]) -> Optional[dict[str, Any]]: keyring = ProofKeyring.from_environment() - normalized_snapshot = _canonical_snapshot(snapshot) + normalized_snapshot = canonicalize_proof_snapshot(snapshot) ttl_seconds = _safe_ttl_seconds() - if keyring is None or normalized_snapshot is None or ttl_seconds is None: + quota_digests = ( + _proof_quota_digests(normalized_snapshot) + if normalized_snapshot is not None + else None + ) + if ( + keyring is None + or normalized_snapshot is None + or ttl_seconds is None + or quota_digests is None + ): return None + binding_digest, principal_digest = quota_digests now = time.time() - expires_at_epoch = now + ttl_seconds - proof_id = secrets.token_urlsafe(18) + # Whole-second expiry is encoded into the opaque proof id so normal + # key rotation can survive a process restart. Ceil avoids shortening a + # configured fractional TTL. + expires_at_epoch = float(math.ceil(now + ttl_seconds)) snapshot_hash = hashlib.sha256(canonical_json(normalized_snapshot)).hexdigest() + proof_id = _encode_proof_id( + expires_at_epoch=expires_at_epoch, + snapshot_hash=snapshot_hash, + ) claims = _signature_claims( kid=keyring.active_kid, proof_id=proof_id, @@ -313,12 +506,44 @@ def issue(self, snapshot: dict[str, Any]) -> Optional[dict[str, str]]: # Do not evict another active session's proof. A saturated registry # degrades to table-only rather than allowing one principal to cancel # another principal's yet-valid confirmation path. - if len(self._records) >= self.max_records or proof_id in self._records: + if ( + len(self._records) >= self.max_records + or self._binding_counts.get(binding_digest, 0) >= self.max_records_per_binding + or self._principal_counts.get(principal_digest, 0) >= self.max_records_per_principal + or proof_id in self._records + ): return None - self._records[proof_id] = _ProofRecord(verified=verified, signature=signature) - return {"evidence_proof": token, "proof_id": proof_id, "kid": keyring.active_kid, "expires_at": expires_at} + self._records[proof_id] = _ProofRecord( + verified=verified, + signature=signature, + binding_digest=binding_digest, + principal_digest=principal_digest, + ) + self._binding_counts[binding_digest] = self._binding_counts.get(binding_digest, 0) + 1 + self._principal_counts[principal_digest] = self._principal_counts.get(principal_digest, 0) + 1 + return { + "evidence_proof": token, + "proof_id": proof_id, + "kid": keyring.active_kid, + "expires_at": expires_at, + # The browser may persist this only when a human confirms an Issue. + # It is the same canonical, bounded, path-free object whose hash is + # signed above; callers receive a copy and cannot mutate registry state. + "a4_evidence_snapshot": json.loads(canonical_json(normalized_snapshot)), + } - def verify(self, token: str, *, now: Optional[float] = None) -> VerifiedProof: + def discard(self, proof_id: str) -> None: + """Remove an unreturned proof minted during response budgeting.""" + with self._lock: + self._remove_record(proof_id) + + def verify( + self, + token: str, + *, + now: Optional[float] = None, + snapshot: Optional[dict[str, Any]] = None, + ) -> VerifiedProof: reference = parse_proof_token(token) if reference is None: raise ProofUnavailable("proof unavailable") @@ -330,12 +555,31 @@ def verify(self, token: str, *, now: Optional[float] = None) -> VerifiedProof: self._purge_expired(current) record = self._records.get(proof_id) known_expired = proof_id in self._expired_ids - if record is None: + embedded = _decode_proof_id(proof_id) + if record is None and embedded is None: if known_expired: raise ProofExpired("proof expired") raise ProofUnavailable("proof unavailable") - if record.verified.kid != kid or record.verified.expires_at_epoch <= current: - raise ProofUnavailable("proof unavailable") + if record is not None: + expires_at_epoch = record.verified.expires_at_epoch + snapshot_hash = record.verified.snapshot_hash + if ( + record.verified.kid != kid + or ( + embedded is not None + and ( + embedded.expires_at_epoch != expires_at_epoch + or not hmac.compare_digest(embedded.snapshot_hash, snapshot_hash) + ) + ) + ): + raise ProofUnavailable("proof unavailable") + else: + if embedded is None: # narrowed above; keeps type checkers honest + raise ProofUnavailable("proof unavailable") + expires_at_epoch = embedded.expires_at_epoch + snapshot_hash = embedded.snapshot_hash + keyring = ProofKeyring.from_environment() key = keyring.verify_keys.get(kid) if keyring is not None else None if key is None: @@ -343,14 +587,37 @@ def verify(self, token: str, *, now: Optional[float] = None) -> VerifiedProof: claims = _signature_claims( kid=kid, proof_id=proof_id, - expires_at_epoch=record.verified.expires_at_epoch, - snapshot_hash=record.verified.snapshot_hash, + expires_at_epoch=expires_at_epoch, + snapshot_hash=snapshot_hash, ) expected = _sign(key, claims) - if not hmac.compare_digest(signature, expected) or not hmac.compare_digest(signature, record.signature): + signature_matches = hmac.compare_digest(signature, expected) + record_matches = record is None or hmac.compare_digest(signature, record.signature) + if not signature_matches or not record_matches: + raise ProofUnavailable("proof unavailable") + if expires_at_epoch <= current: + raise ProofExpired("proof expired") + + if record is not None: + # Callers must not be able to mutate the short-lived registry snapshot. + return replace(record.verified, snapshot=json.loads(canonical_json(record.verified.snapshot))) + + normalized_snapshot = canonicalize_proof_snapshot(snapshot) if snapshot is not None else None + if normalized_snapshot is None: raise ProofUnavailable("proof unavailable") - # Callers must not be able to mutate the short-lived registry snapshot. - return replace(record.verified, snapshot=json.loads(canonical_json(record.verified.snapshot))) + submitted_hash = hashlib.sha256(canonical_json(normalized_snapshot)).hexdigest() + if not hmac.compare_digest(submitted_hash, snapshot_hash): + raise ProofUnavailable("proof unavailable") + expires_at = datetime.fromtimestamp(expires_at_epoch, timezone.utc).isoformat().replace("+00:00", "Z") + return VerifiedProof( + proof_id=proof_id, + kid=kid, + expires_at=expires_at, + expires_at_epoch=expires_at_epoch, + snapshot_hash=snapshot_hash, + snapshot=normalized_snapshot, + proof_digest=proof_digest(token), + ) proof_registry = ProofRegistry() diff --git a/governance-service/tests/test_a4_issues.py b/governance-service/tests/test_a4_issues.py new file mode 100644 index 000000000..01d1cf590 --- /dev/null +++ b/governance-service/tests/test_a4_issues.py @@ -0,0 +1,591 @@ +"""A4 confirmed-row Issue persistence, authorization, and replay contract.""" +from __future__ import annotations + +import concurrent.futures +import importlib +import json +import sqlite3 +import time + +import pytest +from fastapi.testclient import TestClient + +from search.proofs import ProofExpired, ProofRegistry + + +_INTERNAL_TOKEN = "test-internal-a4-context-token" +_SIGNING_KEY = "test-proof-signing-key-material-32bytes" + + +def _trusted_context(*, principal_ref: str = "principal_a4", session_id: str = "review_session_a4") -> dict: + return { + "scope": "session_table_only", + "review_session_id": session_id, + "principal_ref": principal_ref, + "primary_artifact_id": "artifact_a4", + "active_binding_revision": "binding_a4_1", + "model_version_id": "a4_fixture_v1", + "auth_scope": "production", + "mapping_provenance": "server_resolved", + "primary_lease_capability": "verified", + } + + +def _snapshot( + *, + query_id: str = "a4q_issue_fixture_0001", + ifc_guid: str = "0A4DoorLow000000000001", + accepted_prim: str | None = "/World/Doors/Low", +) -> dict: + context = _trusted_context() + return { + "schema_version": "a4-proof-v1", + "query_id": query_id, + "query": "找 4F 防火門且 FireRating < 60", + "normalized_filters": { + "ifc_classes": ["IfcDoor"], + "storeys": ["4F"], + "property_filters": [{"name": "FireRating", "op": "lt", "value": 60}], + }, + "interpretation": { + "mode": "deterministic", + "source": "deterministic_grammar", + "complete": True, + "completion_scope": "complete_table", + "partial_execution": False, + "scan_complete": True, + "truncated": False, + "degraded_to_deterministic": False, + "unresolved_terms": [], + }, + "row": { + "ifc_guid": ifc_guid, + "ifc_class": "IfcDoor", + "name": "Low Door", + "storey": "4F", + "matched_properties": {"FireRating": 30}, + "predicate_trace": ["IfcDoor", "storey=4F", "FireRating=30 < 60"], + "accepted_usd_prim": accepted_prim, + "mapping_observed": accepted_prim is not None, + "usd_prim_path": accepted_prim, + "highlight_eligible": accepted_prim is not None, + }, + "model_version_id": "a4_fixture_v1", + "session_binding": { + **context, + "session_id": context["review_session_id"], + "principal": context["principal_ref"], + "model_artifact": context["primary_artifact_id"], + }, + "mapping_digest": "a" * 64, + } + + +def _payload(registry: ProofRegistry, snapshot: dict | None = None, **overrides) -> dict: + source_evidence = snapshot or _snapshot() + proof = registry.issue(source_evidence) + assert proof is not None + evidence = proof["a4_evidence_snapshot"] + payload = { + "title": "4F 防火門 FireRating 不足", + "description": "請由人工確認並修正。", + "severity": "high", + "assignee": "reviewer-a4", + "ifc_guid": evidence["row"]["ifc_guid"], + "usd_prim_path": evidence["row"]["accepted_usd_prim"], + "evidence_proof": proof["evidence_proof"], + "a4_evidence_snapshot": evidence, + "a4_trusted_context": _trusted_context(), + } + payload.update(overrides) + return payload + + +@pytest.fixture() +def a4_client(tmp_path, monkeypatch): + db_path = str(tmp_path / "gov.db") + monkeypatch.setenv("GOV_DB_PATH", db_path) + monkeypatch.setenv("A4_INTERNAL_CONTEXT_TOKEN", _INTERNAL_TOKEN) + monkeypatch.setenv("A4_PROOF_ACTIVE_KID", "a4_test_kid") + monkeypatch.setenv("A4_PROOF_ACTIVE_KEY", _SIGNING_KEY) + monkeypatch.delenv("A4_PROOF_PREVIOUS_KID", raising=False) + monkeypatch.delenv("A4_PROOF_PREVIOUS_KEY", raising=False) + + import app as app_module + import issues.a4_api as a4_api + + importlib.reload(app_module) + registry = ProofRegistry() + monkeypatch.setattr(a4_api, "proof_registry", registry) + return TestClient(app_module.app), db_path, registry + + +def _post(client: TestClient, payload: dict): + return client.post( + "/api/internal/a4/issues/from-search", + headers={"X-A4-Internal-Token": _INTERNAL_TOKEN}, + json=payload, + ) + + +def _persisted_issue_count(db_path: str) -> int: + with sqlite3.connect(db_path) as conn: + return conn.execute("SELECT COUNT(*) FROM issues").fetchone()[0] + + +def test_confirmed_a4_issue_persists_immutable_provenance_and_audit(a4_client): + from issues.store import IssueStore + + client, db_path, registry = a4_client + payload = _payload(registry) + + response = _post(client, payload) + + assert response.status_code == 201 + body = response.json() + assert body["replayed"] is False + issue = body["issue"] + assert issue["source_type"] == "a4_search" + assert issue["source_ref"] == payload["a4_evidence_snapshot"]["query_id"] + assert issue["model_version_id"] == "a4_fixture_v1" + assert issue["primary_artifact_id"] == "artifact_a4" + assert issue["active_binding_revision"] == "binding_a4_1" + assert issue["a4_evidence_snapshot"] == payload["a4_evidence_snapshot"] + assert len(issue["snapshot_hash"]) == 64 + assert len(issue["proof_digest"]) == 64 + assert len(issue["creation_request_hash"]) == 64 + assert "evidence_proof" not in issue + + detail = client.get(f"/api/issues/{issue['id']}") + assert detail.status_code == 404 + assert client.get("/api/issues").json()["issues"] == [] + events = IssueStore(db_path).get_events(issue["id"]) + assert events[0]["note"] == f"source=a4_search;query_id={issue['source_ref']}" + with sqlite3.connect(db_path) as conn: + assert conn.execute("SELECT COUNT(*) FROM issues").fetchone()[0] == 1 + assert conn.execute("SELECT COUNT(*) FROM a4_issue_evidence").fetchone()[0] == 1 + query_history = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%query%history%'" + ).fetchall() + assert query_history == [] + + +def test_exact_replay_survives_expiry_and_signing_key_removal(a4_client, monkeypatch): + client, db_path, registry = a4_client + payload = _payload(registry) + first = _post(client, payload) + assert first.status_code == 201 + + fresh_replay = _post(client, payload) + assert fresh_replay.status_code == 200 + assert fresh_replay.json()["replayed"] is True + assert fresh_replay.json()["issue"]["id"] == first.json()["issue"]["id"] + + with pytest.raises(ProofExpired): + registry.verify(payload["evidence_proof"], now=time.time() + 10_000) + monkeypatch.delenv("A4_PROOF_ACTIVE_KID") + monkeypatch.delenv("A4_PROOF_ACTIVE_KEY") + replay = _post(client, payload) + + assert replay.status_code == 200 + assert replay.json()["replayed"] is True + assert replay.json()["issue"]["id"] == first.json()["issue"]["id"] + assert _persisted_issue_count(db_path) == 1 + assert client.get("/api/issues").json()["issues"] == [] + + +def test_unconsumed_previous_key_proof_verifies_during_normal_rotation(a4_client, monkeypatch): + import issues.a4_api as a4_api + + client, _, registry = a4_client + payload = _payload(registry) + monkeypatch.setenv("A4_PROOF_ACTIVE_KID", "a4_new_kid") + monkeypatch.setenv("A4_PROOF_ACTIVE_KEY", "new-proof-signing-key-material-32bytes") + monkeypatch.setenv("A4_PROOF_PREVIOUS_KID", "a4_test_kid") + monkeypatch.setenv("A4_PROOF_PREVIOUS_KEY", _SIGNING_KEY) + # Normal key rotation commonly restarts the service. Verification must rely + # on signed opaque claims + the submitted snapshot, not old process memory. + monkeypatch.setattr(a4_api, "proof_registry", ProofRegistry()) + + response = _post(client, payload) + + assert response.status_code == 201 + assert response.json()["issue"]["source_type"] == "a4_search" + + +@pytest.mark.parametrize("mutation", ["draft", "proof_bytes", "snapshot"]) +def test_consumed_proof_conflicting_replay_is_409(a4_client, mutation): + client, db_path, registry = a4_client + payload = _payload(registry) + first = _post(client, payload) + assert first.status_code == 201 + original = first.json()["issue"] + + conflicting = {**payload} + if mutation == "draft": + conflicting["title"] = "altered draft" + elif mutation == "proof_bytes": + proof = payload["evidence_proof"] + conflicting["evidence_proof"] = proof[:-1] + ("0" if proof[-1] != "0" else "1") + else: + conflicting["a4_evidence_snapshot"] = { + **payload["a4_evidence_snapshot"], + "query": "altered query", + } + + response = _post(client, conflicting) + + assert response.status_code == 409 + assert response.json()["detail"]["code"] == "a4_issue_replay_conflict" + current = _post(client, payload).json()["issue"] + assert current["a4_evidence_snapshot"] == original["a4_evidence_snapshot"] + assert _persisted_issue_count(db_path) == 1 + + +def test_unauthorized_replay_is_403_before_digest_details(a4_client): + client, db_path, registry = a4_client + payload = _payload(registry) + assert _post(client, payload).status_code == 201 + + stolen = { + **payload, + "title": "also altered", + "a4_trusted_context": _trusted_context(principal_ref="other_principal"), + } + response = _post(client, stolen) + + assert response.status_code == 403 + assert response.json()["detail"]["code"] == "a4_issue_unauthorized" + assert _persisted_issue_count(db_path) == 1 + + +def test_unconsumed_expired_proof_returns_recovery_hints_with_zero_write(a4_client, monkeypatch): + import issues.a4_api as a4_api + + client, db_path, registry = a4_client + payload = _payload(registry) + + class ExpiredProofAuthority: + def verify(self, token, *, snapshot=None): + return registry.verify(token, snapshot=snapshot, now=time.time() + 10_000) + + monkeypatch.setattr(a4_api, "proof_registry", ExpiredProofAuthority()) + + response = _post(client, payload) + + assert response.status_code == 409 + assert response.json()["detail"] == { + "code": "a4_proof_expired", + "retryable": True, + "recovery": "rerun_query", + "draft_preserved": True, + } + with sqlite3.connect(db_path) as conn: + assert conn.execute("SELECT COUNT(*) FROM issues").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM a4_issue_evidence").fetchone()[0] == 0 + + +def test_cross_boundary_and_forged_proof_are_rejected_without_writes(a4_client): + client, db_path, registry = a4_client + cross_session = _payload( + registry, + a4_trusted_context=_trusted_context(session_id="other_session"), + ) + cross_response = _post(client, cross_session) + assert cross_response.status_code == 403 + assert cross_response.json()["detail"]["code"] == "a4_issue_unauthorized" + + forged = _payload(registry) + proof = forged["evidence_proof"] + forged["evidence_proof"] = proof[:-1] + ("0" if proof[-1] != "0" else "1") + forged_response = _post(client, forged) + assert forged_response.status_code == 409 + assert forged_response.json()["detail"]["code"] == "a4_proof_invalid" + + with sqlite3.connect(db_path) as conn: + assert conn.execute("SELECT COUNT(*) FROM issues").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM a4_issue_evidence").fetchone()[0] == 0 + + +def test_even_signed_incomplete_snapshot_is_rejected_without_writes(a4_client): + client, db_path, registry = a4_client + incomplete = _snapshot() + incomplete["interpretation"] = { + **incomplete["interpretation"], + "complete": False, + "completion_scope": "confirmed_partial_table", + "partial_execution": True, + } + + response = _post(client, _payload(registry, incomplete)) + + assert response.status_code == 422 + assert response.json()["detail"]["code"] == "a4_evidence_snapshot_invalid" + with sqlite3.connect(db_path) as conn: + issue_table = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='issues'" + ).fetchone() + if issue_table is not None: + assert conn.execute("SELECT COUNT(*) FROM issues").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM a4_issue_evidence").fetchone()[0] == 0 + + +def test_same_query_different_rows_create_distinct_issues(a4_client): + client, db_path, registry = a4_client + first_snapshot = _snapshot() + second_snapshot = _snapshot(ifc_guid="0A4DoorLow000000000002", accepted_prim="/World/Doors/Low2") + + first = _post(client, _payload(registry, first_snapshot)) + second = _post(client, _payload(registry, second_snapshot)) + + assert first.status_code == 201 + assert second.status_code == 201 + assert first.json()["issue"]["source_ref"] == second.json()["issue"]["source_ref"] + assert first.json()["issue"]["id"] != second.json()["issue"]["id"] + assert _persisted_issue_count(db_path) == 2 + + +def test_concurrent_identical_requests_have_one_issue_and_one_replay(a4_client): + client, db_path, registry = a4_client + payload = _payload(registry) + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + responses = list(pool.map(lambda _index: _post(client, payload), range(2))) + + assert sorted(response.status_code for response in responses) == [200, 201] + assert sorted(response.json()["replayed"] for response in responses) == [False, True] + assert len({response.json()["issue"]["id"] for response in responses}) == 1 + assert _persisted_issue_count(db_path) == 1 + + +def test_unicode_canonicalization_makes_equivalent_replay_idempotent(a4_client): + client, _, registry = a4_client + decomposed = "Cafe\u0301 防火門" + snapshot = _snapshot() + snapshot["query"] = "Cafe\u0301 door" + snapshot["row"]["name"] = "Cafe\u0301 Door" + payload = _payload(registry, snapshot, title=decomposed) + first = _post(client, payload) + assert first.status_code == 201 + assert first.json()["issue"]["title"] == "Café 防火門" + assert first.json()["issue"]["a4_evidence_snapshot"]["query"] == "Café door" + + composed_snapshot = { + **snapshot, + "query": "Café door", + "row": {**snapshot["row"], "name": "Café Door"}, + } + replay = _post( + client, + { + **payload, + "title": "Café 防火門", + "a4_evidence_snapshot": composed_snapshot, + }, + ) + assert replay.status_code == 200 + assert replay.json()["issue"]["id"] == first.json()["issue"]["id"] + + +def test_proof_snapshot_numeric_values_survive_node_json_roundtrip(a4_client): + client, _, registry = a4_client + snapshot = _snapshot() + snapshot["normalized_filters"]["property_filters"][0]["value"] = 30.0 + snapshot["row"]["matched_properties"] = { + "FloatValue": 30.0, + "UnsafeInteger": 9_007_199_254_740_993, + } + payload = _payload(registry, snapshot) + + wire_snapshot = json.loads(json.dumps(payload["a4_evidence_snapshot"], ensure_ascii=False)) + assert wire_snapshot["normalized_filters"]["property_filters"][0]["value"] == "30.0" + assert wire_snapshot["row"]["matched_properties"] == { + "FloatValue": "30.0", + "UnsafeInteger": "9007199254740993", + } + payload["a4_evidence_snapshot"] = wire_snapshot + + response = _post(client, payload) + + assert response.status_code == 201 + assert response.json()["issue"]["a4_evidence_snapshot"] == wire_snapshot + + +def test_lifecycle_hides_a4_evidence_and_requires_session_authority(a4_client): + from issues.store import IssueStore + + client, db_path, registry = a4_client + created = _post(client, _payload(registry)).json()["issue"] + immutable = { + key: created[key] + for key in ( + "source_type", + "source_ref", + "a4_evidence_snapshot", + "snapshot_hash", + "proof_digest", + "creation_request_hash", + ) + } + + transitioned = client.post( + f"/api/issues/{created['id']}/transition", + json={"to_status": "assigned", "note": "review"}, + ) + assert transitioned.status_code == 404 + assert transitioned.json()["detail"] == "issue not found" + + # Storage-level lifecycle changes remain additive and do not rewrite the + # immutable evidence row. A future session-authorized transition route can + # call this same transaction after it establishes authority. + store = IssueStore(db_path) + store.transition(created["id"], "assigned", "review") + current = store.get_a4_issue(created["id"]) + assert current is not None + assert {key: current[key] for key in immutable} == immutable + + legacy = client.post( + "/api/issues", + json={ + "title": "legacy manual", + "ifc_guid": "LEGACY_GUID", + # Generic callers cannot self-assign A4 provenance; legacy extra + # fields remain ignored and the existing manual semantics win. + "source_type": "a4_search", + "a4_evidence_snapshot": _snapshot(), + }, + ) + assert legacy.status_code == 201 + assert legacy.json()["source_type"] == "manual" + assert "a4_evidence_snapshot" not in legacy.json() + + +def test_additive_schema_keeps_historical_issue_readable_without_backfill(tmp_path): + from issues.store import IssueStore + + db_path = str(tmp_path / "historical.db") + with sqlite3.connect(db_path) as conn: + conn.executescript( + """ + CREATE TABLE issues( + id TEXT PRIMARY KEY, kind TEXT, title TEXT, description TEXT, + status TEXT, severity TEXT, assignee TEXT, ifc_guid TEXT, + usd_prim_path TEXT, model_version_id TEXT, source_type TEXT, + source_ref TEXT, created_at TEXT, updated_at TEXT + ); + CREATE TABLE issue_events( + id TEXT PRIMARY KEY, issue_id TEXT, event_type TEXT, + from_status TEXT, to_status TEXT, note TEXT, created_at TEXT + ); + """ + ) + conn.execute( + "INSERT INTO issues VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "iss_historical", + "issue", + "historical", + None, + "open", + "medium", + None, + "HISTORICAL_GUID", + None, + "mv_old", + "rule_result", + "rule_old", + "2026-01-01T00:00:00Z", + "2026-01-01T00:00:00Z", + ), + ) + conn.execute( + "INSERT INTO issues VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "iss_historical_null_source", + "issue", + "historical null source", + None, + "open", + "medium", + None, + "HISTORICAL_NULL_GUID", + None, + "mv_old", + None, + None, + "2026-01-01T00:00:01Z", + "2026-01-01T00:00:01Z", + ), + ) + + store = IssueStore(db_path) + issue = store.get_issue("iss_historical") + + assert issue is not None + assert issue["source_type"] == "rule_result" + assert "a4_evidence_snapshot" not in issue + assert {item["id"] for item in store.list_issues()} == { + "iss_historical", + "iss_historical_null_source", + } + with sqlite3.connect(db_path) as conn: + assert conn.execute("SELECT COUNT(*) FROM a4_issue_evidence").fetchone()[0] == 0 + + +def test_internal_route_requires_server_token(a4_client): + client, db_path, registry = a4_client + payload = _payload(registry) + + response = client.post("/api/internal/a4/issues/from-search", json=payload) + + assert response.status_code == 401 + assert response.json()["detail"]["code"] == "a4_internal_context_unauthorized" + with sqlite3.connect(db_path) as conn: + issue_table = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='issues'" + ).fetchone() + if issue_table is not None: + assert conn.execute("SELECT COUNT(*) FROM issues").fetchone()[0] == 0 + + +def test_internal_route_rejects_short_configured_token(a4_client, monkeypatch): + client, db_path, registry = a4_client + payload = _payload(registry) + monkeypatch.setenv("A4_INTERNAL_CONTEXT_TOKEN", "short") + + response = client.post( + "/api/internal/a4/issues/from-search", + headers={"X-A4-Internal-Token": "short"}, + json=payload, + ) + + assert response.status_code == 503 + assert response.json()["detail"]["code"] == "a4_internal_context_unavailable" + with sqlite3.connect(db_path) as conn: + issue_table = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='issues'" + ).fetchone() + if issue_table is not None: + assert conn.execute("SELECT COUNT(*) FROM issues").fetchone()[0] == 0 + + +def test_store_authorizes_consumed_replay_before_digest_comparison(a4_client): + from issues.store import A4IssueUnauthorized, IssueStore + + client, db_path, registry = a4_client + payload = _payload(registry) + assert _post(client, payload).status_code == 201 + with sqlite3.connect(db_path) as conn: + conn.row_factory = sqlite3.Row + evidence = conn.execute("SELECT * FROM a4_issue_evidence").fetchone() + assert evidence is not None + + with pytest.raises(A4IssueUnauthorized): + IssueStore(db_path).find_a4_issue_replay( + proof_id=evidence["proof_id"], + review_session_id="different_session", + principal_ref="different_principal", + snapshot_hash="0" * 64, + proof_digest="1" * 64, + creation_request_hash="2" * 64, + ) diff --git a/governance-service/tests/test_search_handoff_api.py b/governance-service/tests/test_search_handoff_api.py index 9c519771d..e36105b86 100644 --- a/governance-service/tests/test_search_handoff_api.py +++ b/governance-service/tests/test_search_handoff_api.py @@ -202,8 +202,12 @@ def test_internal_handoff_api_fails_closed_without_proof_authority(monkeypatch): assert response.json() == {"detail": {"code": "a4_handoff_authority_unavailable"}} -def test_internal_handoff_api_rejects_non_ascii_token_config_without_500(monkeypatch): - monkeypatch.setenv("A4_INTERNAL_CONTEXT_TOKEN", "test-token-非-ascii") +@pytest.mark.parametrize("invalid_token", ["test-token-非-ascii", "short"]) +def test_internal_handoff_api_rejects_invalid_token_config_without_500( + monkeypatch, + invalid_token, +): + monkeypatch.setenv("A4_INTERNAL_CONTEXT_TOKEN", invalid_token) response = TestClient(app_module.app).post( "/api/internal/a4/handoffs/verify", @@ -211,8 +215,8 @@ def test_internal_handoff_api_rejects_non_ascii_token_config_without_500(monkeyp json={"action": "focus", "evidence_proofs": [VALID_SHAPED_UNKNOWN_PROOF], "binding": _binding()}, ) - assert response.status_code == 401 - assert response.json() == {"detail": {"code": "a4_internal_context_unauthorized"}} + assert response.status_code == 503 + assert response.json() == {"detail": {"code": "a4_internal_context_unavailable"}} def test_internal_handoff_api_uses_the_search_proof_registry_by_default(monkeypatch): diff --git a/governance-service/tests/test_search_model.py b/governance-service/tests/test_search_model.py index 8e7191cad..d0cc87e98 100644 --- a/governance-service/tests/test_search_model.py +++ b/governance-service/tests/test_search_model.py @@ -1,13 +1,14 @@ """A4 POST /api/search/model — deterministic semantic search.""" from __future__ import annotations +import json from pathlib import Path import pytest from fastapi.testclient import TestClient from search.handoff import verify_handoff_evidence -from search.interpreter import interpret_query +from search.interpreter import InterpretedFilters, PropertyFilter, interpret_query from search.engine import PartialFallbackUnavailable, SearchRequest, _storey_match, confirm_partial_fallback, run_model_search from search.proofs import ProofRegistry, _safe_ttl_seconds @@ -189,19 +190,86 @@ def test_proof_registry_uses_unambiguous_token_and_never_evicts_an_active_record monkeypatch.setenv("A4_PROOF_ACTIVE_KID", "a4_test_kid") monkeypatch.setenv("A4_PROOF_ACTIVE_KEY", "test-proof-signing-key-material-32bytes") - ids = iter(("proof_id_with_under_score_0001", "proof_id_with_under_score_0002")) - monkeypatch.setattr(proofs_mod.secrets, "token_urlsafe", lambda _n: next(ids)) registry = ProofRegistry(max_records=1) first = registry.issue(_proof_snapshot()) assert first is not None - assert first["evidence_proof"].startswith("a4p.a4_test_kid.proof_id_with_under_score_0001.") - assert registry.verify(first["evidence_proof"]).proof_id == "proof_id_with_under_score_0001" + assert first["evidence_proof"].startswith("a4p.a4_test_kid.") + assert first["evidence_proof"].count(".") == 3 + reference = proofs_mod.parse_proof_token(first["evidence_proof"]) + assert reference is not None + assert reference.proof_id == first["proof_id"] + assert registry.verify(first["evidence_proof"]).proof_id == first["proof_id"] # Saturation degrades the second request to table-only but does not remove a # still-valid proof belonging to the first request. assert registry.issue(_proof_snapshot(query_id="a4q_proof_fixture_0002")) is None - assert registry.verify(first["evidence_proof"]).proof_id == "proof_id_with_under_score_0001" + assert registry.verify(first["evidence_proof"]).proof_id == first["proof_id"] + + +def test_proof_registry_enforces_binding_and_principal_quotas(monkeypatch): + monkeypatch.setenv("A4_PROOF_ACTIVE_KID", "a4_test_kid") + monkeypatch.setenv("A4_PROOF_ACTIVE_KEY", "test-proof-signing-key-material-32bytes") + registry = ProofRegistry( + max_records=10, + max_records_per_binding=1, + max_records_per_principal=2, + ) + + first_snapshot = _proof_snapshot(query_id="a4q_quota_fixture_0001") + first = registry.issue(first_snapshot) + assert first is not None + assert registry.issue(_proof_snapshot(query_id="a4q_quota_fixture_0002")) is None + + second_session = _proof_snapshot(query_id="a4q_quota_fixture_0003") + second_session["session_binding"] = { + **second_session["session_binding"], + "review_session_id": "review_session_deadbeef13", + } + assert registry.issue(second_session) is not None + + third_session = _proof_snapshot(query_id="a4q_quota_fixture_0004") + third_session["session_binding"] = { + **third_session["session_binding"], + "review_session_id": "review_session_deadbeef14", + } + assert registry.issue(third_session) is None + + other_principal = _proof_snapshot(query_id="a4q_quota_fixture_0005") + other_principal["session_binding"] = { + **other_principal["session_binding"], + "review_session_id": "review_session_deadbeef14", + "principal_ref": "a4p_other", + } + assert registry.issue(other_principal) is not None + + # Removing an unreturned proof must release both quota dimensions. + registry.discard(first["proof_id"]) + assert registry.issue(_proof_snapshot(query_id="a4q_quota_fixture_0006")) is not None + + +def test_expiry_purge_releases_binding_and_principal_quotas(monkeypatch): + import search.proofs as proofs_mod + + current_time = [1_000.0] + monkeypatch.setenv("A4_PROOF_ACTIVE_KID", "a4_test_kid") + monkeypatch.setenv("A4_PROOF_ACTIVE_KEY", "test-proof-signing-key-material-32bytes") + monkeypatch.setenv("A4_PROOF_TTL_SECONDS", "1") + monkeypatch.setattr(proofs_mod.time, "time", lambda: current_time[0]) + registry = ProofRegistry( + max_records=10, + max_records_per_binding=1, + max_records_per_principal=1, + ) + + first = registry.issue(_proof_snapshot(query_id="a4q_expiry_quota_0001")) + assert first is not None + assert registry.issue(_proof_snapshot(query_id="a4q_expiry_quota_0002")) is None + + current_time[0] = 1_002.0 + replacement = registry.issue(_proof_snapshot(query_id="a4q_expiry_quota_0003")) + + assert replacement is not None def test_proof_registry_rejects_invalid_config_and_unsafe_snapshot(monkeypatch): @@ -217,6 +285,10 @@ def test_proof_registry_rejects_invalid_config_and_unsafe_snapshot(monkeypatch): unsafe["row"]["ifc_source_path"] = "C:/host/path/never-eligible.ifc" assert registry.issue(unsafe) is None + oversized_integer = _proof_snapshot(query_id="a4q_oversized_integer_0001") + oversized_integer["row"]["matched_properties"] = {"Huge": 10**10_000} + assert registry.issue(oversized_integer) is None + monkeypatch.setenv("A4_PROOF_TTL_SECONDS", "NaN") assert _safe_ttl_seconds() is None assert registry.issue(_proof_snapshot()) is None @@ -254,6 +326,7 @@ def capture_open(path): assert first["status"] == "partial_fallback_confirmation_required" assert first["partial_confirmation_available"] is True assert first["partial_fallback_id"].startswith("a4pf_") + assert first["degraded_to_deterministic"] is False assert first["results"] == [] assert first["stats"]["scanned"] == 0 assert open_calls == [] @@ -262,7 +335,7 @@ def capture_open(path): assert confirmed["status"] == "ok" assert confirmed["completion_scope"] == "partial_table_only" assert confirmed["partial_execution_confirmed"] is True - assert confirmed["degraded_to_deterministic"] is True + assert confirmed["degraded_to_deterministic"] is False assert confirmed["interpreted_filters"]["raw_query"] == "IfcDoor within 3m of exit" assert confirmed["retry_of_query_id"] == first["query_id"] assert confirmed["proof_eligible"] is False @@ -271,6 +344,32 @@ def capture_open(path): assert open_calls == [str(a4_ifc)] +def test_auto_llm_failure_preserves_true_degradation_flag_after_confirmation(a4_ifc, monkeypatch): + import search.engine as engine_mod + + def fail_completion(**_kwargs): + raise engine_mod.LlmError("llm_unavailable", "test fallback") + + monkeypatch.setattr(engine_mod, "chat_completion", fail_completion) + context = _trusted_partial_context() + first = run_model_search( + SearchRequest( + ifc_source_path=str(a4_ifc), + query="IfcDoor within 3m of exit", + interpret_mode="auto", + model_version_id="a4_fixture_v1", + trusted_a4_context=context, + ) + ) + + assert first["status"] == "partial_fallback_confirmation_required" + assert first["degraded_to_deterministic"] is True + confirmed = confirm_partial_fallback(first["partial_fallback_id"], context) + assert confirmed["status"] == "ok" + assert confirmed["degraded_to_deterministic"] is True + assert confirmed["proof_eligible"] is False + + def test_partial_confirmation_rejects_mismatched_or_replayed_binding_without_scan(a4_ifc, monkeypatch): import search.engine as engine_mod @@ -414,6 +513,7 @@ def test_complete_trusted_row_mints_a_path_free_proof(a4_ifc, tmp_path, monkeypa assert body["highlight_eligible"] is True assert row["evidence_proof"].startswith("a4p.") verified = registry.verify(row["evidence_proof"]) + assert row["a4_evidence_snapshot"] == verified.snapshot assert verified.snapshot["row"]["ifc_guid"] == row["ifc_guid"] assert verified.snapshot["row"]["accepted_usd_prim"] == "/World/Doors/Low" assert verified.snapshot["row"]["usd_prim_path"] == "/World/Doors/Low" @@ -439,6 +539,309 @@ def test_complete_trusted_row_mints_a_path_free_proof(a4_ifc, tmp_path, monkeypa assert str(mapping) not in str(verified.snapshot) +def test_proof_response_budget_stays_below_coordinator_limit(a4_ifc, monkeypatch): + import search.engine as engine_mod + + class Candidate: + def __init__(self, identifier: int): + self._identifier = identifier + self.GlobalId = f"A4Budget{identifier:08d}" + self.Name = f"Door {identifier}" + + def id(self): + return self._identifier + + def is_a(self): + return "IfcDoor" + + class Model: + def by_type(self, ifc_class): + return [Candidate(index) for index in range(1_000)] if ifc_class == "IfcDoor" else [] + + monkeypatch.setenv("A4_PROOF_ACTIVE_KID", "a4_test_kid") + monkeypatch.setenv("A4_PROOF_ACTIVE_KEY", "test-proof-signing-key-material-32bytes") + monkeypatch.setattr(engine_mod, "proof_registry", ProofRegistry()) + monkeypatch.setattr(engine_mod, "open_model", lambda _path: Model()) + monkeypatch.setattr(engine_mod, "_storey_name", lambda _element: None) + monkeypatch.setattr(engine_mod, "_psets_flat", lambda _element: {}) + request = SearchRequest( + ifc_source_path=str(a4_ifc), + query="Q" * 4_000, + interpret_mode="deterministic", + model_version_id="a4_fixture_v1", + limit=1_000, + trusted_a4_context=_trusted_partial_context(), + ) + filters = interpret_query("IfcDoor") + evidence_refs: list[dict] = [] + base = engine_mod._base_response(request, filters, evidence_refs) + + body = engine_mod._execute_search( + request, + filters, + evidence_refs, + base, + partial_execution=False, + degraded_to_deterministic=False, + ) + + assert len(body["results"]) == 1_000 + assert 0 < body["stats"]["proof_eligible_returned"] <= engine_mod.MAX_A4_PROOF_ROWS_PER_RESPONSE + assert body["stats"]["proof_limited"] is True + assert len(json.dumps(body, ensure_ascii=False).encode("utf-8")) < 2 * 1024 * 1024 + + +def test_proof_attempt_budget_bounds_saturated_registry_work(a4_ifc, monkeypatch): + import search.engine as engine_mod + + class Candidate: + def __init__(self, identifier: int): + self._identifier = identifier + self.GlobalId = f"A4Attempt{identifier:08d}" + self.Name = f"Door {identifier}" + + def id(self): + return self._identifier + + def is_a(self): + return "IfcDoor" + + class Model: + def by_type(self, ifc_class): + return [Candidate(index) for index in range(1_000)] if ifc_class == "IfcDoor" else [] + + class SaturatedRegistry: + def __init__(self): + self.attempts = 0 + + def issue(self, _snapshot): + self.attempts += 1 + return None + + registry = SaturatedRegistry() + monkeypatch.setattr(engine_mod, "proof_registry", registry) + monkeypatch.setattr(engine_mod, "open_model", lambda _path: Model()) + monkeypatch.setattr(engine_mod, "_storey_name", lambda _element: None) + monkeypatch.setattr(engine_mod, "_psets_flat", lambda _element: {}) + request = SearchRequest( + ifc_source_path=str(a4_ifc), + query="IfcDoor", + interpret_mode="deterministic", + model_version_id="a4_fixture_v1", + limit=1_000, + trusted_a4_context=_trusted_partial_context(), + ) + filters = interpret_query("IfcDoor") + evidence_refs: list[dict] = [] + + body = engine_mod._execute_search( + request, + filters, + evidence_refs, + engine_mod._base_response(request, filters, evidence_refs), + partial_execution=False, + degraded_to_deterministic=False, + ) + + assert registry.attempts == engine_mod.MAX_A4_PROOF_ATTEMPTS_PER_RESPONSE + assert body["stats"]["proof_eligible_returned"] == 0 + assert body["stats"]["proof_limited"] is True + + +def test_oversized_ifc_values_are_projected_before_response_serialization( + a4_ifc, + monkeypatch, +): + import search.engine as engine_mod + + oversized = "9" * 2_000_000 + + class Candidate: + def __init__(self, identifier: int): + self._identifier = identifier + self.GlobalId = f"A4Bounded{identifier:08d}" + self.Name = oversized + + def id(self): + return self._identifier + + def is_a(self): + return "IfcDoor" + + class Model: + def by_type(self, ifc_class): + return [Candidate(index) for index in range(1_000)] if ifc_class == "IfcDoor" else [] + + monkeypatch.setattr(engine_mod, "open_model", lambda _path: Model()) + monkeypatch.setattr(engine_mod, "_storey_name", lambda _element: oversized) + monkeypatch.setattr(engine_mod, "_psets_flat", lambda _element: {"Unused": oversized}) + request = SearchRequest( + ifc_source_path=str(a4_ifc), + query="IfcDoor", + interpret_mode="deterministic", + model_version_id="a4_fixture_v1", + limit=1_000, + ) + filters = interpret_query("IfcDoor") + evidence_refs: list[dict] = [] + + body = engine_mod._execute_search( + request, + filters, + evidence_refs, + engine_mod._base_response(request, filters, evidence_refs), + partial_execution=False, + degraded_to_deterministic=False, + ) + + encoded = json.dumps(body, ensure_ascii=False, separators=(",", ":")) + assert len(body["results"]) == 1_000 + assert all(row["name"] is None and row["name_omitted"] for row in body["results"]) + assert all(row["storey"] is None and row["storey_omitted"] for row in body["results"]) + assert oversized[:1_024] not in encoded + assert len(encoded.encode("utf-8")) < engine_mod.MAX_A4_SEARCH_RESPONSE_BYTES + assert engine_mod._numeric(10**10_000) is None + assert engine_mod._table_scalar(10**10_000) is None + + +@pytest.mark.parametrize( + "filter_kwargs", + [ + {"name_contains": ["Door"]}, + {"storey_tokens": ["4F"]}, + ], +) +def test_oversized_predicate_evidence_cannot_mint_proof( + a4_ifc, + monkeypatch, + filter_kwargs, +): + import search.engine as engine_mod + + oversized = "D" * 2_000_000 + + class Candidate: + GlobalId = "A4OversizedEvidence0001" + Name = oversized + + def id(self): + return 1 + + def is_a(self): + return "IfcDoor" + + class Model: + def by_type(self, ifc_class): + return [Candidate()] if ifc_class == "IfcDoor" else [] + + monkeypatch.setenv("A4_PROOF_ACTIVE_KID", "a4_test_kid") + monkeypatch.setenv("A4_PROOF_ACTIVE_KEY", "test-proof-signing-key-material-32bytes") + monkeypatch.setattr(engine_mod, "proof_registry", ProofRegistry()) + monkeypatch.setattr(engine_mod, "open_model", lambda _path: Model()) + monkeypatch.setattr(engine_mod, "_storey_name", lambda _element: oversized) + monkeypatch.setattr(engine_mod, "_psets_flat", lambda _element: {}) + request = SearchRequest( + ifc_source_path=str(a4_ifc), + query="bounded predicate evidence", + interpret_mode="deterministic", + model_version_id="a4_fixture_v1", + trusted_a4_context=_trusted_partial_context(), + ) + filters = InterpretedFilters( + raw_query=request.query, + ifc_classes=["IfcDoor"], + **filter_kwargs, + ) + filters.refresh_validation() + evidence_refs: list[dict] = [] + + body = engine_mod._execute_search( + request, + filters, + evidence_refs, + engine_mod._base_response(request, filters, evidence_refs), + partial_execution=False, + degraded_to_deterministic=False, + ) + + assert body["stats"]["matched"] == 0 + assert body["results"] == [] + assert body["stats"]["proof_eligible_returned"] == 0 + assert body["proof_eligible"] is False + assert body["issue_eligible"] is False + + +def test_complete_response_budget_truncates_large_valid_rows_honestly(a4_ifc, monkeypatch): + import search.engine as engine_mod + + property_filters = [ + PropertyFilter(name=f"P{index:02d}_" + ("x" * 60), op="<", value=100.0) + for index in range(10) + ] + properties = {predicate.name: 0 for predicate in property_filters} + + class Candidate: + def __init__(self, identifier: int): + self._identifier = identifier + self.GlobalId = f"A4Large{identifier:08d}" + self.Name = ("D" * 251) + f"{identifier:04d}" + + def id(self): + return self._identifier + + def is_a(self): + return "IfcDoor" + + class Model: + def by_type(self, ifc_class): + return [Candidate(index) for index in range(1_000)] if ifc_class == "IfcDoor" else [] + + monkeypatch.setenv("A4_PROOF_ACTIVE_KID", "a4_test_kid") + monkeypatch.setenv("A4_PROOF_ACTIVE_KEY", "test-proof-signing-key-material-32bytes") + monkeypatch.setattr(engine_mod, "proof_registry", ProofRegistry()) + monkeypatch.setattr(engine_mod, "open_model", lambda _path: Model()) + monkeypatch.setattr(engine_mod, "_storey_name", lambda _element: None) + monkeypatch.setattr(engine_mod, "_psets_flat", lambda _element: properties) + request = SearchRequest( + ifc_source_path=str(a4_ifc), + query="Q" * 800, + interpret_mode="deterministic", + model_version_id="a4_fixture_v1", + limit=1_000, + trusted_a4_context=_trusted_partial_context(), + ) + filters = InterpretedFilters( + raw_query=request.query, + ifc_classes=["IfcDoor"], + property_filters=property_filters, + ) + filters.refresh_validation() + evidence_refs: list[dict] = [] + base = engine_mod._base_response(request, filters, evidence_refs) + + body = engine_mod._execute_search( + request, + filters, + evidence_refs, + base, + partial_execution=False, + degraded_to_deterministic=False, + ) + + assert body["stats"]["scanned"] == 1_000 + assert body["stats"]["matched"] == 1_000 + assert 0 < body["stats"]["returned"] < 1_000 + assert body["stats"]["truncated"] is True + assert body["completion_scope"] == "truncated_table" + assert body["proof_eligible"] is False + assert body["next_step"] == "narrow_query_or_reduce_result_limit" + assert len( + json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + ) <= ( + engine_mod.MAX_A4_SEARCH_RESPONSE_BYTES - engine_mod.MAX_A4_SEARCH_RESPONSE_MARGIN_BYTES + ) + + @pytest.mark.parametrize("invalid_ttl", ["NaN", "Infinity", "0", "-1", "901"]) def test_invalid_explicit_proof_ttl_keeps_search_table_only( a4_ifc, diff --git a/openspec/changes/a4-semantic-search-model-qa/tasks.md b/openspec/changes/a4-semantic-search-model-qa/tasks.md index 723ce793f..913eb8782 100644 --- a/openspec/changes/a4-semantic-search-model-qa/tasks.md +++ b/openspec/changes/a4-semantic-search-model-qa/tasks.md @@ -42,13 +42,15 @@ ## 4. A4 Issue 來源證據 -- [ ] 4.1 新增 additive、schema-versioned persistence field/table 保存 immutable `a4_evidence_snapshot`;歷史 Issues 仍可讀,且不需要 fabricated backfill。 -- [ ] 4.2 定義三個不同 digest:`snapshot_hash` 綁 immutable A4 evidence;`proof_digest` 是 exact signed proof envelope bytes 的 SHA-256;`creation_request_hash` 是 server-normalized canonical create payload 的 SHA-256,必須包含初始 title/description/severity/assignee、IFC GUID、accepted prim、model/artifact/revision、`snapshot_hash` 與 `proof_digest`。 -- [ ] 4.3 首次 consume SHALL 驗 signature、`kid`、expiry、current session/principal 與 `snapshot_hash`,並把 Issue、snapshot、proof ID、`proof_digest`、`creation_request_hash` 在同一 transaction 寫入;proof ID 為 unique idempotency key,`source_ref=query_id` 不得阻止同 query 的不同 rows 各自建立 Issue。 -- [ ] 4.4 已 consumed proof replay SHALL 先重新驗 current session/principal,再 constant-time 比對三個 digest;完全相同時,即使 proof 過期或 signing key 已退休也回原 Issue。任一不符回 409,且不得形成 proof-existence oracle。 +- [x] 4.1 新增 additive、schema-versioned persistence field/table 保存 immutable `a4_evidence_snapshot`;歷史 Issues 仍可讀,且不需要 fabricated backfill。 +- [x] 4.2 定義三個不同 digest:`snapshot_hash` 綁 immutable A4 evidence;`proof_digest` 是 exact signed proof envelope bytes 的 SHA-256;`creation_request_hash` 是 server-normalized canonical create payload 的 SHA-256,必須包含初始 title/description/severity/assignee、IFC GUID、accepted prim、model/artifact/revision、`snapshot_hash` 與 `proof_digest`。 +- [x] 4.3 首次 consume SHALL 驗 signature、`kid`、expiry、current session/principal 與 `snapshot_hash`,並把 Issue、snapshot、proof ID、`proof_digest`、`creation_request_hash` 在同一 transaction 寫入;proof ID 為 unique idempotency key,`source_ref=query_id` 不得阻止同 query 的不同 rows 各自建立 Issue。 +- [x] 4.4 已 consumed proof replay SHALL 先重新驗 current session/principal,再 constant-time 比對三個 digest;完全相同時,即使 proof 過期或 signing key 已退休也回原 Issue。任一不符回 409,且不得形成 proof-existence oracle。 - [ ] 4.5 未 consumed proof 過期 SHALL 回 `a4_proof_expired`、`retryable=true`、`recovery=rerun_query`、`draft_preserved=true`。UI 只在 browser memory 保留 draft,重跑原 query/mode 後要求使用者重新核對 current row/binding,不得自動換 proof 或寫 partial DB row。 - [ ] 4.6 正常 rotation SHALL 保留 previous key 至最後一張 proof expiry 加 clock skew;emergency revocation MAY 立即拒絕未 consumed proof 並要求 rerun。已 consumed exact replay 只依 persisted digests,不依賴退休 key。 -- [ ] 4.7 Tests SHALL 涵蓋 exact replay before/after expiry、old key removal、altered draft、不同 proof bytes、concurrent identical requests、same query different rows、unauthorized replay、expired-draft recovery、canonical JSON/Unicode normalization 與既有 source backward compatibility。 +- [x] 4.7 Tests SHALL 涵蓋 exact replay before/after expiry、old key removal、altered draft、不同 proof bytes、concurrent identical requests、same query different rows、unauthorized replay、expired-draft recovery、canonical JSON/Unicode normalization 與既有 source backward compatibility。 + +> S4-C backend slice(2026-07-23,branch `feat/a4-s4c-session-issue-proxy`):完成 4.1–4.4/4.7 的 additive persistence、三 digest、atomic consume、exact replay 與 backward-compatibility tests;另加入 response/proof resource budget、global/session/principal quota、shared internal-token validation,並讓 generic Issue list/detail/transition 對 A4 records fail closed。`3.8` 仍未勾選,因正式 mounted resolver 的 lease capability 仍為 `lab_unverified`,只具 injected production-authority integration evidence;`4.5` 只完成 backend expiry hints,browser-memory draft/recheck UI 尚屬 S4-D;`4.6` 已證明 previous-key restart verification與 consumed replay 不依賴退休 key,但尚無「最後一張 proof expiry + clock skew」的 operational retirement contract。未跑 browser/Kit/live model/design dual gate,`Full completion claimed: no`。 ## 5. Canonical A4 UI 與 Issue 確認 From dc20f525cd1678f37fbe60ce3980630d13ecc3a2 Mon Sep 17 00:00:00 2001 From: monkey1sai <26239865+monkey1sai@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:51:36 +0800 Subject: [PATCH 2/2] fix(a4): narrow Issue response echo allowlist --- .../src/routes/a4IssueRoutes.ts | 16 --------- .../governance-issue-from-a4-session.test.ts | 34 ++++++++++++++++--- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/bim-review-coordinator/src/routes/a4IssueRoutes.ts b/bim-review-coordinator/src/routes/a4IssueRoutes.ts index 0e5a4ae19..63f86237e 100644 --- a/bim-review-coordinator/src/routes/a4IssueRoutes.ts +++ b/bim-review-coordinator/src/routes/a4IssueRoutes.ts @@ -81,21 +81,6 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } -function collectExactStringEchoes(value: unknown, output: string[] = []): string[] { - if (typeof value === "string") { - output.push(value); - return output; - } - if (Array.isArray(value)) { - for (const item of value) collectExactStringEchoes(item, output); - return output; - } - if (isRecord(value)) { - for (const item of Object.values(value)) collectExactStringEchoes(item, output); - } - return output; -} - function normalizedRequestHeaders(request: Request): Record { const headers: Record = {}; for (const [name, value] of Object.entries(request.headers)) { @@ -351,7 +336,6 @@ export function registerA4IssueRoutes(app: Express, deps: A4IssueRouteDeps): voi { ...draft.value, a4_trusted_context: trusted }, [context.ifc_source_path, context.element_mapping_path ?? ""], [ - ...collectExactStringEchoes(draft.value), draft.value.title.normalize("NFC").trim(), ...(draft.value.description ? [draft.value.description.normalize("NFC")] diff --git a/bim-review-coordinator/tests/governance-issue-from-a4-session.test.ts b/bim-review-coordinator/tests/governance-issue-from-a4-session.test.ts index d228973fa..f7d37281d 100644 --- a/bim-review-coordinator/tests/governance-issue-from-a4-session.test.ts +++ b/bim-review-coordinator/tests/governance-issue-from-a4-session.test.ts @@ -179,10 +179,11 @@ describe("session-scoped A4 Issue route", () => { }); }); - it("allows exact path-like draft echoes without hiding a committed Issue", async () => { + it("allows exact path-like Issue field echoes without hiding a committed Issue", async () => { const title = "/Door defect"; const description = "Inspect user note C:\\model"; - const snapshot = evidenceSnapshot({ query: "/Door query" }); + const assignee = "C:\\reviewer"; + const snapshot = evidenceSnapshot(); const governance = await startGovernanceStub({ body: { issue: { @@ -190,6 +191,7 @@ describe("session-scoped A4 Issue route", () => { source_type: "a4_search", title, description, + assignee, a4_evidence_snapshot: snapshot, }, replayed: false, @@ -199,12 +201,36 @@ describe("session-scoped A4 Issue route", () => { const response = await request(routeApp()) .post(`/api/governance/issues/from-a4-search/for-session/${sessionId}`) - .send(issuePayload({ title, description, a4_evidence_snapshot: snapshot })); + .send(issuePayload({ title, description, assignee, a4_evidence_snapshot: snapshot })); expect(response.status).toBe(201); expect(response.body.issue.title).toBe(title); expect(response.body.issue.description).toBe(description); - expect(response.body.issue.a4_evidence_snapshot.query).toBe("/Door query"); + expect(response.body.issue.assignee).toBe(assignee); + expect(governance.calls).toHaveLength(1); + }); + + it("does not allowlist path-like strings from browser-controlled evidence", async () => { + const snapshot = evidenceSnapshot({ query: "/Door query" }); + const governance = await startGovernanceStub({ + body: { + issue: { + id: "iss_a4_snapshot_path", + source_type: "a4_search", + a4_evidence_snapshot: snapshot, + }, + replayed: false, + }, + }); + process.env.GOVERNANCE_API_BASE = governance.baseUrl; + + const response = await request(routeApp()) + .post(`/api/governance/issues/from-a4-search/for-session/${sessionId}`) + .send(issuePayload({ a4_evidence_snapshot: snapshot })); + + expect(response.status).toBe(502); + expect(response.body.error_code).toBe("governance_service_unavailable"); + expect(JSON.stringify(response.body)).not.toContain("/Door query"); expect(governance.calls).toHaveLength(1); });