fix(rectification): product-domain open wrapper runs as invoker; V10 turn append in history test and replay harness; rename segment migrations (BUG-1131)

- open_agentic_rectification_case_v2 (12 args) becomes SECURITY INVOKER: the
  immutable-skill ACL reconciliation leaves EXECUTE on the 11-arg open only to
  service_role, so the definer wrapper hit 42501 on every homepage/new open.
- The pending-opening read moves to owner function
  agentic_rectification_opening_pending_v1, granted to service_role only.
- History test and persisted replay harness append turns through the V10
  request-idempotent overload; the V9 overload is revoked from service_role.
- Opening test fixture adds the required birth_time_source.
- Segment migrations renamed to 20261001* so they sort after staging's
  20260930* migrations on both fresh and existing databases.
- Stale Windows replay replaced with the production-path replay (M1/M2 equal
  to accepted research, implementation_identity included).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-10-01 08:04:05 +08:00
co-authored by Claude Opus 5.5
parent 66f087b588
commit 73605c3b8e
8 changed files with 79203 additions and 79152 deletions
+16
View File
@@ -15165,3 +15165,19 @@
- 防复发:首页首屏只放空白首页用得到的东西;只有少数人或少数时刻用得到的模块(引导、老资料兼容、研究数据)按需加载,并在「一定会用到」的那一刻之前(揭幕前)预加载。别把整个研究 JSON 导入客户端模块。
- 相关记录:BUG-1127、BUG-1128
- 修复版本:分支 `codex/home-first-load-20260930`(`b743b16b`、`e2c3791f`)
- 修复版本:—
## BUG-1131 | 盘型口径的 12 参数开案包装以属主身份调用 11 参数开案,首页 / 新建开案 42501
- 状态:investigating(修复已落 `codex/rectification-varga-resolution-fix-20261001`;真库定向通过,全量真库与部署后 smoke 待补)
- 首次发现 / 最近更新:2026-09-30 / 2026-10-01
- 影响面:`frontend/supabase/migrations/20261001020000_rectification_segment_checks.sql` 的 `open_agentic_rectification_case_v2(…, p_product_domain text)`;`frontend/src/lib/rectification-agentic/v9/case-service.ts::openRectificationCase` 对 `intent !== "session"`(首页、新建)一律走该 12 参数版本。
- 用户现象(若部署):从首页或「新建」发起生时校正全部失败,接口报 `permission denied for function open_agentic_rectification_case_v2`(42501);按会话打开历史 Case 不受影响。
- 触发条件:任何首页 / 新建开案。
- 根因:12 参数包装是 `security definer`,以属主 `schema_owner` 身份调用既有 11 参数 `open_agentic_rectification_case_v2`;而 `20260814010000_immutable_skill_registry.sql` 的 ACL 对账块把 11 参数函数的 EXECUTE 从除 `service_role` 外所有角色(含属主)收回。改成 `security invoker` 后又暴露第二层:运行角色对 `agentic_rectification_cases` / `turns` 没有直接读表权限,包装里「是否待开场」的 `exists(...)` 被拒(`permission denied for table agentic_rectification_cases`)。
- 修复:包装改为 `security invoker`(调用方 service_role 本就可执行 11 参数开案与 `initialize_agentic_rectification_domain_v1`);「是否待开场」判断移入新的属主函数 `agentic_rectification_opening_pending_v1(uuid,uuid)`,只授权 service_role。未给属主或任何非 service_role 角色补 EXECUTE,未改 08-14 迁移。两条新迁移尚未部署到任何环境,原文件修改。
- 验证:本机无 Docker,Claude 用独立 PostgreSQL 17.9 + `docker`/`psql` 本地替身跑 `frontend/tests/database-segment-opening.test.ts`:修复前 1/0/1(42501,与执行方 DinD 现场一致);修复后 1/1/0。测试新增断言:新属主函数 authenticated 不可执行;12 参数包装 `prosecdef = false`。
- 同轮修正的测试缺陷:该测试第二段「真实扫描」开案的出生快照缺 `birth_time_source`,被 11 参数开案的 `agentic_rectification_profile_incomplete` 校验拒绝——此段此前一直被 42501 挡住从未执行。原值:快照无 `birth_time_source`;新值:`birth_time_source: "family_exact"`(与同文件第一段一致);原因:满足原开案资料完整性约束,不放宽约束。
- 防复发:新增 SECURITY DEFINER 函数若调用其他 RPC,必须确认被调函数 ACL 仅 service_role 时属主不可调;优先 `security invoker` 包装 + 属主只读小函数。真库 `database-segment-opening.test.ts` 锁定 `prosecdef` 与 ACL。
- 相关记录:BUG-1115、BUG-1116、BUG-1117、BUG-621。
- 复发自:无(新迁移引入,未部署)
- 修复版本:`codex/rectification-varga-resolution-fix-20261001`(未合入、未部署)。
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,7 @@ import { decideAfterInferenceChange, rectificationFollowupCatalog } from "../src
import type { EvidenceKind } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import { resolveRectificationProductDomain } from "../src/lib/rectification-agentic/v9/product-domain.ts";
import { scoreAndPersistCurrentEvidence } from "../src/lib/rectification-agentic/v9/score-persist.ts";
import { appendV9Turn, confirmV9Evidence, loadV9CaseDossier, proposeV9Evidence } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { confirmV9Evidence, loadV9CaseDossier, proposeV9Evidence } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { startPostgresFixture } from "../tests/helpers/postgres-fixture.ts";
import { segmentReplayOracleAnswer } from "./rectification-segment-oracle.ts";
@@ -81,11 +81,14 @@ try {
assert.equal(seeded.case.rectificationDomain, domain);
trace.domain = seeded.case.rectificationDomain;
trace.targets = targetChartsForDomain(seeded.case.rectificationDomain);
trace.stage = "appendV9Turn";
trace.stage = "append_turn_v10";
trace.ledger_before = ledger();
const turn = await appendV9Turn(service, userId, caseId, {
modelName: "public-benchmark-oracle-not-provider", userMessage: row.events.map(event => event.summary).join("; "),
});
// Same V10 request-idempotent overload as agent-run-prepare.ts; the V9 overload is revoked from service_role.
const appended = await service.rpc("append_agentic_rectification_turn", { p_user_id: userId, p_case_id: caseId,
p_user_message: row.events.map(event => event.summary).join("; "), p_assistant_message: null,
p_model_name: "public-benchmark-oracle-not-provider", p_model_version: null, p_status: "pending", p_request_id: randomUUID() });
assert.equal(appended.error, null, JSON.stringify(appended.error));
const turn = { turnId: (appended.data as { turn_id: string }).turn_id };
trace.stage = "evidence";
for (const event of row.events) {
const proposed = await proposeV9Evidence(service, userId, caseId, {
@@ -24,14 +24,25 @@ begin
return jsonb_build_object('domain',p_domain);
end $$;
-- Owner-side read for the invoker wrapper below: runtime roles have no direct table access.
create or replace function public.agentic_rectification_opening_pending_v1(p_user_id uuid, p_case_id uuid)
returns boolean language sql stable security definer set search_path = '' as $$
select exists(select 1 from public.agentic_rectification_cases c where c.id=p_case_id
and c.user_id=p_user_id and c.rectification_domain is not null and c.status='draft'
and not exists(select 1 from public.agentic_rectification_turns t where t.case_id=c.id));
$$;
-- The extra server-owned argument selects this overload; old 11-argument callers
-- and exact-session opens keep their original immutable Skill/domain contract.
-- SECURITY INVOKER: the immutable-skill ACL reconciliation leaves EXECUTE on the
-- 11-argument open only to service_role (not the owner), so a definer wrapper
-- would hit 42501. The caller is service_role, which may run both inner functions.
create or replace function public.open_agentic_rectification_case_v2(
p_user_id uuid, p_request_id uuid, p_intent text, p_session_id uuid,
p_skill_name text, p_skill_version text, p_skill_sha256 text, p_skill_source_commit text,
p_baseline_profile_fingerprint text, p_baseline_birth_snapshot jsonb, p_candidate_range jsonb,
p_product_domain text
) returns jsonb language plpgsql security definer set search_path = '' as $$
) returns jsonb language plpgsql security invoker set search_path = '' as $$
declare v_result jsonb;
begin
if p_product_domain is null or p_product_domain not in ('general','report','education','career','relationship','marriage','relocation','finance','health','family','other') then
@@ -43,9 +54,7 @@ begin
if v_result->>'disposition' = 'created' then
perform public.initialize_agentic_rectification_domain_v1(p_user_id,(v_result->>'case_id')::uuid,p_product_domain);
end if;
if exists(select 1 from public.agentic_rectification_cases c where c.id=(v_result->>'case_id')::uuid
and c.user_id=p_user_id and c.rectification_domain is not null and c.status='draft'
and not exists(select 1 from public.agentic_rectification_turns t where t.case_id=c.id)) then
if public.agentic_rectification_opening_pending_v1(p_user_id,(v_result->>'case_id')::uuid) then
v_result := v_result || jsonb_build_object('should_start_opening',true);
end if;
return v_result;
@@ -170,11 +179,13 @@ end $$;
revoke all on function public.get_agentic_rectification_case_dossier_before_segments(uuid,uuid) from public,anon,authenticated,service_role;
revoke all on function public.open_agentic_rectification_case_v2(uuid,uuid,text,uuid,text,text,text,text,text,jsonb,jsonb,text),
public.agentic_rectification_opening_pending_v1(uuid,uuid),
public.initialize_agentic_rectification_domain_v1(uuid,uuid,text),
public.write_agentic_rectification_segment_checks_v1(uuid,uuid,jsonb,jsonb),
public.finalize_agentic_rectification_segment_consistency_v1(uuid,uuid,text,text),
public.get_agentic_rectification_case_dossier(uuid,uuid) from public,anon,authenticated;
grant execute on function public.open_agentic_rectification_case_v2(uuid,uuid,text,uuid,text,text,text,text,text,jsonb,jsonb,text),
public.agentic_rectification_opening_pending_v1(uuid,uuid),
public.initialize_agentic_rectification_domain_v1(uuid,uuid,text),
public.write_agentic_rectification_segment_checks_v1(uuid,uuid,jsonb,jsonb),
public.finalize_agentic_rectification_segment_consistency_v1(uuid,uuid,text,text),
@@ -89,15 +89,17 @@ test("segment opening PostgreSQL owns stable domain, full original checks and on
assert.ok(publicView.turns.some(turn => "segment_consistency" in turn));
assert.equal(publicView.case.accepted_time, null);
assert.equal(publicView.case.confirmed_time, null);
for (const signature of ["initialize_agentic_rectification_domain_v1(uuid,uuid,text)", "write_agentic_rectification_segment_checks_v1(uuid,uuid,jsonb,jsonb)",
for (const signature of ["agentic_rectification_opening_pending_v1(uuid,uuid)", "initialize_agentic_rectification_domain_v1(uuid,uuid,text)", "write_agentic_rectification_segment_checks_v1(uuid,uuid,jsonb,jsonb)",
"finalize_agentic_rectification_segment_consistency_v1(uuid,uuid,text,text)", "open_agentic_rectification_case_v2(uuid,uuid,text,uuid,text,text,text,text,text,jsonb,jsonb,text)"]) {
assert.equal(fixture.psql(`select has_function_privilege('authenticated','public.${signature}','execute')::text`), "f");
}
// BUG-1131: the product-domain open wrapper runs as the service caller, never as the owner.
assert.equal(fixture.psql("select prosecdef::text from pg_proc where oid='public.open_agentic_rectification_case_v2(uuid,uuid,text,uuid,text,text,text,text,text,jsonb,jsonb,text)'::regprocedure"), "f");
// Real captured native API scan is a separate nonunique production contract.
const req = golden.request;
const realRange = { start_time: req.start_time, end_time: req.end_time, candidate_intervals: golden.response.decision_receipt.candidate_intervals };
const real = await service.rpc("open_agentic_rectification_case_v2", { ...args, p_request_id: randomUUID(), p_product_domain: "general",
p_candidate_range: realRange, p_baseline_birth_snapshot: { birth_date: req.birth_date, latitude: req.lat, longitude: req.lon, timezone_offset: req.tz } });
p_candidate_range: realRange, p_baseline_birth_snapshot: { birth_date: req.birth_date, latitude: req.lat, longitude: req.lon, timezone_offset: req.tz, birth_time_source: "family_exact" } });
assert.equal(real.error, null);
const realCaseId = (real.data as { case_id: string }).case_id;
globalThis.fetch = (async () => Response.json(golden.scan)) as typeof fetch;
@@ -17,7 +17,7 @@ import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action
import { buildChoiceFrame } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { appendV9Turn, insertV9SkillRunReceipt, loadV9CaseDossier, loadV9CaseSkillIdentity, persistV9Candidate } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { insertV9SkillRunReceipt, loadV9CaseDossier, loadV9CaseSkillIdentity, persistV9Candidate } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const golden = JSON.parse(readFileSync(new URL("./fixtures/varga-api-response.golden.json", import.meta.url), "utf8"));
@@ -138,8 +138,14 @@ test("post-bump historical exact Skill survives real PostgreSQL list, session op
&& candidate.raw_posterior_score === undefined && candidate.raw_eliminated === undefined));
assert.equal(fixture.psql(`select count(*) from public.agentic_rectification_inference_transitions where case_id='${caseId}'`), "1");
assert.equal(fixture.psql(`select count(*) from public.agentic_rectification_choice_actions where case_id='${caseId}' and status in ('applied','narrated')`), "1");
const turn = await appendV9Turn(service, userId, caseId, { modelName: "fictional-history-receipt", userMessage: "Fictional historical action" });
await insertV9SkillRunReceipt(service, userId, caseId, turn.turnId, randomUUID(), "turn", bound);
// Runtime turns use the V10 request-idempotent overload (agent-run-prepare.ts); the V9
// overload behind appendV9Turn is revoked from service_role by 20260814020000.
const appended = await service.rpc("append_agentic_rectification_turn", { p_user_id: userId, p_case_id: caseId,
p_user_message: "Fictional historical action", p_assistant_message: null, p_model_name: "fictional-history-receipt",
p_model_version: null, p_status: "pending", p_request_id: randomUUID() });
assert.equal(appended.error, null, JSON.stringify(appended.error));
const turnId = (appended.data as { turn_id: string }).turn_id;
await insertV9SkillRunReceipt(service, userId, caseId, turnId, randomUUID(), "turn", bound);
assert.equal(fixture.psql(`select skill_version || ':' || skill_sha256 from public.agentic_rectification_skill_run_receipts where case_id='${caseId}'`), `${old.version}:${old.sha256}`);
assert.deepEqual(await loadV9CaseSkillIdentity(service, userId, caseId), bound, "choice/cache/run action never upgrades to active");
} finally {
@@ -112,7 +112,7 @@ for (const domain of [null, "marriage", "career", "general", "report", "other"]
}
test("product identity covers every SQL-legal persisted domain without opting legacy in", () => {
const sql = readFileSync(new URL("../supabase/migrations/20260930020000_rectification_segment_checks.sql", import.meta.url), "utf8");
const sql = readFileSync(new URL("../supabase/migrations/20261001020000_rectification_segment_checks.sql", import.meta.url), "utf8");
const domainRows = ["general", "report", "education", "career", "relationship", "marriage", "relocation", "finance", "health", "family", "other"];
assert.deepEqual([...sql.matchAll(/p_(?:product_)?domain not in \(([^)]+)\)/g)].map(match =>
[...match[1].matchAll(/'([^']+)'/g)].map(domain => domain[1])), [domainRows, domainRows]);