框架(数据库+确认通道): 落库 99 候选+决策,建正文 Canonical 写入层,confirm 改 DB 语义

- example_candidate(七态状态机,DB 级评测候选禁接受硬拒 05§8.4) + example_user_decision(append-only+理据)
- write_canonical.py:accept/discard 单事务(写正文块CAS revision+1→来源归因落块→命令幂等→决策归档→翻候选态),
  任一失败整体回滚;dry-run + eval/draft 守卫已验证;复用 db skill DSN
- confirm/SKILL.md 从 git commit 语义改为写库语义(正文走 write_canonical;知识卡走双轨;规划暂 git)
This commit is contained in:
zizi 2026-07-31 11:02:16 +08:00
parent 338e3a7dbb
commit aa82445ec7
3 changed files with 348 additions and 24 deletions

View File

@ -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 <candidate_id> \
--expected-revision <N> --rationale "为什么接受" --basis-ref "大纲@日期" --command-id <幂等ID>
# 先试跑(完整走一遍事务再回滚,校验不落库):加 --dry-run
```
- 丢弃:
```bash
.venv/bin/python .claude/skills/confirm/scripts/write_canonical.py discard <candidate_id> --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`;失败原样报错,不静默。

View File

@ -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)

View File

@ -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();