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:
co-authored by
Claude Opus 5.5
parent
66f087b588
commit
73605c3b8e
@@ -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, {
|
||||
|
||||
+15
-4
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user