@taopao/memoir 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/assets/migrations-pg/0100_baseline.sql +1115 -0
  4. package/assets/migrations-pg/0101_functions.sql +93 -0
  5. package/assets/migrations-pg/0102_views.sql +2425 -0
  6. package/assets/migrations-pg/0103_constraints.sql +147 -0
  7. package/assets/migrations-pg/0104_protection.sql +33 -0
  8. package/assets/migrations-pg/0105_bocom_credit_card.sql +43 -0
  9. package/assets/migrations-pg/0106_psbc_debit_card.sql +87 -0
  10. package/assets/migrations-pg/0107_tailong_debit_card.sql +87 -0
  11. package/assets/migrations-pg/0108_ccb_debit_card.sql +87 -0
  12. package/assets/migrations-pg/0109_ccb_credit_card.sql +63 -0
  13. package/assets/migrations-pg/0110_icbc_debit_card.sql +87 -0
  14. package/assets/migrations-pg/0111_icbc_credit_card.sql +66 -0
  15. package/assets/migrations-pg/0112_boc_debit_card.sql +88 -0
  16. package/assets/migrations-pg/0113_jsb_debit_card.sql +88 -0
  17. package/assets/migrations-pg/0114_cmbc_debit_card.sql +88 -0
  18. package/assets/migrations-pg/0115_hzbank_debit_card.sql +88 -0
  19. package/assets/migrations-pg/0116_unified_bank_documents.sql +197 -0
  20. package/assets/migrations-pg/0117_agent_submissions.sql +50 -0
  21. package/assets/migrations-pg/0118_agent_reversions_analysis.sql +34 -0
  22. package/assets/migrations-pg/0119_agent_extension_registry.sql +72 -0
  23. package/assets/migrations-pg/0120_agent_record_sources.sql +22 -0
  24. package/assets/migrations-pg/0121_agent_extension_deprecation.sql +30 -0
  25. package/assets/migrations-pg/0122_agent_extension_controlled_revert.sql +107 -0
  26. package/assets/migrations-pg/0123_agent_extension_versions.sql +54 -0
  27. package/assets/migrations-pg/0124_agent_extension_retirement_groups.sql +26 -0
  28. package/assets/migrations-pg/0125_agent_candidate_reviews.sql +30 -0
  29. package/assets/migrations-pg/0126_agent_module_lifecycle.sql +93 -0
  30. package/assets/migrations-pg/0127_agent_tasks.sql +64 -0
  31. package/assets/migrations-pg/0128_agent_task_leases.sql +80 -0
  32. package/assets/migrations-pg/0129_agent_task_plans.sql +66 -0
  33. package/assets/migrations-pg/0130_agent_task_submissions.sql +72 -0
  34. package/assets/migrations-pg/0131_agent_task_completions.sql +93 -0
  35. package/assets/migrations-pg/0132_agent_task_changes.sql +113 -0
  36. package/assets/migrations-pg/0133_agent_imported_source_snapshots.sql +152 -0
  37. package/assets/migrations-pg/0134_agent_analysis_datasets.sql +6 -0
  38. package/assets/migrations-pg/0135_agent_maintenance_tasks.sql +139 -0
  39. package/assets/migrations-pg/0136_agent_task_structures.sql +66 -0
  40. package/assets/migrations-pg/0137_agent_personal_identity.sql +149 -0
  41. package/assets/migrations-pg/0138_agent_work_records.sql +24 -0
  42. package/assets/migrations-pg/0139_shared_files.sql +43 -0
  43. package/assets/migrations-pg/0140_shared_script_runs.sql +36 -0
  44. package/assets/migrations-pg/0141_payment_bank_links.sql +96 -0
  45. package/assets/migrations-pg/0142_consumption_order_types_and_places.sql +287 -0
  46. package/assets/migrations-pg/0143_skill_direct_write.sql +330 -0
  47. package/assets/migrations-pg/0144_skill_schema_archive.sql +73 -0
  48. package/assets/migrations-pg/0145_skill_ingest_wiki.sql +91 -0
  49. package/assets/migrations-pg/0146_skill_load.sql +17 -0
  50. package/assets/migrations-pg/0147_drop_agent_schema.sql +147 -0
  51. package/assets/migrations-pg/0148_ingest_lanes.sql +19 -0
  52. package/assets/migrations-pg/0149_account_ownership.sql +233 -0
  53. package/assets/migrations-pg/0150_bank_card_exact_link.sql +24 -0
  54. package/assets/migrations-pg/0151_account_ownership_enforce.sql +35 -0
  55. package/assets/migrations-pg/0152_account_ownership_views.sql +750 -0
  56. package/assets/migrations-pg/0153_web_frontend.sql +34 -0
  57. package/assets/migrations-pg/0154_audio_record_owners.sql +103 -0
  58. package/assets/migrations-pg/0155_ingest_knowledge_tables.sql +171 -0
  59. package/assets/migrations-pg/0156_batch_playbook_note.sql +17 -0
  60. package/assets/migrations-pg/0157_last_contact.sql +166 -0
  61. package/assets/migrations-pg/0158_refund_match_hash_join.sql +91 -0
  62. package/assets/migrations-pg/0159_fact_occurred_time.sql +88 -0
  63. package/assets/migrations-pg/0160_agent_notes.sql +118 -0
  64. package/assets/migrations-pg/0161_life_history.sql +314 -0
  65. package/assets/migrations-pg/0162_person_card.sql +427 -0
  66. package/assets/migrations-pg/0163_parking_drop_personal_defaults.sql +12 -0
  67. package/assets/native/darwin-arm64.node +0 -0
  68. package/assets/playbooks/audio.md +83 -0
  69. package/assets/playbooks/chat.md +37 -0
  70. package/assets/playbooks/common.md +66 -0
  71. package/assets/playbooks/intimate.md +19 -0
  72. package/assets/playbooks/journal.md +17 -0
  73. package/assets/playbooks/orders.md +15 -0
  74. package/assets/playbooks/payments.md +16 -0
  75. package/assets/playbooks/self.md +29 -0
  76. package/bin/install-codex-skill.mjs +77 -0
  77. package/bin/memoir.mjs +5 -0
  78. package/dist/cli.js +34062 -0
  79. package/package.json +40 -4
  80. package/release-manifest.json +432 -0
  81. package/skill/SKILL.md +120 -0
  82. package/skill/references//345/256/211/350/243/205/344/275/215/347/275/256.md +21 -0
  83. package/skill/references//346/225/260/346/215/256/345/217/243/345/276/204.md +36 -0
  84. package/skill/scripts/memoir.mjs +82 -0
@@ -0,0 +1,197 @@
1
+ -- 统一银行卡/信用卡事实;原字段逐列复制,原键、金额、原始 JSON、批次和历史审计均保留。
2
+ -- 本文件由迁移执行器包在 migration 会话和同一事务内,不创建任何备份。
3
+ SELECT pg_advisory_xact_lock(hashtext('relation-vault:write'));
4
+ CREATE TEMP TABLE vault_bank_merge_map (
5
+ old_table text PRIMARY KEY, target_table text NOT NULL, source_kind text NOT NULL,
6
+ key_column text NOT NULL, columns_sql text
7
+ ) ON COMMIT DROP;
8
+ INSERT INTO vault_bank_merge_map(old_table,target_table,source_kind,key_column) VALUES
9
+ ('psbc_bank_statements','bank_card_statements','psbc_debit_card','statement_key'),
10
+ ('tailong_bank_statements','bank_card_statements','tailong_debit_card','statement_key'),
11
+ ('ccb_bank_statements','bank_card_statements','ccb_debit_card','statement_key'),
12
+ ('icbc_bank_statements','bank_card_statements','icbc_debit_card','statement_key'),
13
+ ('boc_bank_statements','bank_card_statements','boc_debit_card','statement_key'),
14
+ ('jsb_bank_statements','bank_card_statements','jsb_debit_card','statement_key'),
15
+ ('cmbc_bank_statements','bank_card_statements','cmbc_debit_card','statement_key'),
16
+ ('hzbank_bank_statements','bank_card_statements','hzbank_debit_card','statement_key'),
17
+ ('psbc_bank_transactions','bank_card_transactions','psbc_debit_card','transaction_key'),
18
+ ('tailong_bank_transactions','bank_card_transactions','tailong_debit_card','transaction_key'),
19
+ ('ccb_bank_transactions','bank_card_transactions','ccb_debit_card','transaction_key'),
20
+ ('icbc_bank_transactions','bank_card_transactions','icbc_debit_card','transaction_key'),
21
+ ('boc_bank_transactions','bank_card_transactions','boc_debit_card','transaction_key'),
22
+ ('jsb_bank_transactions','bank_card_transactions','jsb_debit_card','transaction_key'),
23
+ ('cmbc_bank_transactions','bank_card_transactions','cmbc_debit_card','transaction_key'),
24
+ ('hzbank_bank_transactions','bank_card_transactions','hzbank_debit_card','transaction_key'),
25
+ ('ccb_credit_card_transactions','credit_card_transactions','ccb_credit_card','transaction_key'),
26
+ ('icbc_credit_card_transactions','credit_card_transactions','icbc_credit_card','transaction_key');
27
+
28
+ -- 原共享借记卡表含仅适用于三个旧来源的 CHECK,改为统一来源范围。
29
+ DO $$ DECLARE r record; BEGIN
30
+ FOR r IN SELECT conrelid::regclass AS tbl,conname FROM pg_constraint
31
+ WHERE conrelid IN ('bank_card_statements'::regclass,'bank_card_transactions'::regclass)
32
+ AND contype='c' AND pg_get_constraintdef(oid) LIKE '%source_kind%'
33
+ LOOP EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I',r.tbl,r.conname); END LOOP;
34
+ END $$;
35
+ ALTER TABLE bank_card_statements ALTER COLUMN page_count DROP NOT NULL;
36
+ ALTER TABLE bank_card_statements ADD CONSTRAINT bank_statement_source_check CHECK(source_kind IN
37
+ ('citic_debit_card','cmb_debit_card','bocom_debit_card','psbc_debit_card','tailong_debit_card','ccb_debit_card','icbc_debit_card','boc_debit_card','jsb_debit_card','cmbc_debit_card','hzbank_debit_card'));
38
+ ALTER TABLE bank_card_transactions ADD CONSTRAINT bank_transaction_source_check CHECK(source_kind IN
39
+ ('citic_debit_card','cmb_debit_card','bocom_debit_card','psbc_debit_card','tailong_debit_card','ccb_debit_card','icbc_debit_card','boc_debit_card','jsb_debit_card','cmbc_debit_card','hzbank_debit_card'));
40
+ ALTER TABLE credit_card_transactions ALTER COLUMN statement_key DROP NOT NULL;
41
+ ALTER TABLE credit_card_transactions ADD COLUMN source_kind text;
42
+ ALTER TABLE credit_card_transactions ADD COLUMN card_key text;
43
+ UPDATE credit_card_transactions t SET source_kind=s.source_kind,card_key=s.card_key
44
+ FROM credit_card_statements s WHERE s.statement_key=t.statement_key;
45
+ ALTER TABLE credit_card_transactions ALTER COLUMN source_kind SET NOT NULL;
46
+ ALTER TABLE credit_card_transactions ALTER COLUMN card_key SET NOT NULL;
47
+ ALTER TABLE credit_card_transactions ADD CONSTRAINT credit_transaction_source_check
48
+ CHECK(source_kind IN ('citic_credit_card','cmb_credit_card','bocom_credit_card','ccb_credit_card','icbc_credit_card'));
49
+
50
+ -- 根据旧表实际目录建立字段并集,不能凭手写字段名单漏掉银行特有列。
51
+ DO $$ DECLARE m record; c record; cols text; different boolean; BEGIN
52
+ FOR m IN SELECT * FROM vault_bank_merge_map ORDER BY old_table LOOP
53
+ SELECT string_agg(quote_ident(attname),',' ORDER BY attnum) INTO cols
54
+ FROM pg_attribute WHERE attrelid=to_regclass(m.old_table) AND attnum>0 AND NOT attisdropped;
55
+ IF cols IS NULL THEN RAISE EXCEPTION '待合并事实表不存在: %',m.old_table; END IF;
56
+ UPDATE vault_bank_merge_map SET columns_sql=cols WHERE old_table=m.old_table;
57
+ FOR c IN SELECT attname,format_type(atttypid,atttypmod) AS typ FROM pg_attribute
58
+ WHERE attrelid=to_regclass(m.old_table) AND attnum>0 AND NOT attisdropped LOOP
59
+ IF NOT EXISTS(SELECT 1 FROM pg_attribute WHERE attrelid=to_regclass(m.target_table) AND attname=c.attname AND attnum>0 AND NOT attisdropped)
60
+ THEN EXECUTE format('ALTER TABLE %I ADD COLUMN %I %s',m.target_table,c.attname,c.typ); END IF;
61
+ END LOOP;
62
+ END LOOP;
63
+ -- 账单头先于交易复制,任何键冲突直接回滚,不使用忽略冲突丢弃数据。
64
+ FOR m IN SELECT * FROM vault_bank_merge_map ORDER BY (key_column='transaction_key'),old_table LOOP
65
+ EXECUTE format('INSERT INTO %I (%s) SELECT %s FROM %I',m.target_table,m.columns_sql,m.columns_sql,m.old_table);
66
+ -- 对每一列做双向集合比较,确认重命名或合并没有改变已存字段。
67
+ EXECUTE format('SELECT EXISTS((SELECT %s FROM %I EXCEPT SELECT %s FROM %I) UNION ALL (SELECT %s FROM %I WHERE %I IN (SELECT %I FROM %I) EXCEPT SELECT %s FROM %I))',
68
+ m.columns_sql,m.old_table,m.columns_sql,m.target_table,m.columns_sql,m.target_table,m.key_column,m.key_column,m.old_table,m.columns_sql,m.old_table) INTO different;
69
+ IF different THEN RAISE EXCEPTION '字段保留核对失败: %',m.old_table; END IF;
70
+ END LOOP;
71
+ END $$;
72
+
73
+ ALTER TABLE bank_card_statements ADD COLUMN bank_card_id text REFERENCES bank_cards(id);
74
+ ALTER TABLE bank_card_transactions ADD COLUMN bank_card_id text REFERENCES bank_cards(id);
75
+ ALTER TABLE credit_card_statements ADD COLUMN bank_card_id text REFERENCES bank_cards(id);
76
+ ALTER TABLE credit_card_transactions ADD COLUMN bank_card_id text REFERENCES bank_cards(id);
77
+ ALTER TABLE bank_card_transactions ADD CONSTRAINT bank_transaction_statement_fk FOREIGN KEY(statement_key) REFERENCES bank_card_statements(statement_key);
78
+
79
+ -- 来源内账户标识仍是事实身份,卡片 ID 只是明确归属后的关联;不按尾号单独猜卡。
80
+ CREATE FUNCTION vault_match_bank_card(src text, identity_key text, masked text, last_four text)
81
+ RETURNS text LANGUAGE plpgsql STABLE AS $$
82
+ DECLARE bank text; typ text; result text; n bigint; known text; fingerprint text;
83
+ BEGIN
84
+ bank := CASE split_part(src,'_',1) WHEN 'citic' THEN '中信银行' WHEN 'cmb' THEN '招商银行'
85
+ WHEN 'bocom' THEN '交通银行' WHEN 'psbc' THEN '邮储银行' WHEN 'tailong' THEN '浙江泰隆商业银行'
86
+ WHEN 'ccb' THEN '建设银行' WHEN 'icbc' THEN '工商银行' WHEN 'boc' THEN '中国银行'
87
+ WHEN 'jsb' THEN '江苏银行' WHEN 'cmbc' THEN '民生银行' WHEN 'hzbank' THEN '杭州银行' END;
88
+ typ := CASE WHEN src LIKE '%_credit_card' THEN 'credit' ELSE 'debit' END;
89
+ known := regexp_replace(coalesce(masked,''),'[^0-9*]','','g');
90
+ fingerprint := substring(identity_key from ':([0-9a-f]{64})$');
91
+ SELECT count(*),min(id) INTO n,result FROM bank_cards c WHERE c.deleted_at IS NULL
92
+ AND c.card_type=typ AND c.bank_name LIKE bank||'%' AND right(c.card_number,4)=last_four
93
+ AND (known='' OR c.card_number ~ ('^'||replace(known,'*','[0-9]*')||'$'))
94
+ AND (src<>'citic_credit_card' OR left(c.card_number,6)=split_part(identity_key,':',1))
95
+ AND (fingerprint IS NULL OR fingerprint IN (
96
+ encode(sha256(convert_to(to_json(c.card_number)::text,'UTF8')),'hex'),
97
+ encode(sha256(convert_to(c.card_number,'UTF8')),'hex'),
98
+ encode(sha256(convert_to(to_json(known)::text,'UTF8')),'hex')));
99
+ RETURN CASE WHEN n=1 THEN result ELSE NULL END;
100
+ END $$;
101
+
102
+ CREATE FUNCTION vault_link_bank_card() RETURNS trigger LANGUAGE plpgsql AS $$
103
+ DECLARE r jsonb; identity_key text; masked text; tail text;
104
+ BEGIN
105
+ IF TG_TABLE_NAME='credit_card_transactions' AND NEW.statement_key IS NOT NULL THEN
106
+ SELECT s.source_kind,s.card_key,s.bank_card_id INTO NEW.source_kind,NEW.card_key,NEW.bank_card_id FROM credit_card_statements s WHERE s.statement_key=NEW.statement_key;
107
+ END IF;
108
+ r:=to_jsonb(NEW); identity_key:=coalesce(r->>'account_key',r->>'card_key');
109
+ masked:=r->>'account_masked'; tail:=coalesce(r->>'card_last_four',right(masked,4));
110
+ IF NEW.bank_card_id IS NULL THEN NEW.bank_card_id:=vault_match_bank_card(NEW.source_kind,identity_key,masked,tail); END IF;
111
+ RETURN NEW;
112
+ END $$;
113
+ DO $$ DECLARE tbl text; BEGIN
114
+ FOREACH tbl IN ARRAY ARRAY['bank_card_statements','bank_card_transactions','credit_card_statements','credit_card_transactions'] LOOP
115
+ EXECUTE format('CREATE TRIGGER trg_%s_card_link BEFORE INSERT OR UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION vault_link_bank_card()',tbl,tbl);
116
+ EXECUTE format('UPDATE %I SET bank_card_id=bank_card_id',tbl);
117
+ EXECUTE format('CREATE INDEX ix_%s_card ON %I(bank_card_id)',tbl,tbl);
118
+ END LOOP;
119
+ END $$;
120
+ CREATE INDEX ix_bank_transaction_source_date ON bank_card_transactions(source_kind,occurred_at);
121
+ CREATE INDEX ix_credit_transaction_source_date ON credit_card_transactions(source_kind,posting_date);
122
+ CREATE INDEX ix_credit_transaction_card_date ON credit_card_transactions(card_key,posting_date);
123
+
124
+ -- 原件关联是一对多/多对多关系,空账单和多卡文件也必须保留。
125
+ CREATE TABLE bank_card_document_links (
126
+ link_id text PRIMARY KEY, batch_id text NOT NULL, file_sha256 text NOT NULL,
127
+ source_kind text NOT NULL, bank_card_id text REFERENCES bank_cards(id),
128
+ account_key text NOT NULL, bank_statement_key text REFERENCES bank_card_statements(statement_key),
129
+ credit_statement_key text REFERENCES credit_card_statements(statement_key),
130
+ period_from text,period_to text,link_basis text NOT NULL,
131
+ FOREIGN KEY(batch_id,file_sha256) REFERENCES import_files(batch_id,file_sha256),
132
+ CHECK(num_nonnulls(bank_statement_key,credit_statement_key)<=1)
133
+ );
134
+ CREATE TABLE bank_document_records (
135
+ record_id text PRIMARY KEY,batch_id text NOT NULL,file_sha256 text NOT NULL,source_kind text NOT NULL,
136
+ bank_card_id text REFERENCES bank_cards(id),record_kind text NOT NULL,locator_json text NOT NULL CHECK(vault_json_valid(locator_json)),
137
+ raw_json text NOT NULL CHECK(vault_json_valid(raw_json)),normalized_json text NOT NULL CHECK(vault_json_valid(normalized_json)),
138
+ parser_version text NOT NULL,coverage_status text NOT NULL CHECK(coverage_status IN ('mapped','raw_only','partial','conflict')),
139
+ bank_statement_key text REFERENCES bank_card_statements(statement_key),bank_transaction_key text REFERENCES bank_card_transactions(transaction_key),
140
+ credit_statement_key text REFERENCES credit_card_statements(statement_key),credit_transaction_key text REFERENCES credit_card_transactions(transaction_key),
141
+ FOREIGN KEY(batch_id,file_sha256) REFERENCES import_files(batch_id,file_sha256),
142
+ CHECK(num_nonnulls(bank_statement_key,bank_transaction_key,credit_statement_key,credit_transaction_key)<=1)
143
+ );
144
+ CREATE INDEX ix_bank_document_card_file ON bank_card_document_links(bank_card_id,batch_id,file_sha256);
145
+ CREATE INDEX ix_bank_document_record_file ON bank_document_records(batch_id,file_sha256,record_kind);
146
+ CREATE INDEX ix_bank_document_record_card ON bank_document_records(bank_card_id,record_kind);
147
+
148
+ -- 新来源记录同样受事实写保护、逐行审计与 TRUNCATE 保护,不允许普通 SQL 悄悄改写。
149
+ DO $$ DECLARE tbl text; key_col text; g record; BEGIN
150
+ FOREACH tbl IN ARRAY ARRAY['bank_card_document_links','bank_document_records'] LOOP
151
+ key_col:=CASE WHEN tbl='bank_card_document_links' THEN 'link_id' ELSE 'record_id' END;
152
+ EXECUTE format('CREATE TRIGGER trg_%s_guard BEFORE INSERT OR UPDATE OR DELETE ON %I FOR EACH ROW EXECUTE FUNCTION vault_guard(''fact'')',tbl,tbl);
153
+ EXECUTE format('CREATE TRIGGER trg_%s_truncate BEFORE TRUNCATE ON %I FOR EACH STATEMENT EXECUTE FUNCTION vault_guard(''fact'')',tbl,tbl);
154
+ EXECUTE format('CREATE TRIGGER trg_%s_version AFTER INSERT OR UPDATE OR DELETE ON %I FOR EACH STATEMENT EXECUTE FUNCTION vault_touch()',tbl,tbl);
155
+ EXECUTE format('CREATE TRIGGER trg_%s_audit AFTER INSERT OR UPDATE OR DELETE ON %I FOR EACH ROW EXECUTE FUNCTION vault_audit(%L)',tbl,tbl,key_col);
156
+ FOR g IN SELECT grantee,privilege_type FROM information_schema.role_table_grants WHERE table_schema='public' AND table_name='bank_card_transactions' AND grantee<>current_user AND privilege_type IN ('SELECT','INSERT','UPDATE','DELETE') LOOP
157
+ EXECUTE format('GRANT %s ON %I TO %s',g.privilege_type,tbl,CASE WHEN g.grantee='PUBLIC' THEN 'PUBLIC' ELSE quote_ident(g.grantee) END);
158
+ END LOOP;
159
+ END LOOP;
160
+ END $$;
161
+
162
+ -- 在删除旧物理表前保存其全部已入库字段,后续从归档补入每份文件的原始页/单元格及解析记录。
163
+ DO $$ DECLARE tbl text; target text; key_col text; ref_col text; src_sql text; m record; BEGIN
164
+ FOR m IN SELECT old_table AS tbl,target_table FROM vault_bank_merge_map
165
+ UNION ALL SELECT x,x FROM unnest(ARRAY['bank_card_statements','bank_card_transactions','credit_card_statements','credit_card_transactions']) x LOOP
166
+ tbl:=m.tbl; target:=m.target_table;
167
+ key_col:=CASE WHEN target LIKE '%statements' THEN 'statement_key' ELSE 'transaction_key' END;
168
+ ref_col:=CASE WHEN target LIKE 'bank_%' THEN 'bank_' ELSE 'credit_' END||key_col;
169
+ src_sql:=CASE WHEN target='credit_card_transactions' AND tbl='credit_card_transactions' THEN 't.source_kind' ELSE 't.source_kind' END;
170
+ EXECUTE format($sql$
171
+ INSERT INTO bank_document_records(record_id,batch_id,file_sha256,source_kind,bank_card_id,record_kind,locator_json,raw_json,normalized_json,parser_version,coverage_status,%I)
172
+ SELECT md5(%L||t.%I||f.batch_id||f.file_sha256),f.batch_id,f.file_sha256,%s,u.bank_card_id,%L,
173
+ json_build_object('legacy_table',%L,'key',t.%I)::text,t.raw_json,row_to_json(t)::text,b.importer_version,'mapped',t.%I
174
+ FROM %I t JOIN %I u ON u.%I=t.%I
175
+ JOIN LATERAL (SELECT ff.* FROM import_files ff WHERE ff.file_sha256=t.source_sha256 ORDER BY (ff.batch_id=t.first_batch_id) DESC,ff.batch_id LIMIT 1) f ON true
176
+ JOIN import_batches b ON b.id=f.batch_id
177
+ ON CONFLICT(record_id) DO NOTHING
178
+ $sql$,ref_col,tbl,key_col,src_sql,CASE WHEN key_col='statement_key' THEN 'statement' ELSE 'transaction' END,tbl,key_col,key_col,tbl,target,key_col,key_col);
179
+ END LOOP;
180
+ END $$;
181
+
182
+ -- 不保留第二份事实数据;旧名称成为严格只读视图,历史查询仍然按原列回查。
183
+ CREATE FUNCTION vault_bank_legacy_readonly() RETURNS trigger LANGUAGE plpgsql AS $$
184
+ BEGIN RAISE EXCEPTION '旧银行表已统一,兼容视图仅供读取'; END $$;
185
+ DO $$ DECLARE m record; g record; BEGIN
186
+ FOR m IN SELECT * FROM vault_bank_merge_map ORDER BY (key_column='statement_key'),old_table LOOP
187
+ EXECUTE format('DROP TABLE %I',m.old_table);
188
+ END LOOP;
189
+ FOR m IN SELECT * FROM vault_bank_merge_map LOOP
190
+ EXECUTE format('CREATE VIEW %I AS SELECT %s FROM %I WHERE source_kind=%L',m.old_table,m.columns_sql,m.target_table,m.source_kind);
191
+ EXECUTE format('CREATE TRIGGER trg_%s_readonly INSTEAD OF INSERT OR UPDATE OR DELETE ON %I FOR EACH ROW EXECUTE FUNCTION vault_bank_legacy_readonly()',m.old_table,m.old_table);
192
+ FOR g IN SELECT grantee FROM information_schema.role_table_grants WHERE table_schema='public' AND table_name=m.target_table AND privilege_type='SELECT' AND grantee<>current_user LOOP
193
+ EXECUTE format('GRANT SELECT ON %I TO %s',m.old_table,CASE WHEN g.grantee='PUBLIC' THEN 'PUBLIC' ELSE quote_ident(g.grantee) END);
194
+ END LOOP;
195
+ END LOOP;
196
+ END $$;
197
+
@@ -0,0 +1,50 @@
1
+ -- Agent 提交与审计基础;只新增版本,不修改既有事实与历史,不生成备份。
2
+ SELECT pg_advisory_xact_lock(hashtext('relation-vault:write'));
3
+ ALTER TABLE public.audit_log ADD COLUMN change_set text;
4
+ ALTER TABLE public.audit_log ADD COLUMN object_schema text;
5
+ ALTER TABLE public.audit_log ADD COLUMN primary_key_json text CHECK(primary_key_json IS NULL OR public.vault_json_valid(primary_key_json));
6
+ CREATE INDEX ix_audit_change_set ON public.audit_log(change_set,id) WHERE change_set IS NOT NULL;
7
+ CREATE OR REPLACE FUNCTION public.vault_audit() RETURNS trigger LANGUAGE plpgsql AS $$
8
+ DECLARE item jsonb; row_key text; key_value jsonb; old_value text; new_value text;
9
+ BEGIN
10
+ item:=CASE WHEN TG_OP='DELETE' THEN to_jsonb(OLD) ELSE to_jsonb(NEW) END;
11
+ SELECT string_agg(item ->> key, ':' ORDER BY ord),jsonb_object_agg(key,item -> key)
12
+ INTO row_key,key_value FROM unnest(TG_ARGV) WITH ORDINALITY AS keys(key,ord);
13
+ IF TG_OP<>'INSERT' THEN old_value:=to_jsonb(OLD)::text; END IF;
14
+ IF TG_OP<>'DELETE' THEN new_value:=to_jsonb(NEW)::text; END IF;
15
+ INSERT INTO public.audit_log(at,actor,reason,table_name,row_key,op,before_json,after_json,change_set,object_schema,primary_key_json)
16
+ VALUES(to_char(clock_timestamp() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD HH24:MI:SS'),
17
+ current_setting('vault.actor'),nullif(current_setting('vault.reason',true),''),TG_TABLE_NAME,row_key,lower(TG_OP),old_value,new_value,
18
+ nullif(current_setting('vault.change_set',true),''),TG_TABLE_SCHEMA,key_value::text);
19
+ RETURN NULL;
20
+ END $$;
21
+ CREATE SCHEMA agent;
22
+ CREATE TABLE agent.submissions (
23
+ id text PRIMARY KEY,actor text NOT NULL,idempotency_key text NOT NULL,fingerprint text NOT NULL,
24
+ source_id text NOT NULL REFERENCES public.journal_entries(id),
25
+ result_json text NOT NULL CHECK(public.vault_json_valid(result_json)),
26
+ created_at text NOT NULL,UNIQUE(actor,idempotency_key)
27
+ );
28
+ CREATE TABLE agent.candidates (
29
+ id text PRIMARY KEY,submission_id text NOT NULL REFERENCES agent.submissions(id),
30
+ operation_key text NOT NULL,reason text NOT NULL,payload_json text NOT NULL CHECK(public.vault_json_valid(payload_json)),
31
+ source_id text NOT NULL REFERENCES public.journal_entries(id),quote text,
32
+ state text NOT NULL DEFAULT 'pending' CHECK(state IN ('pending','resolved','dismissed')),
33
+ created_at text NOT NULL,created_by text NOT NULL,UNIQUE(submission_id,operation_key)
34
+ );
35
+ -- 管理记录同样需要写会话、审计和版本;核心写入从不依赖这些表存在。
36
+ DO $$ DECLARE tbl text; g record; BEGIN
37
+ FOREACH tbl IN ARRAY ARRAY['submissions','candidates'] LOOP
38
+ EXECUTE format('CREATE TRIGGER trg_%s_guard BEFORE INSERT OR UPDATE OR DELETE ON agent.%I FOR EACH ROW EXECUTE FUNCTION public.vault_guard(''session'')',tbl,tbl);
39
+ EXECUTE format('CREATE TRIGGER trg_%s_truncate BEFORE TRUNCATE ON agent.%I FOR EACH STATEMENT EXECUTE FUNCTION public.vault_guard(''session'')',tbl,tbl);
40
+ EXECUTE format('CREATE TRIGGER trg_%s_audit AFTER INSERT OR UPDATE OR DELETE ON agent.%I FOR EACH ROW EXECUTE FUNCTION public.vault_audit(''id'')',tbl,tbl);
41
+ EXECUTE format('CREATE TRIGGER trg_%s_version AFTER INSERT OR UPDATE OR DELETE ON agent.%I FOR EACH STATEMENT EXECUTE FUNCTION public.vault_touch()',tbl,tbl);
42
+ -- 继承既有角色的最小权限,不猜测部署角色名,不授予删除或结构修改权限。
43
+ FOR g IN SELECT DISTINCT grantee,privilege_type FROM information_schema.role_table_grants
44
+ WHERE table_schema='public' AND table_name='persons' AND grantee<>'PUBLIC' AND privilege_type IN ('SELECT','INSERT','UPDATE')
45
+ LOOP
46
+ EXECUTE format('GRANT USAGE ON SCHEMA agent TO %I',g.grantee);
47
+ EXECUTE format('GRANT %s ON agent.%I TO %I',g.privilege_type,tbl,g.grantee);
48
+ END LOOP;
49
+ END LOOP;
50
+ END $$;
@@ -0,0 +1,34 @@
1
+ -- 撤销映射和分析资料均持久保留;不回删原批次、原文、审计或版本,不创建备份。
2
+ SELECT pg_advisory_xact_lock(hashtext('relation-vault:write'));
3
+ CREATE TABLE agent.reversions (
4
+ id text PRIMARY KEY,submission_id text NOT NULL UNIQUE REFERENCES agent.submissions(id),
5
+ actor text NOT NULL,reason text NOT NULL,result_json text NOT NULL CHECK(public.vault_json_valid(result_json)),created_at text NOT NULL
6
+ );
7
+ CREATE TABLE agent.analysis_reports (
8
+ id text PRIMARY KEY,actor text NOT NULL,idempotency_key text NOT NULL,fingerprint text NOT NULL,
9
+ title text NOT NULL,body text NOT NULL,metrics_json text NOT NULL CHECK(public.vault_json_valid(metrics_json)),
10
+ scope_note text NOT NULL,period_from text,period_to text,script_text text,
11
+ sensitivity text NOT NULL CHECK(sensitivity IN ('normal','private','intimate')),created_at text NOT NULL,
12
+ UNIQUE(actor,idempotency_key),CHECK(period_to IS NULL OR period_from IS NULL OR period_to>=period_from)
13
+ );
14
+ CREATE TABLE agent.analysis_sources (
15
+ id text PRIMARY KEY,report_id text NOT NULL REFERENCES agent.analysis_reports(id),
16
+ object_schema text NOT NULL,table_name text NOT NULL,primary_key_json text NOT NULL CHECK(public.vault_json_valid(primary_key_json)),
17
+ source_revision text NOT NULL,quote text NOT NULL
18
+ );
19
+ -- 撤销记录和报告是历史资料,默认只增;不提供修改旧报告的捷径。
20
+ DO $$ DECLARE tbl text; g record; BEGIN
21
+ FOREACH tbl IN ARRAY ARRAY['reversions','analysis_reports','analysis_sources'] LOOP
22
+ EXECUTE format('CREATE TRIGGER trg_%s_guard BEFORE INSERT OR UPDATE OR DELETE ON agent.%I FOR EACH ROW EXECUTE FUNCTION public.vault_guard(''append'')',tbl,tbl);
23
+ EXECUTE format('CREATE TRIGGER trg_%s_truncate BEFORE TRUNCATE ON agent.%I FOR EACH STATEMENT EXECUTE FUNCTION public.vault_guard(''append'')',tbl,tbl);
24
+ EXECUTE format('CREATE TRIGGER trg_%s_audit AFTER INSERT OR UPDATE OR DELETE ON agent.%I FOR EACH ROW EXECUTE FUNCTION public.vault_audit(''id'')',tbl,tbl);
25
+ EXECUTE format('CREATE TRIGGER trg_%s_version AFTER INSERT OR UPDATE OR DELETE ON agent.%I FOR EACH STATEMENT EXECUTE FUNCTION public.vault_touch()',tbl,tbl);
26
+ FOR g IN SELECT DISTINCT grantee,privilege_type FROM information_schema.role_table_grants
27
+ WHERE table_schema='public' AND table_name='persons' AND grantee<>'PUBLIC' AND privilege_type IN ('SELECT','INSERT')
28
+ LOOP
29
+ EXECUTE format('GRANT USAGE ON SCHEMA agent TO %I',g.grantee);
30
+ EXECUTE format('GRANT %s ON agent.%I TO %I',g.privilege_type,tbl,g.grantee);
31
+ END LOOP;
32
+ END LOOP;
33
+ END $$;
34
+ CREATE INDEX ix_agent_analysis_source ON agent.analysis_sources(object_schema,table_name);
@@ -0,0 +1,72 @@
1
+ -- 扩展注册与历史由结构服务写入;不改动已发布迁移,不创建业务扩展表或任何备份。
2
+ SELECT pg_advisory_xact_lock(hashtext('relation-vault:write'));
3
+ CREATE SCHEMA agent_ext;
4
+ CREATE SCHEMA agent_archive;
5
+ CREATE TABLE agent.extension_domains (
6
+ key text PRIMARY KEY,description text NOT NULL,created_at text NOT NULL,created_by text NOT NULL
7
+ );
8
+ CREATE TABLE agent.extensions (
9
+ id text PRIMARY KEY,domain_key text NOT NULL REFERENCES agent.extension_domains(key),
10
+ schema_name text NOT NULL CHECK(schema_name IN ('agent_ext','agent_archive')),table_name text NOT NULL,
11
+ version integer NOT NULL CHECK(version>0),checksum text NOT NULL,fingerprint text NOT NULL UNIQUE,
12
+ purpose_key text NOT NULL,description text NOT NULL,semantics text NOT NULL CHECK(semantics IN ('recorded_fact','observation','analysis')),
13
+ sensitivity text NOT NULL CHECK(sensitivity IN ('normal','private','intimate')),
14
+ state text NOT NULL CHECK(state IN ('active','deprecated','retired')),
15
+ descriptor_json text NOT NULL CHECK(public.vault_json_valid(descriptor_json)),distinct_reason text,
16
+ catalog_json text NOT NULL CHECK(public.vault_json_valid(catalog_json)),
17
+ created_at text NOT NULL,created_by text NOT NULL,updated_at text NOT NULL,updated_by text NOT NULL,
18
+ UNIQUE(schema_name,table_name)
19
+ );
20
+ CREATE TABLE agent.extension_migrations (
21
+ extension_id text NOT NULL REFERENCES agent.extensions(id),version integer NOT NULL,checksum text NOT NULL,
22
+ descriptor_json text NOT NULL CHECK(public.vault_json_valid(descriptor_json)),ddl_sql text NOT NULL,
23
+ trial_json text NOT NULL CHECK(public.vault_json_valid(trial_json)),result_json text NOT NULL CHECK(public.vault_json_valid(result_json)),
24
+ applied_at text NOT NULL,actor text NOT NULL,PRIMARY KEY(extension_id,version)
25
+ );
26
+ CREATE TABLE agent.extension_reads (
27
+ extension_id text NOT NULL REFERENCES agent.extensions(id),actor text NOT NULL,version integer NOT NULL,seen_at text NOT NULL,
28
+ PRIMARY KEY(extension_id,actor)
29
+ );
30
+ -- 稳定数据库层持有保护函数。卸载执行包后仍读取保留的注册描述,不依赖在线 Agent。
31
+ CREATE FUNCTION public.vault_extension_guard() RETURNS trigger LANGUAGE plpgsql AS $$
32
+ DECLARE actor text:=nullif(current_setting('vault.actor',true),''); item record; col text;
33
+ BEGIN
34
+ IF TG_OP IN ('DELETE','TRUNCATE') THEN RAISE EXCEPTION '扩展只允许软删除'; END IF;
35
+ IF actor IS NULL THEN RAISE EXCEPTION '未设置写入会话'; END IF;
36
+ SELECT schema_name,table_name,state INTO item FROM agent.extensions WHERE id=TG_ARGV[0];
37
+ IF NOT FOUND OR item.schema_name<>TG_TABLE_SCHEMA OR item.table_name<>TG_TABLE_NAME THEN RAISE EXCEPTION '扩展注册与物理对象不一致'; END IF;
38
+ IF actor='migration' THEN RETURN NEW; END IF;
39
+ IF item.state<>'active' THEN RAISE EXCEPTION '扩展已停止写入'; END IF;
40
+ IF nullif(current_setting('vault.change_set',true),'') IS NULL THEN RAISE EXCEPTION '扩展写入需要批次'; END IF;
41
+ IF NEW.updated_by<>actor OR (TG_OP='INSERT' AND NEW.created_by<>actor) THEN RAISE EXCEPTION '扩展审计身份不一致'; END IF;
42
+ IF TG_OP='UPDATE' THEN
43
+ IF NEW.created_at IS DISTINCT FROM OLD.created_at OR NEW.created_by IS DISTINCT FROM OLD.created_by THEN RAISE EXCEPTION '扩展创建信息不可修改'; END IF;
44
+ FOREACH col IN ARRAY TG_ARGV[1:array_length(TG_ARGV,1)] LOOP
45
+ IF (to_jsonb(NEW)->col) IS DISTINCT FROM (to_jsonb(OLD)->col) THEN RAISE EXCEPTION '扩展主键不可修改'; END IF;
46
+ END LOOP;
47
+ END IF;
48
+ RETURN NEW;
49
+ END $$;
50
+ DO $$ DECLARE tbl text; mode text; keys text; g record; BEGIN
51
+ FOREACH tbl IN ARRAY ARRAY['extension_domains','extensions','extension_migrations','extension_reads'] LOOP
52
+ mode:=CASE WHEN tbl IN ('extension_domains','extension_migrations') THEN 'append' ELSE 'session' END;
53
+ keys:=CASE WHEN tbl='extension_domains' THEN '''key''' WHEN tbl='extension_migrations' THEN '''extension_id'',''version''' WHEN tbl='extension_reads' THEN '''extension_id'',''actor''' ELSE '''id''' END;
54
+ EXECUTE format('CREATE TRIGGER trg_%s_guard BEFORE INSERT OR UPDATE OR DELETE ON agent.%I FOR EACH ROW EXECUTE FUNCTION public.vault_guard(%L)',tbl,tbl,mode);
55
+ EXECUTE format('CREATE TRIGGER trg_%s_truncate BEFORE TRUNCATE ON agent.%I FOR EACH STATEMENT EXECUTE FUNCTION public.vault_guard(%L)',tbl,tbl,mode);
56
+ EXECUTE format('CREATE TRIGGER trg_%s_audit AFTER INSERT OR UPDATE OR DELETE ON agent.%I FOR EACH ROW EXECUTE FUNCTION public.vault_audit(%s)',tbl,tbl,keys);
57
+ EXECUTE format('CREATE TRIGGER trg_%s_version AFTER INSERT OR UPDATE OR DELETE ON agent.%I FOR EACH STATEMENT EXECUTE FUNCTION public.vault_touch()',tbl,tbl);
58
+ -- 部署可能存在默认授权,不能因此让普通 app 改写结构注册与迁移历史。
59
+ EXECUTE format('REVOKE ALL ON agent.%I FROM PUBLIC',tbl);
60
+ FOR g IN SELECT DISTINCT r.rolname AS grantee FROM pg_class c CROSS JOIN LATERAL aclexplode(c.relacl) a JOIN pg_roles r ON r.oid=a.grantee WHERE c.oid=to_regclass('agent.'||tbl) AND a.grantee<>c.relowner LOOP
61
+ EXECUTE format('REVOKE ALL ON agent.%I FROM %I',tbl,g.grantee);
62
+ END LOOP;
63
+ FOR g IN SELECT DISTINCT grantee,privilege_type FROM information_schema.role_table_grants WHERE table_schema='public' AND table_name='persons' AND grantee<>'PUBLIC' AND privilege_type='SELECT' LOOP
64
+ EXECUTE format('GRANT USAGE ON SCHEMA agent_ext,agent_archive TO %I',g.grantee);
65
+ EXECUTE format('GRANT SELECT ON agent.%I TO %I',tbl,g.grantee);
66
+ END LOOP;
67
+ END LOOP;
68
+ -- 已读状态可由 app 写入,结构描述及迁移记录仅由 owner 结构服务维护。
69
+ FOR g IN SELECT DISTINCT grantee,privilege_type FROM information_schema.role_table_grants WHERE table_schema='public' AND table_name='persons' AND grantee<>'PUBLIC' AND privilege_type IN ('INSERT','UPDATE') LOOP
70
+ EXECUTE format('GRANT %s ON agent.extension_reads TO %I',g.privilege_type,g.grantee);
71
+ END LOOP;
72
+ END $$;
@@ -0,0 +1,22 @@
1
+ -- 扩展写入的结构化来源单独保留,不能把带复合键的引用伪装成普通网页手工证据。
2
+ SELECT pg_advisory_xact_lock(hashtext('relation-vault:write'));
3
+ CREATE TABLE agent.record_sources (
4
+ id text PRIMARY KEY,submission_id text NOT NULL REFERENCES agent.submissions(id),
5
+ extension_id text NOT NULL REFERENCES agent.extensions(id),record_key_json text NOT NULL CHECK(public.vault_json_valid(record_key_json)),
6
+ source_schema text NOT NULL,source_table text NOT NULL,source_key_json text NOT NULL CHECK(public.vault_json_valid(source_key_json)),
7
+ source_revision text NOT NULL,quote text NOT NULL
8
+ );
9
+ CREATE INDEX ix_agent_record_source ON agent.record_sources(source_schema,source_table);
10
+ CREATE TRIGGER trg_record_sources_guard BEFORE INSERT OR UPDATE OR DELETE ON agent.record_sources FOR EACH ROW EXECUTE FUNCTION public.vault_guard('append');
11
+ CREATE TRIGGER trg_record_sources_truncate BEFORE TRUNCATE ON agent.record_sources FOR EACH STATEMENT EXECUTE FUNCTION public.vault_guard('append');
12
+ CREATE TRIGGER trg_record_sources_audit AFTER INSERT OR UPDATE OR DELETE ON agent.record_sources FOR EACH ROW EXECUTE FUNCTION public.vault_audit('id');
13
+ CREATE TRIGGER trg_record_sources_version AFTER INSERT OR UPDATE OR DELETE ON agent.record_sources FOR EACH STATEMENT EXECUTE FUNCTION public.vault_touch();
14
+ DO $$ DECLARE g record; BEGIN
15
+ REVOKE ALL ON agent.record_sources FROM PUBLIC;
16
+ FOR g IN SELECT DISTINCT r.rolname AS grantee FROM pg_class c CROSS JOIN LATERAL aclexplode(c.relacl) a JOIN pg_roles r ON r.oid=a.grantee WHERE c.oid='agent.record_sources'::regclass AND a.grantee<>c.relowner LOOP
17
+ EXECUTE format('REVOKE ALL ON agent.record_sources FROM %I',g.grantee);
18
+ END LOOP;
19
+ FOR g IN SELECT DISTINCT grantee,privilege_type FROM information_schema.role_table_grants WHERE table_schema='public' AND table_name='persons' AND grantee<>'PUBLIC' AND privilege_type IN ('SELECT','INSERT') LOOP
20
+ EXECUTE format('GRANT %s ON agent.record_sources TO %I',g.privilege_type,g.grantee);
21
+ END LOOP;
22
+ END $$;
@@ -0,0 +1,30 @@
1
+ -- 生命周期修订独立于字段结构版本;保留迁移、业务行和来源历史,不自动备份。
2
+ SELECT pg_advisory_xact_lock(hashtext('relation-vault:write'));
3
+ ALTER TABLE agent.extensions ADD COLUMN lifecycle_revision integer NOT NULL DEFAULT 1 CHECK(lifecycle_revision>0);
4
+ ALTER TABLE agent.extensions ADD COLUMN deprecation_reason text;
5
+ ALTER TABLE agent.extensions ADD COLUMN superseded_by text REFERENCES agent.extensions(id);
6
+ ALTER TABLE agent.extensions ADD CONSTRAINT ck_extension_superseded_by CHECK(superseded_by IS NULL OR superseded_by<>id);
7
+ -- 旧已读标记对应初始生命周期修订 1,不因新增通知字段把所有已读结构重置。
8
+ ALTER TABLE agent.extension_reads ADD COLUMN lifecycle_revision integer NOT NULL DEFAULT 1 CHECK(lifecycle_revision>0);
9
+ CREATE TABLE agent.extension_lifecycle_events (
10
+ id text PRIMARY KEY,extension_id text NOT NULL REFERENCES agent.extensions(id),
11
+ actor text NOT NULL,idempotency_key text NOT NULL,fingerprint text NOT NULL,
12
+ from_state text NOT NULL CHECK(from_state IN ('active','deprecated','retired')),to_state text NOT NULL CHECK(to_state IN ('active','deprecated','retired')),reason text NOT NULL,
13
+ superseded_by text REFERENCES agent.extensions(id),lifecycle_revision integer NOT NULL CHECK(lifecycle_revision>1),
14
+ result_json text NOT NULL CHECK(public.vault_json_valid(result_json)),created_at text NOT NULL,
15
+ UNIQUE(actor,idempotency_key),UNIQUE(extension_id,lifecycle_revision)
16
+ );
17
+ CREATE TRIGGER trg_extension_lifecycle_events_guard BEFORE INSERT OR UPDATE OR DELETE ON agent.extension_lifecycle_events FOR EACH ROW EXECUTE FUNCTION public.vault_guard('append');
18
+ CREATE TRIGGER trg_extension_lifecycle_events_truncate BEFORE TRUNCATE ON agent.extension_lifecycle_events FOR EACH STATEMENT EXECUTE FUNCTION public.vault_guard('append');
19
+ CREATE TRIGGER trg_extension_lifecycle_events_audit AFTER INSERT OR UPDATE OR DELETE ON agent.extension_lifecycle_events FOR EACH ROW EXECUTE FUNCTION public.vault_audit('id');
20
+ CREATE TRIGGER trg_extension_lifecycle_events_version AFTER INSERT OR UPDATE OR DELETE ON agent.extension_lifecycle_events FOR EACH STATEMENT EXECUTE FUNCTION public.vault_touch();
21
+ DO $$ DECLARE g record; BEGIN
22
+ REVOKE ALL ON agent.extension_lifecycle_events FROM PUBLIC;
23
+ FOR g IN SELECT DISTINCT r.rolname AS grantee FROM pg_class c CROSS JOIN LATERAL aclexplode(c.relacl) a JOIN pg_roles r ON r.oid=a.grantee WHERE c.oid='agent.extension_lifecycle_events'::regclass AND a.grantee<>c.relowner LOOP
24
+ EXECUTE format('REVOKE ALL ON agent.extension_lifecycle_events FROM %I',g.grantee);
25
+ END LOOP;
26
+ -- 普通调用者只读生命周期历史;状态和事件由内部结构服务在同一事务写入。
27
+ FOR g IN SELECT DISTINCT grantee FROM information_schema.role_table_grants WHERE table_schema='public' AND table_name='persons' AND grantee<>'PUBLIC' AND privilege_type='SELECT' LOOP
28
+ EXECUTE format('GRANT SELECT ON agent.extension_lifecycle_events TO %I',g.grantee);
29
+ END LOOP;
30
+ END $$;
@@ -0,0 +1,107 @@
1
+ -- 停止写入的扩展只允许限定的历史恢复,不恢复 app 写 ACL、不更改生命周期,不自动备份。
2
+ SELECT pg_advisory_xact_lock(hashtext('relation-vault:write'));
3
+
4
+ CREATE OR REPLACE FUNCTION public.vault_extension_guard() RETURNS trigger
5
+ LANGUAGE plpgsql SECURITY INVOKER SET search_path=pg_catalog,pg_temp AS $$
6
+ DECLARE actor text:=nullif(current_setting('vault.actor',true),''); item record; col text; controlled boolean;
7
+ BEGIN
8
+ IF TG_OP IN ('DELETE','TRUNCATE') THEN RAISE EXCEPTION '扩展只允许软删除'; END IF;
9
+ IF actor IS NULL THEN RAISE EXCEPTION '未设置写入会话'; END IF;
10
+ SELECT schema_name,table_name,state INTO item FROM agent.extensions WHERE id=TG_ARGV[0];
11
+ IF NOT FOUND OR item.schema_name<>TG_TABLE_SCHEMA OR item.table_name<>TG_TABLE_NAME THEN RAISE EXCEPTION '扩展注册与物理对象不一致'; END IF;
12
+ IF actor='migration' THEN RETURN NEW; END IF;
13
+ -- 标志本身不是权限:必须处于限定恢复函数的 owner 执行身份,app 即使自行 SET 也不能写。
14
+ SELECT TG_OP='UPDATE' AND nullif(current_setting('vault.extension_revert',true),'')=TG_ARGV[0]
15
+ AND c.relowner=r.oid INTO controlled FROM pg_class c JOIN pg_roles r ON r.rolname=current_user WHERE c.oid=TG_RELID;
16
+ IF item.state<>'active' AND NOT coalesce(controlled,false) THEN RAISE EXCEPTION '扩展已停止写入'; END IF;
17
+ IF nullif(current_setting('vault.change_set',true),'') IS NULL THEN RAISE EXCEPTION '扩展写入需要批次'; END IF;
18
+ IF NEW.updated_by<>actor OR (TG_OP='INSERT' AND NEW.created_by<>actor) THEN RAISE EXCEPTION '扩展审计身份不一致'; END IF;
19
+ IF TG_OP='UPDATE' THEN
20
+ IF NEW.created_at IS DISTINCT FROM OLD.created_at OR NEW.created_by IS DISTINCT FROM OLD.created_by THEN RAISE EXCEPTION '扩展创建信息不可修改'; END IF;
21
+ FOREACH col IN ARRAY TG_ARGV[1:array_length(TG_ARGV,1)] LOOP
22
+ IF (to_jsonb(NEW)->col) IS DISTINCT FROM (to_jsonb(OLD)->col) THEN RAISE EXCEPTION '扩展主键不可修改'; END IF;
23
+ END LOOP;
24
+ END IF;
25
+ RETURN NEW;
26
+ END $$;
27
+
28
+ -- 只在已认证的原提交与新撤销会话中锁注册扩展,解决 app 对只读表没有强表锁权限的问题。
29
+ -- 锁随调用方事务持有,函数不创建独立事务,也不授予该表 UPDATE。
30
+ CREATE FUNCTION public.vault_lock_extension_revert(p_extension_id text,p_submission_id text) RETURNS void
31
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
32
+ DECLARE e record; role_oid oid; v_actor text:=nullif(current_setting('vault.actor',true),''); v_change_set text:=nullif(current_setting('vault.change_set',true),'');
33
+ BEGIN
34
+ IF v_actor IS NULL OR v_change_set IS NULL OR v_change_set=p_submission_id OR nullif(current_setting('vault.reason',true),'') IS NULL THEN RAISE EXCEPTION 'Agent 撤销会话无效'; END IF;
35
+ PERFORM pg_advisory_xact_lock(hashtext('relation-vault:write'));
36
+ IF NOT EXISTS(SELECT 1 FROM agent.submissions s WHERE s.id=p_submission_id AND s.actor=v_actor) OR EXISTS(SELECT 1 FROM agent.reversions r WHERE r.submission_id=p_submission_id) THEN RAISE EXCEPTION 'Agent 原提交不存在、身份不匹配或已撤销'; END IF;
37
+ SELECT * INTO e FROM agent.extensions WHERE id=p_extension_id FOR SHARE;
38
+ IF NOT FOUND OR e.schema_name NOT IN ('agent_ext','agent_archive') OR e.state NOT IN ('active','deprecated','retired') THEN RAISE EXCEPTION 'Agent 撤销扩展未登记'; END IF;
39
+ SELECT oid INTO role_oid FROM pg_roles WHERE rolname=current_user;
40
+ IF NOT EXISTS(SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=e.schema_name AND c.relname=e.table_name AND c.relkind='r' AND c.relowner=role_oid) THEN RAISE EXCEPTION 'Agent 撤销函数与扩展属主不一致'; END IF;
41
+ EXECUTE format('LOCK TABLE %I.%I IN SHARE ROW EXCLUSIVE MODE',e.schema_name,e.table_name);
42
+ END $$;
43
+
44
+ -- 参数只能定位原审计行,不接受调用者提供待恢复字段、SQL 或操作者。
45
+ -- 核心依赖/隐私校验仍由同一事务中的业务撤销入口编排,不能单独调用此内部函数绕过协议。
46
+ CREATE FUNCTION public.vault_restore_extension_revert(p_extension_id text,p_submission_id text,p_key_json text,p_last_audit bigint) RETURNS void
47
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
48
+ DECLARE e record; audit_row record; first_before jsonb; final_after jsonb; previous_after jsonb; key_value jsonb:=p_key_json::jsonb;
49
+ original_object text; current_object text; columns_now text[]; columns_audit text[]; pk_now text[]; pk_input text[];
50
+ chain_count integer:=0; last_id bigint; current_row jsonb; statement text; updates text; changed bigint; stamp text;
51
+ previous_marker text:=current_setting('vault.extension_revert',true); v_actor text:=nullif(current_setting('vault.actor',true),'');
52
+ BEGIN
53
+ PERFORM public.vault_lock_extension_revert(p_extension_id,p_submission_id);
54
+ SELECT * INTO e FROM agent.extensions WHERE id=p_extension_id;
55
+ IF e.state NOT IN ('deprecated','retired') OR e.version<>1 THEN RAISE EXCEPTION 'Agent 限定恢复仅用于结构兼容的已废弃或归档扩展'; END IF;
56
+ original_object:='agent_ext.'||(e.descriptor_json::jsonb->'domain'->>'key')||'_'||(e.descriptor_json::jsonb->>'entity');
57
+ current_object:=e.schema_name||'.'||e.table_name;
58
+ IF (e.state='deprecated' AND current_object IS DISTINCT FROM original_object)
59
+ OR (e.state='retired' AND current_object IS DISTINCT FROM 'agent_archive.ext_'||replace(e.id,'-',''))
60
+ OR NOT EXISTS(SELECT 1 FROM agent.extension_migrations m WHERE m.extension_id=e.id AND m.version=e.version AND m.checksum=e.checksum AND m.descriptor_json::jsonb=e.descriptor_json::jsonb)
61
+ THEN RAISE EXCEPTION 'Agent 扩展结构或位置历史不一致'; END IF;
62
+ IF jsonb_typeof(key_value)<>'object' OR key_value='{}'::jsonb OR EXISTS(SELECT 1 FROM jsonb_each(key_value) kv WHERE jsonb_typeof(kv.value) NOT IN ('string','number')) THEN RAISE EXCEPTION 'Agent 撤销主键无效'; END IF;
63
+ -- 字段名使用固定字符序比较,不能让数据库地区排序规则影响结构兼容判断。
64
+ SELECT array_agg(x ORDER BY x COLLATE "C") INTO pk_input FROM jsonb_object_keys(key_value) x;
65
+ SELECT array_agg(a.attname::text ORDER BY k.ord) INTO pk_now FROM pg_index i CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY k(attnum,ord) JOIN pg_attribute a ON a.attrelid=i.indrelid AND a.attnum=k.attnum WHERE i.indrelid=to_regclass(format('%I.%I',e.schema_name,e.table_name)) AND i.indisprimary;
66
+ IF pk_now IS NULL OR to_jsonb(pk_now) IS DISTINCT FROM e.descriptor_json::jsonb->'primary_key'
67
+ OR (SELECT array_agg(x ORDER BY x COLLATE "C") FROM unnest(pk_now) x) IS DISTINCT FROM pk_input THEN RAISE EXCEPTION 'Agent 撤销主键结构已变化'; END IF;
68
+ -- 新旧物理位置共同定位同一稳定对象,保持原审计文本,不按冒号拆分复合键。
69
+ FOR audit_row IN SELECT l.* FROM public.audit_log l WHERE l.change_set=p_submission_id AND l.primary_key_json::jsonb=key_value AND coalesce(l.object_schema,'public')||'.'||l.table_name IN (original_object,current_object) ORDER BY l.id LIMIT 4001 LOOP
70
+ chain_count:=chain_count+1;
71
+ IF audit_row.after_json IS NULL OR audit_row.actor IS DISTINCT FROM v_actor OR (chain_count>1 AND previous_after IS DISTINCT FROM audit_row.before_json::jsonb) THEN RAISE EXCEPTION 'Agent 扩展审计链不完整'; END IF;
72
+ IF chain_count=1 THEN first_before:=audit_row.before_json::jsonb; END IF;
73
+ final_after:=audit_row.after_json::jsonb; previous_after:=final_after; last_id:=audit_row.id;
74
+ IF NOT (key_value<@final_after) OR (audit_row.before_json IS NOT NULL AND NOT (key_value<@audit_row.before_json::jsonb)) THEN RAISE EXCEPTION 'Agent 扩展审计主键不一致'; END IF;
75
+ END LOOP;
76
+ IF chain_count=0 OR chain_count>4000 OR last_id IS DISTINCT FROM p_last_audit THEN RAISE EXCEPTION 'Agent 扩展审计末端已变化'; END IF;
77
+ SELECT array_agg(attname::text ORDER BY attname::text COLLATE "C") INTO columns_now FROM pg_attribute WHERE attrelid=to_regclass(format('%I.%I',e.schema_name,e.table_name)) AND attnum>0 AND NOT attisdropped;
78
+ SELECT array_agg(x ORDER BY x COLLATE "C") INTO columns_audit FROM jsonb_object_keys(final_after) x;
79
+ IF columns_now IS DISTINCT FROM columns_audit OR NOT ('deleted_at'=ANY(columns_now)) OR (first_before IS NOT NULL AND (SELECT array_agg(x ORDER BY x COLLATE "C") FROM jsonb_object_keys(first_before) x) IS DISTINCT FROM columns_now) THEN RAISE EXCEPTION 'Agent 扩展审计字段与当前结构不兼容'; END IF;
80
+ EXECUTE format('SELECT to_jsonb(t) FROM %I.%I t WHERE $1 <@ to_jsonb(t) FOR UPDATE',e.schema_name,e.table_name) INTO current_row USING key_value;
81
+ IF current_row IS DISTINCT FROM final_after OR EXISTS(SELECT 1 FROM public.audit_log l WHERE l.id>last_id AND l.primary_key_json::jsonb=key_value AND coalesce(l.object_schema,'public')||'.'||l.table_name IN (original_object,current_object)) THEN RAISE EXCEPTION 'Agent 扩展行或后续审计已变化'; END IF;
82
+ stamp:=to_char(clock_timestamp() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD HH24:MI:SS');
83
+ IF first_before IS NULL THEN updates:='deleted_at=$2';
84
+ ELSE
85
+ SELECT string_agg(format('%1$I=(jsonb_populate_record(NULL::%2$I.%3$I,$1)).%1$I',c,e.schema_name,e.table_name),',' ORDER BY c) INTO updates
86
+ FROM unnest(columns_now) c WHERE NOT (c=ANY(pk_now)) AND c NOT IN ('created_at','created_by','updated_at','updated_by');
87
+ END IF;
88
+ statement:=format('UPDATE %I.%I t SET %s,updated_at=$2,updated_by=$3 WHERE $4 <@ to_jsonb(t)',e.schema_name,e.table_name,updates);
89
+ PERFORM set_config('vault.extension_revert',e.id,true);
90
+ EXECUTE statement USING first_before,stamp,v_actor,key_value;
91
+ GET DIAGNOSTICS changed=ROW_COUNT;
92
+ PERFORM set_config('vault.extension_revert',coalesce(previous_marker,''),true);
93
+ IF changed<>1 THEN RAISE EXCEPTION 'Agent 扩展限定恢复未命中唯一记录'; END IF;
94
+ END $$;
95
+
96
+ -- 新函数默认 PUBLIC EXECUTE 必须收回,也清除部署默认 ACL;仅映射现有业务更新角色。
97
+ DO $$ DECLARE f text; g record; BEGIN
98
+ FOREACH f IN ARRAY ARRAY['public.vault_lock_extension_revert(text,text)','public.vault_restore_extension_revert(text,text,text,bigint)'] LOOP
99
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC',f);
100
+ FOR g IN SELECT DISTINCT r.rolname FROM pg_proc p CROSS JOIN LATERAL aclexplode(p.proacl) a JOIN pg_roles r ON r.oid=a.grantee WHERE p.oid=to_regprocedure(f) AND a.grantee<>p.proowner LOOP
101
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM %I',f,g.rolname);
102
+ END LOOP;
103
+ FOR g IN SELECT DISTINCT r.rolname FROM pg_class c CROSS JOIN LATERAL aclexplode(coalesce(c.relacl,acldefault('r',c.relowner))) a JOIN pg_roles r ON r.oid=a.grantee WHERE c.oid='public.persons'::regclass AND a.privilege_type='UPDATE' LOOP
104
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO %I',f,g.rolname);
105
+ END LOOP;
106
+ END LOOP;
107
+ END $$;
@@ -0,0 +1,54 @@
1
+ -- 结构升级后的停止使用扩展仍可撤销同结构批次;不修改 0122 已发布文件,不回填旧业务值。
2
+ -- CREATE OR REPLACE 保留原函数属主与 EXECUTE ACL,固定搜索路径、锁、原审计及完整列校验保持生效。
3
+ -- 升级前审计缺少新增列时继续拒绝恢复,不能把未知字段补造为历史事实。
4
+ SELECT pg_advisory_xact_lock(hashtext('relation-vault:write'));
5
+
6
+ CREATE OR REPLACE FUNCTION public.vault_restore_extension_revert(p_extension_id text,p_submission_id text,p_key_json text,p_last_audit bigint) RETURNS void
7
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
8
+ DECLARE e record; audit_row record; first_before jsonb; final_after jsonb; previous_after jsonb; key_value jsonb:=p_key_json::jsonb;
9
+ original_object text; current_object text; columns_now text[]; columns_audit text[]; pk_now text[]; pk_input text[];
10
+ chain_count integer:=0; last_id bigint; current_row jsonb; statement text; updates text; changed bigint; stamp text;
11
+ previous_marker text:=current_setting('vault.extension_revert',true); v_actor text:=nullif(current_setting('vault.actor',true),'');
12
+ BEGIN
13
+ PERFORM public.vault_lock_extension_revert(p_extension_id,p_submission_id);
14
+ SELECT * INTO e FROM agent.extensions WHERE id=p_extension_id;
15
+ IF e.state NOT IN ('deprecated','retired') OR (e.version<1 OR (e.descriptor_json::jsonb->>'version')::integer IS DISTINCT FROM e.version) THEN RAISE EXCEPTION 'Agent 限定恢复仅用于结构兼容的已废弃或归档扩展'; END IF;
16
+ original_object:='agent_ext.'||(e.descriptor_json::jsonb->'domain'->>'key')||'_'||(e.descriptor_json::jsonb->>'entity');
17
+ current_object:=e.schema_name||'.'||e.table_name;
18
+ IF (e.state='deprecated' AND current_object IS DISTINCT FROM original_object)
19
+ OR (e.state='retired' AND current_object IS DISTINCT FROM 'agent_archive.ext_'||replace(e.id,'-',''))
20
+ OR NOT EXISTS(SELECT 1 FROM agent.extension_migrations m WHERE m.extension_id=e.id AND m.version=e.version AND m.checksum=e.checksum AND m.descriptor_json::jsonb=e.descriptor_json::jsonb)
21
+ THEN RAISE EXCEPTION 'Agent 扩展结构或位置历史不一致'; END IF;
22
+ IF jsonb_typeof(key_value)<>'object' OR key_value='{}'::jsonb OR EXISTS(SELECT 1 FROM jsonb_each(key_value) kv WHERE jsonb_typeof(kv.value) NOT IN ('string','number')) THEN RAISE EXCEPTION 'Agent 撤销主键无效'; END IF;
23
+ -- 字段名使用固定字符序比较,不能让数据库地区排序规则影响结构兼容判断。
24
+ SELECT array_agg(x ORDER BY x COLLATE "C") INTO pk_input FROM jsonb_object_keys(key_value) x;
25
+ SELECT array_agg(a.attname::text ORDER BY k.ord) INTO pk_now FROM pg_index i CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY k(attnum,ord) JOIN pg_attribute a ON a.attrelid=i.indrelid AND a.attnum=k.attnum WHERE i.indrelid=to_regclass(format('%I.%I',e.schema_name,e.table_name)) AND i.indisprimary;
26
+ IF pk_now IS NULL OR to_jsonb(pk_now) IS DISTINCT FROM e.descriptor_json::jsonb->'primary_key'
27
+ OR (SELECT array_agg(x ORDER BY x COLLATE "C") FROM unnest(pk_now) x) IS DISTINCT FROM pk_input THEN RAISE EXCEPTION 'Agent 撤销主键结构已变化'; END IF;
28
+ -- 新旧物理位置共同定位同一稳定对象,保持原审计文本,不按冒号拆分复合键。
29
+ FOR audit_row IN SELECT l.* FROM public.audit_log l WHERE l.change_set=p_submission_id AND l.primary_key_json::jsonb=key_value AND coalesce(l.object_schema,'public')||'.'||l.table_name IN (original_object,current_object) ORDER BY l.id LIMIT 4001 LOOP
30
+ chain_count:=chain_count+1;
31
+ IF audit_row.after_json IS NULL OR audit_row.actor IS DISTINCT FROM v_actor OR (chain_count>1 AND previous_after IS DISTINCT FROM audit_row.before_json::jsonb) THEN RAISE EXCEPTION 'Agent 扩展审计链不完整'; END IF;
32
+ IF chain_count=1 THEN first_before:=audit_row.before_json::jsonb; END IF;
33
+ final_after:=audit_row.after_json::jsonb; previous_after:=final_after; last_id:=audit_row.id;
34
+ IF NOT (key_value<@final_after) OR (audit_row.before_json IS NOT NULL AND NOT (key_value<@audit_row.before_json::jsonb)) THEN RAISE EXCEPTION 'Agent 扩展审计主键不一致'; END IF;
35
+ END LOOP;
36
+ IF chain_count=0 OR chain_count>4000 OR last_id IS DISTINCT FROM p_last_audit THEN RAISE EXCEPTION 'Agent 扩展审计末端已变化'; END IF;
37
+ SELECT array_agg(attname::text ORDER BY attname::text COLLATE "C") INTO columns_now FROM pg_attribute WHERE attrelid=to_regclass(format('%I.%I',e.schema_name,e.table_name)) AND attnum>0 AND NOT attisdropped;
38
+ SELECT array_agg(x ORDER BY x COLLATE "C") INTO columns_audit FROM jsonb_object_keys(final_after) x;
39
+ IF columns_now IS DISTINCT FROM columns_audit OR NOT ('deleted_at'=ANY(columns_now)) OR (first_before IS NOT NULL AND (SELECT array_agg(x ORDER BY x COLLATE "C") FROM jsonb_object_keys(first_before) x) IS DISTINCT FROM columns_now) THEN RAISE EXCEPTION 'Agent 扩展审计字段与当前结构不兼容'; END IF;
40
+ EXECUTE format('SELECT to_jsonb(t) FROM %I.%I t WHERE $1 <@ to_jsonb(t) FOR UPDATE',e.schema_name,e.table_name) INTO current_row USING key_value;
41
+ IF current_row IS DISTINCT FROM final_after OR EXISTS(SELECT 1 FROM public.audit_log l WHERE l.id>last_id AND l.primary_key_json::jsonb=key_value AND coalesce(l.object_schema,'public')||'.'||l.table_name IN (original_object,current_object)) THEN RAISE EXCEPTION 'Agent 扩展行或后续审计已变化'; END IF;
42
+ stamp:=to_char(clock_timestamp() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD HH24:MI:SS');
43
+ IF first_before IS NULL THEN updates:='deleted_at=$2';
44
+ ELSE
45
+ SELECT string_agg(format('%1$I=(jsonb_populate_record(NULL::%2$I.%3$I,$1)).%1$I',c,e.schema_name,e.table_name),',' ORDER BY c) INTO updates
46
+ FROM unnest(columns_now) c WHERE NOT (c=ANY(pk_now)) AND c NOT IN ('created_at','created_by','updated_at','updated_by');
47
+ END IF;
48
+ statement:=format('UPDATE %I.%I t SET %s,updated_at=$2,updated_by=$3 WHERE $4 <@ to_jsonb(t)',e.schema_name,e.table_name,updates);
49
+ PERFORM set_config('vault.extension_revert',e.id,true);
50
+ EXECUTE statement USING first_before,stamp,v_actor,key_value;
51
+ GET DIAGNOSTICS changed=ROW_COUNT;
52
+ PERFORM set_config('vault.extension_revert',coalesce(previous_marker,''),true);
53
+ IF changed<>1 THEN RAISE EXCEPTION 'Agent 扩展限定恢复未命中唯一记录'; END IF;
54
+ END $$;