diff --git a/.claude/skills/confirm/SKILL.md b/.claude/skills/confirm/SKILL.md index ed15439..7939ae0 100644 --- a/.claude/skills/confirm/SKILL.md +++ b/.claude/skills/confirm/SKILL.md @@ -1,42 +1,50 @@ --- name: confirm -description: 确认=提交。用户裁决后,把创作产出从待审(未提交)转为正式事实(git commit),或丢弃(restore)。全仓唯一的确认通道,仅由用户指令触发。 +description: 确认=把创作产出从待审(Shadow)转为正式事实(Canonical)。正文候选经 scripts/write_canonical.py 单事务写库(正文块+来源归因+决策归档+翻候选态);知识卡走 draft→entity 双轨。全仓唯一的确认通道,仅由用户指令触发。 --- # 确认 / 丢弃(Shadow→Canonical 的唯一入口) **只在用户明确说"采纳/确认/丢弃"后执行,主会话与智能体不得自行发起。** -## 正文候选 v1 接受前置检查 +数据权威在 PostgreSQL(领域索引 §2)。正文的 Shadow→Canonical = 写库内正文块,**不是 git commit**;Git 只管代码与文档留痕。 -正文实验台先调用 `scripts/check_writer_acceptance.py` 的纯函数,不直接写正文文件、数据库、git 或抽取队列: +## 正文候选:接受 / 丢弃(DB 写路径) -1. `check_shadow_ready` 严格校验生产模式、`acceptanceEligible=true`、候选正文 hash、冻结上下文 ID/hash、`writer-production-v1` 策略、授权快照、来源状态、候选有效期,以及 detector 最终报告对当前 `attempt/candidateVersion/candidateSha256` 的绑定。 -2. detector 最终轨迹必须同时为机械门通过、语义接口通过且无失败码。当前真实模型 detector 尚未实现;fake 通过只用于接口测试,不能作为真实正文接受依据。 -3. `accept/merge/discard` 三个决策都要求用户明确确认。`accept/merge` 还必须实时匹配 `expectedRevision`;冲突返回 `REVISION_CONFLICT`,不得覆盖 Canonical。 -4. `merge` 必须以编辑前候选为基线生成严格的 `candidateVersion+1`,正文 hash 必须变化,并用编辑后的版本重新跑完整 detector,再回到 Shadow。 -5. 实验纯函数通过后只返回提交后副作用意图,固定标明 `canonicalMutationPerformed=false`、`commandIntent.allowed=false` 和 `requiresCanonicalCommit=true`;正式提交层验证 Canonical 提交凭证后才可异步排队章后抽卡。本轮不执行 Canonical 写入或抽取。 +两步,顺序不可颠倒: -`acceptanceEligible=false` 的诊断/评测候选在第一道门硬拒绝,不能通过修改参数、重跑 fake 或用户确认进入接受链。 +1. **先过接受前置检查(纯函数,不写库)**:`scripts/check_writer_acceptance.py` + - `check_shadow_ready` 校验生产模式、`acceptanceEligible=true`、候选正文 hash、冻结上下文 ID/hash、`writer-production-v1` 策略、来源状态、候选有效期,以及 detector 最终报告对当前 `attempt/candidateVersion/candidateSha256` 的绑定。 + - `accept/merge` 必须实时匹配 `expectedRevision`;冲突返回 `REVISION_CONFLICT`,不得覆盖 Canonical。 + - `acceptanceEligible=false` 的诊断/评测候选在第一道门硬拒绝,不能靠改参数、重跑 fake 或用户确认混进接受链。 + - detector 真实模型尚未实现;fake 通过只用于接口测试,不能作为真实正文接受依据。 -## 既有创作文件采纳 +2. **前置通过后,经写入层落库(单事务)**:`scripts/write_canonical.py`(复用 db skill 的 DSN) + - 接受: + ```bash + .venv/bin/python .claude/skills/confirm/scripts/write_canonical.py accept \ + --expected-revision --rationale "为什么接受" --basis-ref "大纲@日期" --command-id <幂等ID> + # 先试跑(完整走一遍事务再回滚,校验不落库):加 --dry-run + ``` + - 丢弃: + ```bash + .venv/bin/python .claude/skills/confirm/scripts/write_canonical.py discard --rationale "为什么丢弃" + ``` + - 写入层按落库设计 §2.9 单事务执行:写正文块(`content_text`,revision+1,CAS 乐观锁)→ 写来源归因(`muse_content_block_source_attribution`,来源权威落块,架构-02 §3)→ 写命令幂等审计 → 写决策归档(`example_user_decision`)→ 翻候选 `state='accepted'`。**任一失败整体回滚,绝不留无来源指针的正式正文。** + - DB 级兜底硬校验(不靠调用方自觉):`run_type` 非 production 拒绝接受(05 §8.4)、`state` 非 passed 拒绝、revision 冲突拒绝。 -以下步骤只适用于已经由正式写入层形成的既有创作文件,不是正文实验纯函数的一部分: +## merge(修改后合并) -1. `git status` 核对待审清单,向用户复述本次将确认哪些文件(正文?知识卡?规划?),多类混杂时建议分次确认。 -2. 更新回算字段:章文件「字数」、`设定.md` frontmatter「已确认章数/总字数」、章 frontmatter「依据」(来源归因:写手/大纲版本/上下文回显文件名)。 -3. 只 `git add` 用户点名的创作文件——**严禁混入框架文件(agents/skills/meta)与 `评审/`**。 -4. 提交信息格式: - - 正文:`作品(书名): 采纳 第NNN章《标题》 | 来源: writer | 依据: 大纲@日期` - - 规划:`作品(书名): 确认 设定包/大纲vN | 来源: planner` - - 知识:`作品(书名): 确认知识 第NNN章抽取(新卡M/改卡K) | 来源: extractor` -5. 提醒用户:**采纳正文 ≠ 确认知识**——抽取产出的卡变更要单独确认;有「⚠ 冲突待裁决」的卡必须先裁决再确认。 +- 用户以编辑前候选为基线编辑,生成严格 `candidateVersion+1` 的新候选,正文 hash 必须变化,重跑完整 detector 与接受前置检查,回到 Shadow 再走上面的 accept 流程。 +- 接受时归因 `--source-type user_merge`,记 `writer+用户修订`。**不得修改后不经重检直接合并。** -## 丢弃 / 部分采纳 +## 知识卡 / 规划:各自的确认轨 -- 丢弃:`git restore <文件>`(新文件用 `git clean` 前先向用户列清单); -- 正文候选部分采纳:用户编辑后形成新 candidateVersion,重新跑 detector、Shadow 准入与接受前置检查;不得修改后直接合并。正式写入层完成 Canonical 提交后,来源归因记 `writer+用户修订`。 +- **知识卡**:确认 = `muse_knowledge_draft` 翻 `confirmed` + 落 `muse_knowledge_entity(active)`(走 db skill,同一条确认链);**采纳正文 ≠ 确认知识**,抽取产出的卡变更要单独确认;有冲突的卡先裁决再确认。 +- **规划**(大纲/细纲/设定):规划表(100)落库前,暂以 git 提交确认——只 `git add` 用户点名的创作文件,**严禁混入框架文件(agents/skills/meta)**;提交信息 `作品(书名): 确认 设定包/大纲vN | 来源: planner`。规划表建成后改为库内 shadow→confirmed。 -## 推送 +## 红线 -用户要求时 `git push origin main`;推送失败原样报错,不静默。 +- 评测/诊断候选(run_type≠production)任何情况下不得成为正式正文。 +- 写正文必带来源归因;不接受绕过归因的直接写块。 +- 推送:用户要求时 `git push origin main`;失败原样报错,不静默。 diff --git a/.claude/skills/confirm/scripts/write_canonical.py b/.claude/skills/confirm/scripts/write_canonical.py new file mode 100644 index 0000000..3a8836c --- /dev/null +++ b/.claude/skills/confirm/scripts/write_canonical.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""正文 Canonical 写入层 —— 候选接受/丢弃的唯一 DB 写路径(Shadow→Canonical)。 + +落库设计 §2.9 的单事务序列(任一失败整体回滚,绝不留"无来源指针的正式正文"): + + accept preflight(主会话先调 check_writer_acceptance 纯函数做生产模式全检) + -> 读候选 + 硬校验(run_type=production、state=passed,DB 级兜底) + -> 写 muse_content_block(content_text,revision+1,CAS 乐观锁) [拿到 block_id] + -> 写 muse_content_block_source_attribution(block_id, revision, lineage_payload=候选身份;来源权威落块,架构-02 §3) + -> 写 muse_content_command_log(command_id 幂等审计) + -> 写 example_user_decision(canonical_block_id, command_id, 理据) + -> UPDATE example_candidate SET state='accepted' + +只由 confirm 入口调用;主会话与智能体不得自行拼接这些写。 +--dry-run 试跑:完整走一遍事务再回滚,校验通过但不落库。 +""" +import json +import sys +from pathlib import Path + +# 复用 db skill 锁死的 DSN(.claude/skills/db/scripts),不另硬编码连接串 +DB_SCRIPTS = Path(__file__).resolve().parents[2] / "db" / "scripts" +sys.path.insert(0, str(DB_SCRIPTS)) +from db import connect # noqa: E402 + +CREATOR = "confirm" + + +class ConflictError(Exception): + """接受/丢弃被拒(状态不对、revision 冲突、候选不存在等)——失败关闭,不静默。""" + + +def accept(candidate_id, decided_by="1", rationale=None, basis_ref=None, + expected_revision=None, command_id=None, source_type="ai_candidate", + dry_run=False): + """接受候选为正式正文。单事务;返回 {status, block_id, revision, decision_id, ...}。""" + with connect() as conn: + try: + # 0) command_id 幂等:重放同一命令直接返回,不重复写 + if command_id and conn.execute( + "SELECT 1 FROM muse_content_command_log WHERE command_id=%s AND deleted=false", + (command_id,)).fetchone(): + return {"status": "already_applied", "command_id": command_id} + # 1) 读候选 + DB 级硬校验(preflight 的兜底,不靠调用方自觉) + row = conn.execute( + "SELECT id, work_id, target_chapter, run_id, attempt, run_type, candidate_version, " + "candidate_sha256, candidate_body, mode, source_role, state FROM example_candidate " + "WHERE id=%s AND deleted=false", (candidate_id,)).fetchone() + if not row: + raise ConflictError(f"候选 {candidate_id} 不存在") + cid, work_id, chap, run_id, attempt, run_type, cver, csha, body, mode, source_role, state = row + if run_type != "production": + raise ConflictError(f"run_type={run_type} 为评测/诊断候选,不得接受(05 §8.4)") + if state != "passed": + raise ConflictError(f"候选 state={state},只有 passed 可接受") + if not body: + raise ConflictError("候选正文为空,不能接受") + # 2) 定位章 → 现有正文块(一章一块);revision CAS 乐观锁 + ch = conn.execute( + "SELECT id, title FROM muse_content_chapter WHERE work_id=%s AND order_no=%s AND deleted=false", + (work_id, chap)).fetchone() + if not ch: + raise ConflictError(f"作品 {work_id} 无第 {chap} 章,无法写正文") + chapter_id, chapter_title = ch + blk = conn.execute( + "SELECT id, revision FROM muse_content_block WHERE chapter_id=%s AND deleted=false " + "ORDER BY revision DESC LIMIT 1", (chapter_id,)).fetchone() + word_count = len(body) + if blk: + block_id, cur_rev = blk + if expected_revision is not None and int(expected_revision) != cur_rev: + raise ConflictError(f"REVISION_CONFLICT:期望 {expected_revision},正文块当前 {cur_rev}") + new_rev = cur_rev + 1 + n = conn.execute( + "UPDATE muse_content_block SET content_text=%s, revision=%s, word_count=%s, " + "command_id=%s, updater=%s WHERE id=%s AND revision=%s", + (body, new_rev, word_count, command_id, decided_by, block_id, cur_rev)).rowcount + if n != 1: + raise ConflictError("REVISION_CONFLICT:正文块被并发修改") + else: + if expected_revision not in (None, 0): + raise ConflictError(f"REVISION_CONFLICT:正文块不存在但 expected_revision={expected_revision}") + new_rev = 1 + block_id = conn.execute( + "INSERT INTO muse_content_block(work_id, chapter_id, order_no, block_type, title, " + "content_text, revision, word_count, command_id, creator, updater) " + "VALUES (%s,%s,%s,'scene',%s,%s,1,%s,%s,%s,%s) RETURNING id", + (work_id, chapter_id, chap, chapter_title, body, word_count, command_id, + decided_by, decided_by)).fetchone()[0] + # 3) 来源归因落块(来源权威;block_id+revision 唯一,按版本归因) + lineage = {"candidate_id": cid, "run_id": run_id, "candidate_version": cver, + "candidate_sha256": csha, "mode": mode, "source_role": source_role, + "source_type": source_type} + conn.execute( + "INSERT INTO muse_content_block_source_attribution(work_id, block_id, revision, source_type, " + "source_object_id, source_version, lineage_payload, creator) " + "VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb,%s)", + (work_id, block_id, new_rev, source_type, str(cid), int(attempt or 1), + json.dumps(lineage, ensure_ascii=False), CREATOR)) + # 4) 命令幂等审计 + if command_id: + conn.execute( + "INSERT INTO muse_content_command_log(command_id, command_type, owner_user_id, target_type, " + "target_id, request_hash, result_snapshot, creator) " + "VALUES (%s,'accept_candidate',1,'content_block',%s,%s,%s::jsonb,%s)", + (command_id, block_id, csha, + json.dumps({"canonical_block_id": block_id, "revision": new_rev}, ensure_ascii=False), + CREATOR)) + # 5) 决策归档(append-only) + dec_id = conn.execute( + "INSERT INTO example_user_decision(candidate_id, work_id, target_chapter, decision, rationale, " + "basis_ref, decided_by, expected_revision, command_id, canonical_block_id, creator) " + "VALUES (%s,%s,%s,'accept',%s,%s,%s,%s,%s,%s,%s) RETURNING id", + (cid, work_id, chap, rationale, basis_ref, decided_by, expected_revision, command_id, + block_id, CREATOR)).fetchone()[0] + # 6) 翻候选状态 + n = conn.execute( + "UPDATE example_candidate SET state='accepted', updater=%s WHERE id=%s AND state='passed'", + (decided_by, cid)).rowcount + if n != 1: + raise ConflictError("候选状态被并发修改") + if dry_run: + conn.rollback() + return {"status": "dry_run_ok", "block_id": block_id, "revision": new_rev, + "decision_id": dec_id, "word_count": word_count, "note": "试跑已回滚,未落库"} + conn.commit() + return {"status": "accepted", "block_id": block_id, "revision": new_rev, + "decision_id": dec_id, "word_count": word_count} + except Exception: + conn.rollback() + raise + + +def discard(candidate_id, decided_by="1", rationale=None, basis_ref=None, + command_id=None, dry_run=False): + """丢弃候选:正式正文不变,记一条 discard 决策 + 候选翻 discarded。""" + with connect() as conn: + try: + if command_id and conn.execute( + "SELECT 1 FROM muse_content_command_log WHERE command_id=%s AND deleted=false", + (command_id,)).fetchone(): + return {"status": "already_applied", "command_id": command_id} + row = conn.execute( + "SELECT id, work_id, target_chapter, state FROM example_candidate WHERE id=%s AND deleted=false", + (candidate_id,)).fetchone() + if not row: + raise ConflictError(f"候选 {candidate_id} 不存在") + cid, work_id, chap, state = row + if state in ("accepted", "discarded", "archived"): + raise ConflictError(f"候选 state={state},不可再丢弃") + if command_id: + conn.execute( + "INSERT INTO muse_content_command_log(command_id, command_type, owner_user_id, target_type, " + "target_id, request_hash, creator) VALUES (%s,'discard_candidate',1,'candidate',%s,%s,%s)", + (command_id, cid, str(cid), CREATOR)) + dec_id = conn.execute( + "INSERT INTO example_user_decision(candidate_id, work_id, target_chapter, decision, rationale, " + "basis_ref, decided_by, command_id, creator) VALUES (%s,%s,%s,'discard',%s,%s,%s,%s,%s) RETURNING id", + (cid, work_id, chap, rationale, basis_ref, decided_by, command_id, CREATOR)).fetchone()[0] + conn.execute("UPDATE example_candidate SET state='discarded', updater=%s WHERE id=%s", + (decided_by, cid)) + if dry_run: + conn.rollback() + return {"status": "dry_run_ok", "decision_id": dec_id, "note": "试跑已回滚,未落库"} + conn.commit() + return {"status": "discarded", "decision_id": dec_id} + except Exception: + conn.rollback() + raise + + +if __name__ == "__main__": + import argparse + ap = argparse.ArgumentParser(description="正文 Canonical 写入层(候选接受/丢弃)") + sub = ap.add_subparsers(dest="cmd", required=True) + pa = sub.add_parser("accept", help="接受候选为正式正文") + pa.add_argument("candidate_id", type=int) + pa.add_argument("--decided-by", default="1") + pa.add_argument("--rationale", default=None) + pa.add_argument("--basis-ref", default=None) + pa.add_argument("--expected-revision", type=int, default=None) + pa.add_argument("--command-id", default=None) + pa.add_argument("--source-type", default="ai_candidate", + choices=["ai_candidate", "user_merge"]) + pa.add_argument("--dry-run", action="store_true") + pd = sub.add_parser("discard", help="丢弃候选") + pd.add_argument("candidate_id", type=int) + pd.add_argument("--decided-by", default="1") + pd.add_argument("--rationale", default=None) + pd.add_argument("--basis-ref", default=None) + pd.add_argument("--command-id", default=None) + pd.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + try: + if args.cmd == "accept": + result = accept(args.candidate_id, decided_by=args.decided_by, rationale=args.rationale, + basis_ref=args.basis_ref, expected_revision=args.expected_revision, + command_id=args.command_id, source_type=args.source_type, dry_run=args.dry_run) + else: + result = discard(args.candidate_id, decided_by=args.decided_by, rationale=args.rationale, + basis_ref=args.basis_ref, command_id=args.command_id, dry_run=args.dry_run) + print(json.dumps(result, ensure_ascii=False)) + except ConflictError as e: + print(f"[拒绝] {e}", file=sys.stderr) + sys.exit(1) diff --git a/db/ddl/99-example候选与决策.sql b/db/ddl/99-example候选与决策.sql new file mode 100644 index 0000000..d2d8588 --- /dev/null +++ b/db/ddl/99-example候选与决策.sql @@ -0,0 +1,111 @@ +-- 候选与决策两表:example_candidate(待审候选 Shadow)+ example_user_decision(用户决策归档)。 +-- 设计拍板 2026-07-30(docs/2026-07-30-落库与看板设计.md §2.4/§2.5,含评审加固)。 +-- 分工:candidate 登记 AI 待审候选(可变,状态机流转,字段平移自 CandidateEnvelope v2); +-- user_decision 记录用户 accept/merge/discard + 理据(append-only,= 父仓 Candidate Decision Archive +-- 的单用户落地,不是 ADR-018 删的 Decision Envelope;不缓存裁决,接受时实时校验 05 §5)。 +-- 候选正文单存 candidate_body,不重复塞 raw 表;候选表整体按 raw 级访问控制。 +-- 全部对象 IF NOT EXISTS / OR REPLACE,重复 apply 幂等。 + +-- ══ example_candidate:待审候选(可变,状态机;05 §3)══ +CREATE TABLE IF NOT EXISTS example_candidate ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + work_id BIGINT NOT NULL, -- → muse_content_work.id(软引用) + target_chapter INTEGER NOT NULL, -- 目标章序 + run_id VARCHAR(64), -- → example_run.run_id(merge 候选=复检重跑那次运行) + attempt INTEGER, -- 尝试号 + run_type VARCHAR(20) NOT NULL, -- production/eval/diagnostic;评测候选不得成正文(05 §8.4) + candidate_version VARCHAR(32) NOT NULL, -- 候选版本 + candidate_sha256 CHAR(64) NOT NULL, -- 候选正文哈希(裸 64 hex) + candidate_body TEXT, -- 候选正文(看板待审视图直接读;按 raw 级访问控制) + context_sha256 CHAR(64), -- → example_context_freeze.context_sha256(冻结上下文) + quality_policy_version VARCHAR(32), -- 质量政策版本 + mode VARCHAR(20), -- continuation/rewrite/expansion/polish + source_role VARCHAR(20), -- 产生角色(writer/planner) + state VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft/checking/passed/rejected/accepted/discarded/archived + acceptance_eligible BOOLEAN, -- 由 run_type 派生的缓存位(以 run_type 为准) + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + tenant_id BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_example_candidate UNIQUE (tenant_id, work_id, target_chapter, candidate_version), + CONSTRAINT chk_example_candidate_run_type + CHECK (run_type IN ('production','eval','diagnostic')), + CONSTRAINT chk_example_candidate_state + CHECK (state IN ('draft','checking','passed','rejected','accepted','discarded','archived')), + CONSTRAINT chk_example_candidate_mode + CHECK (mode IS NULL OR mode IN ('continuation','rewrite','expansion','polish')), + CONSTRAINT chk_example_candidate_sha256 + CHECK (candidate_sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT chk_example_candidate_context_sha256 + CHECK (context_sha256 IS NULL OR context_sha256 ~ '^[0-9a-f]{64}$') +); +CREATE INDEX IF NOT EXISTS idx_example_candidate_work_state ON example_candidate(tenant_id, work_id, state); +CREATE INDEX IF NOT EXISTS idx_example_candidate_run_type ON example_candidate(tenant_id, run_type); +CREATE OR REPLACE TRIGGER trg_example_candidate_updated_at BEFORE UPDATE ON example_candidate FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 机械隔离(05 §8.4):评测/诊断候选不得被接受为正式正文——DB 级硬拒,不靠调用方自觉 +CREATE OR REPLACE FUNCTION reject_non_production_accept() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.state = 'accepted' AND NEW.run_type <> 'production' THEN + RAISE EXCEPTION '评测/诊断候选(run_type=%)不得接受为正式正文(05 §8.4 机械隔离)', NEW.run_type; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE TRIGGER trg_example_candidate_no_eval_accept +BEFORE INSERT OR UPDATE ON example_candidate +FOR EACH ROW EXECUTE FUNCTION reject_non_production_accept(); + +-- ══ example_user_decision:用户决策归档(append-only;05 §4)══ +CREATE TABLE IF NOT EXISTS example_user_decision ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + candidate_id BIGINT, -- → example_candidate.id(软引用) + work_id BIGINT, -- 决策当时的作品(快照,append-only 不失步) + target_chapter INTEGER, -- 决策当时的章(快照) + decision VARCHAR(20) NOT NULL, -- accept/merge/discard + rationale TEXT, -- 理据(为什么这么定) + basis_ref VARCHAR(256), -- 依据(如 大纲@日期 / 细纲版本) + decided_by VARCHAR(64), -- 决策人(单用户=创始人) + expected_revision INTEGER, -- 接受时比对的正文 revision(冲突报 REVISION_CONFLICT) + command_id VARCHAR(120), -- 幂等键,直连 muse_content_command_log.command_id + canonical_block_id BIGINT, -- → muse_content_block.id(冗余正向指针;来源权威在正文块归因,§2.9) + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + tenant_id BIGINT NOT NULL DEFAULT 0, + CONSTRAINT chk_example_user_decision_kind + CHECK (decision IN ('accept','merge','discard')) +); +CREATE INDEX IF NOT EXISTS idx_example_user_decision_work ON example_user_decision(tenant_id, work_id); +CREATE INDEX IF NOT EXISTS idx_example_user_decision_candidate ON example_user_decision(candidate_id) WHERE candidate_id IS NOT NULL; + +CREATE OR REPLACE FUNCTION example_reject_truncate() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION '% 是 append-only 表,禁止 TRUNCATE', TG_TABLE_NAME; +END; +$$; + +CREATE OR REPLACE FUNCTION reject_example_user_decision_mutation() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'example_user_decision 是 append-only 表,禁止 UPDATE/DELETE'; +END; +$$; + +CREATE OR REPLACE TRIGGER trg_example_user_decision_append_only +BEFORE UPDATE OR DELETE ON example_user_decision +FOR EACH ROW EXECUTE FUNCTION reject_example_user_decision_mutation(); + +CREATE OR REPLACE TRIGGER trg_example_user_decision_no_truncate +BEFORE TRUNCATE ON example_user_decision +FOR EACH STATEMENT EXECUTE FUNCTION example_reject_truncate();