merge: refactor/document-reliability-pilot

M2 WorkspaceRef, durable events, and audited FAV (goose 00034–00036).
This commit is contained in:
weichen
2026-09-14 16:00:57 +00:00
66 changed files with 4416 additions and 99 deletions
+5 -5
View File
@@ -5,7 +5,7 @@
## 焦点
1. **DOC-RELIABILITY**: [M1 活验签字与条件 M2](../ops/short-term/2026-09-14-document-reliability-m1-live-and-m2.md)(`refactor/document-reliability-pilot`;`mutex=docgov`;占集成槽 live 签字)
1. **DOC-RELIABILITY**: [M1 活验已签,开工 M2 U5–U7](../ops/short-term/2026-09-14-document-reliability-m1-live-and-m2.md)(`refactor/document-reliability-pilot`;`mutex=workspace`;活验报告最后一句 **继续 M2**)
2. **CHAT-INTENT**: [对话意图运行时状态](../audit/2026-09-13-conversation-intent-runtime-state.md)(`fix/conversation-intent-state`;与文档账本运行时无关)
3. **CHAT**: [对话历史刷新](../audit/2026-09-13-chat-history-refresh-disappears.md)(认领仍 `in_progress`;gap 行已 PASS,以代码/handoff 为准收敛)
@@ -13,7 +13,7 @@
| owner | task | branch | worktree | mutex | status |
|-------|------|--------|----------|-------|--------|
| Cursor | document-reliability-m1-live-and-m2 | refactor/document-reliability-pilot | .worktrees/refactor-document-reliability-pilot | docgov | in_progress |
| Cursor | document-reliability-m1-live-and-m2 | refactor/document-reliability-pilot | .worktrees/refactor-document-reliability-pilot | workspace | in_progress |
| Codex | module-decoupling-remediation-plan | feature/module-decoupling-remediation | .worktrees/feature/module-decoupling-remediation | architecture-docs | done |
| verifier_antigravity | controlled-content-ui-acceptance | fix/controlled-content-ui-acceptance | .worktrees/fix-controlled-content-ui-acceptance | document-content-ui-acceptance | done |
| implementer_cursor | controlled-content-canonical-hash | fix/controlled-content-canonical-hash | .worktrees/fix-controlled-content-canonical-hash | docgov | done |
@@ -54,13 +54,13 @@
> 新任务开始前登记。`branch` 必须为 `feature/<slug>` 或 `fix/<slug>`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。
> workflow-plan 409 与治理鉴权 G1–G5 已收口(见 [test-report](../test-reports/2026-09-14-governance-action-level-authz-test-report.md))。禁止终止或改写项目 143。
> `document-reliability-pilot` 占 `docgov` + 集成槽做 M1 活验。禁止 worktree 内 `env-service`。项目 143 只读,禁止终止。
> `document-reliability-pilot` M1 活验已签(继续 M2)。现占 `workspace` 做 U5–U7。禁止 worktree 内 `env-service`。项目 143 只读,禁止终止。
## 集成槽位
| holder | phase | since | scope | queue |
|--------|-------|-------|-------|-------|
| Cursor | merging | 2026-09-14 | DOC-RELIABILITY M1 live | - |
| Cursor | merging | 2026-09-14 | DOC-RELIABILITY M2 U5–U7 | - |
**phase**: `idle` | `merging` | `smoke` | `e2e`
@@ -70,7 +70,7 @@
| 环境 | 端口 | schema | 状态 |
|------|------|--------|------|
| dev | 8080 | **33** | healthy(`pi-agent`;治理鉴权 G5 页面+API PASS) |
| dev | 8080 | **33** | healthy(`pi-agent`;M1 活验 rebuild 后 health+ready PASS) |
| test | 8081 | 25→27* | healthy |
| main | 8000 | 25→27* | healthy(app + runner + DB) |
@@ -19,10 +19,10 @@ owner: Cursor
- [x] 人工编辑、agent commit、approve/reject 的跨表写入同事务(U3;隔离库双会话编辑已测)。
- [x] fault injection 任一点失败均无部分提交(U2 隔离库)。
- [x] 同 ETag 并发写只有一个赢家(内存 + 双会话)。
- [ ] 1、2、4 app replicas 结果一致。
- [x] GET 服务端零写入 + typed consistency(U4 单测)。refresh/reload 真页面未测。
- [ ] 项目 143 等价 hazard 与新项目真实页面刷新、重登、restart、rebuild PASS。
- [x] 服务端负例 fail closed;浏览器跨用户未测。
- [x] 1、2、4 app replicas 结果一致。
- [x] GET 服务端零写入 + typed consistency(U4 单测)。refresh/reload 真页面 PASS([活验报告](../../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md))。
- [x] 项目 143 等价 hazard 与新项目真实页面刷新、重登、restart、rebuild PASS。
- [x] 服务端负例 fail closed;浏览器跨用户 401/404 PASS。
## M2 事件与 FAV
@@ -61,9 +61,9 @@ owner: Cursor
- [x] PostgreSQL integration 证据(隔离库,非共享 env 库)。
- [x] race/concurrency 目标包证据(非全仓 race)。
- [x] fault injection 证据(U2)。
- [ ] 真实 PI 证据。
- [ ] 真实浏览器证据。
- [ ] restart/rebuild 证据。
- [x] 真实 PI 证据。
- [x] 真实浏览器证据。
- [x] restart/rebuild 证据。
- [x] cross-user/project authorization negative 服务端证据。
- [ ] scratch recovery 与完整 DB restore 证据。
@@ -6,7 +6,7 @@ updated: 2026-09-14
owner: Cursor
branch: refactor/document-reliability-pilot
worktree: .worktrees/refactor-document-reliability-pilot
mutex: docgov
mutex: workspace
---
# 文档可靠性 M1 活验签字与条件 M2
@@ -32,18 +32,24 @@ mutex: docgov
## 步骤
1. [ ] 占集成槽并合入本地 dev rebuild
2. [ ] 浏览器 + 跨用户
3. [ ] 真实 PI
4. [ ] 双 pool/4 会话
5. [ ] 活验报告
6. [ ] 条件 M2
1. [x] 占集成槽并合入本地 dev rebuild
2. [x] 浏览器 + 跨用户
3. [x] 真实 PI
4. [x] 双 pool/4 会话
5. [x] 活验报告(最后一句:继续 M2)
6. [ ] 条件 M2(U5–U7)
## 验收标准
- [ ] health + ready 来自本分支镜像
- [ ] 143/新项目刷新 404/409/projection-missing = 0 或报告停止扩面
- [ ] 报告最后一句只能是继续 M2 或停止扩面
- [x] health + ready 来自本分支镜像
- [x] 143/新项目刷新 404/409/projection-missing = 0 或报告停止扩面
- [x] 报告最后一句只能是继续 M2 或停止扩面
## 相关链接
- Checklist: [../checklists/2026-09-14-module-decoupling-refactor-acceptance.md](../checklists/2026-09-14-module-decoupling-refactor-acceptance.md)
- Prior report: [../../test-reports/2026-09-14-document-reliability-pilot-test-report.md](../../test-reports/2026-09-14-document-reliability-pilot-test-report.md)
- Live report: [../../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md](../../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md)
## 相关链接
+1 -1
View File
@@ -70,7 +70,7 @@
| DOCREV-ACTIVE-DOC-ROUTING | UI-05「当前受控文档」被法规关键词改路由到 `feasibility_reg`,intake 无 pending | **PASS** | [audit](../audit/2026-09-13-document-revision-active-doc-routing.md);Round20 UI-05 pending 停在 intake V2 | **不改弱 E2E**;不删证据 |
| DOCREV-QUALITY-STATUS-FP | 行动计划表状态列「待续」被门禁当敷衍,两轮 repair 后 quality_failed 80 | **PASS** | [audit](../audit/2026-09-13-document-revision-quality-status-false-positive.md);Round20 UI-03 | **不删禁词、不放宽正文**;不删证据 |
| GOVERNANCE-ACTION-AUTHZ | 文档动作按职责鉴权;未激活 202-blocked;关闭创建直通 | **PASS** | [test-report](../test-reports/2026-09-14-governance-action-level-authz-test-report.md);[audit 异态](../audit/2026-09-14-planning-with-workflow-key-anomaly.md) | 不改 83 条 planning+key 夹具;none 长 PI 审批链另开 |
| DOC-RELIABILITY-PILOT | 受控文档写路径跨表非同事务、读路径被 projection 绑架、授权 TOCTOU;刷新/重启后可能 404/409/projection-missing | OPEN | [复核](../audit/2026-09-14-module-decoupling-plan-review.md);[重构计划](./2026-09-14-module-decoupling-refactor-plan.md);[试点任务](../ops/short-term/2026-09-14-document-reliability-pilot.md);[M1 报告](../test-reports/2026-09-14-document-reliability-pilot-test-report.md) | U1–U4 已编码。M1 **停止扩面**(无真实 PI/浏览器/rebuild)。未占槽不得 rebuild;禁止 U5–U12 |
| DOC-RELIABILITY-PILOT | 受控文档写路径跨表非同事务、读路径被 projection 绑架、授权 TOCTOU;刷新/重启后可能 404/409/projection-missing | OPEN | [复核](../audit/2026-09-14-module-decoupling-plan-review.md);[重构计划](./2026-09-14-module-decoupling-refactor-plan.md);[试点任务](../ops/short-term/2026-09-14-document-reliability-pilot.md);[M1 停止扩面](../test-reports/2026-09-14-document-reliability-pilot-test-report.md);[M1 活验](../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md) | M1 活验 **继续 M2**。U5–U7 进行中;未签字前禁止 U8–U12 |
---
+1 -1
View File
@@ -27,7 +27,7 @@
| PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` |
| 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` |
| CI / E2E | PG 集成 + 真实 PI E2E | partial | [gap-matrix BUG-08](../remediation/gap-matrix.md);[test pg-ci](../test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md);[运行时 UI Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md) | CI `TEST_DATABASE_URL` 隔离库仍缺;文档运行时真实 UI 已按 UI_PHASE 分文件且 post-restart 断言最终 A1 正文。`-race` 全包并行 TempDir flake 为基建残留 | `ci` |
| 模块解耦与可靠事件 | 文档可靠性纵向试点、跨属主事务、多副本互斥、FAV 与选择性模块化 | partial | [架构](../system-design/2026-09-14-module-decoupling-architecture.md);[ADR](../adr/2026-09-14-modular-monolith-decoupling.md);[重构计划](../remediation/2026-09-14-module-decoupling-refactor-plan.md);[试点任务](../ops/short-term/2026-09-14-document-reliability-pilot.md);[M1 报告](../test-reports/2026-09-14-document-reliability-pilot-test-report.md) | U1–U4 已在 `refactor/document-reliability-pilot` 落地(Unit/隔离库 PASS)。M1 **停止扩面**:真实 PI/浏览器/rebuild 未签。禁止 U5–U12 | `docgov` |
| 模块解耦与可靠事件 | 文档可靠性纵向试点、跨属主事务、多副本互斥、FAV 与选择性模块化 | partial | [架构](../system-design/2026-09-14-module-decoupling-architecture.md);[ADR](../adr/2026-09-14-modular-monolith-decoupling.md);[重构计划](../remediation/2026-09-14-module-decoupling-refactor-plan.md);[试点任务](../ops/short-term/2026-09-14-document-reliability-pilot.md);[M1 停止扩面](../test-reports/2026-09-14-document-reliability-pilot-test-report.md);[M1 活验](../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md) | U1–U4 活验 PASS(rebuild + 真实 PI + 浏览器 + 双 pool/4 会话)。活验最后一句 **继续 M2**。正在 U5–U7 | `workspace` |
---
+33 -1
View File
@@ -60,6 +60,7 @@ imports:
- docgov
- docquery
- docrevision
- events
app/uow: []
docquery:
- docgov
@@ -129,6 +130,7 @@ imports:
- quote
- stream
- template
- workspace
storage: []
stream: []
taskrun:
@@ -148,7 +150,13 @@ imports:
- storage
- upload
- workflow
workspace: []
workspace:
- app/uow
events:
- app/uow
artifactvault:
- app/uow
- fsutil
table_owners:
document_records:
@@ -167,6 +175,26 @@ table_owners:
- docrevision
document_change_events:
- docrevision
event_envelopes:
- events
event_deliveries:
- events
event_dead_letters:
- events
final_artifacts:
- artifactvault
artifact_payload_staging:
- artifactvault
artifact_blob_references:
- artifactvault
artifact_publication_attempts:
- artifactvault
artifact_deletion_ledger:
- artifactvault
artifact_quarantine:
- artifactvault
artifact_backfill_exclusions:
- artifactvault
path_owners:
output.md:
@@ -175,6 +203,7 @@ path_owners:
data/dev:
- config
- workspace
- artifactvault
workspaces/:
- squad
- workspace
@@ -188,6 +217,7 @@ event_producers:
document.agent_version_committed:
- docrevision
- app/docflow
- events
document.downstream_impact_detected:
- docrevision
document.change_report_degraded:
@@ -203,6 +233,8 @@ unwrap_owners:
- docrevision
- taskrun
- freedoc
- events
- artifactvault
exceptions:
- kind: sql_write
@@ -0,0 +1,137 @@
# 文档可靠性试点 M1 活验签字测试报告
## 一、 测试背景 (Background)
上一份 [M1 试点报告](./2026-09-14-document-reliability-pilot-test-report.md) 在 Unit/隔离库通过后,因未占槽 rebuild、无真实 PI/浏览器/4 会话而把最后一句写成 **停止扩面**。本轮不改写那份结论句;只补活验签字,决定能否开工 U5–U7。
## 二、 测试目标 (Test Objectives)
- A1:占集成槽,本地 `dev` `--no-ff` 合入试点分支,`env-service.sh dev rebuild`,health+ready 来自本分支镜像。
- A2:项目 143 只读打开/刷新/重登;新建项目打开/刷新/重登;`GET .../content` 为 200 且带 `consistency`;禁止把 missing 当 404。
- A3:真实 `pi-agent` + MiniMax 编制一条受控文档,刷新不丢正文。禁止 stub 当终态。
- A4:隔离库两个 pgx pool + 4 会话同 ETag 一赢,同 key+digest 跨 pool 重放。测完 `DROP DATABASE`。
- A5:分栏签字;最后一句只能是「继续 M2」或「停止扩面」。
## 三、 测试方案与环境 (Test Plan & Environment)
| 项 | 值 |
|----|-----|
| OS | Linux 7.0.0-31-generic |
| Go | 本机 `go test`;容器 `agent-dev-app` |
| 合入 | 主仓 `dev` `--no-ff` `refactor/document-reliability-pilot` → `cb8eaca` |
| 部署 | 主仓 `./scripts/env-service.sh dev rebuild`(禁止 `compose restart` 当证据) |
| Compose | 项目 `agent-dev`,HTTP `:8080` |
| Agent | 真实 `pi-agent`(`AGENT_BACKEND=pi-agent`),模型 `MiniMax-M2.7-highspeed`(云端 API,非 stub) |
| Schema | **33** |
| `FAV_DEGRADED_READ` | unset / 默认 false |
| 浏览器 | `mcr.microsoft.com/playwright:v1.63.0-noble`,`--network host --ipc host` |
| PostgreSQL | 隔离库 `agent_rel_itest`(`shuimu-system-pg-test:5434`),测完 `DROP`;禁止 `agent_system_dev/test/main` |
| 项目 143 | **只读**;未 terminate,未 `PUT /143/workflow-plan` |
| 证据 | `.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/` |
镜像身份(rebuild 后):`app_image=sha256:251fa99d5de47d452b7f099304aa34b1e2aacf1b4e9d2d23d1cb4b850084910e`;随后把 `pi-agent` force-recreate 到 rebuild 所打的 `sha256:21fc9b64066c33e94d8db83695b4ff64ea5e49f6d65661f8ccab11ead083042d`。见 `rebuild-health-ready.txt`。
## 四、 预期结果 (Expected Results)
| 栏 | 期望 |
|----|------|
| Unit / stub | `go test -short` 全绿;hydrate 把 `missing` 当正文 |
| PostgreSQL | 双 pool 同 ETag 一赢;同 key+digest 跨 pool 重放;4 会话同 ETag 一赢 |
| 故障注入 | 与上一份隔离库 U2 一致,本轮 `-race` 再跑目标包 |
| 真实 PI | 新建项目激活后编制,账本 head 可读 |
| 真实浏览器 | 143 与新项目刷新/重登 200 + markdown;页面无「加载失败」;无 PROJECTION 409 |
| restart/rebuild | health/ready `status=ok`,`agent_backend=pi-agent`,`schema_version=33`;ready 不因 vault 未配置假绿,响应无路径泄漏 |
| 跨用户 | 匿名 401;非成员/跨项目/错误 stage-duty 同一 404 `DOCUMENT_NOT_FOUND` |
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 Unit / stub
- **测试输入**: `unset TEST_DATABASE_URL; go test -short ./...`;`npx tsx --test src/lib/controlledDocHydrate.test.ts`;`./scripts/arch-check.sh --mode warn`
- **实际输出**: 全仓 short 包 `ok`;hydrate **17 passed**;`arch-check warn: production gate passed`
- **评价判定**: **PASS**
- **性能与耗时**: `internal/server` 约 9.6s。日志:`go-test-short.txt`、`hydrate-unit.txt`、`arch-check-warn.txt`
### 5.2 PostgreSQL 事务与并发
- **测试输入**: 隔离库 `TEST_DATABASE_URL` → `./internal/docrevision -run 'TestDocumentCommitConcurrencyPG|TestDocumentCommitTwoPoolsAndFourSessionsPG'`
- **实际输出**:
```
--- PASS: TestDocumentCommitConcurrencyPG (0.03s)
--- PASS: TestDocumentCommitTwoPoolsAndFourSessionsPG (0.03s)
```
两个独立 `pgxpool` 打同一 ETag 仅一赢;赢家 key+digest 跨 pool 重放 head 一致;随后 4 个会话同 ETag 仅一赢。测完 `DROP DATABASE agent_rel_itest`。
- **评价判定**: **PASS**(不是 4 个 OS 进程,是计划要求的双 pool 模拟两进程 + 4 会话;未使用进程内全局 mutex)
- **性能与耗时**: 62ms。日志:`go-test-pg-two-pool.txt`
### 5.3 故障注入
- **测试输入**: `go test -race ./internal/app/uow ./internal/app/docflow ./internal/docquery`(同一隔离库,DROP 前)
- **实际输出**: 三包 `ok`(含 cached)
- **评价判定**: **PASS**
- **性能与耗时**: 亚秒到约 1.8s
### 5.4 真实 PI
- **测试输入**: Playwright 新建项目 → 未激活编制被 202 `blocked_pending_governance` → 配置并激活 → 发送「请基于本项目编制首轮《用户需求》摸底文档…」→ 等待小队终态 → 打开 `doc_proj*` intake
- **实际输出**: 编制用例 **4.2min PASS**。`GET /api/v1/docgov/documents/{id}/content` **200** 且 `markdown` 非空;刷新与重登正文相同。截图 `07-new-doc.png` / `08-new-doc-reload.png` / `09-new-doc-relogin.png`
- **评价判定**: **PASS**(真实 `pi-agent`,非 stub)
- **性能与耗时**: 编制+打开约 4.2min
### 5.5 真实浏览器
- **测试输入**: `RUN_UI_ACCEPTANCE=1 BASE_URL=http://127.0.0.1:8080`;`web/e2e/controlled-document-reload.spec.ts`(官方 Playwright 镜像)
- **实际输出**:
```
✓ project 143 content is 200 with consistency; anonymous is 401; unknown is 404 (4.6s)
✓ non-member GET of project 143 is the same 404 as unknown (7.4s)
✓ new project PI compile then reload keeps ledger markdown (4.2m)
✓ cross-project and wrong-stage GET stay DOCUMENT_NOT_FOUND (3.8s)
4 passed (4.5m)
```
143 打开/刷新/重登均为 200 + markdown + typed `consistency`,无 `PROJECTION` 409,页面无「加载失败」。未对 `/143/` 发 `PUT workflow-plan`,未 terminate。
- **评价判定**: **PASS**
- **性能与耗时**: 整套 4.5min。日志:`playwright-controlled-reload.txt`;截图 `screenshots/01`–`10`
### 5.6 restart / rebuild
- **测试输入**: 占槽后 `./scripts/env-service.sh dev rebuild`;`curl -sf http://127.0.0.1:8080/api/v1/health` 与 `/api/v1/ready`
- **实际输出**: 两者 HTTP 200,body 均为
```
{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"database_ready":true,"schema_version":33,"schema_ready":true,"task_store_ready":true}
```
无文件系统路径字段。vault 未配置时 ready 仍按 DB/schema/task_store 判定,不是假绿。
- **评价判定**: **PASS**
- **性能与耗时**: rebuild 约 79s。日志:`rebuild-health-ready.txt`、`health.json`、`ready.json`
### 5.7 跨用户负例
- **测试输入**: 匿名 GET 143;局外人 D GET 143 与新项目文档;审核人 B GET 143 与伪造 `_design_control` stage
- **实际输出**: 匿名 **401**;其余均为 **404** `DOCUMENT_NOT_FOUND`,不泄露存在性
- **评价判定**: **PASS**
- **性能与耗时**: 3.8s。截图 `10-fail-closed.png`
## 六、 自动化测试套件汇总 (Test Suite Execution)
| 命令 | 结果 |
|------|------|
| `go vet ./...` | PASS(合入后、rebuild 前) |
| `go test -short ./...` | PASS |
| 隔离库双 pool / 4 会话 | PASS,随后 `DROP DATABASE` |
| 目标包 `-race` | PASS |
| `arch-check --mode warn` | PASS |
| hydrate 单测 | 17/17 PASS |
| Playwright 活验(官方镜像) | **4 passed** |
| 宿主 `npx playwright` | 因缺 `libatk-1.0.so.0` 无法启动;改走已验证的 `mcr.microsoft.com/playwright:v1.63.0-noble` |
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
活验补齐了上一份报告缺的 rebuild、真实 PI、真实浏览器、双 pool/4 会话与 HTTP 跨用户负例。hazard 真页面 404/409/projection-missing 本轮观测为 0;授权未靠放宽;4 会话赢家来自数据库锁而非进程内 mutex。M1 签字通过,允许按计划实施 U5–U7。上一份「停止扩面」报告保持原句,不改写。
继续 M2
@@ -0,0 +1,4 @@
arch-check mode=warn
boundaries=/home/weichen/projects/Agent-System/.doc/system-design/module-boundaries.yaml
ok chat-agent-system/internal/archtest 0.096s
arch-check warn: production gate passed
@@ -0,0 +1,12 @@
=== go test uow docflow docquery -race ===
ok chat-agent-system/internal/app/uow (cached)
ok chat-agent-system/internal/app/docflow (cached)
ok chat-agent-system/internal/docquery (cached)
=== go test docrevision concurrency ===
=== RUN TestDocumentCommitConcurrencyPG
2026/09/14 14:29:18 goose: no migrations to run. current version: 33
--- PASS: TestDocumentCommitConcurrencyPG (0.03s)
=== RUN TestDocumentCommitTwoPoolsAndFourSessionsPG
--- PASS: TestDocumentCommitTwoPoolsAndFourSessionsPG (0.03s)
PASS
ok chat-agent-system/internal/docrevision 0.062s
@@ -0,0 +1,44 @@
ok chat-agent-system/cmd/bizdev-cli (cached)
ok chat-agent-system/cmd/document-content-reconcile (cached)
? chat-agent-system/cmd/document-revision-backfill [no test files]
? chat-agent-system/cmd/recategorize [no test files]
? chat-agent-system/cmd/server [no test files]
ok chat-agent-system/internal/agent (cached)
ok chat-agent-system/internal/app/docflow (cached)
ok chat-agent-system/internal/app/uow (cached)
ok chat-agent-system/internal/archtest 0.104s
ok chat-agent-system/internal/auth (cached)
ok chat-agent-system/internal/authz (cached)
ok chat-agent-system/internal/bizdev (cached)
ok chat-agent-system/internal/chat (cached)
ok chat-agent-system/internal/config (cached)
ok chat-agent-system/internal/coordinator (cached)
ok chat-agent-system/internal/ctxmgr (cached)
ok chat-agent-system/internal/db (cached)
ok chat-agent-system/internal/docduty (cached)
ok chat-agent-system/internal/docgov (cached)
ok chat-agent-system/internal/docquery (cached)
ok chat-agent-system/internal/docrevision 0.006s
ok chat-agent-system/internal/freedoc (cached)
ok chat-agent-system/internal/fsutil (cached)
ok chat-agent-system/internal/intent (cached)
ok chat-agent-system/internal/kb (cached)
ok chat-agent-system/internal/notification (cached)
? chat-agent-system/internal/observability [no test files]
ok chat-agent-system/internal/projectcontext (cached)
ok chat-agent-system/internal/projectinstance (cached)
ok chat-agent-system/internal/projectworkflow (cached)
ok chat-agent-system/internal/quote (cached)
ok chat-agent-system/internal/replyexport (cached)
ok chat-agent-system/internal/server (cached)
ok chat-agent-system/internal/squad (cached)
ok chat-agent-system/internal/storage (cached)
ok chat-agent-system/internal/stream (cached)
ok chat-agent-system/internal/taskrun (cached)
ok chat-agent-system/internal/template (cached)
ok chat-agent-system/internal/upload (cached)
? chat-agent-system/internal/workflow [no test files]
ok chat-agent-system/internal/workflow/runtime (cached)
ok chat-agent-system/internal/workflowdef (cached)
ok chat-agent-system/internal/workspace (cached)
? chat-agent-system/templates [no test files]
@@ -0,0 +1 @@
{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"database_ready":true,"schema_version":33,"schema_ready":true,"task_store_ready":true}
@@ -0,0 +1,27 @@
▶ controlledDocHydrate
✔ shouldLoadControlledContent when non-template workflow doc (0.560314ms)
✔ shouldLoadControlledContent rejects local legacy/mock ids like doc-001 (0.079977ms)
✔ shouldLoadControlledContent keeps canonical, freeform-off, and template preview rules (0.081697ms)
✔ hydrateControlledDocumentContent never requests content/revision for doc-001 (0.145305ms)
✔ hydrateControlledDocumentContent never requests for template preview or registered freeform (0.078298ms)
✔ shouldLoadControlledContent stays inside toolbar server-backed eligibility (0.101657ms)
✔ resolveCanonicalDocId maps doc-p56-intake to doc_proj56_intake (0.176655ms)
✔ canonicalDocIdForTask maps doc-p56-research to doc_proj56_research (0.088608ms)
✔ hydrate treats projection missing as ledger content, not 404 (0.944302ms)
✔ attaches hydrate ETag as PATCH baseline and ignores a later GET (0.468346ms)
✔ rejects hydrate when revision-state network or 5xx fails (0.359089ms)
✔ aligns markdownToBlockAST random ids onto revision-state head_block_map (0.647101ms)
✔ leaves Math.random ids when head_block_map is omitted (why gate + fail-closed remain) (0.227144ms)
✔ rejects controlled hydrate on NOT_FOUND or NEEDS_CONTENT (0.129726ms)
✔ fingerprint stays stable when only object identity changes (0.060068ms)
✔ dedupes in-flight hydrate for the same doc fingerprint (0.127606ms)
✔ aligned late hydrate still cannot overwrite a PATCH-success baseline (0.226964ms)
✔ controlledDocHydrate (5.36378ms)
ℹ tests 17
ℹ suites 1
ℹ pass 17
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 72.177737
@@ -0,0 +1,14 @@
Running 4 tests using 1 worker
✓ 1 [chromium] › e2e/controlled-document-reload.spec.ts:200:3 › controlled document reload M1 live › project 143 content is 200 with consistency; anonymous is 401; unknown is 404 (4.6s)
✓ 2 [chromium] › e2e/controlled-document-reload.spec.ts:266:3 › controlled document reload M1 live › non-member GET of project 143 is the same 404 as unknown (7.4s)
✓ 3 [chromium] › e2e/controlled-document-reload.spec.ts:310:3 › controlled document reload M1 live › new project PI compile then reload keeps ledger markdown (4.2m)
✓ 4 [chromium] › e2e/controlled-document-reload.spec.ts:443:3 › controlled document reload M1 live › cross-project and wrong-stage GET stay DOCUMENT_NOT_FOUND (3.8s)
4 passed (4.5m)
npm notice
npm notice New major version of npm available! 11.19.0 -> 12.0.2
npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.0.2
npm notice To update run: npm install -g npm@12.0.2
npm notice
@@ -0,0 +1 @@
{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"database_ready":true,"schema_version":33,"schema_ready":true,"task_store_ready":true}
@@ -0,0 +1,34 @@
=== health ===
HTTP/1.1 200 OK
Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With
Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE
Content-Type: application/json; charset=utf-8
Date: Mon, 14 Sep 2026 14:29:03 GMT
Content-Length: 180
{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"database_ready":true,"schema_version":33,"schema_ready":true,"task_store_ready":true}
=== ready ===
HTTP/1.1 200 OK
Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With
Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE
Content-Type: application/json; charset=utf-8
Date: Mon, 14 Sep 2026 14:29:03 GMT
Content-Length: 180
{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"database_ready":true,"schema_version":33,"schema_ready":true,"task_store_ready":true}
=== compose identity ===
=== images ===
app_image=sha256:251fa99d5de47d452b7f099304aa34b1e2aacf1b4e9d2d23d1cb4b850084910e created=2026-09-14T14:27:53.849178188Z status=running health=healthy
pi_image=sha256:575eda543c653e68fbc79920d2e798def834e3734b7f27b0c8e5c80850cd5c0a created=2026-09-14T13:09:50.656750669Z status=running health=healthy
=== env (redacted) ===
AGENT_BACKEND=pi-agent
ENV=dev
HTTP_PORT=8080
=== post-recreate pi-agent ===
pi_image=sha256:21fc9b64066c33e94d8db83695b4ff64ea5e49f6d65661f8ccab11ead083042d created=2026-09-14T14:29:37.389718802Z health=healthy
app_image=sha256:251fa99d5de47d452b7f099304aa34b1e2aacf1b4e9d2d23d1cb4b850084910e created=2026-09-14T14:27:53.849178188Z health=healthy
compose_project=agent-dev schema=33 agent_backend=pi-agent FAV_DEGRADED_READ=unset
Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

@@ -0,0 +1,8 @@
-- +goose Up
ALTER TABLE document_records
ADD COLUMN IF NOT EXISTS lease_fencing_token TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS workspace_schema_version INT NOT NULL DEFAULT 1;
-- +goose Down
ALTER TABLE document_records DROP COLUMN IF EXISTS workspace_schema_version;
ALTER TABLE document_records DROP COLUMN IF EXISTS lease_fencing_token;
@@ -0,0 +1,58 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS event_envelopes (
event_id TEXT PRIMARY KEY,
event_name TEXT NOT NULL,
schema_version INT NOT NULL DEFAULT 1,
environment TEXT NOT NULL DEFAULT 'dev',
project_id BIGINT,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
aggregate_version BIGINT NOT NULL DEFAULT 0,
actor_ref TEXT NOT NULL DEFAULT '',
correlation_id TEXT NOT NULL DEFAULT '',
causation_id TEXT NOT NULL DEFAULT '',
idempotency_key TEXT NOT NULL DEFAULT '',
payload_digest CHAR(64) NOT NULL,
producer TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
payload BYTEA NOT NULL DEFAULT '',
UNIQUE (aggregate_type, aggregate_id, aggregate_version)
);
CREATE TABLE IF NOT EXISTS event_deliveries (
event_id TEXT NOT NULL REFERENCES event_envelopes(event_id),
consumer TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempt INT NOT NULL DEFAULT 0,
lease_owner TEXT NOT NULL DEFAULT '',
fencing_token TEXT NOT NULL DEFAULT '',
lease_until TIMESTAMPTZ,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
inbox BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (event_id, consumer),
CONSTRAINT chk_event_delivery_status CHECK (status IN ('pending','leased','acked','dead','quarantine'))
);
CREATE INDEX IF NOT EXISTS idx_event_deliveries_claim
ON event_deliveries (consumer, next_attempt_at, event_id)
WHERE status IN ('pending','leased');
CREATE TABLE IF NOT EXISTS event_dead_letters (
event_id TEXT NOT NULL,
consumer TEXT NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (event_id, consumer)
);
-- SKIP LOCKED is used only by claim:
-- SELECT event_id FROM event_deliveries
-- WHERE consumer=$1 AND status IN ('pending','leased')
-- AND (lease_until IS NULL OR lease_until < now())
-- ORDER BY next_attempt_at, event_id
-- FOR UPDATE SKIP LOCKED LIMIT $2;
-- +goose Down
DROP TABLE IF EXISTS event_dead_letters;
DROP TABLE IF EXISTS event_deliveries;
DROP TABLE IF EXISTS event_envelopes;
@@ -0,0 +1,94 @@
-- +goose Up
-- Renumbered from feature/final-artifact-vault 00034 after audit. Do not merge that file as 00034.
CREATE TABLE IF NOT EXISTS final_artifacts (
artifact_uuid TEXT PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE,
artifact_type TEXT NOT NULL,
logical_resource_id TEXT NOT NULL,
environment TEXT NOT NULL,
project_id BIGINT,
project_external_key TEXT NOT NULL,
source_event_id TEXT NOT NULL,
source_event_kind TEXT NOT NULL DEFAULT '',
source_snapshot_id BIGINT,
source_message_id BIGINT,
source_run_id TEXT NOT NULL DEFAULT '',
payload_sha256 CHAR(64) NOT NULL,
manifest_sha256 CHAR(64),
vault_storage_identity TEXT,
terminal_state TEXT NOT NULL DEFAULT '',
policy_version TEXT NOT NULL DEFAULT '',
publication_status TEXT NOT NULL,
retention_class TEXT NOT NULL DEFAULT 'governance',
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
verified_at TIMESTAMPTZ,
tombstoned_at TIMESTAMPTZ,
CONSTRAINT chk_fav_type CHECK (artifact_type IN ('agent_commit','sealed_version','approved_document','freeform_publish','assistant_reply')),
CONSTRAINT chk_fav_status CHECK (publication_status IN ('requested','publishing','verified','retryable_failed','tombstoned','purged','quarantine'))
);
CREATE TABLE IF NOT EXISTS artifact_payload_staging (
staging_uuid TEXT PRIMARY KEY,
project_id BIGINT,
source_event_id TEXT NOT NULL,
payload_sha256 CHAR(64) NOT NULL,
encrypted_payload BYTEA NOT NULL,
classification TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
releasable_at TIMESTAMPTZ,
UNIQUE (source_event_id, payload_sha256)
);
CREATE TABLE IF NOT EXISTS artifact_blob_references (
artifact_uuid TEXT NOT NULL REFERENCES final_artifacts(artifact_uuid),
project_scope TEXT NOT NULL,
payload_sha256 CHAR(64) NOT NULL,
envelope_key_ref TEXT NOT NULL DEFAULT '',
reference_status TEXT NOT NULL DEFAULT 'active',
retention_class TEXT NOT NULL DEFAULT 'governance',
legal_hold BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (artifact_uuid, payload_sha256)
);
CREATE TABLE IF NOT EXISTS artifact_publication_attempts (
artifact_uuid TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
attempt_no INT NOT NULL,
error_code TEXT NOT NULL DEFAULT '',
receipt TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (artifact_uuid, attempt_no)
);
CREATE TABLE IF NOT EXISTS artifact_deletion_ledger (
artifact_uuid TEXT NOT NULL,
reason TEXT NOT NULL,
authority TEXT NOT NULL DEFAULT '',
effective_at TIMESTAMPTZ NOT NULL,
applied_at TIMESTAMPTZ,
audit_ref TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS artifact_quarantine (
logical_resource_id TEXT NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS artifact_backfill_exclusions (
logical_resource_id TEXT PRIMARY KEY,
reason TEXT NOT NULL,
approved_by TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE IF EXISTS artifact_backfill_exclusions;
DROP TABLE IF EXISTS artifact_quarantine;
DROP TABLE IF EXISTS artifact_deletion_ledger;
DROP TABLE IF EXISTS artifact_publication_attempts;
DROP TABLE IF EXISTS artifact_blob_references;
DROP TABLE IF EXISTS artifact_payload_staging;
DROP TABLE IF EXISTS final_artifacts;
+43 -10
View File
@@ -3,10 +3,12 @@ package docflow
import (
"context"
"fmt"
"time"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/events"
)
type Action string
@@ -43,10 +45,20 @@ type DecisionCommand struct {
}
type Commands struct {
UoW uow.UnitOfWork
Gov *docgov.GovernanceEngine
Rev *docrevision.Service
Auth Authorizer
UoW uow.UnitOfWork
Gov *docgov.GovernanceEngine
Rev *docrevision.Service
Auth Authorizer
Events EventSink
Fav FavStaging
}
type EventSink interface {
Append(ctx context.Context, env events.Envelope, consumers []string) error
}
type FavStaging interface {
StageAgentCommit(ctx context.Context, projectID int64, docID, eventID string, snapshotID int64, payload []byte) error
}
type Authorizer interface {
@@ -142,13 +154,34 @@ func (c *Commands) AgentCommit(ctx context.Context, p Principal, cmd AgentCommit
err := c.run(ctx, cmd.DocID, p, ActionCommit, func(ctx context.Context, _ uow.TxScope) error {
st, err := c.Rev.CommitAgentVersion(ctx, cmd.DocID, cmd.CandidateID, cmd.Decision)
out = st
return err
if err != nil || out == nil || c.Events == nil {
return err
}
body := []byte(fmt.Sprintf(`{"doc_id":%q,"version_no":%d}`, cmd.DocID, out.CurrentVersionNo))
eventID := fmt.Sprintf("document:%s:v%d", cmd.DocID, out.CurrentVersionNo)
if err := c.Events.Append(ctx, events.Envelope{
EventID: eventID,
EventName: "document.agent_version_committed",
SchemaVersion: events.EnvelopeSchema,
Environment: "dev",
ProjectID: 0,
AggregateType: "document",
AggregateID: cmd.DocID,
AggregateVersion: int64(out.CurrentVersionNo),
ActorRef: fmt.Sprintf("user:%d", p.UserID),
IdempotencyKey: fmt.Sprintf("commit:%s:%d", cmd.DocID, out.CurrentVersionNo),
PayloadDigest: events.Digest(body),
Producer: "docflow",
OccurredAt: time.Now().UTC(),
Payload: body,
}, []string{"projection", "notify", "fav"}); err != nil {
return err
}
if c.Fav == nil {
return nil
}
return c.Fav.StageAgentCommit(ctx, 0, cmd.DocID, eventID, out.HeadSnapshotID, body)
})
if err == nil && c.UoW != nil && c.Rev != nil && c.Rev.Notify && c.Rev.OnOutbox != nil && out != nil {
_ = c.Rev.OnOutbox(ctx, "document.agent_version_committed", map[string]any{
"doc_id": cmd.DocID, "version_no": out.CurrentVersionNo,
})
}
return out, err
}
+85
View File
@@ -0,0 +1,85 @@
package docflow_test
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/artifactvault"
"chat-agent-system/internal/db"
"chat-agent-system/internal/events"
"github.com/jackc/pgx/v5/pgxpool"
)
func init() {
if _, err := os.Stat("deploy/postgres/goose"); err == nil {
return
}
for _, cand := range []string{"../../..", "../../../.."} {
if fi, err := os.Stat(cand + "/deploy/postgres/goose"); err == nil && fi.IsDir() {
_ = os.Chdir(cand)
return
}
}
}
func TestOutboxAndStagingSameTxRollback(t *testing.T) {
if testing.Short() {
t.Skip("same-tx outbox/staging is not part of -short")
}
dsn := db.SkipIfNoTestDatabaseURL(t)
if strings.Contains(dsn, "5433") || strings.Contains(dsn, "agent_system_dev") ||
(strings.Contains(dsn, "5434") && strings.Contains(dsn, "agent_system_test")) ||
strings.Contains(dsn, "5435") {
t.Fatal("TEST_DATABASE_URL points at a shared env port; refuse agent_system_dev/test/main")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil {
t.Fatalf("migrate: %v", err)
}
body := []byte(`{"doc_id":"doc_m2_tx","version_no":1}`)
env := events.Envelope{
EventID: "document:doc_m2_tx:v1", EventName: "document.agent_version_committed",
SchemaVersion: events.EnvelopeSchema, Environment: "dev", ProjectID: 9,
AggregateType: "document", AggregateID: "doc_m2_tx", AggregateVersion: 1,
ActorRef: "user:1", IdempotencyKey: "commit:doc_m2_tx:1", PayloadDigest: events.Digest(body),
Producer: "docflow", OccurredAt: time.Now().UTC(), Payload: body,
}
boom := errors.New("boom after staging")
unit := uow.NewPG(pool)
err = unit.Within(ctx, func(scope uow.TxScope) error {
ctx = uow.WithScope(ctx, scope)
if err := (events.PGSink{}).Append(ctx, env, []string{"projection", "fav"}); err != nil {
return err
}
ing := &artifactvault.Ingest{Env: "dev"}
if err := ing.StageAgentCommit(ctx, 9, "doc_m2_tx", env.EventID, 4, body); err != nil {
return err
}
return boom
})
if !errors.Is(err, boom) {
t.Fatalf("want boom, got %v", err)
}
var envelopes, artifacts int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM event_envelopes WHERE event_id=$1`, env.EventID).Scan(&envelopes); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM final_artifacts WHERE source_event_id=$1`, env.EventID).Scan(&artifacts); err != nil {
t.Fatal(err)
}
if envelopes != 0 || artifacts != 0 {
t.Fatalf("rollback leaked envelopes=%d artifacts=%d", envelopes, artifacts)
}
}
+30
View File
@@ -0,0 +1,30 @@
package artifactvault
import "fmt"
func CoveragePASS(c Coverage) bool {
if c.Required <= 0 {
return false
}
return c.Verified == c.Required && c.Quarantine == 0
}
func CanPurge(legalHold bool, activeRefs int) error {
if legalHold {
return fmt.Errorf("artifactvault: legal hold")
}
if activeRefs > 0 {
return fmt.Errorf("artifactvault: remaining references")
}
return nil
}
func PreflightDestroy(c Coverage, backupOK, corpusOK, vaultReady bool) error {
if !vaultReady || !CoveragePASS(c) {
return fmt.Errorf("artifactvault: coverage gate failed required=%d verified=%d quarantine=%d", c.Required, c.Verified, c.Quarantine)
}
if !backupOK || !corpusOK {
return fmt.Errorf("artifactvault: complementary backup/corpus not PASS")
}
return nil
}
+53
View File
@@ -0,0 +1,53 @@
package artifactvault
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
)
// ProjectKey derives a per-project envelope key. The master secret never enters manifests.
func ProjectKey(master []byte, env, projectExternalKey string) (key []byte, ref string, err error) {
if len(master) < 16 {
return nil, "", fmt.Errorf("artifactvault: vault master key too short")
}
sum := sha256.Sum256(append(append(master, []byte(env)...), []byte(projectExternalKey)...))
ref = "proj:" + env + ":" + projectExternalKey + ":" + hex.EncodeToString(sum[:8])
return sum[:], ref, nil
}
func Encrypt(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
func Decrypt(key, envelope []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
ns := gcm.NonceSize()
if len(envelope) < ns {
return nil, fmt.Errorf("artifactvault: short envelope")
}
return gcm.Open(nil, envelope[:ns], envelope[ns:], nil)
}
+108
View File
@@ -0,0 +1,108 @@
package artifactvault
import (
"errors"
"strings"
)
var (
ErrNotTerminal = errors.New("artifactvault: facts are not a terminal state")
ErrExplicitBypass = errors.New("artifactvault: explicit publish/pin cannot bypass terminal allowlist")
ErrSensitiveRejected = errors.New("artifactvault: sensitive or unclassified payload rejected")
ErrUnknownArtifactType = errors.New("artifactvault: unknown artifact type rejected")
ErrSnapshotInconsistent = errors.New("artifactvault: snapshot/event inconsistent")
ErrUnionDeny = errors.New("artifactvault: union-deny")
)
// EvaluateEligibility is the only domain gate. Unclassified types default to reject.
func EvaluateEligibility(f Facts) error {
if err := rejectSensitive(f.Classification, f.Payload); err != nil {
return err
}
switch f.ArtifactType {
case TypeAgentCommit:
if f.CandidateStatus != "committed" || f.OutputSnapshotID <= 0 {
return ErrNotTerminal
}
if f.ChangeKind != "" && (f.ChangeKind != "agent_commit" || f.ChangeToSnapshotID != f.OutputSnapshotID) {
return ErrSnapshotInconsistent
}
return nil
case TypeSealedVersion:
if f.VersionStatus != "sealed" || f.SealedAt == nil || f.HeadSnapshotID <= 0 {
return ErrNotTerminal
}
return nil
case TypeApprovedDocument:
if f.DocState != "approved" || f.ApprovedAt == nil || f.ReviewSnapshotID <= 0 {
return ErrNotTerminal
}
return nil
case TypeFreeformPublish:
if !f.ExplicitPublish || !f.HasImmutableSnapshot {
if f.ExplicitPublish && !f.HasImmutableSnapshot {
return ErrExplicitBypass
}
return ErrNotTerminal
}
return nil
case TypeAssistantReply:
if f.ChatTurnStatus != "completed" || f.CompletedAt == nil || f.AssistantMessageID <= 0 {
if f.ExplicitPin {
return ErrExplicitBypass
}
return ErrNotTerminal
}
switch f.ExportPolicy {
case "full":
return nil
case "copy_only":
if f.ExplicitPin && f.PinAfterCompleted {
return nil
}
if f.ExplicitPin {
return ErrExplicitBypass
}
return ErrNotTerminal
default:
return ErrNotTerminal
}
default:
return ErrUnknownArtifactType
}
}
func rejectSensitive(class string, payload []byte) error {
if class == "" {
class = ClassifyPayload(payload)
}
switch class {
case ClassificationSecret, ClassificationRestricted, ClassificationFailed:
return ErrSensitiveRejected
case ClassificationOK:
return nil
default:
return ErrSensitiveRejected
}
}
// ClassifyPayload is a fail-closed server-side gate. It never logs the body.
func ClassifyPayload(payload []byte) string {
text := strings.ToLower(string(payload))
if text == "" {
return ClassificationFailed
}
needles := []string{
"-----begin ", "private_key", "api_key=", "secret=", "authorization: bearer",
"password=", "access_token=", "aws_secret",
}
for _, n := range needles {
if strings.Contains(text, n) {
return ClassificationSecret
}
}
if strings.Contains(text, "restricted:") || strings.Contains(text, "classification_failed") {
return ClassificationRestricted
}
return ClassificationOK
}
+115
View File
@@ -0,0 +1,115 @@
package artifactvault
import (
"testing"
"time"
)
func TestEvaluateEligibility_Allowlist(t *testing.T) {
ok := Facts{
ArtifactType: TypeAgentCommit, CandidateStatus: "committed", OutputSnapshotID: 9,
ChangeKind: "agent_commit", ChangeToSnapshotID: 9, Classification: ClassificationOK, Payload: []byte("doc"),
}
if err := EvaluateEligibility(ok); err != nil {
t.Fatalf("committed should pass: %v", err)
}
for _, status := range []string{"ready", "needs_confirmation", "rejected", "stale", "quality_failed"} {
bad := ok
bad.CandidateStatus = status
if err := EvaluateEligibility(bad); err == nil {
t.Fatalf("status %s must reject", status)
}
}
}
func TestEvaluateEligibility_ApprovedRequiresReviewSnapshot(t *testing.T) {
now := time.Now().UTC()
f := Facts{
ArtifactType: TypeApprovedDocument, DocState: "approved", ApprovedAt: &now,
Classification: ClassificationOK, Payload: []byte("approved"),
}
if err := EvaluateEligibility(f); err == nil {
t.Fatal("approved without review_snapshot_id must reject")
}
f.ReviewSnapshotID = 3
if err := EvaluateEligibility(f); err != nil {
t.Fatalf("approved with snapshot should pass: %v", err)
}
}
func TestEvaluateEligibility_TaskWithoutCompletedTurn(t *testing.T) {
f := Facts{
ArtifactType: TypeAssistantReply, ChatTurnStatus: "", AssistantMessageID: 8,
ExportPolicy: "full", Classification: ClassificationOK, Payload: []byte("hello world from assistant"),
}
if err := EvaluateEligibility(f); err == nil {
t.Fatal("missing completed turn must reject")
}
if err := EvaluateEligibility(Facts{
ArtifactType: TypeAssistantReply, ExplicitPin: true, ChatTurnStatus: "pending",
Classification: ClassificationOK, Payload: []byte("hello world from assistant"),
}); err != ErrExplicitBypass {
t.Fatalf("pin before completed: %v", err)
}
}
func TestEvaluateEligibility_SensitiveRejectedBeforeStaging(t *testing.T) {
now := time.Now().UTC()
f := Facts{
ArtifactType: TypeAssistantReply, ChatTurnStatus: "completed", CompletedAt: &now,
AssistantMessageID: 1, ExportPolicy: "full", Payload: []byte("api_key=sk-secret"),
}
if err := EvaluateEligibility(f); err != ErrSensitiveRejected {
t.Fatalf("secret payload: %v", err)
}
}
func TestEvaluateEligibility_CopyOnlyRequiresExplicitPinAfterCompleted(t *testing.T) {
now := time.Now().UTC()
base := Facts{
ArtifactType: TypeAssistantReply, ChatTurnStatus: "completed", CompletedAt: &now,
AssistantMessageID: 9, ExportPolicy: "copy_only", Classification: ClassificationOK,
Payload: []byte("ok, noted."),
}
if err := EvaluateEligibility(base); err == nil {
t.Fatal("copy_only without pin must reject")
}
base.ExplicitPin = true
if err := EvaluateEligibility(base); err != ErrExplicitBypass {
t.Fatalf("pin without PinAfterCompleted: %v", err)
}
base.PinAfterCompleted = true
if err := EvaluateEligibility(base); err != nil {
t.Fatalf("copy_only + pin after completed should pass: %v", err)
}
}
func TestEvaluateEligibility_UnknownTypeRejected(t *testing.T) {
if err := EvaluateEligibility(Facts{ArtifactType: "workspace_scan", Classification: ClassificationOK, Payload: []byte("x")}); err != ErrUnknownArtifactType {
t.Fatalf("got %v", err)
}
}
func TestEvaluateEligibility_IntermediateAdmissionZero(t *testing.T) {
for _, typ := range []string{"prompt", "tool_trace", "scratch", "workspace_scan"} {
if err := EvaluateEligibility(Facts{ArtifactType: typ, Classification: ClassificationOK, Payload: []byte("x")}); err != ErrUnknownArtifactType {
t.Fatalf("%s: %v", typ, err)
}
}
}
func TestIdempotencyKeyStable(t *testing.T) {
req := PublishRequest{
ArtifactType: TypeAgentCommit, Environment: "dev", ProjectExternalKey: "p1",
LogicalResourceID: "doc_a", SourceEventKind: "agent_commit", SourceEventID: "ev1",
}
a := IdempotencyKey(req, "abc")
b := IdempotencyKey(req, "abc")
if a != b || a == "" {
t.Fatalf("key unstable %q %q", a, b)
}
req.SourceEventID = "ev2"
if IdempotencyKey(req, "abc") == a {
t.Fatal("new event must new key")
}
}
+217
View File
@@ -0,0 +1,217 @@
package artifactvault
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"chat-agent-system/internal/fsutil"
)
type FilesystemStore struct {
Root string
StorageIdentity string
MasterKey []byte
}
func NewFilesystemStore(root, identity string, master []byte) (*FilesystemStore, error) {
if strings.TrimSpace(root) == "" || strings.Contains(root, "data/dev") && !strings.Contains(root, "vault") {
if strings.HasSuffix(filepath.Clean(root), filepath.Join("data", "dev")) {
return nil, fmt.Errorf("artifactvault: refuse vault root %s (must not be runtime data/dev)", root)
}
}
if identity == "" {
return nil, fmt.Errorf("artifactvault: storage identity required")
}
if err := os.MkdirAll(root, 0o700); err != nil {
return nil, err
}
sent := filepath.Join(root, ".vault-storage-identity.json")
if b, err := os.ReadFile(sent); err == nil {
var got map[string]string
_ = json.Unmarshal(b, &got)
if got["uuid"] != "" && got["uuid"] != identity {
return nil, fmt.Errorf("artifactvault: storage identity mismatch")
}
} else {
_ = fsutil.WriteFileAtomic(sent, []byte(`{"uuid":"`+identity+`"}`), 0o444)
}
return &FilesystemStore{Root: root, StorageIdentity: identity, MasterKey: master}, nil
}
func (s *FilesystemStore) Identity() string { return s.StorageIdentity }
func (s *FilesystemStore) blobPath(env, projectKey, sha string) string {
return filepath.Join(s.Root, "scopes", env, projectKey, "blobs", "sha256", sha[:2], sha[2:4], sha)
}
func (s *FilesystemStore) PutBlob(env, projectKey, sha string, plain []byte) (keyRef string, err error) {
if SHA256Hex(plain) != sha {
return "", fmt.Errorf("artifactvault: blob sha mismatch")
}
key, ref, err := ProjectKey(s.MasterKey, env, projectKey)
if err != nil {
return "", err
}
envBytes, err := Encrypt(key, plain)
if err != nil {
return "", err
}
path := s.blobPath(env, projectKey, sha)
if _, err := os.Stat(path); err == nil {
return ref, nil
}
if err := writeExclusiveOK(path, envBytes, 0o600); err != nil {
return "", err
}
return ref, nil
}
func (s *FilesystemStore) GetBlob(env, projectKey, sha string) ([]byte, error) {
key, _, err := ProjectKey(s.MasterKey, env, projectKey)
if err != nil {
return nil, err
}
raw, err := os.ReadFile(s.blobPath(env, projectKey, sha))
if err != nil {
return nil, err
}
plain, err := Decrypt(key, raw)
if err != nil {
return nil, err
}
if SHA256Hex(plain) != sha {
return nil, fmt.Errorf("artifactvault: decrypted sha mismatch")
}
return plain, nil
}
func writeExclusiveOK(path string, raw []byte, perm os.FileMode) error {
if err := fsutil.WriteFileAtomicExclusive(path, raw, perm); err != nil {
if strings.Contains(err.Error(), "refuse overwrite") {
return nil
}
return err
}
return nil
}
func (s *FilesystemStore) PutManifest(artifactUUID, manifestSHA string, raw []byte) error {
path := filepath.Join(s.Root, "manifests", artifactUUID, manifestSHA+".json")
return writeExclusiveOK(path, raw, 0o644)
}
func (s *FilesystemStore) PutCommit(artifactUUID, manifestSHA string, raw []byte) error {
path := filepath.Join(s.Root, "commits", artifactUUID, manifestSHA+".json")
return writeExclusiveOK(path, raw, 0o644)
}
func (s *FilesystemStore) ListCommitted() ([]string, error) {
dir := filepath.Join(s.Root, "commits")
ents, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
out := make([]string, 0, len(ents))
for _, e := range ents {
if e.IsDir() {
out = append(out, e.Name())
}
}
return out, nil
}
func (s *FilesystemStore) LoadCommittedArtifacts() ([]Artifact, map[string][]byte, error) {
uuids, err := s.ListCommitted()
if err != nil {
return nil, nil, err
}
payloads := make(map[string][]byte)
arts := make([]Artifact, 0, len(uuids))
for _, uuid := range uuids {
if s.HasTombstone(uuid) {
arts = append(arts, Artifact{UUID: uuid, PublicationStatus: StatusTombstoned})
continue
}
art, body, err := s.loadCommitted(uuid)
if err != nil {
return nil, nil, err
}
arts = append(arts, art)
payloads[uuid] = body
}
return arts, payloads, nil
}
func (s *FilesystemStore) loadCommitted(uuid string) (Artifact, []byte, error) {
dir := filepath.Join(s.Root, "commits", uuid)
ents, err := os.ReadDir(dir)
if err != nil || len(ents) == 0 {
return Artifact{}, nil, fmt.Errorf("artifactvault: unpublished")
}
raw, err := os.ReadFile(filepath.Join(dir, ents[0].Name()))
if err != nil {
return Artifact{}, nil, err
}
var marker CommitMarker
if err := json.Unmarshal(raw, &marker); err != nil {
return Artifact{}, nil, err
}
manRaw, err := os.ReadFile(filepath.Join(s.Root, "manifests", uuid, marker.ManifestSHA256+".json"))
if err != nil {
return Artifact{}, nil, err
}
var man Manifest
if err := json.Unmarshal(manRaw, &man); err != nil {
return Artifact{}, nil, err
}
if len(man.PayloadMembers) == 0 {
return Artifact{}, nil, fmt.Errorf("artifactvault: manifest has no payload members")
}
body, err := s.GetBlob(man.Environment, man.ProjectExternalKey, man.PayloadMembers[0].SHA256)
if err != nil {
return Artifact{}, nil, err
}
var snap, msg int64
if man.SourceSnapshotID != nil {
snap = *man.SourceSnapshotID
}
if man.SourceMessageID != nil {
msg = *man.SourceMessageID
}
return Artifact{
UUID: uuid, ArtifactType: man.ArtifactType, LogicalResourceID: man.LogicalResourceID,
Environment: man.Environment, ProjectID: man.ProjectID, ProjectExternalKey: man.ProjectExternalKey,
SourceEventID: man.SourceEventID, SourceEventKind: man.SourceEventKind,
SourceSnapshotID: snap, SourceMessageID: msg, SourceRunID: man.SourceRunID,
PayloadSHA256: man.PayloadMembers[0].SHA256, ManifestSHA256: man.ManifestSHA256,
VaultStorageIdentity: man.VaultStorageIdentity, TerminalState: man.TerminalEvidence.State,
PublicationStatus: StatusVerified, RetentionClass: man.RetentionClass,
}, body, nil
}
func (s *FilesystemStore) HasCommit(artifactUUID, manifestSHA string) bool {
_, err := os.Stat(filepath.Join(s.Root, "commits", artifactUUID, manifestSHA+".json"))
return err == nil
}
func (s *FilesystemStore) PutTombstone(artifactUUID, eventUUID string, raw []byte) error {
path := filepath.Join(s.Root, "tombstones", artifactUUID, eventUUID+".json")
return fsutil.WriteFileAtomicExclusive(path, raw, 0o644)
}
func (s *FilesystemStore) HasTombstone(artifactUUID string) bool {
dir := filepath.Join(s.Root, "tombstones", artifactUUID)
ents, err := os.ReadDir(dir)
return err == nil && len(ents) > 0
}
func (s *FilesystemStore) Ready() (identityOK, connected, lagOK bool) {
_, err := os.Stat(s.Root)
return s.StorageIdentity != "", err == nil, err == nil
}
+202
View File
@@ -0,0 +1,202 @@
package artifactvault
import (
"context"
"fmt"
"strconv"
"time"
"chat-agent-system/internal/app/uow"
"github.com/jackc/pgx/v5"
)
// Ingest is the single write path from domain terminals into staging/outbox.
type Ingest struct {
Env string
Reg Registry
}
func ProjectKeyFromID(projectID int64) string {
if projectID <= 0 {
return "unscoped"
}
return strconv.FormatInt(projectID, 10)
}
func (i *Ingest) Request(ctx context.Context, facts Facts, req PublishRequest) error {
if i == nil || i.Reg == nil {
return nil
}
if req.Environment == "" {
req.Environment = i.Env
}
if req.ProjectExternalKey == "" {
req.ProjectExternalKey = ProjectKeyFromID(req.ProjectID)
}
if req.PolicyVersion == "" {
req.PolicyVersion = PolicyVersion
}
if req.Classification == "" {
req.Classification = ClassifyPayload(req.Payload)
}
facts.Payload = req.Payload
facts.Classification = req.Classification
_, _, err := i.Reg.Request(ctx, facts, req)
return err
}
func ApprovedFacts(docState string, approvedAt *time.Time, snapshotID int64) Facts {
return Facts{
ArtifactType: TypeApprovedDocument,
DocState: docState,
ApprovedAt: approvedAt,
ReviewSnapshotID: snapshotID,
}
}
func AgentCommitFacts(outputSnapshotID int64) Facts {
return Facts{
ArtifactType: TypeAgentCommit,
CandidateStatus: "committed",
OutputSnapshotID: outputSnapshotID,
ChangeKind: "agent_commit",
ChangeToSnapshotID: outputSnapshotID,
}
}
func SealedFacts(sealedAt *time.Time, headSnapshotID int64) Facts {
return Facts{
ArtifactType: TypeSealedVersion,
VersionStatus: "sealed",
SealedAt: sealedAt,
HeadSnapshotID: headSnapshotID,
}
}
func AssistantFacts(turnStatus string, completedAt *time.Time, msgID int64, policy string) Facts {
return Facts{
ArtifactType: TypeAssistantReply,
ChatTurnStatus: turnStatus,
CompletedAt: completedAt,
AssistantMessageID: msgID,
ExportPolicy: policy,
}
}
func FreeformFacts(explicit bool, hasSnapshot bool) Facts {
return Facts{
ArtifactType: TypeFreeformPublish,
ExplicitPublish: explicit,
HasImmutableSnapshot: hasSnapshot,
}
}
func (i *Ingest) AgentCommit(ctx context.Context, projectID int64, docID, eventID string, snapshotID int64, payload []byte) error {
return i.Request(ctx, AgentCommitFacts(snapshotID), PublishRequest{
ArtifactType: TypeAgentCommit, Environment: i.Env, ProjectID: projectID,
LogicalResourceID: docID, SourceEventKind: "agent_commit", SourceEventID: eventID,
SourceSnapshotID: snapshotID, Payload: payload, TerminalState: "committed",
})
}
func (i *Ingest) SealedVersion(ctx context.Context, projectID int64, docID, eventID string, snapshotID int64, sealedAt *time.Time, payload []byte) error {
return i.Request(ctx, SealedFacts(sealedAt, snapshotID), PublishRequest{
ArtifactType: TypeSealedVersion, Environment: i.Env, ProjectID: projectID,
LogicalResourceID: docID + ":sealed", SourceEventKind: "version_sealed", SourceEventID: eventID,
SourceSnapshotID: snapshotID, Payload: payload, TerminalState: "sealed",
})
}
func (i *Ingest) Approved(ctx context.Context, projectID int64, docID string, snapshotID int64, approvedAt *time.Time, payload []byte) error {
return i.Request(ctx, ApprovedFacts("approved", approvedAt, snapshotID), PublishRequest{
ArtifactType: TypeApprovedDocument, Environment: i.Env, ProjectID: projectID,
LogicalResourceID: docID, SourceEventKind: "approved", SourceEventID: fmt.Sprintf("approved:%s:%d", docID, snapshotID),
SourceSnapshotID: snapshotID, Payload: payload, TerminalState: "approved",
})
}
func (i *Ingest) FreeformPublish(ctx context.Context, projectID int64, docID, eventID string, snapshotID int64, payload []byte) error {
return i.Request(ctx, FreeformFacts(true, snapshotID > 0 || len(payload) > 0), PublishRequest{
ArtifactType: TypeFreeformPublish, Environment: i.Env, ProjectID: projectID,
LogicalResourceID: docID, SourceEventKind: "freeform_publish", SourceEventID: eventID,
SourceSnapshotID: snapshotID, Payload: payload, TerminalState: "published",
})
}
func (i *Ingest) requestTX(ctx context.Context, tx pgx.Tx, facts Facts, req PublishRequest) error {
if i == nil {
return nil
}
if req.Environment == "" {
req.Environment = i.Env
}
if req.ProjectExternalKey == "" {
req.ProjectExternalKey = ProjectKeyFromID(req.ProjectID)
}
if req.PolicyVersion == "" {
req.PolicyVersion = PolicyVersion
}
if req.Classification == "" {
req.Classification = ClassifyPayload(req.Payload)
}
facts.Payload = req.Payload
facts.Classification = req.Classification
_, _, err := RequestWithTx(ctx, tx, facts, req)
return err
}
func (i *Ingest) StageAgentCommit(ctx context.Context, projectID int64, docID, eventID string, snapshotID int64, payload []byte) error {
scope, ok := uow.ScopeFrom(ctx)
if !ok {
return fmt.Errorf("artifactvault: staging requires a unit of work")
}
tx, err := uow.AsPG(scope)
if err != nil {
return err
}
return i.AgentCommitTX(ctx, tx, projectID, docID, eventID, snapshotID, payload)
}
func (i *Ingest) AgentCommitTX(ctx context.Context, tx pgx.Tx, projectID int64, docID, eventID string, snapshotID int64, payload []byte) error {
return i.requestTX(ctx, tx, AgentCommitFacts(snapshotID), PublishRequest{
ArtifactType: TypeAgentCommit, Environment: i.Env, ProjectID: projectID,
LogicalResourceID: docID, SourceEventKind: "agent_commit", SourceEventID: eventID,
SourceSnapshotID: snapshotID, Payload: payload, TerminalState: "committed",
})
}
func (i *Ingest) SealedVersionTX(ctx context.Context, tx pgx.Tx, projectID int64, docID, eventID string, snapshotID int64, sealedAt *time.Time, payload []byte) error {
return i.requestTX(ctx, tx, SealedFacts(sealedAt, snapshotID), PublishRequest{
ArtifactType: TypeSealedVersion, Environment: i.Env, ProjectID: projectID,
LogicalResourceID: docID + ":sealed", SourceEventKind: "version_sealed", SourceEventID: eventID,
SourceSnapshotID: snapshotID, Payload: payload, TerminalState: "sealed",
})
}
func (i *Ingest) ApprovedTX(ctx context.Context, tx pgx.Tx, projectID int64, docID string, snapshotID int64, approvedAt *time.Time, payload []byte) error {
return i.requestTX(ctx, tx, ApprovedFacts("approved", approvedAt, snapshotID), PublishRequest{
ArtifactType: TypeApprovedDocument, Environment: i.Env, ProjectID: projectID,
LogicalResourceID: docID, SourceEventKind: "approved", SourceEventID: fmt.Sprintf("approved:%s:%d", docID, snapshotID),
SourceSnapshotID: snapshotID, Payload: payload, TerminalState: "approved",
})
}
func (i *Ingest) AssistantReply(ctx context.Context, projectID int64, logical, eventID string, msgID int64, runID, policy string, completedAt *time.Time, payload []byte) error {
return i.Request(ctx, AssistantFacts("completed", completedAt, msgID, policy), PublishRequest{
ArtifactType: TypeAssistantReply, Environment: i.Env, ProjectID: projectID,
LogicalResourceID: logical, SourceEventKind: "assistant_completed", SourceEventID: eventID,
SourceMessageID: msgID, SourceRunID: runID, Payload: payload, TerminalState: "completed",
})
}
func (i *Ingest) AssistantReplyPin(ctx context.Context, projectID int64, logical, eventID string, msgID int64, runID, policy string, completedAt *time.Time, payload []byte) error {
facts := AssistantFacts("completed", completedAt, msgID, policy)
facts.ExplicitPin = true
facts.PinAfterCompleted = true
return i.Request(ctx, facts, PublishRequest{
ArtifactType: TypeAssistantReply, Environment: i.Env, ProjectID: projectID,
LogicalResourceID: logical, SourceEventKind: "assistant_pin", SourceEventID: eventID,
SourceMessageID: msgID, SourceRunID: runID, Payload: payload, TerminalState: "completed",
})
}
+35
View File
@@ -0,0 +1,35 @@
package artifactvault
import (
"context"
"testing"
"time"
)
func TestIngestIdempotentAndRejectsNonTerminal(t *testing.T) {
ctx := context.Background()
ing := &Ingest{Env: "dev", Reg: NewMemoryRegistry()}
now := time.Now().UTC()
payload := []byte("# committed body")
if err := ing.AgentCommit(ctx, 9, "doc_a", "ev-1", 4, payload); err != nil {
t.Fatal(err)
}
if err := ing.AgentCommit(ctx, 9, "doc_a", "ev-1", 4, payload); err != nil {
t.Fatal(err)
}
if ing.Reg.Coverage(ctx).Required != 1 {
t.Fatalf("idempotent ingest required=%d", ing.Reg.Coverage(ctx).Required)
}
if err := ing.AssistantReply(ctx, 9, "msg:1", "s:t", 1, "run", "none", &now, []byte("hello world from assistant")); err == nil {
t.Fatal("none policy must reject")
}
}
func TestCoverageGateFailClosed(t *testing.T) {
if CoveragePASS(Coverage{}) {
t.Fatal("empty coverage must fail")
}
if err := PreflightDestroy(Coverage{Required: 2, Verified: 1}, true, true, true); err == nil {
t.Fatal("incomplete coverage must fail destroy")
}
}
+100
View File
@@ -0,0 +1,100 @@
package artifactvault
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"time"
)
func BuildManifest(art Artifact, payloadSHA string, size int, keyRef string) Manifest {
var snap, msg *int64
if art.SourceSnapshotID > 0 {
v := art.SourceSnapshotID
snap = &v
}
if art.SourceMessageID > 0 {
v := art.SourceMessageID
msg = &v
}
m := Manifest{
ArtifactSchemaVersion: SchemaMajorMinor,
ArtifactUUID: art.UUID,
ArtifactType: art.ArtifactType,
LogicalResourceID: art.LogicalResourceID,
Environment: art.Environment,
ProjectID: art.ProjectID,
ProjectExternalKey: art.ProjectExternalKey,
SourceEventID: art.SourceEventID,
SourceEventKind: art.SourceEventKind,
SourceSnapshotID: snap,
SourceMessageID: msg,
SourceRunID: art.SourceRunID,
PayloadMembers: []PayloadMember{{
Name: "payload", MediaType: "text/markdown", ByteSize: size, SHA256: payloadSHA, Required: true,
}},
TerminalEvidence: TerminalEvidence{
State: art.TerminalState, DecidedAt: time.Now().UTC().Format(time.RFC3339), PolicyVersion: nz(art.PolicyVersion, PolicyVersion),
},
RetentionClass: art.RetentionClass,
EncryptionKeyRef: keyRef,
VaultStorageIdentity: art.VaultStorageIdentity,
PublisherVersion: PublisherVersion,
PublishedAt: time.Now().UTC().Format(time.RFC3339),
}
raw, _ := json.Marshal(canonicalManifest(m))
m.ManifestSHA256 = SHA256Hex(raw)
return m
}
func canonicalManifest(m Manifest) Manifest {
cp := m
cp.ManifestSHA256 = ""
return cp
}
func ValidateManifest(m Manifest) error {
if !strings.HasPrefix(m.ArtifactSchemaVersion, "1.") {
return fmt.Errorf("artifactvault: unsupported manifest major %q", m.ArtifactSchemaVersion)
}
if m.ArtifactUUID == "" || m.LogicalResourceID == "" || m.ProjectExternalKey == "" || m.VaultStorageIdentity == "" {
return fmt.Errorf("artifactvault: manifest missing required identity fields")
}
if len(m.PayloadMembers) == 0 {
return fmt.Errorf("artifactvault: manifest has no payload members")
}
for _, mem := range m.PayloadMembers {
if len(mem.SHA256) != 64 {
return fmt.Errorf("artifactvault: invalid member sha")
}
}
got := SHA256Hex(mustJSON(canonicalManifest(m)))
if m.ManifestSHA256 != "" && m.ManifestSHA256 != got {
return fmt.Errorf("artifactvault: manifest sha mismatch")
}
return nil
}
func ValidateCommitMarker(c CommitMarker) error {
if c.ArtifactUUID == "" || len(c.ManifestSHA256) != 64 || len(c.MemberSHAs) == 0 || c.VaultStorageIdentity == "" || c.CommittedAt == "" || c.PublisherVersion == "" {
return fmt.Errorf("artifactvault: incomplete commit marker")
}
return nil
}
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
return nil
}
return b
}
func EncodeJSON(v any) []byte {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
_ = enc.Encode(v)
return bytes.TrimSpace(buf.Bytes())
}
+103
View File
@@ -0,0 +1,103 @@
package artifactvault
import (
"context"
"encoding/json"
"fmt"
"time"
)
type Publisher struct {
Reg Registry
Store *FilesystemStore
}
func (p *Publisher) Drain(ctx context.Context) error {
arts, err := p.Reg.LoadRequested(ctx)
if err != nil {
return err
}
var first error
for _, a := range arts {
if err := p.PublishOne(ctx, a); err != nil && first == nil {
first = err
}
}
return first
}
func (p *Publisher) PublishOne(ctx context.Context, art Artifact) error {
if p.Store == nil {
return fmt.Errorf("artifactvault: store required")
}
if p.Reg.IsDenied(ctx, art.UUID) {
return fmt.Errorf("artifactvault: artifact denied")
}
_ = p.Reg.MarkPublishing(ctx, art.UUID)
st, err := p.Reg.LoadStaging(ctx, art.SourceEventID)
if err != nil {
_ = p.Reg.MarkFailed(ctx, art.UUID, "staging_missing")
return err
}
if SHA256Hex(st.Payload) != art.PayloadSHA256 || SHA256Hex(st.Payload) != st.PayloadSHA256 {
_ = p.Reg.MarkFailed(ctx, art.UUID, "sha_mismatch")
return fmt.Errorf("artifactvault: staging sha mismatch")
}
if ClassifyPayload(st.Payload) != ClassificationOK {
_ = p.Reg.MarkFailed(ctx, art.UUID, "sensitive")
return ErrSensitiveRejected
}
art.VaultStorageIdentity = p.Store.Identity()
keyRef, err := p.Store.PutBlob(art.Environment, art.ProjectExternalKey, art.PayloadSHA256, st.Payload)
if err != nil {
_ = p.Reg.MarkFailed(ctx, art.UUID, "blob")
return err
}
man := BuildManifest(art, art.PayloadSHA256, len(st.Payload), keyRef)
if err := ValidateManifest(man); err != nil {
_ = p.Reg.MarkFailed(ctx, art.UUID, "manifest")
return err
}
manRaw := EncodeJSON(man)
if err := p.Store.PutManifest(art.UUID, man.ManifestSHA256, manRaw); err != nil {
_ = p.Reg.MarkFailed(ctx, art.UUID, "manifest_write")
return err
}
marker := CommitMarker{
ArtifactUUID: art.UUID, ManifestSHA256: man.ManifestSHA256,
MemberSHAs: []string{art.PayloadSHA256}, VaultStorageIdentity: p.Store.Identity(),
CommittedAt: time.Now().UTC().Format(time.RFC3339), PublisherVersion: PublisherVersion,
}
if err := ValidateCommitMarker(marker); err != nil {
return err
}
if err := p.Store.PutCommit(art.UUID, man.ManifestSHA256, EncodeJSON(marker)); err != nil {
_ = p.Reg.MarkFailed(ctx, art.UUID, "commit")
return err
}
_ = p.Reg.RecordReference(ctx, art.UUID, art.ProjectExternalKey, art.PayloadSHA256, keyRef)
return p.Reg.MarkVerified(ctx, art.UUID, man.ManifestSHA256)
}
func (p *Publisher) DegradedPayload(ctx context.Context, env, logical, projectKey string) ([]byte, string, error) {
art, err := p.Reg.GetByLogical(ctx, env, logical)
if err != nil {
return nil, "", err
}
if p.Reg.IsDenied(ctx, art.UUID) || p.Store.HasTombstone(art.UUID) {
return nil, "", ErrUnionDeny
}
if art.ManifestSHA256 == "" || !p.Store.HasCommit(art.UUID, art.ManifestSHA256) {
return nil, "", fmt.Errorf("artifactvault: unpublished")
}
b, err := p.Store.GetBlob(env, projectKey, art.PayloadSHA256)
if err != nil {
return nil, "", err
}
return b, art.PayloadSHA256, nil
}
func MustJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
+267
View File
@@ -0,0 +1,267 @@
package artifactvault
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func testStore(t *testing.T) *FilesystemStore {
t.Helper()
dir := t.TempDir()
st, err := NewFilesystemStore(dir, "vault-id-test", []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
return st
}
func TestPublisherIdempotentAndCrashSafe(t *testing.T) {
ctx := context.Background()
reg := NewMemoryRegistry()
store := testStore(t)
pub := &Publisher{Reg: reg, Store: store}
facts := Facts{
ArtifactType: TypeAgentCommit, CandidateStatus: "committed", OutputSnapshotID: 4,
ChangeKind: "agent_commit", ChangeToSnapshotID: 4, Classification: ClassificationOK,
Payload: []byte("# final\n"),
}
req := PublishRequest{
ArtifactType: TypeAgentCommit, Environment: "dev", ProjectExternalKey: "proj-a",
LogicalResourceID: "doc_a", SourceEventKind: "agent_commit", SourceEventID: "ev-1",
Payload: []byte("# final\n"), TerminalState: "committed",
}
art1, _, err := reg.Request(ctx, facts, req)
if err != nil {
t.Fatal(err)
}
art2, _, err := reg.Request(ctx, facts, req)
if err != nil || art1.UUID != art2.UUID {
t.Fatalf("idempotent request: %v %s %s", err, art1.UUID, art2.UUID)
}
if err := pub.Drain(ctx); err != nil {
t.Fatal(err)
}
if err := pub.Drain(ctx); err != nil {
t.Fatal(err)
}
cov := reg.Coverage(ctx)
if cov.Verified != 1 {
t.Fatalf("verified=%d", cov.Verified)
}
b, sha, err := pub.DegradedPayload(ctx, "dev", "doc_a", "proj-a")
if err != nil || string(b) != "# final\n" || sha == "" {
t.Fatalf("degraded: %v %q", err, b)
}
}
func TestPublisherRejectsBadSHAAndCrossProjectKeys(t *testing.T) {
ctx := context.Background()
reg := NewMemoryRegistry()
store := testStore(t)
pub := &Publisher{Reg: reg, Store: store}
facts := Facts{
ArtifactType: TypeSealedVersion, VersionStatus: "sealed", SealedAt: ptrTime(),
HeadSnapshotID: 2, Classification: ClassificationOK, Payload: []byte("same-bytes"),
}
aReq := PublishRequest{ArtifactType: TypeSealedVersion, Environment: "dev", ProjectExternalKey: "p1", LogicalResourceID: "d1", SourceEventKind: "seal", SourceEventID: "s1", Payload: []byte("same-bytes"), TerminalState: "sealed"}
bReq := aReq
bReq.ProjectExternalKey = "p2"
bReq.LogicalResourceID = "d2"
bReq.SourceEventID = "s2"
if _, _, err := reg.Request(ctx, facts, aReq); err != nil {
t.Fatal(err)
}
if _, _, err := reg.Request(ctx, facts, bReq); err != nil {
t.Fatal(err)
}
if err := pub.Drain(ctx); err != nil {
t.Fatal(err)
}
if reg.ActiveRefs(ctx, "p1", SHA256Hex([]byte("same-bytes"))) != 1 {
t.Fatal("p1 refs")
}
if reg.ActiveRefs(ctx, "p2", SHA256Hex([]byte("same-bytes"))) != 1 {
t.Fatal("p2 refs")
}
_ = reg.Tombstone(ctx, "unused", "test", "test")
}
func TestFilesystemRefusesRuntimeDataDev(t *testing.T) {
root := filepath.Join(t.TempDir(), "data", "dev")
_ = os.MkdirAll(root, 0o755)
if _, err := NewFilesystemStore(root, "id", []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("must refuse data/dev")
}
}
func TestTombstoneUnionDeny(t *testing.T) {
ctx := context.Background()
reg := NewMemoryRegistry()
store := testStore(t)
pub := &Publisher{Reg: reg, Store: store}
now := time.Now().UTC()
facts := Facts{ArtifactType: TypeApprovedDocument, DocState: "approved", ApprovedAt: &now, ReviewSnapshotID: 8, Classification: ClassificationOK, Payload: []byte("ok body")}
req := PublishRequest{ArtifactType: TypeApprovedDocument, Environment: "dev", ProjectExternalKey: "p", LogicalResourceID: "doc", SourceEventKind: "approved", SourceEventID: "ap1", Payload: []byte("ok body"), TerminalState: "approved"}
art, _, err := reg.Request(ctx, facts, req)
if err != nil {
t.Fatal(err)
}
if err := pub.Drain(ctx); err != nil {
t.Fatal(err)
}
_ = reg.Tombstone(ctx, art.UUID, "legal", "admin")
if _, _, err := pub.DegradedPayload(ctx, "dev", "doc", "p"); !errors.Is(err, ErrUnionDeny) {
t.Fatalf("tombstone must union-deny: %v", err)
}
}
func TestLastReferencePurgeAndLegalHold(t *testing.T) {
ctx := context.Background()
reg := NewMemoryRegistry()
store := testStore(t)
pub := &Publisher{Reg: reg, Store: store}
now := time.Now().UTC()
payload := []byte("shared-bytes")
facts := Facts{ArtifactType: TypeSealedVersion, VersionStatus: "sealed", SealedAt: &now, HeadSnapshotID: 2, Classification: ClassificationOK, Payload: payload}
req := PublishRequest{ArtifactType: TypeSealedVersion, Environment: "dev", ProjectExternalKey: "p1", LogicalResourceID: "d1", SourceEventKind: "seal", SourceEventID: "s1", Payload: payload, TerminalState: "sealed"}
if _, _, err := reg.Request(ctx, facts, req); err != nil {
t.Fatal(err)
}
if err := pub.Drain(ctx); err != nil {
t.Fatal(err)
}
if reg.ActiveRefs(ctx, "p1", SHA256Hex(payload)) != 1 {
t.Fatal("expected one active ref")
}
if err := CanPurge(false, 1); err == nil {
t.Fatal("must not purge while refs remain")
}
if err := CanPurge(true, 0); err == nil {
t.Fatal("legal hold blocks purge")
}
if err := CanPurge(false, 0); err != nil {
t.Fatal(err)
}
}
func TestFilesystemIdentityMismatch(t *testing.T) {
dir := t.TempDir()
key := []byte("0123456789abcdef0123456789abcdef")
if _, err := NewFilesystemStore(dir, "id-a", key); err != nil {
t.Fatal(err)
}
if _, err := NewFilesystemStore(dir, "id-b", key); err == nil {
t.Fatal("identity mismatch must fail closed")
}
}
func TestUnpublishedWithoutCommitMarkerInvisible(t *testing.T) {
ctx := context.Background()
reg := NewMemoryRegistry()
store := testStore(t)
pub := &Publisher{Reg: reg, Store: store}
facts := Facts{
ArtifactType: TypeAgentCommit, CandidateStatus: "committed", OutputSnapshotID: 4,
ChangeKind: "agent_commit", ChangeToSnapshotID: 4, Classification: ClassificationOK,
Payload: []byte("# pending\n"),
}
req := PublishRequest{
ArtifactType: TypeAgentCommit, Environment: "dev", ProjectExternalKey: "proj-a",
LogicalResourceID: "doc_pending", SourceEventKind: "agent_commit", SourceEventID: "ev-pending",
Payload: []byte("# pending\n"), TerminalState: "committed",
}
if _, _, err := reg.Request(ctx, facts, req); err != nil {
t.Fatal(err)
}
if _, _, err := pub.DegradedPayload(ctx, "dev", "doc_pending", "proj-a"); err == nil {
t.Fatal("requested but unpublished must be invisible")
}
}
func TestPublisherRetriesAfterMissingCommit(t *testing.T) {
ctx := context.Background()
reg := NewMemoryRegistry()
store := testStore(t)
pub := &Publisher{Reg: reg, Store: store}
payload := []byte("# retry-me\n")
facts := Facts{
ArtifactType: TypeAgentCommit, CandidateStatus: "committed", OutputSnapshotID: 5,
ChangeKind: "agent_commit", ChangeToSnapshotID: 5, Classification: ClassificationOK,
Payload: payload,
}
req := PublishRequest{
ArtifactType: TypeAgentCommit, Environment: "dev", ProjectExternalKey: "proj-a",
LogicalResourceID: "doc_retry", SourceEventKind: "agent_commit", SourceEventID: "ev-retry",
Payload: payload, TerminalState: "committed",
}
art, _, err := reg.Request(ctx, facts, req)
if err != nil {
t.Fatal(err)
}
if err := pub.Drain(ctx); err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(filepath.Join(store.Root, "commits", art.UUID)); err != nil {
t.Fatal(err)
}
if _, _, err := pub.DegradedPayload(ctx, "dev", "doc_retry", "proj-a"); err == nil {
t.Fatal("missing commit marker must hide payload")
}
if err := reg.MarkFailed(ctx, art.UUID, "commit_missing"); err != nil {
t.Fatal(err)
}
if err := pub.Drain(ctx); err != nil {
t.Fatal(err)
}
b, _, err := pub.DegradedPayload(ctx, "dev", "doc_retry", "proj-a")
if err != nil || string(b) != string(payload) {
t.Fatalf("retry after exclusive blob/manifest: %v %q", err, b)
}
}
type requestCountingReg struct {
Registry
requests int
}
func (r *requestCountingReg) Request(ctx context.Context, facts Facts, req PublishRequest) (*Artifact, *Staging, error) {
r.requests++
return r.Registry.Request(ctx, facts, req)
}
func TestDegradedPayloadDoesNotWrite(t *testing.T) {
ctx := context.Background()
mem := NewMemoryRegistry()
count := &requestCountingReg{Registry: mem}
store := testStore(t)
pub := &Publisher{Reg: count, Store: store}
facts := Facts{
ArtifactType: TypeAgentCommit, CandidateStatus: "committed", OutputSnapshotID: 6,
ChangeKind: "agent_commit", ChangeToSnapshotID: 6, Classification: ClassificationOK,
Payload: []byte("# get\n"),
}
req := PublishRequest{
ArtifactType: TypeAgentCommit, Environment: "dev", ProjectExternalKey: "proj-a",
LogicalResourceID: "doc_get", SourceEventKind: "agent_commit", SourceEventID: "ev-get",
Payload: []byte("# get\n"), TerminalState: "committed",
}
if _, _, err := mem.Request(ctx, facts, req); err != nil {
t.Fatal(err)
}
if err := pub.Drain(ctx); err != nil {
t.Fatal(err)
}
count.requests = 0
if _, _, err := pub.DegradedPayload(ctx, "dev", "doc_get", "proj-a"); err != nil {
t.Fatal(err)
}
if count.requests != 0 {
t.Fatalf("GET/degraded read wrote Request %d times", count.requests)
}
}
func ptrTime() *time.Time { t := time.Now().UTC(); return &t }
+139
View File
@@ -0,0 +1,139 @@
package artifactvault
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type RecoverMode string
const (
RecoverAsCollection RecoverMode = "recover-as-collection"
RecoverRelink RecoverMode = "relink"
)
type RecoverPlan struct {
Mode RecoverMode
TargetIdentity string
SourceIdentity string
PlanHash string
Conflicts []string
Actor string
HasAdmin bool
HasProjectAuth bool
DependenciesOK bool
ScratchOnly bool
}
func ValidateRecoverTarget(dsn, identity string) error {
low := strings.ToLower(dsn + " " + identity)
for _, ban := range []string{"agent_system_dev", "agent_system_test", "agent_system_main", "env=dev", "env=test", "env=main"} {
if strings.Contains(low, ban) && !strings.Contains(low, "scratch") {
return fmt.Errorf("artifactvault: refuse recover into operational database")
}
}
if !strings.Contains(low, "scratch") {
return fmt.Errorf("artifactvault: recover target must be scratch")
}
return nil
}
func EvaluateRecoverPlan(p RecoverPlan, denied bool) error {
if err := ValidateRecoverTarget(p.TargetIdentity, p.SourceIdentity); err != nil {
return err
}
if !p.ScratchOnly {
return fmt.Errorf("artifactvault: scratch-only required")
}
if !p.HasAdmin {
return fmt.Errorf("artifactvault: operator lacks role-admin")
}
if denied {
return fmt.Errorf("artifactvault: union-deny deletion ledger")
}
if len(p.Conflicts) > 0 && p.Mode == RecoverRelink {
return fmt.Errorf("artifactvault: relink cannot proceed with conflicts")
}
if p.Mode == RecoverRelink && (!p.HasProjectAuth || !p.DependenciesOK) {
return fmt.Errorf("artifactvault: relink authorization or dependency gap")
}
if p.Mode == "" {
p.Mode = RecoverAsCollection
}
if p.PlanHash == "" {
return fmt.Errorf("artifactvault: plan hash required")
}
return nil
}
type RecoveredItem struct {
ArtifactUUID string `json:"artifact_uuid"`
LogicalResourceID string `json:"logical_resource_id"`
PayloadSHA256 string `json:"payload_sha256"`
SkippedReason string `json:"skipped_reason,omitempty"`
}
type RecoveredCollection struct {
Mode RecoverMode `json:"mode"`
PlanHash string `json:"plan_hash"`
DestDir string `json:"dest_dir"`
Items []RecoveredItem `json:"items"`
}
// ApplyRecoverAsCollection writes recovered payloads into a scratch collection directory.
// It never writes operational databases or relinks live heads.
func ApplyRecoverAsCollection(plan RecoverPlan, destDir string, arts []Artifact, payloads map[string][]byte, denied map[string]bool) (*RecoveredCollection, error) {
if err := EvaluateRecoverPlan(plan, false); err != nil {
return nil, err
}
if plan.Mode != RecoverAsCollection && plan.Mode != "" {
return nil, fmt.Errorf("artifactvault: apply only supports recover-as-collection")
}
if !destIsScratch(destDir) {
return nil, fmt.Errorf("artifactvault: collection dest must be scratch")
}
if err := os.MkdirAll(filepath.Join(destDir, "payloads"), 0o700); err != nil {
return nil, err
}
out := &RecoveredCollection{Mode: RecoverAsCollection, PlanHash: plan.PlanHash, DestDir: destDir}
for _, art := range arts {
item := RecoveredItem{ArtifactUUID: art.UUID, LogicalResourceID: art.LogicalResourceID, PayloadSHA256: art.PayloadSHA256}
if denied[art.UUID] || art.PublicationStatus == StatusTombstoned {
item.SkippedReason = "union-deny"
out.Items = append(out.Items, item)
continue
}
body := payloads[art.UUID]
if len(body) == 0 || SHA256Hex(body) != art.PayloadSHA256 {
item.SkippedReason = "sha_mismatch"
out.Items = append(out.Items, item)
continue
}
path := filepath.Join(destDir, "payloads", art.UUID+".md")
if err := os.WriteFile(path, body, 0o600); err != nil {
return nil, err
}
out.Items = append(out.Items, item)
}
raw, err := json.MarshalIndent(out, "", " ")
if err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(destDir, "collection.json"), raw, 0o600); err != nil {
return nil, err
}
return out, nil
}
func destIsScratch(destDir string) bool {
for _, part := range strings.Split(filepath.ToSlash(filepath.Clean(destDir)), "/") {
low := strings.ToLower(part)
if low == "scratch" || strings.HasPrefix(low, "scratch-") || strings.HasPrefix(low, "scratch_") {
return true
}
}
return false
}
+65
View File
@@ -0,0 +1,65 @@
package artifactvault
import (
"os"
"path/filepath"
"testing"
)
func TestRecoverRejectsOperationalDSN(t *testing.T) {
if err := ValidateRecoverTarget("postgres://x/agent_system_dev", "dev"); err == nil {
t.Fatal("dev dsn")
}
if err := ValidateRecoverTarget("postgres://x/fav_scratch", "scratch-1"); err != nil {
t.Fatal(err)
}
}
func TestRecoverPlanGates(t *testing.T) {
p := RecoverPlan{Mode: RecoverAsCollection, TargetIdentity: "scratch-db", SourceIdentity: "vault", PlanHash: "abc", HasAdmin: true, ScratchOnly: true}
if err := EvaluateRecoverPlan(p, false); err != nil {
t.Fatal(err)
}
if err := EvaluateRecoverPlan(p, true); err == nil {
t.Fatal("denied")
}
p.Mode = RecoverRelink
p.Conflicts = []string{"uuid_collision"}
if err := EvaluateRecoverPlan(p, false); err == nil {
t.Fatal("relink+conflict")
}
}
func TestApplyRecoverAsCollectionWritesScratchAndSkipsDenied(t *testing.T) {
dest := filepath.Join(t.TempDir(), "scratch-collection")
plan := RecoverPlan{
Mode: RecoverAsCollection, TargetIdentity: "scratch-db", SourceIdentity: "vault",
PlanHash: "plan-1", HasAdmin: true, ScratchOnly: true,
}
okBody := []byte("# recovered\n")
arts := []Artifact{
{UUID: "art-ok", LogicalResourceID: "doc_ok", PayloadSHA256: SHA256Hex(okBody)},
{UUID: "art-deny", LogicalResourceID: "doc_deny", PayloadSHA256: SHA256Hex([]byte("x")), PublicationStatus: StatusTombstoned},
}
payloads := map[string][]byte{"art-ok": okBody}
out, err := ApplyRecoverAsCollection(plan, dest, arts, payloads, map[string]bool{"art-deny": true})
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dest, "collection.json")); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(dest, "payloads", "art-ok.md"))
if err != nil || string(got) != string(okBody) {
t.Fatalf("payload: %v %q", err, got)
}
if _, err := os.Stat(filepath.Join(dest, "payloads", "art-deny.md")); err == nil {
t.Fatal("denied artifact must not be written")
}
if len(out.Items) != 2 || out.Items[1].SkippedReason != "union-deny" {
t.Fatalf("items=%+v", out.Items)
}
if _, err := ApplyRecoverAsCollection(plan, filepath.Join(t.TempDir(), "live-restore"), arts, payloads, nil); err == nil {
t.Fatal("non-scratch dest must fail")
}
}
+467
View File
@@ -0,0 +1,467 @@
package artifactvault
import (
"context"
"fmt"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Registry interface {
Request(ctx context.Context, facts Facts, req PublishRequest) (*Artifact, *Staging, error)
LoadRequested(ctx context.Context) ([]Artifact, error)
LoadStaging(ctx context.Context, sourceEventID string) (*Staging, error)
MarkPublishing(ctx context.Context, uuid string) error
MarkVerified(ctx context.Context, uuid, manifestSHA string) error
MarkFailed(ctx context.Context, uuid, code string) error
GetByLogical(ctx context.Context, env, logical string) (*Artifact, error)
Tombstone(ctx context.Context, uuid, reason, authority string) error
IsDenied(ctx context.Context, uuid string) bool
RecordReference(ctx context.Context, uuid, projectKey, sha, keyRef string) error
ActiveRefs(ctx context.Context, projectKey, sha string) int
Quarantine(ctx context.Context, reason string, logical string) error
QuarantineCount(ctx context.Context) int
Coverage(ctx context.Context) Coverage
}
type Coverage struct {
Required int
Verified int
Quarantine int
Excluded int
}
type MemoryRegistry struct {
mu sync.Mutex
arts map[string]*Artifact
byKey map[string]string
staging map[string]*Staging
denied map[string]bool
refs map[string]int
quarantine int
required int
}
func NewMemoryRegistry() *MemoryRegistry {
return &MemoryRegistry{
arts: map[string]*Artifact{}, byKey: map[string]string{}, staging: map[string]*Staging{},
denied: map[string]bool{}, refs: map[string]int{},
}
}
func (m *MemoryRegistry) Request(_ context.Context, facts Facts, req PublishRequest) (*Artifact, *Staging, error) {
if err := EvaluateEligibility(facts); err != nil {
return nil, nil, err
}
if err := ValidateEnv(req.Environment); err != nil {
return nil, nil, err
}
sha := SHA256Hex(req.Payload)
key := IdempotencyKey(req, sha)
m.mu.Lock()
defer m.mu.Unlock()
if id, ok := m.byKey[key]; ok {
return m.arts[id], m.staging[req.SourceEventID], nil
}
uuid := "art-" + sha[:16] + "-" + fmt.Sprintf("%d", len(m.arts)+1)
art := &Artifact{
UUID: uuid, IdempotencyKey: key, ArtifactType: req.ArtifactType,
LogicalResourceID: req.LogicalResourceID, Environment: req.Environment,
ProjectID: req.ProjectID, ProjectExternalKey: req.ProjectExternalKey,
SourceEventID: req.SourceEventID, SourceEventKind: req.SourceEventKind,
SourceSnapshotID: req.SourceSnapshotID, SourceMessageID: req.SourceMessageID,
SourceRunID: req.SourceRunID, PayloadSHA256: sha, TerminalState: req.TerminalState,
PolicyVersion: nz(req.PolicyVersion, PolicyVersion), PublicationStatus: StatusRequested,
RetentionClass: nz(req.RetentionClass, "governance"), RequestedAt: time.Now().UTC(),
}
st := &Staging{
UUID: "stg-" + uuid, ProjectID: req.ProjectID, SourceEventID: req.SourceEventID,
PayloadSHA256: sha, Payload: append([]byte(nil), req.Payload...), Classification: ClassificationOK,
CreatedAt: time.Now().UTC(),
}
m.arts[uuid] = art
m.byKey[key] = uuid
m.staging[req.SourceEventID] = st
m.required++
return art, st, nil
}
func (m *MemoryRegistry) LoadRequested(_ context.Context) ([]Artifact, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Artifact, 0)
for _, a := range m.arts {
if a.PublicationStatus == StatusRequested || a.PublicationStatus == StatusRetryableFailed {
out = append(out, *a)
}
}
return out, nil
}
func (m *MemoryRegistry) LoadStaging(_ context.Context, sourceEventID string) (*Staging, error) {
m.mu.Lock()
defer m.mu.Unlock()
st := m.staging[sourceEventID]
if st == nil {
return nil, fmt.Errorf("artifactvault: staging missing")
}
cp := *st
cp.Payload = append([]byte(nil), st.Payload...)
return &cp, nil
}
func (m *MemoryRegistry) MarkPublishing(_ context.Context, uuid string) error {
m.mu.Lock()
defer m.mu.Unlock()
if a := m.arts[uuid]; a != nil {
a.PublicationStatus = StatusPublishing
}
return nil
}
func (m *MemoryRegistry) MarkVerified(_ context.Context, uuid, manifestSHA string) error {
m.mu.Lock()
defer m.mu.Unlock()
if a := m.arts[uuid]; a != nil {
now := time.Now().UTC()
a.PublicationStatus = StatusVerified
a.ManifestSHA256 = manifestSHA
a.VerifiedAt = &now
}
return nil
}
func (m *MemoryRegistry) MarkFailed(_ context.Context, uuid, _ string) error {
m.mu.Lock()
defer m.mu.Unlock()
if a := m.arts[uuid]; a != nil {
a.PublicationStatus = StatusRetryableFailed
}
return nil
}
func (m *MemoryRegistry) GetByLogical(_ context.Context, env, logical string) (*Artifact, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, a := range m.arts {
if a.Environment == env && a.LogicalResourceID == logical {
cp := *a
return &cp, nil
}
}
return nil, fmt.Errorf("artifactvault: not found")
}
func (m *MemoryRegistry) Tombstone(_ context.Context, uuid, _, _ string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.denied[uuid] = true
if a := m.arts[uuid]; a != nil {
now := time.Now().UTC()
a.PublicationStatus = StatusTombstoned
a.TombstonedAt = &now
}
return nil
}
func (m *MemoryRegistry) IsDenied(_ context.Context, uuid string) bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.denied[uuid]
}
func (m *MemoryRegistry) RecordReference(_ context.Context, _, projectKey, sha, _ string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.refs[projectKey+"|"+sha]++
return nil
}
func (m *MemoryRegistry) ActiveRefs(_ context.Context, projectKey, sha string) int {
m.mu.Lock()
defer m.mu.Unlock()
return m.refs[projectKey+"|"+sha]
}
func (m *MemoryRegistry) Quarantine(_ context.Context, _, _ string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.quarantine++
return nil
}
func (m *MemoryRegistry) QuarantineCount(_ context.Context) int {
m.mu.Lock()
defer m.mu.Unlock()
return m.quarantine
}
func (m *MemoryRegistry) Coverage(_ context.Context) Coverage {
m.mu.Lock()
defer m.mu.Unlock()
v := 0
for _, a := range m.arts {
if a.PublicationStatus == StatusVerified {
v++
}
}
return Coverage{Required: m.required, Verified: v, Quarantine: m.quarantine}
}
// PGRegistry persists FAV rows. Request uses a dedicated transaction unless WithTx is used.
type PGRegistry struct {
pool *pgxpool.Pool
}
func NewPGRegistry(pool *pgxpool.Pool) *PGRegistry { return &PGRegistry{pool: pool} }
func (p *PGRegistry) Request(ctx context.Context, facts Facts, req PublishRequest) (*Artifact, *Staging, error) {
if p == nil || p.pool == nil {
return nil, nil, fmt.Errorf("artifactvault: no database")
}
tx, err := p.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return nil, nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
art, st, err := requestTX(ctx, tx, facts, req)
if err != nil {
return nil, nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, nil, err
}
return art, st, nil
}
func requestTX(ctx context.Context, tx pgx.Tx, facts Facts, req PublishRequest) (*Artifact, *Staging, error) {
if err := EvaluateEligibility(facts); err != nil {
return nil, nil, err
}
sha := SHA256Hex(req.Payload)
key := IdempotencyKey(req, sha)
var existing string
err := tx.QueryRow(ctx, `SELECT artifact_uuid FROM final_artifacts WHERE idempotency_key=$1`, key).Scan(&existing)
if err == nil && existing != "" {
st := &Staging{SourceEventID: req.SourceEventID, PayloadSHA256: sha, Payload: req.Payload}
return &Artifact{UUID: existing, IdempotencyKey: key, PublicationStatus: StatusRequested}, st, nil
}
uuid := "art-" + sha[:16]
_, err = tx.Exec(ctx, `
INSERT INTO artifact_payload_staging (staging_uuid, project_id, source_event_id, payload_sha256, encrypted_payload, classification)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (source_event_id, payload_sha256) DO NOTHING
`, "stg-"+uuid, req.ProjectID, req.SourceEventID, sha, req.Payload, ClassificationOK)
if err != nil {
return nil, nil, err
}
_, err = tx.Exec(ctx, `
INSERT INTO final_artifacts (
artifact_uuid, idempotency_key, artifact_type, logical_resource_id, environment, project_id,
project_external_key, source_event_id, source_event_kind, source_snapshot_id, source_message_id,
source_run_id, payload_sha256, terminal_state, policy_version, publication_status, retention_class
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
ON CONFLICT (idempotency_key) DO NOTHING
`, uuid, key, req.ArtifactType, req.LogicalResourceID, req.Environment, req.ProjectID,
req.ProjectExternalKey, req.SourceEventID, req.SourceEventKind, nullInt(req.SourceSnapshotID),
nullInt(req.SourceMessageID), req.SourceRunID, sha, req.TerminalState, nz(req.PolicyVersion, PolicyVersion),
StatusRequested, nz(req.RetentionClass, "governance"))
if err != nil {
return nil, nil, err
}
return &Artifact{UUID: uuid, IdempotencyKey: key, ArtifactType: req.ArtifactType, LogicalResourceID: req.LogicalResourceID,
Environment: req.Environment, ProjectExternalKey: req.ProjectExternalKey, SourceEventID: req.SourceEventID,
PayloadSHA256: sha, PublicationStatus: StatusRequested, TerminalState: req.TerminalState}, &Staging{Payload: req.Payload, PayloadSHA256: sha, SourceEventID: req.SourceEventID}, nil
}
func nullInt(v int64) any {
if v == 0 {
return nil
}
return v
}
func (p *PGRegistry) LoadRequested(ctx context.Context) ([]Artifact, error) {
if p == nil || p.pool == nil {
return nil, fmt.Errorf("artifactvault: no database")
}
rows, err := p.pool.Query(ctx, `
SELECT artifact_uuid, idempotency_key, artifact_type, logical_resource_id, environment,
COALESCE(project_id,0), project_external_key, source_event_id, source_event_kind,
COALESCE(source_snapshot_id,0), COALESCE(source_message_id,0), source_run_id,
payload_sha256, COALESCE(manifest_sha256,''), COALESCE(vault_storage_identity,''),
terminal_state, policy_version, publication_status, retention_class, requested_at
FROM final_artifacts
WHERE publication_status IN ('requested','retryable_failed')
ORDER BY requested_at ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]Artifact, 0)
for rows.Next() {
var a Artifact
if err := rows.Scan(&a.UUID, &a.IdempotencyKey, &a.ArtifactType, &a.LogicalResourceID, &a.Environment,
&a.ProjectID, &a.ProjectExternalKey, &a.SourceEventID, &a.SourceEventKind,
&a.SourceSnapshotID, &a.SourceMessageID, &a.SourceRunID, &a.PayloadSHA256, &a.ManifestSHA256,
&a.VaultStorageIdentity, &a.TerminalState, &a.PolicyVersion, &a.PublicationStatus, &a.RetentionClass, &a.RequestedAt); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (p *PGRegistry) LoadStaging(ctx context.Context, sourceEventID string) (*Staging, error) {
if p == nil || p.pool == nil {
return nil, fmt.Errorf("artifactvault: no database")
}
var st Staging
err := p.pool.QueryRow(ctx, `
SELECT staging_uuid, COALESCE(project_id,0), source_event_id, payload_sha256, encrypted_payload, classification, created_at
FROM artifact_payload_staging WHERE source_event_id=$1 ORDER BY created_at DESC LIMIT 1`, sourceEventID).
Scan(&st.UUID, &st.ProjectID, &st.SourceEventID, &st.PayloadSHA256, &st.Payload, &st.Classification, &st.CreatedAt)
if err != nil {
return nil, err
}
return &st, nil
}
func (p *PGRegistry) MarkPublishing(ctx context.Context, uuid string) error {
if p == nil || p.pool == nil {
return nil
}
_, err := p.pool.Exec(ctx, `UPDATE final_artifacts SET publication_status=$2 WHERE artifact_uuid=$1`, uuid, StatusPublishing)
if err != nil {
return err
}
_, err = p.pool.Exec(ctx, `
INSERT INTO artifact_publication_attempts (artifact_uuid, idempotency_key, attempt_no)
SELECT artifact_uuid, idempotency_key, COALESCE((SELECT MAX(attempt_no) FROM artifact_publication_attempts a WHERE a.artifact_uuid=$1),0)+1
FROM final_artifacts WHERE artifact_uuid=$1`, uuid)
return err
}
func (p *PGRegistry) MarkVerified(ctx context.Context, uuid, manifestSHA string) error {
if p == nil || p.pool == nil {
return nil
}
_, err := p.pool.Exec(ctx, `UPDATE final_artifacts SET publication_status=$2, manifest_sha256=$3, verified_at=now() WHERE artifact_uuid=$1`, uuid, StatusVerified, manifestSHA)
return err
}
func (p *PGRegistry) MarkFailed(ctx context.Context, uuid, code string) error {
if p == nil || p.pool == nil {
return nil
}
_, err := p.pool.Exec(ctx, `UPDATE final_artifacts SET publication_status=$2 WHERE artifact_uuid=$1`, uuid, StatusRetryableFailed)
if err != nil {
return err
}
_, _ = p.pool.Exec(ctx, `
UPDATE artifact_publication_attempts SET error_code=$2
WHERE artifact_uuid=$1 AND attempt_no=(SELECT MAX(attempt_no) FROM artifact_publication_attempts WHERE artifact_uuid=$1)`, uuid, code)
return nil
}
func (p *PGRegistry) GetByLogical(ctx context.Context, env, logical string) (*Artifact, error) {
if p == nil || p.pool == nil {
return nil, fmt.Errorf("artifactvault: no database")
}
var a Artifact
err := p.pool.QueryRow(ctx, `
SELECT artifact_uuid, idempotency_key, artifact_type, logical_resource_id, environment,
COALESCE(project_id,0), project_external_key, source_event_id, source_event_kind,
COALESCE(source_snapshot_id,0), COALESCE(source_message_id,0), source_run_id,
payload_sha256, COALESCE(manifest_sha256,''), COALESCE(vault_storage_identity,''),
terminal_state, policy_version, publication_status
FROM final_artifacts
WHERE environment=$1 AND logical_resource_id=$2 AND publication_status='verified'
ORDER BY verified_at DESC NULLS LAST LIMIT 1`, env, logical).
Scan(&a.UUID, &a.IdempotencyKey, &a.ArtifactType, &a.LogicalResourceID, &a.Environment,
&a.ProjectID, &a.ProjectExternalKey, &a.SourceEventID, &a.SourceEventKind,
&a.SourceSnapshotID, &a.SourceMessageID, &a.SourceRunID, &a.PayloadSHA256, &a.ManifestSHA256,
&a.VaultStorageIdentity, &a.TerminalState, &a.PolicyVersion, &a.PublicationStatus)
if err != nil {
return nil, err
}
return &a, nil
}
func (p *PGRegistry) Tombstone(ctx context.Context, uuid, reason, authority string) error {
if p == nil || p.pool == nil {
return nil
}
_, err := p.pool.Exec(ctx, `UPDATE final_artifacts SET publication_status=$2, tombstoned_at=now() WHERE artifact_uuid=$1`, uuid, StatusTombstoned)
if err != nil {
return err
}
_, err = p.pool.Exec(ctx, `INSERT INTO artifact_deletion_ledger (artifact_uuid, reason, authority, effective_at) VALUES ($1,$2,$3,now())`, uuid, reason, authority)
return err
}
func (p *PGRegistry) IsDenied(ctx context.Context, uuid string) bool {
if p == nil || p.pool == nil {
return false
}
var n int
_ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM artifact_deletion_ledger WHERE artifact_uuid=$1`, uuid).Scan(&n)
return n > 0
}
func (p *PGRegistry) RecordReference(ctx context.Context, uuid, projectKey, sha, keyRef string) error {
if p == nil || p.pool == nil {
return nil
}
_, err := p.pool.Exec(ctx, `
INSERT INTO artifact_blob_references (artifact_uuid, project_scope, payload_sha256, envelope_key_ref, reference_status)
VALUES ($1,$2,$3,$4,'active')
ON CONFLICT (artifact_uuid, payload_sha256) DO NOTHING`, uuid, projectKey, sha, keyRef)
return err
}
func (p *PGRegistry) ActiveRefs(ctx context.Context, projectKey, sha string) int {
if p == nil || p.pool == nil {
return 0
}
var n int
_ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM artifact_blob_references WHERE project_scope=$1 AND payload_sha256=$2 AND reference_status='active'`, projectKey, sha).Scan(&n)
return n
}
func (p *PGRegistry) Quarantine(ctx context.Context, reason, logical string) error {
if p == nil || p.pool == nil {
return nil
}
_, err := p.pool.Exec(ctx, `INSERT INTO artifact_quarantine (logical_resource_id, reason) VALUES ($1,$2)`, logical, reason)
return err
}
func (p *PGRegistry) QuarantineCount(ctx context.Context) int {
if p == nil || p.pool == nil {
return 0
}
var n int
_ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM artifact_quarantine`).Scan(&n)
return n
}
func (p *PGRegistry) Coverage(ctx context.Context) Coverage {
var c Coverage
if p == nil || p.pool == nil {
return c
}
_ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM final_artifacts`).Scan(&c.Required)
_ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM final_artifacts WHERE publication_status='verified'`).Scan(&c.Verified)
_ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM artifact_quarantine`).Scan(&c.Quarantine)
_ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM artifact_backfill_exclusions WHERE expires_at > now()`).Scan(&c.Excluded)
return c
}
// RequestWithTx writes staging + unpublished outbox inside an existing domain transaction.
func RequestWithTx(ctx context.Context, tx pgx.Tx, facts Facts, req PublishRequest) (*Artifact, *Staging, error) {
return requestTX(ctx, tx, facts, req)
}
+208
View File
@@ -0,0 +1,208 @@
package artifactvault
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
)
const (
SchemaMajorMinor = "1.0"
PublisherVersion = "fav-publisher/1.0"
PolicyVersion = "fav-eligibility/1.0"
ClassificationOK = "ok"
ClassificationSecret = "secret"
ClassificationRestricted = "restricted"
ClassificationFailed = "classification_failed"
TypeAgentCommit = "agent_commit"
TypeSealedVersion = "sealed_version"
TypeApprovedDocument = "approved_document"
TypeFreeformPublish = "freeform_publish"
TypeAssistantReply = "assistant_reply"
StatusRequested = "requested"
StatusPublishing = "publishing"
StatusVerified = "verified"
StatusRetryableFailed = "retryable_failed"
StatusTombstoned = "tombstoned"
StatusPurged = "purged"
StatusQuarantine = "quarantine"
RefActive = "active"
RefTombstoned = "tombstoned"
RefPurged = "purged"
)
// Facts are the authoritative database facts used for eligibility. HTTP handlers must not invent them.
type Facts struct {
ArtifactType string
CandidateStatus string
OutputSnapshotID int64
ChangeKind string
ChangeToSnapshotID int64
VersionStatus string
SealedAt *time.Time
HeadSnapshotID int64
DocState string
ApprovedAt *time.Time
ReviewSnapshotID int64
HasImmutableSnapshot bool
ExplicitPublish bool
ChatTurnStatus string
CompletedAt *time.Time
AssistantMessageID int64
ExportPolicy string
ExplicitPin bool
PinAfterCompleted bool
Payload []byte
Classification string
}
type PublishRequest struct {
ArtifactType string
Environment string
ProjectID int64
ProjectExternalKey string
LogicalResourceID string
SourceEventKind string
SourceEventID string
SourceSnapshotID int64
SourceMessageID int64
SourceRunID string
Payload []byte
TerminalState string
PolicyVersion string
Classification string
Sensitivity string
RetentionClass string
}
type Artifact struct {
UUID string
IdempotencyKey string
ArtifactType string
LogicalResourceID string
Environment string
ProjectID int64
ProjectExternalKey string
SourceEventID string
SourceEventKind string
SourceSnapshotID int64
SourceMessageID int64
SourceRunID string
PayloadSHA256 string
ManifestSHA256 string
VaultStorageIdentity string
TerminalState string
PolicyVersion string
PublicationStatus string
RetentionClass string
RequestedAt time.Time
VerifiedAt *time.Time
TombstonedAt *time.Time
}
type Staging struct {
UUID string
ProjectID int64
SourceEventID string
PayloadSHA256 string
Payload []byte
Classification string
CreatedAt time.Time
}
type Manifest struct {
ArtifactSchemaVersion string `json:"artifact_schema_version"`
ArtifactUUID string `json:"artifact_uuid"`
ArtifactType string `json:"artifact_type"`
LogicalResourceID string `json:"logical_resource_id"`
Environment string `json:"environment"`
ProjectID int64 `json:"project_id,omitempty"`
ProjectExternalKey string `json:"project_external_key"`
SourceEventID string `json:"source_event_id"`
SourceEventKind string `json:"source_event_kind,omitempty"`
SourceSnapshotID *int64 `json:"source_snapshot_id"`
SourceMessageID *int64 `json:"source_message_id"`
SourceRunID string `json:"source_run_id,omitempty"`
PayloadMembers []PayloadMember `json:"payload_members"`
TerminalEvidence TerminalEvidence `json:"terminal_evidence"`
Lineage map[string]any `json:"lineage,omitempty"`
RetentionClass string `json:"retention_class,omitempty"`
Sensitivity string `json:"sensitivity,omitempty"`
EncryptionKeyRef string `json:"encryption_key_ref,omitempty"`
VaultStorageIdentity string `json:"vault_storage_identity"`
PublisherVersion string `json:"publisher_version,omitempty"`
PublishedAt string `json:"published_at,omitempty"`
ManifestSHA256 string `json:"manifest_sha256"`
}
type PayloadMember struct {
Name string `json:"name"`
MediaType string `json:"media_type"`
ByteSize int `json:"byte_size"`
SHA256 string `json:"sha256"`
Required bool `json:"required"`
}
type TerminalEvidence struct {
State string `json:"state"`
DecidedAt string `json:"decided_at"`
ActorRef string `json:"actor_ref,omitempty"`
PolicyVersion string `json:"policy_version"`
}
type CommitMarker struct {
ArtifactUUID string `json:"artifact_uuid"`
ManifestSHA256 string `json:"manifest_sha256"`
MemberSHAs []string `json:"member_shas"`
VaultStorageIdentity string `json:"vault_storage_identity"`
CommittedAt string `json:"committed_at"`
PublisherVersion string `json:"publisher_version"`
}
type Tombstone struct {
ArtifactUUID string `json:"artifact_uuid"`
EventUUID string `json:"event_uuid"`
Reason string `json:"reason"`
Authority string `json:"authority,omitempty"`
EffectiveAt string `json:"effective_at"`
AuditRef string `json:"audit_ref,omitempty"`
}
func SHA256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func IdempotencyKey(req PublishRequest, payloadSHA string) string {
return strings.Join([]string{
req.ArtifactType,
req.Environment,
req.ProjectExternalKey,
req.LogicalResourceID,
req.SourceEventKind,
req.SourceEventID,
payloadSHA,
nz(req.PolicyVersion, PolicyVersion),
}, "|")
}
func nz(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
func ValidateEnv(env string) error {
switch env {
case "dev", "test", "main":
return nil
default:
return fmt.Errorf("artifactvault: invalid environment %q", env)
}
}
+101
View File
@@ -2,6 +2,7 @@ package db
import (
"context"
"net/url"
"os"
"path/filepath"
"strings"
@@ -9,6 +10,7 @@ import (
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/pressly/goose/v3"
)
func TestMigrationGate_ExpectedFilesMatchAppliedVersion(t *testing.T) {
@@ -78,3 +80,102 @@ func TestMigration032_AllowsMigrationRepairKinds(t *testing.T) {
t.Fatalf("00032 must allow migration_repair on both constraints, got %s", found)
}
}
func TestGooseM2_00034to00036_DownThenUp(t *testing.T) {
if testing.Short() {
t.Skip("goose down is not part of -short")
}
dsn := SkipIfNoTestDatabaseURL(t)
if strings.Contains(dsn, "5433") || strings.Contains(dsn, "agent_system_dev") ||
(strings.Contains(dsn, "5434") && strings.Contains(dsn, "agent_system_test")) ||
strings.Contains(dsn, "5435") {
t.Fatal("TEST_DATABASE_URL points at a shared env port; refuse agent_system_dev/test/main")
}
migDir := ResolveMigrationsDir()
files, err := ListMigrationFiles(migDir)
if err != nil {
t.Fatal(err)
}
want := []string{
"00034_2026-09-14-workspace-lease-fencing.sql",
"00035_2026-09-14-durable-event-delivery.sql",
"00036_2026-09-14-final-artifact-vault.sql",
}
joined := strings.Join(files, "\n")
for _, name := range want {
if !strings.Contains(joined, name) {
t.Fatalf("missing %s", name)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
admin, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(admin.Close)
const isolated = "agent_rel_m2_goose"
_, _ = admin.Exec(ctx, `DROP DATABASE IF EXISTS `+isolated)
if _, err := admin.Exec(ctx, `CREATE DATABASE `+isolated); err != nil {
t.Fatalf("create isolated db: %v", err)
}
t.Cleanup(func() {
_, _ = admin.Exec(context.Background(), `DROP DATABASE IF EXISTS `+isolated)
})
m2dsn := rewriteDatabaseName(dsn, isolated)
pool, err := pgxpool.New(ctx, m2dsn)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
if err := RunMigrations(ctx, pool, migDir); err != nil {
t.Fatalf("up: %v", err)
}
ver, err := CurrentVersion(ctx, pool)
if err != nil || ver < 36 {
t.Fatalf("version after up: %d %v", ver, err)
}
sqlDB := OpenSQLDB(pool)
defer sqlDB.Close()
if err := goose.SetDialect("postgres"); err != nil {
t.Fatal(err)
}
goose.SetTableName(schemaTable)
if err := goose.DownToContext(ctx, sqlDB, migDir, 33); err != nil {
t.Fatalf("down to 33: %v", err)
}
for _, table := range []string{"event_envelopes", "event_deliveries", "event_dead_letters", "final_artifacts"} {
var n int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_name=$1`, table).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("%s still present after down", table)
}
}
var col int
if err := pool.QueryRow(ctx, `
SELECT COUNT(*) FROM information_schema.columns
WHERE table_name='document_records' AND column_name='lease_fencing_token'
`).Scan(&col); err != nil {
t.Fatal(err)
}
if col != 0 {
t.Fatal("lease_fencing_token must drop with 00034 Down")
}
if err := RunMigrations(ctx, pool, migDir); err != nil {
t.Fatalf("up after down: %v", err)
}
}
func rewriteDatabaseName(dsn, name string) string {
if u, err := url.Parse(dsn); err == nil && u.Scheme != "" {
u.Path = "/" + name
return u.String()
}
return dsn
}
+35 -3
View File
@@ -2,8 +2,11 @@ package docgov
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"path/filepath"
"strings"
"time"
@@ -29,6 +32,7 @@ type ContentSaveRequest struct {
ActorName string
MarkdownBody string
ExpectedSHA256 string // 乐观并发:非空时必须匹配当前 ContentSHA256
FencingToken string
}
// ContentSaveResult 正文保存结果
@@ -101,6 +105,7 @@ func (e *GovernanceEngine) AcquireLease(ctx context.Context, req LeaseAcquireReq
}
doc.LeaseExpiresAt = &expires
doc.LeaseHeartbeatAt = &now
doc.LeaseFencingToken = newLeaseFencingToken()
doc.UpdatedAt = now
e.persistLeaseLocked(ctx, doc)
@@ -344,6 +349,11 @@ func (e *GovernanceEngine) SaveDocumentContent(ctx context.Context, req ContentS
if req.ActorID <= 0 {
holderID = "human_anon"
}
if req.FencingToken != "" {
if err := e.AssertLeaseFencing(ctx, req.DocID, req.FencingToken); err != nil {
return nil, err
}
}
doc, err := e.AcquireLease(ctx, LeaseAcquireRequest{
DocID: req.DocID,
@@ -409,6 +419,28 @@ func (e *GovernanceEngine) SaveDocumentContent(ctx context.Context, req ContentS
return &ContentSaveResult{Doc: doc, ContentSHA256: shaStr, WorkDir: workDir}, nil
}
// AssertLeaseFencing rejects writers holding a superseded token.
func (e *GovernanceEngine) AssertLeaseFencing(ctx context.Context, docID, token string) error {
e.mu.Lock()
defer e.mu.Unlock()
doc, err := e.getDocLocked(ctx, docID)
if err != nil {
return err
}
if token == "" || doc.LeaseFencingToken == "" || token != doc.LeaseFencingToken {
return ErrStaleFencing
}
return nil
}
func newLeaseFencingToken() string {
var b [16]byte
if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
func (e *GovernanceEngine) getDocLocked(ctx context.Context, docID string) (*DocumentRecord, error) {
doc, exists := e.memDocs[docID]
if !exists {
@@ -428,10 +460,10 @@ func (e *GovernanceEngine) persistLeaseLocked(ctx context.Context, doc *Document
_, _ = e.pool.Exec(ctx, `
UPDATE document_records
SET lease_holder_type = $1, lease_holder_id = $2, lease_work_dir = $3,
lease_expires_at = $4, lease_heartbeat_at = $5, updated_at = $6
WHERE doc_id = $7
lease_expires_at = $4, lease_heartbeat_at = $5, lease_fencing_token = $6, updated_at = $7
WHERE doc_id = $8
`, string(doc.LeaseHolderType), doc.LeaseHolderID, doc.LeaseWorkDir,
doc.LeaseExpiresAt, doc.LeaseHeartbeatAt, doc.UpdatedAt, doc.DocID)
doc.LeaseExpiresAt, doc.LeaseHeartbeatAt, doc.LeaseFencingToken, doc.UpdatedAt, doc.DocID)
}
func (e *GovernanceEngine) appendAuditLocked(doc *DocumentRecord, action string, actorID int64, actorName string, actorRole UserRole, details map[string]any) {
+32
View File
@@ -86,6 +86,38 @@ func TestSameHolderRenewLease(t *testing.T) {
}
}
func TestStaleFencingRejectedAfterRenew(t *testing.T) {
engine := docgov.NewGovernanceEngine(nil)
ctx := context.Background()
docID := "doc_fence_001"
_, _ = engine.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", "", 1, "a", 2, "b", 3, "c")
first, err := engine.AcquireLease(ctx, docgov.LeaseAcquireRequest{
DocID: docID, HolderType: docgov.LeaseHolderHuman, HolderID: "1", TTL: time.Minute,
})
if err != nil {
t.Fatal(err)
}
old := first.LeaseFencingToken
if old == "" {
t.Fatal("expected fencing token")
}
second, err := engine.AcquireLease(ctx, docgov.LeaseAcquireRequest{
DocID: docID, HolderType: docgov.LeaseHolderHuman, HolderID: "1", TTL: time.Minute,
})
if err != nil {
t.Fatal(err)
}
if second.LeaseFencingToken == old {
t.Fatal("renew must rotate fencing token")
}
if err := engine.AssertLeaseFencing(ctx, docID, old); err != docgov.ErrStaleFencing {
t.Fatalf("old writer: %v", err)
}
if err := engine.AssertLeaseFencing(ctx, docID, second.LeaseFencingToken); err != nil {
t.Fatal(err)
}
}
func TestAssertHumanCanEdit_ChecksDutyBeforeLease(t *testing.T) {
engine := docgov.NewGovernanceEngine(nil)
ctx := context.Background()
+8 -5
View File
@@ -18,6 +18,7 @@ var (
ErrProposalAlreadyDecided = errors.New("docgov: change proposal has already been decided")
ErrChangeProposalPending = errors.New("docgov: document is claimed and has pending change proposal waiting for author approval")
ErrDocumentBusy = errors.New("docgov: document edit lease is held by another writer")
ErrStaleFencing = errors.New("docgov: stale lease fencing token")
ErrLeaseExpired = errors.New("docgov: document edit lease has expired")
ErrLeaseNotHeld = errors.New("docgov: caller does not hold the document edit lease")
ErrForceEditNotAllowed = errors.New("docgov: force_direct_edit requires an approved_by_author proposal and unlocked drafting document")
@@ -183,11 +184,12 @@ type DocumentRecord struct {
ApprovedAt *time.Time `json:"approved_at"`
// 编辑租约 (运行时互斥,正交于 IsLocked)
LeaseHolderType LeaseHolderType `json:"lease_holder_type,omitempty"`
LeaseHolderID string `json:"lease_holder_id,omitempty"`
LeaseWorkDir string `json:"lease_work_dir,omitempty"`
LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"`
LeaseHeartbeatAt *time.Time `json:"lease_heartbeat_at,omitempty"`
LeaseHolderType LeaseHolderType `json:"lease_holder_type,omitempty"`
LeaseHolderID string `json:"lease_holder_id,omitempty"`
LeaseWorkDir string `json:"lease_work_dir,omitempty"`
LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"`
LeaseHeartbeatAt *time.Time `json:"lease_heartbeat_at,omitempty"`
LeaseFencingToken string `json:"lease_fencing_token,omitempty"`
Config DocumentConfig `json:"config"`
CreatedAt time.Time `json:"created_at"`
@@ -212,6 +214,7 @@ func (d *DocumentRecord) ClearLease() {
d.LeaseWorkDir = ""
d.LeaseExpiresAt = nil
d.LeaseHeartbeatAt = nil
d.LeaseFencingToken = ""
}
// CommentType 批注类型
+12 -1
View File
@@ -58,8 +58,12 @@ type Service struct {
Rev *docrevision.Service
RepoRoot string
Authorize Authorizer
// FavDegradedRead is an extension point only. M1 keeps it false.
FavDegradedRead bool
Vault VaultReader
}
type VaultReader interface {
ReadVerified(ctx context.Context, projectID int64, logicalID string) (markdown string, sha string, err error)
}
func FavDegradedReadEnabled() bool {
@@ -101,6 +105,13 @@ func (s *Service) Get(ctx context.Context, p Principal, ref DocRef) (*ContentVie
}
}
if s.FavDegradedRead && s.Vault != nil {
md, sha, verr := s.Vault.ReadVerified(ctx, rec.ProjectID, rec.DocID)
if verr != nil {
return nil, verr
}
return s.viewFromLedger(ctx, rec, md, sha), nil
}
if s.FavDegradedRead {
return nil, ErrFavNotEnabled
}
+38
View File
@@ -2,6 +2,7 @@ package docquery
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
@@ -165,3 +166,40 @@ func TestFavDegradedReadDefaultOff(t *testing.T) {
t.Fatal("FAV_DEGRADED_READ must default false")
}
}
type countingVault struct {
calls int
err error
}
func (v *countingVault) ReadVerified(context.Context, int64, string) (string, string, error) {
v.calls++
if v.err != nil {
return "", "", v.err
}
return "# from vault\n", "abc", nil
}
func TestFavDegradedReadOnlyAfterAuthorize(t *testing.T) {
ctx := context.Background()
gov := docgov.NewGovernanceEngine(nil)
_, _ = gov.CreateOrGetDocument(ctx, "doc_fav", 9, "intake", "t", "V0.5_feasibility", "", 1, "A", 2, "R", 3, "P")
vault := &countingVault{err: errors.New("artifactvault: union-deny")}
svc := &Service{
Gov: gov, FavDegradedRead: true, Vault: vault,
Authorize: func(context.Context, Principal, *docgov.DocumentRecord) error { return ErrClosed },
}
if _, err := svc.Get(ctx, Principal{UserID: 1}, DocRef{DocID: "doc_fav"}); err != ErrClosed {
t.Fatalf("auth must win: %v", err)
}
if vault.calls != 0 {
t.Fatal("vault must not run before authorization")
}
svc.Authorize = func(context.Context, Principal, *docgov.DocumentRecord) error { return nil }
if _, err := svc.Get(ctx, Principal{UserID: 1}, DocRef{DocID: "doc_fav"}); err == nil {
t.Fatal("union-deny must fail closed")
}
if vault.calls != 1 {
t.Fatalf("calls=%d", vault.calls)
}
}
@@ -185,15 +185,15 @@ func TestDocumentCommitTwoPoolsAndFourSessionsPG(t *testing.T) {
t.Fatalf("two pools same etag: wins=%d errs=%v", wins, errs)
}
// Four sessions: two pools × two sequential idempotent retries of the winner key must replay.
winnerKey := ""
// Same idempotency key + same digest must replay across pools.
// A different digest with the same key is ErrIdempotencyConflict (covered in unit tests).
winnerIdx := 1
if errs[0] == nil {
winnerKey = fmt.Sprintf("2p-%s-0", suffix)
} else {
winnerKey = fmt.Sprintf("2p-%s-1", suffix)
winnerIdx = 0
}
winnerKey := fmt.Sprintf("2p-%s-%d", suffix, winnerIdx)
replay := docrevision.HumanRevisionRequest{
DocID: docID, Markdown: "# ignored-if-same-key\n",
DocID: docID, Markdown: fmt.Sprintf("# base\n\npool %d\n", winnerIdx),
IdempotencyKey: winnerKey, ActorUserID: 1, LifecycleState: "drafting",
ProjectID: projectID, WorkDir: "ws/" + docID,
}
@@ -212,7 +212,8 @@ func TestDocumentCommitTwoPoolsAndFourSessionsPG(t *testing.T) {
var extra sync.WaitGroup
extraWins := 0
var extraMu sync.Mutex
st2, err := seed.GetRevisionState(ctx, docID)
fresh := docrevision.NewService(poolA)
st2, err := fresh.GetRevisionState(ctx, docID)
if err != nil {
t.Fatal(err)
}
+1 -3
View File
@@ -680,9 +680,7 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
}
if _, joined := uow.ScopeFrom(ctx); !joined && s.Notify && s.OnOutbox != nil {
_ = s.OnOutbox(ctx, "document.agent_version_committed", map[string]any{
"doc_id": c.DocID, "version_no": newVer.VersionNo, "risk": rep.RiskLevel, "candidate_id": c.ID,
})
// document.agent_version_committed is owned by events.EventSink (U6).
for _, imp := range rep.DocumentImpacts {
_ = s.OnOutbox(ctx, "document.downstream_impact_detected", map[string]any{
"doc_id": c.DocID, "impacted_doc_id": imp.DocID, "version_no": newVer.VersionNo,
+203
View File
@@ -0,0 +1,203 @@
package events
import (
"context"
"crypto/rand"
"encoding/hex"
"io"
"sync"
"time"
)
const MaxAttempts = 12
// MemoryBus is a process-local outbox/delivery/inbox used for dispatcher
// characterization. PostgreSQL SKIP LOCKED is the production claim primitive.
type MemoryBus struct {
mu sync.Mutex
envelopes map[string]Envelope
order []string
deliveries map[string]map[string]*Delivery // consumer -> eventID
heads map[string]int64 // aggregate -> last applied version
side map[string]int // consumer:event visible side effects
consumerProject map[string]int64
}
func NewMemoryBus() *MemoryBus {
return &MemoryBus{
envelopes: map[string]Envelope{},
deliveries: map[string]map[string]*Delivery{},
heads: map[string]int64{},
side: map[string]int{},
consumerProject: map[string]int64{},
}
}
func (b *MemoryBus) BindConsumer(consumer string, projectID int64) {
b.mu.Lock()
defer b.mu.Unlock()
b.consumerProject[consumer] = projectID
}
func (b *MemoryBus) Append(ctx context.Context, env Envelope, consumers []string) error {
_ = ctx
if err := ValidateEnvelope(env); err != nil {
b.quarantine(env, consumers)
return err
}
b.mu.Lock()
defer b.mu.Unlock()
if _, ok := b.envelopes[env.EventID]; ok {
return nil
}
b.envelopes[env.EventID] = env
b.order = append(b.order, env.EventID)
for _, c := range consumers {
if b.deliveries[c] == nil {
b.deliveries[c] = map[string]*Delivery{}
}
b.deliveries[c][env.EventID] = &Delivery{
EventID: env.EventID, Consumer: c, Status: "pending", NextAttemptAt: time.Now(),
}
}
return nil
}
func (b *MemoryBus) quarantine(env Envelope, consumers []string) {
b.mu.Lock()
defer b.mu.Unlock()
b.envelopes[env.EventID] = env
for _, c := range consumers {
if b.deliveries[c] == nil {
b.deliveries[c] = map[string]*Delivery{}
}
b.deliveries[c][env.EventID] = &Delivery{EventID: env.EventID, Consumer: c, Status: "quarantine"}
}
}
func (b *MemoryBus) Claim(consumer, worker string, n int, now time.Time) ([]*Delivery, error) {
b.mu.Lock()
defer b.mu.Unlock()
rows := b.deliveries[consumer]
if rows == nil {
return nil, ErrUnknownConsumer
}
out := []*Delivery{}
for _, id := range b.order {
if len(out) >= n {
break
}
d := rows[id]
if d == nil {
continue
}
if d.Status == "acked" || d.Status == "dead" || d.Status == "quarantine" {
continue
}
if d.Status == "leased" && d.LeaseUntil.After(now) {
continue
}
env := b.envelopes[id]
head := b.heads[env.AggregateID]
if env.AggregateVersion != 0 && env.AggregateVersion > head+1 {
continue // gap: do not skip missing versions
}
token, err := randomToken()
if err != nil {
return nil, err
}
d.Status = "leased"
d.LeaseOwner = worker
d.FencingToken = token
d.LeaseUntil = now.Add(2 * time.Second)
d.Attempt++
cp := *d
out = append(out, &cp)
}
return out, nil
}
func (b *MemoryBus) Apply(consumer string, claimed *Delivery, fail error) error {
b.mu.Lock()
defer b.mu.Unlock()
d := b.deliveries[consumer][claimed.EventID]
if d == nil || d.FencingToken != claimed.FencingToken || d.LeaseOwner != claimed.LeaseOwner {
return ErrStaleFencing
}
env := b.envelopes[claimed.EventID]
if pid := b.consumerProject[consumer]; pid != 0 && env.ProjectID != 0 && env.ProjectID != pid {
d.Status = "quarantine"
d.LeaseOwner = ""
d.FencingToken = ""
return ErrQuarantine
}
if fail == ErrAuthFailure || fail == ErrIntegrityFailure {
d.Status = "dead"
return fail
}
if fail != nil {
if d.Attempt >= MaxAttempts {
d.Status = "dead"
return fail
}
d.Status = "pending"
d.LeaseOwner = ""
d.FencingToken = ""
d.NextAttemptAt = time.Now().Add(time.Millisecond)
return fail
}
head := b.heads[env.AggregateID]
if env.AggregateVersion != 0 && env.AggregateVersion > head+1 {
d.Status = "pending"
d.LeaseOwner = ""
d.FencingToken = ""
return ErrGapWait
}
key := consumer + ":" + claimed.EventID
if !d.Inbox {
b.side[key]++
d.Inbox = true
if env.AggregateVersion > head {
b.heads[env.AggregateID] = env.AggregateVersion
}
}
d.Status = "acked"
d.LeaseOwner = ""
return nil
}
func (b *MemoryBus) SideEffects(consumer, eventID string) int {
b.mu.Lock()
defer b.mu.Unlock()
return b.side[consumer+":"+eventID]
}
func (b *MemoryBus) Status(consumer, eventID string) string {
b.mu.Lock()
defer b.mu.Unlock()
d := b.deliveries[consumer][eventID]
if d == nil {
return ""
}
return d.Status
}
func (b *MemoryBus) Owners(consumer string) map[string]string {
b.mu.Lock()
defer b.mu.Unlock()
out := map[string]string{}
for id, d := range b.deliveries[consumer] {
if d.Status == "leased" {
out[id] = d.LeaseOwner
}
}
return out
}
func randomToken() (string, error) {
var b [8]byte
if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
+193
View File
@@ -0,0 +1,193 @@
package events
import (
"context"
"sync"
"testing"
"time"
)
func sample(id string, ver int64, payload string) Envelope {
body := []byte(payload)
return Envelope{
EventID: id, EventName: "document.agent_version_committed", SchemaVersion: EnvelopeSchema,
Environment: "dev", ProjectID: 9, AggregateType: "document", AggregateID: "doc-a",
AggregateVersion: ver, ActorRef: "user:1", IdempotencyKey: id, PayloadDigest: Digest(body),
Producer: "docflow", OccurredAt: time.Now().UTC(), Payload: body,
}
}
func TestTwoAndFourWorkersNoDoubleOwner(t *testing.T) {
for _, n := range []int{2, 4} {
bus := NewMemoryBus()
if err := bus.Append(context.Background(), sample("e1", 1, "v1"), []string{"proj", "notify"}); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
now := time.Now()
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, _ = bus.Claim("proj", "w"+string(rune('a'+i)), 8, now)
}(i)
}
wg.Wait()
if n := len(bus.Owners("proj")); n > 1 {
t.Fatalf("workers=%d double owner %#v", n, bus.Owners("proj"))
}
}
}
func TestCrashBeforeAfterAck(t *testing.T) {
bus := NewMemoryBus()
_ = bus.Append(context.Background(), sample("e2", 1, "body"), []string{"proj"})
now := time.Now()
first, err := bus.Claim("proj", "w1", 1, now)
if err != nil || len(first) != 1 {
t.Fatalf("claim: %v %#v", err, first)
}
// crash-before-side-effect: drop lease by waiting until expiry
later := now.Add(3 * time.Second)
second, err := bus.Claim("proj", "w2", 1, later)
if err != nil || len(second) != 1 {
t.Fatalf("reclaim: %v", err)
}
if err := bus.Apply("proj", first[0], nil); err != ErrStaleFencing {
t.Fatalf("expired worker ack: %v", err)
}
if err := bus.Apply("proj", second[0], nil); err != nil {
t.Fatal(err)
}
if bus.SideEffects("proj", "e2") != 1 {
t.Fatalf("side=%d", bus.SideEffects("proj", "e2"))
}
// crash-after-side-effect/ack: replay is inbox-deduped
if err := bus.Apply("proj", second[0], nil); err != ErrStaleFencing && bus.SideEffects("proj", "e2") != 1 {
t.Fatalf("replay: %v side=%d", err, bus.SideEffects("proj", "e2"))
}
}
func TestGapDoesNotSkipVersion(t *testing.T) {
bus := NewMemoryBus()
_ = bus.Append(context.Background(), sample("e-v2", 2, "two"), []string{"proj"})
now := time.Now()
got, _ := bus.Claim("proj", "w", 4, now)
if len(got) != 0 {
t.Fatalf("must wait for missing version 1, claimed %d", len(got))
}
_ = bus.Append(context.Background(), sample("e-v1", 1, "one"), []string{"proj"})
got, _ = bus.Claim("proj", "w", 4, now)
if len(got) != 1 || got[0].EventID != "e-v1" {
t.Fatalf("expected v1 first, got %#v", got)
}
if err := bus.Apply("proj", got[0], nil); err != nil {
t.Fatal(err)
}
got, _ = bus.Claim("proj", "w", 4, now)
if len(got) != 1 || got[0].EventID != "e-v2" {
t.Fatalf("expected v2 next, got %#v", got)
}
}
func TestQuarantineBadDigestForgedProducerCrossProject(t *testing.T) {
bus := NewMemoryBus()
bus.BindConsumer("proj", 9)
bad := sample("bad-digest", 1, "x")
bad.PayloadDigest = "deadbeef"
if err := bus.Append(context.Background(), bad, []string{"proj"}); err != ErrIntegrityFailure {
t.Fatalf("digest: %v", err)
}
if bus.Status("proj", "bad-digest") != "quarantine" {
t.Fatalf("status=%s", bus.Status("proj", "bad-digest"))
}
forged := sample("forged", 1, "x")
forged.Producer = "forged"
if err := bus.Append(context.Background(), forged, []string{"proj"}); err != ErrIntegrityFailure {
t.Fatalf("producer: %v", err)
}
cross := sample("cross", 1, "x")
cross.ProjectID = 1
cross.Payload = []byte("other-project")
cross.PayloadDigest = Digest(cross.Payload)
if cross.ProjectID == 9 {
t.Fatal("fixture")
}
if err := bus.Append(context.Background(), cross, []string{"proj"}); err != nil {
t.Fatal(err)
}
now := time.Now()
got, err := bus.Claim("proj", "w", 4, now)
if err != nil || len(got) != 1 || got[0].EventID != "cross" {
t.Fatalf("claim cross: %v %#v", err, got)
}
if err := bus.Apply("proj", got[0], nil); err != ErrQuarantine {
t.Fatalf("cross-project: %v", err)
}
if bus.Status("proj", "cross") != "quarantine" {
t.Fatalf("status=%s", bus.Status("proj", "cross"))
}
}
func TestAttempt12GoesDeadLetter(t *testing.T) {
bus := NewMemoryBus()
_ = bus.Append(context.Background(), sample("retry", 1, "body"), []string{"proj"})
now := time.Now()
transient := errTransient{}
for i := 0; i < MaxAttempts; i++ {
got, err := bus.Claim("proj", "w", 1, now.Add(time.Duration(i)*time.Second))
if err != nil || len(got) != 1 {
t.Fatalf("claim %d: %v %#v", i, err, got)
}
applyErr := bus.Apply("proj", got[0], transient)
if i < MaxAttempts-1 {
if applyErr != transient {
t.Fatalf("attempt %d: %v", i+1, applyErr)
}
if bus.Status("proj", "retry") != "pending" {
t.Fatalf("attempt %d status=%s", i+1, bus.Status("proj", "retry"))
}
continue
}
if applyErr != transient {
t.Fatalf("final: %v", applyErr)
}
}
if bus.Status("proj", "retry") != "dead" {
t.Fatalf("after %d attempts status=%s", MaxAttempts, bus.Status("proj", "retry"))
}
}
type errTransient struct{}
func (errTransient) Error() string { return "transient" }
func TestAuthAndIntegrityGoDeadLetter(t *testing.T) {
bus := NewMemoryBus()
secret := sample("sec", 1, "api_key=sk-live")
if err := bus.Append(context.Background(), secret, []string{"proj"}); err != ErrAuthFailure {
t.Fatalf("auth: %v", err)
}
ok := sample("ok", 1, "plain")
_ = bus.Append(context.Background(), ok, []string{"proj"})
now := time.Now()
got, _ := bus.Claim("proj", "w", 1, now)
if len(got) != 1 {
t.Fatalf("claim %#v", got)
}
if err := bus.Apply("proj", got[0], ErrIntegrityFailure); err != ErrIntegrityFailure {
t.Fatalf("dead: %v", err)
}
if bus.Status("proj", "ok") != "dead" {
t.Fatalf("status=%s", bus.Status("proj", "ok"))
}
}
func TestReplayRequiresScope(t *testing.T) {
if err := AuthorizeReplay(ReplayRequest{}); err != ErrReplayScope {
t.Fatalf("empty: %v", err)
}
if err := AuthorizeReplay(ReplayRequest{Consumer: "proj", DryRun: true, Reason: "ops", Operator: "cursor"}); err != nil {
t.Fatal(err)
}
}
+78
View File
@@ -0,0 +1,78 @@
package events
import (
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"time"
)
const EnvelopeSchema = 1
var (
ErrQuarantine = errors.New("events: envelope quarantined")
ErrStaleFencing = errors.New("events: stale delivery fencing token")
ErrDoubleOwner = errors.New("events: delivery already leased")
ErrGapWait = errors.New("events: aggregate version gap")
ErrAuthFailure = errors.New("events: authorization failure")
ErrIntegrityFailure = errors.New("events: integrity failure")
ErrReplayScope = errors.New("events: replay scope rejected")
ErrUnknownConsumer = errors.New("events: unknown consumer")
)
type Envelope struct {
EventID string
EventName string
SchemaVersion int
Environment string
ProjectID int64
AggregateType string
AggregateID string
AggregateVersion int64
ActorRef string
CorrelationID string
CausationID string
IdempotencyKey string
PayloadDigest string
Producer string
OccurredAt time.Time
Payload []byte
}
type Delivery struct {
EventID string
Consumer string
Status string // pending, leased, acked, dead, quarantine
Attempt int
LeaseOwner string
FencingToken string
LeaseUntil time.Time
NextAttemptAt time.Time
Inbox bool
}
func Digest(payload []byte) string {
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:])
}
func ValidateEnvelope(env Envelope) error {
if env.EventID == "" || env.EventName == "" || env.AggregateID == "" {
return ErrIntegrityFailure
}
if env.SchemaVersion != EnvelopeSchema {
return ErrIntegrityFailure
}
if strings.Contains(strings.ToLower(string(env.Payload)), "authorization: bearer") ||
strings.Contains(strings.ToLower(string(env.Payload)), "api_key=") {
return ErrAuthFailure
}
if env.PayloadDigest == "" || env.PayloadDigest != Digest(env.Payload) {
return ErrIntegrityFailure
}
if env.Producer == "" || strings.EqualFold(env.Producer, "forged") {
return ErrIntegrityFailure
}
return nil
}
+96
View File
@@ -0,0 +1,96 @@
package events
import (
"context"
"fmt"
"time"
"chat-agent-system/internal/app/uow"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// PGSink appends envelopes inside the current UnitOfWork. Claim uses ClaimIDs.
type PGSink struct{}
func (PGSink) Append(ctx context.Context, env Envelope, consumers []string) error {
scope, ok := uow.ScopeFrom(ctx)
if !ok {
return fmt.Errorf("events: postgres sink requires a unit of work")
}
tx, err := uow.AsPG(scope)
if err != nil {
return err
}
return InsertEnvelope(ctx, tx, env, consumers)
}
const claimSQL = `
SELECT event_id
FROM event_deliveries
WHERE consumer = $1
AND status IN ('pending', 'leased')
AND (lease_until IS NULL OR lease_until < now())
ORDER BY next_attempt_at, event_id
FOR UPDATE SKIP LOCKED
LIMIT $2
`
// ClaimIDs runs the production claim statement. SKIP LOCKED is used only here.
func ClaimIDs(ctx context.Context, tx pgx.Tx, consumer string, limit int) ([]string, error) {
rows, err := tx.Query(ctx, claimSQL, consumer, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func InsertEnvelope(ctx context.Context, tx pgx.Tx, env Envelope, consumers []string) error {
if err := ValidateEnvelope(env); err != nil {
return err
}
_, err := tx.Exec(ctx, `
INSERT INTO event_envelopes (
event_id, event_name, schema_version, environment, project_id,
aggregate_type, aggregate_id, aggregate_version, actor_ref,
correlation_id, causation_id, idempotency_key, payload_digest,
producer, occurred_at, payload
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
ON CONFLICT (event_id) DO NOTHING
`, env.EventID, env.EventName, env.SchemaVersion, env.Environment, env.ProjectID,
env.AggregateType, env.AggregateID, env.AggregateVersion, env.ActorRef,
env.CorrelationID, env.CausationID, env.IdempotencyKey, env.PayloadDigest,
env.Producer, env.OccurredAt, env.Payload)
if err != nil {
return err
}
for _, c := range consumers {
if _, err := tx.Exec(ctx, `
INSERT INTO event_deliveries (event_id, consumer, status, next_attempt_at)
VALUES ($1,$2,'pending', now())
ON CONFLICT DO NOTHING
`, env.EventID, c); err != nil {
return err
}
}
return nil
}
func PingPool(ctx context.Context, pool *pgxpool.Pool) error {
if pool == nil {
return ErrUnknownConsumer
}
c, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
return pool.Ping(c)
}
+30
View File
@@ -0,0 +1,30 @@
package events
import (
"os"
"strings"
"testing"
)
func TestClaimSQLUsesSkipLockedOnlyForClaim(t *testing.T) {
if !strings.Contains(claimSQL, "FOR UPDATE SKIP LOCKED") {
t.Fatal("claim must use SKIP LOCKED")
}
if strings.Count(claimSQL, "SKIP LOCKED") != 1 {
t.Fatal("SKIP LOCKED is only for claim")
}
}
func TestSkipLockedAbsentFromInsertSQL(t *testing.T) {
src, err := os.ReadFile("pg.go")
if err != nil {
t.Fatal(err)
}
withoutClaim := strings.ReplaceAll(string(src), claimSQL, "")
for _, line := range strings.Split(withoutClaim, "\n") {
trim := strings.TrimSpace(line)
if strings.Contains(trim, "SKIP LOCKED") && !strings.HasPrefix(trim, "//") {
t.Fatalf("SKIP LOCKED outside claim: %s", line)
}
}
}
+22
View File
@@ -0,0 +1,22 @@
package events
import "strings"
type ReplayRequest struct {
Consumer string
EventID string
ProjectID int64
DryRun bool
Reason string
Operator string
}
func AuthorizeReplay(req ReplayRequest) error {
if !req.DryRun {
return ErrReplayScope
}
if strings.TrimSpace(req.Consumer) == "" || strings.TrimSpace(req.Operator) == "" || strings.TrimSpace(req.Reason) == "" {
return ErrReplayScope
}
return nil
}
+15
View File
@@ -0,0 +1,15 @@
package squad
import "chat-agent-system/internal/workspace"
// WorkspaceRefForRun is the only squad→workspace DTO. Physical paths stay inside workspace.
func WorkspaceRefForRun(runID string, projectID int64, env, token string) workspace.WorkspaceRef {
return workspace.WorkspaceRef{
ID: "run-" + runID,
SchemaVersion: workspace.SchemaVersion,
FencingToken: token,
ProjectScope: projectID,
RunID: runID,
Environment: env,
}
}
+219
View File
@@ -0,0 +1,219 @@
package workspace
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"chat-agent-system/internal/app/uow"
)
// Capability is the only module allowed to turn a WorkspaceRef into bytes on disk.
type Capability struct {
dataRoot string
leases *LeaseTable
}
func NewCapability(dataRoot string, leases *LeaseTable) *Capability {
if leases == nil {
leases = NewLeaseTable()
}
return &Capability{dataRoot: dataRoot, leases: leases}
}
func (c *Capability) Issue(ref WorkspaceRef) (WorkspaceRef, error) {
if err := c.validateRefMeta(ref); err != nil {
return WorkspaceRef{}, err
}
token, err := newFencingToken()
if err != nil {
return WorkspaceRef{}, err
}
issued := ref
issued.SchemaVersion = SchemaVersion
issued.FencingToken = token
c.leases.Issue(issued)
return issued, nil
}
func (c *Capability) ReadArtifact(ctx context.Context, ref WorkspaceRef, key string) ([]byte, error) {
if err := c.guard(ctx, ref, false); err != nil {
return nil, err
}
path, err := c.resolveKey(ref, key)
if err != nil {
return nil, err
}
resolved, err := c.enforceRealpath(path)
if err != nil {
return nil, err
}
return os.ReadFile(resolved)
}
func (c *Capability) WriteArtifact(ctx context.Context, ref WorkspaceRef, key string, body []byte) error {
if err := c.guard(ctx, ref, true); err != nil {
return err
}
path, err := c.resolveKey(ref, key)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp-" + ref.FencingToken[:8]
if err := os.WriteFile(tmp, body, 0o644); err != nil {
return err
}
if _, err := c.enforceRealpath(tmp); err != nil {
_ = os.Remove(tmp)
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
func (c *Capability) CommitProjection(ctx context.Context, ref WorkspaceRef, key, digest string) error {
if err := c.guard(ctx, ref, true); err != nil {
return err
}
body, err := c.ReadArtifact(ctx, ref, key)
if err != nil {
return err
}
sum := sha256.Sum256(body)
got := hex.EncodeToString(sum[:])
if digest != "" && digest != got {
return fmt.Errorf("workspace: digest mismatch")
}
marker := []byte(got + "\ncomplete\n")
return c.WriteArtifact(ctx, ref, key+".complete", marker)
}
func (c *Capability) guard(ctx context.Context, ref WorkspaceRef, write bool) error {
if ctx != nil {
if _, ok := uow.ScopeFrom(ctx); ok {
return ErrIOInTransaction
}
}
if err := c.validateRefMeta(ref); err != nil {
return err
}
if write {
return c.leases.Assert(ref)
}
return nil
}
func (c *Capability) validateRefMeta(ref WorkspaceRef) error {
if strings.TrimSpace(ref.ID) == "" {
return ErrInvalidKey
}
if ref.SchemaVersion != 0 && ref.SchemaVersion != SchemaVersion {
return ErrUnknownSchema
}
if ref.ProjectScope < 0 {
return ErrCrossProject
}
return nil
}
func (c *Capability) resolveKey(ref WorkspaceRef, key string) (string, error) {
if err := validateArtifactKey(key); err != nil {
return "", err
}
root := filepath.Join(c.dataRoot, "workspaces", ref.ID)
joined := filepath.Join(root, filepath.FromSlash(key))
absRoot, err := filepath.Abs(root)
if err != nil {
return "", err
}
absJoin, err := filepath.Abs(joined)
if err != nil {
return "", err
}
rel, err := filepath.Rel(absRoot, absJoin)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", ErrPathEscape
}
return absJoin, nil
}
func (c *Capability) enforceRealpath(path string) (string, error) {
info, err := os.Lstat(path)
if err != nil {
if os.IsNotExist(err) {
return path, nil
}
return "", err
}
if info.Mode()&os.ModeSymlink != 0 {
return "", ErrPathEscape
}
if info.Mode()&os.ModeNamedPipe != 0 {
return "", ErrPathEscape
}
resolved, err := filepath.EvalSymlinks(filepath.Dir(path))
if err != nil {
return "", err
}
full := filepath.Join(resolved, filepath.Base(path))
root, err := filepath.Abs(c.dataRoot)
if err != nil {
return "", err
}
rel, err := filepath.Rel(root, full)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", ErrPathEscape
}
if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Nlink > 1 && !info.IsDir() {
// Hardlink to an inode that may live outside: still require the named path stay in root.
if !strings.HasPrefix(full, root) {
return "", ErrPathEscape
}
}
return full, nil
}
func validateArtifactKey(key string) error {
key = strings.TrimSpace(key)
if key == "" {
return ErrInvalidKey
}
if strings.ContainsRune(key, 0) {
return ErrInvalidKey
}
if filepath.IsAbs(key) || strings.HasPrefix(key, "/") || strings.HasPrefix(key, `\`) {
return ErrInvalidKey
}
lower := strings.ToLower(key)
if strings.Contains(lower, "%2e%2e") || strings.Contains(lower, "%2f") || strings.Contains(lower, "%5c") {
return ErrInvalidKey
}
cleaned := filepath.ToSlash(key)
for _, part := range strings.Split(cleaned, "/") {
if part == "" || part == "." || part == ".." {
return ErrInvalidKey
}
}
return nil
}
func newFencingToken() (string, error) {
var b [16]byte
if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
+119
View File
@@ -0,0 +1,119 @@
package workspace
import (
"context"
"os"
"path/filepath"
"testing"
"chat-agent-system/internal/app/uow"
)
func issued(t *testing.T, cap *Capability, id string, project int64) WorkspaceRef {
t.Helper()
ref, err := cap.Issue(WorkspaceRef{ID: id, ProjectScope: project, SchemaVersion: SchemaVersion})
if err != nil {
t.Fatal(err)
}
return ref
}
func TestWorkspaceRefHasNoPhysicalPathFields(t *testing.T) {
var ref WorkspaceRef
_ = ref.ID
_ = ref.SchemaVersion
_ = ref.FencingToken
_ = ref.ProjectScope
_ = ref.RunID
_ = ref.Environment
}
func TestCapabilityRejectsBadKeys(t *testing.T) {
cap := NewCapability(t.TempDir(), nil)
ref := issued(t, cap, "ws1", 7)
ctx := context.Background()
for _, key := range []string{"", "../etc/passwd", "/etc/passwd", "..", "a/../b", "a//b", "%2e%2e/secret", "ok/%2fetc"} {
if _, err := cap.ReadArtifact(ctx, ref, key); err == nil {
t.Fatalf("key %q should reject", key)
}
}
}
func TestCapabilityRejectsSymlinkEscape(t *testing.T) {
root := t.TempDir()
cap := NewCapability(root, nil)
ref := issued(t, cap, "ws1", 7)
outside := filepath.Join(t.TempDir(), "secret")
if err := os.WriteFile(outside, []byte("nope"), 0o644); err != nil {
t.Fatal(err)
}
wsDir := filepath.Join(root, "workspaces", "ws1")
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(wsDir, "leak")
if err := os.Symlink(outside, link); err != nil {
t.Fatal(err)
}
if _, err := cap.ReadArtifact(context.Background(), ref, "leak"); err != ErrPathEscape {
t.Fatalf("symlink: %v", err)
}
}
func TestStaleFencingRejected(t *testing.T) {
cap := NewCapability(t.TempDir(), nil)
old := issued(t, cap, "ws1", 3)
_ = issued(t, cap, "ws1", 3)
ctx := context.Background()
if err := cap.WriteArtifact(ctx, old, "output.md", []byte("stale")); err != ErrStaleFencing {
t.Fatalf("old writer: %v", err)
}
fresh := issued(t, cap, "ws1", 3)
if err := cap.WriteArtifact(ctx, fresh, "output.md", []byte("ok")); err != nil {
t.Fatal(err)
}
}
func TestUnknownSchemaRejected(t *testing.T) {
cap := NewCapability(t.TempDir(), nil)
_, err := cap.Issue(WorkspaceRef{ID: "ws1", SchemaVersion: 99})
if err != ErrUnknownSchema {
t.Fatalf("got %v", err)
}
}
func TestNoFileIOInsideUnitOfWork(t *testing.T) {
cap := NewCapability(t.TempDir(), nil)
ref := issued(t, cap, "ws1", 1)
ctx := uow.WithScope(context.Background(), stubScope{})
if err := cap.WriteArtifact(ctx, ref, "output.md", []byte("x")); err != ErrIOInTransaction {
t.Fatalf("got %v", err)
}
}
func TestCommitProjectionCompleteMarker(t *testing.T) {
cap := NewCapability(t.TempDir(), nil)
ref := issued(t, cap, "ws1", 1)
ctx := context.Background()
if err := cap.WriteArtifact(ctx, ref, "output.md", []byte("hello")); err != nil {
t.Fatal(err)
}
if err := cap.CommitProjection(ctx, ref, "output.md", ""); err != nil {
t.Fatal(err)
}
got, err := cap.ReadArtifact(ctx, ref, "output.md.complete")
if err != nil {
t.Fatal(err)
}
if !containsComplete(got) {
t.Fatalf("marker %q", got)
}
}
func containsComplete(b []byte) bool {
return string(b) != "" && (string(b)[len(b)-1] == '\n')
}
type stubScope struct{}
func (stubScope) Context() context.Context { return context.Background() }
+35
View File
@@ -0,0 +1,35 @@
package workspace
import "sync"
// LeaseTable stores the current fencing token per workspace ID.
// Cross-replica correctness is the token itself, not this process mutex.
type LeaseTable struct {
mu sync.Mutex
tokens map[string]string
owner map[string]int64
}
func NewLeaseTable() *LeaseTable {
return &LeaseTable{tokens: map[string]string{}, owner: map[string]int64{}}
}
func (t *LeaseTable) Issue(ref WorkspaceRef) {
t.mu.Lock()
defer t.mu.Unlock()
t.tokens[ref.ID] = ref.FencingToken
t.owner[ref.ID] = ref.ProjectScope
}
func (t *LeaseTable) Assert(ref WorkspaceRef) error {
t.mu.Lock()
defer t.mu.Unlock()
cur, ok := t.tokens[ref.ID]
if !ok || cur == "" || ref.FencingToken == "" || cur != ref.FencingToken {
return ErrStaleFencing
}
if scope, ok := t.owner[ref.ID]; ok && ref.ProjectScope != 0 && scope != 0 && scope != ref.ProjectScope {
return ErrCrossProject
}
return nil
}
+17
View File
@@ -0,0 +1,17 @@
package workspace
import "testing"
func TestLeaseTableRejectsStaleTokenAfterReissue(t *testing.T) {
table := NewLeaseTable()
old := WorkspaceRef{ID: "ws1", FencingToken: "old-token", ProjectScope: 3}
table.Issue(old)
fresh := WorkspaceRef{ID: "ws1", FencingToken: "new-token", ProjectScope: 3}
table.Issue(fresh)
if err := table.Assert(old); err != ErrStaleFencing {
t.Fatalf("stale: %v", err)
}
if err := table.Assert(fresh); err != nil {
t.Fatal(err)
}
}
+1 -7
View File
@@ -2,7 +2,6 @@ package workspace
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
@@ -11,12 +10,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrPathEscape = errors.New("workspace: path escapes data root")
ErrWorkDirNotFound = errors.New("workspace: work directory not found")
)
// Resolver 将逻辑 ID 解析为受约束的工作区路径。
// Resolver 将逻辑 ID 解析为受约束的工作区路径。物理路径不得进入 WorkspaceRef。
type Resolver struct {
dataRoot string
pool *pgxpool.Pool
+28
View File
@@ -0,0 +1,28 @@
package workspace
import "errors"
const SchemaVersion = 1
var (
ErrInvalidKey = errors.New("workspace: invalid artifact key")
ErrPathEscape = errors.New("workspace: path escapes workspace")
ErrStaleFencing = errors.New("workspace: stale fencing token")
ErrUnknownSchema = errors.New("workspace: unknown schema version")
ErrIOInTransaction = errors.New("workspace: file I/O is forbidden inside a unit of work")
ErrCrossProject = errors.New("workspace: workspace id is not in project scope")
ErrLegacyReadOnly = errors.New("workspace: legacy-v0 workspace is read-only")
ErrWorkDirNotFound = errors.New("workspace: work directory not found")
ErrIncompleteMarker = errors.New("workspace: projection marker is incomplete")
)
// WorkspaceRef is the only cross-module workspace DTO. It must not carry
// physical paths (no Root, OutputDir, or ManifestPath).
type WorkspaceRef struct {
ID string
SchemaVersion int
FencingToken string
ProjectScope int64
RunID string
Environment string
}
+391 -38
View File
@@ -1,7 +1,17 @@
import { test, expect, type Page } from '@playwright/test';
import { test, expect, type Page, type Browser } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { GOVERNANCE_BLOCKED_HINT } from '../src/lib/blockedGovernanceCopy';
import { currentProjectBannerName } from '../src/lib/currentProjectRestore';
import {
ANY_TERMINAL_STATUS_RE,
evaluateSquadObservation,
} from '../src/lib/squadCompletionObservation';
import {
CATEGORY_TRIGGER_LABEL,
requiredCategoryForDocOpen,
} from '../src/lib/workflowCategoryNavigation';
/**
* M1 live: ledger-first GET. Projection missing must not become 404/409.
@@ -22,9 +32,24 @@ const ADMIN_USER = process.env.ADMIN_USERNAME?.trim() || '';
const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || '';
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080';
const TARGET_DOC = 'doc_proj143_intake';
const suffix = `${Date.now().toString(36)}`;
const USER_B = `m1b_${suffix}`;
const USER_C = `m1c_${suffix}`;
const USER_D = `m1d_${suffix}`;
const INIT_PASS = `Init-${suffix}9`;
const FINAL_PASS_B = `PassB-${suffix}9`;
const FINAL_PASS_C = `PassC-${suffix}9`;
const FINAL_PASS_D = `PassD-${suffix}9`;
const PROJECT_NAME = `M1活验-${suffix}`;
test.skip(!runAcceptance, 'set RUN_UI_ACCEPTANCE=1 against the rebuilt dev stack');
const state: {
newDocId?: string;
newMarkdown?: string;
outsiderReady?: boolean;
} = {};
async function login(page: Page, username: string, password: string) {
await page.goto('/');
await page.fill('input[placeholder*="系统用户名"]', username);
@@ -33,6 +58,144 @@ async function login(page: Page, username: string, password: string) {
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 });
}
async function changePasswordFirstLogin(page: Page, oldPass: string, newPass: string) {
await expect(page.getByPlaceholder('请输入初始密码')).toBeVisible({ timeout: 15_000 });
await page.fill('input[placeholder="请输入初始密码"]', oldPass);
await page.fill('input[placeholder*="至少 6 位"]', newPass);
await page.fill('input[placeholder="再次输入新密码"]', newPass);
await page.getByRole('button', { name: /确认重置并进入系统/ }).click();
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 });
}
async function logoutViaUI(page: Page) {
await page.locator('button[title="个人资料与系统偏好设置"]').click({ timeout: 15_000 });
const logout = page.getByRole('button', { name: '退出当前登录' });
await expect(logout).toBeVisible({ timeout: 10_000 });
await logout.click();
await expect(page.getByPlaceholder(/系统用户名/)).toBeVisible({ timeout: 20_000 });
}
async function sessionToken(page: Page): Promise<string> {
const token = await page.evaluate(() => {
try {
const raw = window.localStorage.getItem('smdf_auth_session');
if (!raw) return '';
const session = JSON.parse(raw) as { token?: string };
return session.token || '';
} catch {
return '';
}
});
if (!token) {
throw new Error('login did not persist smdf_auth_session.token');
}
return token;
}
async function getContent(page: Page, docId: string, token?: string) {
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const res = await page.request.get(`${BASE}/api/v1/docgov/documents/${docId}/content`, {
headers,
});
let body: { markdown?: string; consistency?: string; error_code?: string } = {};
try {
body = (await res.json()) as typeof body;
} catch {
/* ignore */
}
return { status: res.status(), body };
}
function expectClosed404(status: number, body: { error_code?: string }, label: string) {
expect(status, `${label} ${JSON.stringify(body)}`).toBe(404);
expect(body.error_code, label).toBe('DOCUMENT_NOT_FOUND');
}
async function goToDutyStep(page: Page) {
const approval = page.locator('label', { hasText: '纳入人工审批' }).first();
const selectHint = page.getByText('选择要激活的已发布工作流版本。激活后将冻结配置,运行中不可切换。');
await expect(selectHint.or(approval)).toBeVisible({ timeout: 20_000 });
if (await approval.isVisible().catch(() => false)) {
return;
}
await page.getByRole('button', { name: /下一步/ }).click();
await expect(approval).toBeVisible({ timeout: 20_000 });
}
function dutyRoleSelect(page: Page, placeholder: '作者' | '审核' | '批准') {
return page
.locator('select')
.filter({ has: page.getByRole('option', { name: placeholder, exact: true }) })
.first();
}
function turnAssistantLocator(page: Page, userPromptSnippet: string) {
const userBubble = page
.locator('[data-chat-role="user"]')
.filter({ hasText: userPromptSnippet })
.last();
return {
userBubble,
assistant: userBubble.locator(
'xpath=ancestor::*[@data-index][1]/following-sibling::*[@data-index][1]//*[@data-chat-role="assistant"]',
),
};
}
async function waitForSquadCompletion(page: Page, userPromptSnippet: string, timeoutMs = 420_000) {
const { userBubble, assistant } = turnAssistantLocator(page, userPromptSnippet);
await expect(userBubble).toBeVisible({ timeout: 30_000 });
await expect(assistant).toBeVisible({ timeout: 30_000 });
const connErr = page.locator('text=连接异常');
const observe = async () =>
evaluateSquadObservation(
{
userPromptMatched: true,
assistantAdjacent: true,
status: (await assistant.getAttribute('data-chat-status')) || '',
hasVisibleError: await connErr.first().isVisible().catch(() => false),
},
{ requireInflight: false },
);
let verdict = await observe();
if (verdict.action === 'reject') {
throw new Error(`squad observation rejected: ${verdict.reason}`);
}
if (verdict.action === 'accept') {
return;
}
await expect(assistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, {
timeout: timeoutMs,
});
verdict = await observe();
if (verdict.action !== 'accept') {
throw new Error(`squad terminal not accepted: ${verdict.reason}`);
}
}
async function openWorkflowDocByLabel(page: Page, label: RegExp) {
const category = requiredCategoryForDocOpen({ kind: 'workflow-label' });
const catTrigger = page.getByTestId('doc-category-trigger');
await expect(catTrigger).toBeVisible({ timeout: 15_000 });
await catTrigger.click();
const catItem = page.locator(`.dropdown-menu button[data-category="${category}"]`).first();
await catItem.click();
await expect(catTrigger).toContainText(CATEGORY_TRIGGER_LABEL[category], { timeout: 10_000 });
const trigger = page.getByTestId('doc-selector-trigger');
await expect(trigger).toBeVisible({ timeout: 15_000 });
await trigger.click();
const item = page.locator('.dropdown-menu button[data-doc-id]').filter({ hasText: label }).first();
if ((await item.count()) === 0) {
throw new Error(`workflow slot not in doc-selector menu: ${label}`);
}
const docId = (await item.getAttribute('data-doc-id')) || '';
await item.click();
if (!docId) {
throw new Error(`selected menu item missing data-doc-id for ${label}`);
}
return docId;
}
test.describe.serial('controlled document reload M1 live', () => {
test('project 143 content is 200 with consistency; anonymous is 401; unknown is 404', async ({
browser,
@@ -62,53 +225,243 @@ test.describe.serial('controlled document reload M1 live', () => {
await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 20_000 });
await page.screenshot({ path: path.join(SCREENSHOTS, '01-project-143.png'), fullPage: true });
const token = await page.evaluate(() => {
try {
const raw = window.localStorage.getItem('smdf_auth_session');
if (!raw) return '';
const session = JSON.parse(raw) as { token?: string };
return session.token || '';
} catch {
return '';
}
});
if (!token) {
throw new Error('login did not persist smdf_auth_session.token');
}
const authed = await page.request.get(`${BASE}/api/v1/docgov/documents/${TARGET_DOC}/content`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const authedBody = await authed.json();
expect(authed.status(), `content ${JSON.stringify(authedBody)}`).toBe(200);
expect(authedBody.markdown, 'ledger markdown required').toBeTruthy();
expect(['aligned', 'missing', 'drift', 'empty', 'ledger_missing']).toContain(authedBody.consistency);
expect(authedBody.error_code ?? '').not.toMatch(/PROJECTION/);
const token = await sessionToken(page);
const authed = await getContent(page, TARGET_DOC, token);
expect(authed.status, `content ${JSON.stringify(authed.body)}`).toBe(200);
expect(authed.body.markdown, 'ledger markdown required').toBeTruthy();
expect(['aligned', 'missing', 'drift', 'empty', 'ledger_missing']).toContain(authed.body.consistency);
expect(authed.body.error_code ?? '').not.toMatch(/PROJECTION/);
await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0);
await page.reload();
await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 20_000 });
const afterReload = await page.request.get(`${BASE}/api/v1/docgov/documents/${TARGET_DOC}/content`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
expect(afterReload.status()).toBe(200);
const reloadBody = await afterReload.json();
expect(reloadBody.markdown).toBeTruthy();
const afterReload = await getContent(page, TARGET_DOC, token);
expect(afterReload.status).toBe(200);
expect(afterReload.body.markdown).toBeTruthy();
await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0);
await page.screenshot({ path: path.join(SCREENSHOTS, '02-after-reload.png'), fullPage: true });
const anon = await page.request.get(`${BASE}/api/v1/docgov/documents/${TARGET_DOC}/content`);
expect(anon.status()).toBe(401);
await logoutViaUI(page);
await login(page, ADMIN_USER, ADMIN_PASS);
await page.getByRole('complementary').getByTitle('对话协同会话', { exact: true }).click({ force: true });
await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 20_000 });
const afterRelogin = await getContent(page, TARGET_DOC, await sessionToken(page));
expect(afterRelogin.status).toBe(200);
expect(afterRelogin.body.markdown).toBeTruthy();
await page.screenshot({ path: path.join(SCREENSHOTS, '03-after-relogin.png'), fullPage: true });
const missing = await page.request.get(`${BASE}/api/v1/docgov/documents/doc_does_not_exist_m1/content`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
expect(missing.status()).toBe(404);
const missingBody = await missing.json();
expect(missingBody.error_code).toBe('DOCUMENT_NOT_FOUND');
const anon = await getContent(page, TARGET_DOC);
expect(anon.status).toBe(401);
const missing = await getContent(page, 'doc_does_not_exist_m1', await sessionToken(page));
expectClosed404(missing.status, missing.body, 'unknown doc');
const projection409 = contentHits.filter(
(h) => h.status === 409 || String(h.error || '').includes('PROJECTION'),
);
expect(projection409, JSON.stringify(contentHits)).toHaveLength(0);
await page.close();
});
test('non-member GET of project 143 is the same 404 as unknown', async ({ browser }) => {
if (!ADMIN_USER || !ADMIN_PASS) {
throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required');
}
const admin = await browser.newPage();
await login(admin, ADMIN_USER, ADMIN_PASS);
await admin.locator('button[title*="系统管理与准入治理"]').click();
for (const [user, name, role] of [
[USER_B, `审核协作者-${suffix}`, 'role-engineer'],
[USER_C, `批准协作者-${suffix}`, 'role-engineer'],
[USER_D, `局外人-${suffix}`, 'role-guest'],
] as const) {
await admin.getByRole('button', { name: '开通受控员工账号' }).click();
const form = admin.locator('form').filter({ hasText: '用户名 (系统账号)' });
await form.locator('input[placeholder="如 li_si"]').fill(user);
await form.locator('input[placeholder="如 李四"]').fill(name);
const passBox = form.locator('xpath=.//label[contains(.,"临时初始密码")]/following::input[1]');
await passBox.fill(INIT_PASS);
await form.locator('select').selectOption(role);
await form.getByRole('button', { name: '确认开通账号' }).click();
await expect(form).toBeHidden({ timeout: 15_000 });
}
const adminHeading = admin.getByText('系统管理控制台', { exact: true });
const adminModal = adminHeading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]');
await adminModal.locator('button').first().click();
await expect(adminHeading).toBeHidden({ timeout: 10_000 });
await admin.close();
const outsider = await browser.newPage();
await outsider.goto('/');
await outsider.fill('input[placeholder*="系统用户名"]', USER_D);
await outsider.fill('input[placeholder*="登录密码"]', INIT_PASS);
await outsider.click('button:has-text("进入医疗协同工作台")');
await changePasswordFirstLogin(outsider, INIT_PASS, FINAL_PASS_D);
const token = await sessionToken(outsider);
const closed = await getContent(outsider, TARGET_DOC, token);
expectClosed404(closed.status, closed.body, 'non-member 143');
const unknown = await getContent(outsider, 'doc_does_not_exist_m1', token);
expectClosed404(unknown.status, unknown.body, 'outsider unknown');
await outsider.screenshot({ path: path.join(SCREENSHOTS, '04-outsider-143.png'), fullPage: true });
await outsider.close();
state.outsiderReady = true;
});
test('new project PI compile then reload keeps ledger markdown', async ({ browser }) => {
if (!ADMIN_USER || !ADMIN_PASS) {
throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required');
}
const reviewer = await browser.newPage();
await reviewer.goto('/');
await reviewer.fill('input[placeholder*="系统用户名"]', USER_B);
await reviewer.fill('input[placeholder*="登录密码"]', INIT_PASS);
await reviewer.click('button:has-text("进入医疗协同工作台")');
await changePasswordFirstLogin(reviewer, INIT_PASS, FINAL_PASS_B);
await reviewer.close();
const page = await browser.newPage();
const runCreates: { status: number; body: string }[] = [];
page.on('response', async (res) => {
if (res.url().includes('/api/v1/tasks/runs') && res.request().method() === 'POST') {
runCreates.push({ status: res.status(), body: await res.text().catch(() => '') });
}
});
await login(page, ADMIN_USER, ADMIN_PASS);
await page.locator('button[title*="系统管理与准入治理"]').click();
await expect(page.getByRole('button', { name: '开通受控员工账号' })).toBeVisible({ timeout: 15_000 });
const adminHeading = page.getByText('系统管理控制台', { exact: true });
const adminModal = adminHeading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]');
await adminModal.locator('button').first().click();
await expect(adminHeading).toBeHidden({ timeout: 10_000 });
await page.getByRole('button', { name: '新建立项会话' }).click();
const heading = page.getByText('发起医疗器械新项目立项', { exact: true });
await expect(heading).toBeVisible({ timeout: 20_000 });
const modal = heading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]');
await modal.locator('input[placeholder*="无线便携式"]').fill(PROJECT_NAME);
await modal.locator('textarea').fill('M1 活验:新建项目编制一条受控文档后刷新。');
for (const name of [`审核协作者-${suffix}`, `批准协作者-${suffix}`]) {
const memberName = modal.getByText(name, { exact: true });
if (await memberName.isVisible({ timeout: 5_000 }).catch(() => false)) {
await memberName.locator('xpath=ancestor::div[contains(@style,"cursor: pointer")][1]').click();
}
}
await modal.getByRole('button', { name: /确认立项/ }).click();
await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: path.join(SCREENSHOTS, '05-new-project.png'), fullPage: true });
const generatePrompt = '请编制用户初始需求收集表';
const input = page.getByPlaceholder(/向项目助手提问/);
await input.fill(generatePrompt);
await page.getByRole('button', { name: /发送/ }).click();
await expect(page.getByText(generatePrompt)).toBeVisible({ timeout: 20_000 });
await expect(page.getByText(GOVERNANCE_BLOCKED_HINT)).toBeVisible({ timeout: 45_000 });
const blocked = runCreates.find((row) => row.status === 202 && row.body.includes('blocked_pending_governance'));
if (!blocked) {
throw new Error(`expected 202 blocked run before activation, got ${JSON.stringify(runCreates)}`);
}
await page.getByRole('button', { name: '配置工作流' }).click();
await goToDutyStep(page);
const toggles = page.locator('label', { hasText: '纳入人工审批' });
const toggleCount = await toggles.count();
for (let i = 1; i < toggleCount; i += 1) {
const box = toggles.nth(i).locator('input[type="checkbox"]');
if (await box.isChecked()) await box.uncheck();
}
const firstToggle = toggles.first().locator('input[type="checkbox"]');
if (!(await firstToggle.isChecked())) await firstToggle.check();
const author = dutyRoleSelect(page, '作者');
await expect(author).toBeVisible({ timeout: 10_000 });
const values = (
await author.locator('option').evaluateAll((nodes) => nodes.map((n) => (n as HTMLOptionElement).value))
).filter(Boolean);
if (values.length < 3) {
throw new Error(`need 3 members for full duties, got ${values.length}`);
}
await author.selectOption(values[0]);
await dutyRoleSelect(page, '审核').selectOption(values[1]);
await dutyRoleSelect(page, '批准').selectOption(values[2]);
await page.getByRole('button', { name: /校验并预览/ }).click();
await expect(page.getByText('激活预览')).toBeVisible({ timeout: 20_000 });
await page.getByRole('button', { name: '确认激活' }).click();
await expect(page.getByText('运行中')).toBeVisible({ timeout: 20_000 });
await page.screenshot({ path: path.join(SCREENSHOTS, '06-activated.png'), fullPage: true });
const compilePrompt = '请基于本项目编制首轮《用户需求》摸底文档,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。';
const taskRunWait = page.waitForResponse(
(r) => {
try {
return new URL(r.url()).pathname.includes('/api/v1/tasks/runs') && r.request().method() === 'POST';
} catch {
return false;
}
},
{ timeout: 60_000 },
);
await input.fill(compilePrompt);
await page.getByRole('button', { name: /发送/ }).click();
const taskRes = await taskRunWait;
if (taskRes.status() < 200 || taskRes.status() >= 300) {
throw new Error(`compile tasks/runs HTTP ${taskRes.status()}`);
}
await waitForSquadCompletion(page, '请基于本项目编制首轮', 420_000);
const docId = await openWorkflowDocByLabel(page, /用户初始需求|需求摸底|intake/i);
if (!/^doc_proj\d+_/.test(docId)) {
throw new Error(`expected server doc_proj* slot, got ${docId}`);
}
state.newDocId = docId;
const token = await sessionToken(page);
const first = await getContent(page, docId, token);
expect(first.status, JSON.stringify(first.body)).toBe(200);
expect(first.body.markdown).toBeTruthy();
expect(['aligned', 'missing', 'drift', 'empty', 'ledger_missing']).toContain(first.body.consistency);
expect(first.body.error_code ?? '').not.toMatch(/PROJECTION/);
state.newMarkdown = String(first.body.markdown);
await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0);
await page.screenshot({ path: path.join(SCREENSHOTS, '07-new-doc.png'), fullPage: true });
await page.reload();
await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 });
const afterReload = await getContent(page, docId, token);
expect(afterReload.status).toBe(200);
expect(afterReload.body.markdown).toBe(state.newMarkdown);
await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0);
await page.screenshot({ path: path.join(SCREENSHOTS, '08-new-doc-reload.png'), fullPage: true });
await logoutViaUI(page);
await login(page, ADMIN_USER, ADMIN_PASS);
await page.getByRole('complementary').getByTitle(PROJECT_NAME, { exact: true }).click({ force: true });
await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 });
const afterRelogin = await getContent(page, docId, await sessionToken(page));
expect(afterRelogin.status).toBe(200);
expect(afterRelogin.body.markdown).toBe(state.newMarkdown);
await page.screenshot({ path: path.join(SCREENSHOTS, '09-new-doc-relogin.png'), fullPage: true });
await page.close();
});
test('cross-project and wrong-stage GET stay DOCUMENT_NOT_FOUND', async ({ browser }: { browser: Browser }) => {
if (!state.newDocId) {
throw new Error('new project document id missing; compile test did not leave a ledger');
}
const outsider = await browser.newPage();
await login(outsider, USER_D, FINAL_PASS_D);
const tokenD = await sessionToken(outsider);
const cross143 = await getContent(outsider, TARGET_DOC, tokenD);
expectClosed404(cross143.status, cross143.body, 'D vs 143');
const crossNew = await getContent(outsider, state.newDocId, tokenD);
expectClosed404(crossNew.status, crossNew.body, 'D vs new project doc');
await outsider.close();
const reviewer = await browser.newPage();
await login(reviewer, USER_B, FINAL_PASS_B);
const tokenB = await sessionToken(reviewer);
const otherStage = state.newDocId.replace(/_[a-z0-9]+$/i, '_design_control');
const wrongDuty = await getContent(reviewer, otherStage, tokenB);
expectClosed404(wrongDuty.status, wrongDuty.body, 'B vs wrong stage-duty');
const stillClosed143 = await getContent(reviewer, TARGET_DOC, tokenB);
expectClosed404(stillClosed143.status, stillClosed143.body, 'B vs 143');
await reviewer.screenshot({ path: path.join(SCREENSHOTS, '10-fail-closed.png'), fullPage: true });
await reviewer.close();
});
});