diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 50212938..0d32bb93 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **MULTI-INTENT HITL**:方案已复核,U1 契约未开工([设计](../system-design/2026-09-15-multi-intent-hitl-plan.md);[short-term](./short-term/2026-09-15-multi-intent-hitl.md)) +1. **MULTI-INTENT HITL**:U1 契约已冻结([U1 报告](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U1 任务](./short-term/2026-09-15-multi-intent-u1-contracts.md));U2 规则分段未开工 2. **发布门禁**:CI `TEST_DATABASE_URL`、真实 PI 三工作流 E2E、前端人工抽检仍开放([gap-matrix](../remediation/gap-matrix.md)) 3. **CHAT-INTENT**:双标签 busy 已签(项目 **6275**);不删 `sess-p143-u9`;项目 143 只读 @@ -13,6 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-15-multi-intent-u1-contracts | feature/multi-intent-u1-contracts | - | architecture-docs | done | | Cursor | app-composition-u11-u12 | feature/app-composition-u11-u12 | - | app | done | | Cursor | chatlog-contextasm-u10 | feature/chatlog-contextasm-u10 | - | chat | done | | Cursor | chat-history-refresh-remediation | fix/chat-history-refresh | - | chat | done | @@ -84,7 +85,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | - | - | - | +| Cursor | merging | 2026-09-15 | multi-intent-u1-contracts | - | **phase**: `idle` | `merging` | `smoke` | `e2e` @@ -118,6 +119,7 @@ | Phase 2 方案审阅 | [../audit/2026-09-15-module-decoupling-phase2-plan-review.md](../audit/2026-09-15-module-decoupling-phase2-plan-review.md) | | Phase 2 修订计划 | [../remediation/2026-09-15-module-decoupling-ipc-phase2-remediation-plan.md](../remediation/2026-09-15-module-decoupling-ipc-phase2-remediation-plan.md) | | 多意图 HITL 复核方案 | [../system-design/2026-09-15-multi-intent-hitl-plan.md](../system-design/2026-09-15-multi-intent-hitl-plan.md) | +| 多意图 HITL U1 契约 | [../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md) | | 文档可靠性 M1 报告 | [../test-reports/2026-09-14-document-reliability-pilot-test-report.md](../test-reports/2026-09-14-document-reliability-pilot-test-report.md) | | 文档可靠性 M1 活验 | [../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md](../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md) | | 文档可靠性 M2 | [../test-reports/2026-09-14-document-reliability-m2-test-report.md](../test-reports/2026-09-14-document-reliability-m2-test-report.md) | diff --git a/.doc/ops/checklists/2026-09-15-multi-intent-u1-contracts.md b/.doc/ops/checklists/2026-09-15-multi-intent-u1-contracts.md new file mode 100644 index 00000000..2bcfafd5 --- /dev/null +++ b/.doc/ops/checklists/2026-09-15-multi-intent-u1-contracts.md @@ -0,0 +1,22 @@ +--- +title: "多意图 HITL U1 契约冻结 checklist" +status: done +created: 2026-09-15 +updated: 2026-09-15 +related_task: "2026-09-15-multi-intent-u1-contracts" +--- + +# 多意图 HITL U1 契约冻结 checklist + +## Wave 0 + +- [x] YAML `app/intentflow` allowlist + 三表 owner + unwrap_owners — 证据:`.doc/system-design/module-boundaries.yaml` +- [x] 三条 `intent.plan.*` YAML ↔ StaticContracts parity — 证据:`go test ./internal/events -run RegistryParity` +- [x] archtest 扫描 `intent.plan.*` — 证据:`go test ./internal/archtest` +- [x] intentflow 状态/错误码/Service/政策/legacy tier — 证据:`go test ./internal/app/intentflow` +- [x] CSRF/Bearer 结论落盘 — 证据:U1 short-term +- [x] `go test -short ./...` — 证据:[U1 报告](../../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md) + +## 完成定义 + +- [x] 全部勾选且有证据链接 diff --git a/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md b/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md index 7c0cd10a..fca7da0d 100644 --- a/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md +++ b/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md @@ -1,12 +1,12 @@ --- title: "多意图持久计划与 HITL(核心 U1–U8,扩面 U9–U12)" -status: planned +status: in_progress created: 2026-09-15 updated: 2026-09-15 -owner: "-(待逐单元认领)" -branch: feature/multi-intent-u1-contracts +owner: "-(U1 已完成;U2 待认领)" +branch: "-" worktree: "-" -mutex: architecture-docs +mutex: "-" --- # 多意图持久计划与 HITL(核心 U1–U8,扩面 U9–U12) @@ -51,7 +51,7 @@ mutex: architecture-docs ## 步骤 -1. [ ] U1 冻结边界、状态机、批量策略、错误码、事件和默认 legacy tier; +1. [x] U1 冻结边界、状态机、批量策略、错误码、事件和默认 legacy tier([独立任务](./2026-09-15-multi-intent-u1-contracts.md);[报告](../../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md)); 2. [ ] U2 characterization-first 建 golden/hard-negative 与 shadow 基线; 3. [ ] U3 建 intentflow owner、PG schema、CAS/lease/fencing/retention; 4. [ ] U4 接 Track E outbox/dispatcher,只投影状态事实; @@ -92,12 +92,12 @@ mutex: architecture-docs ## 相关链接 -- Checklist: U1 实施时建立逐波 checklist +- Checklist: [U1](../checklists/2026-09-15-multi-intent-u1-contracts.md) - Handoff: 每个 U-ID 独立建立 -- Test report: 每个 U-ID 独立归档 `.doc/test-reports/` +- Test report: [U1](../../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md) ## 证据 / 备注 - 当前 `plan_builder.go` 最多两段且写意图返回 nil;`action_planner.go` 的确认仍是文字提示。 - 当前 `module-boundaries.yaml` 中 intent 仅依赖 maturity;Track E live,FAV off。 -- 本次只修订方案与任务文档,不实施、不提交、不推送。 +- U1 已冻结 intentflow 契约、事件与 BatchActionPolicy,默认 `MULTI_INTENT_PLAN_TIER=legacy`。 diff --git a/.doc/ops/short-term/2026-09-15-multi-intent-u1-contracts.md b/.doc/ops/short-term/2026-09-15-multi-intent-u1-contracts.md new file mode 100644 index 00000000..b57cf7ef --- /dev/null +++ b/.doc/ops/short-term/2026-09-15-multi-intent-u1-contracts.md @@ -0,0 +1,76 @@ +--- +title: "多意图 HITL U1:契约冻结" +status: done +created: 2026-09-15 +updated: 2026-09-15 +owner: Cursor +branch: feature/multi-intent-u1-contracts +worktree: "-" +mutex: architecture-docs +--- + +# 多意图 HITL U1:契约冻结 + +## 目标 + +冻结 `app/intentflow` 边界、状态机、BatchActionPolicy、错误码、事件契约与默认 `legacy` tier;不改识别路由、不建表、不接 HTTP。 + +## 范围 + +- `module-boundaries.yaml`:`app/intentflow` allowlist、三表 owner、`unwrap_owners`、三条 `intent.plan.*` 事件 +- `events.StaticContracts` 与 YAML 对等;archtest 扫描 `intent.plan.*` +- `internal/app/intentflow` 骨架:状态、错误码、Service 端口、政策、tier 开关 +- CSRF/认证结论:现有 Bearer + CORS 对 U5 足够,不是 blocker + +## 非目标 + +- 不修改 `internal/intent` 业务类型或 `MULTI_INTENT_ENABLED` +- 不建 Goose 表、不接线 `app.Assemble`、不改 chat/coordinator/Web +- 不 Append 事件、不实施 U2–U12 + +## REST(U5 再挂路由) + +- `GET /api/v1/intent-plans/active` +- `GET /api/v1/intent-plans/{plan_id}` +- `POST /api/v1/intent-plans/{plan_id}/decisions` + - body:`expected_revision`、`decision_nonce`、`decision_digest`、`kind`、步骤选择 + +## CSRF / 认证(U5 前置结论,非 blocker) + +现有认证是 `Authorization: Bearer` 或 `X-Session-Token` 请求头(`internal/server/auth_handlers.go`),无 cookie 会话,无独立 CSRF 中间件;另有 Origin allowlist CORS。 + +- 自定义头不会被浏览器跨站表单自动带上,现有模式对 U5 足够 +- `decision_nonce` / 幂等键只防重放与并发,**不替代** CSRF 或身份认证 +- U5 必须 `requireAuthenticatedUser` + 四元作用域(environment/project/session/user) +- **禁止**为计划 API 改成 cookie 自动提交 +- 若未来改 cookie 认证,另立 auth 任务,不得在 intent-plan 端点发明弱 CSRF + +## 步骤 + +1. [x] 从最新 `dev` 检出 `feature/multi-intent-u1-contracts` 并认领 CURRENT +2. [x] 登记边界 YAML、事件契约、archtest regex +3. [x] 落地 intentflow 骨架与 BatchActionPolicy +4. [x] arch/parity/short 测试与 U1 报告 + +## 验收标准 + +- [x] `intent -> maturity` 保持;server/chat 不直写计划表 +- [x] YAML ↔ StaticContracts parity 绿 +- [x] 未知 action/policy key fail closed;ActionRegistry 无 batch/tier 字段 +- [x] 默认 `MULTI_INTENT_PLAN_TIER=legacy` +- [x] 单意图生成策略不变(本单元无运行时改动) + +## 相关链接 + +- 总览: [2026-09-15-multi-intent-hitl.md](./2026-09-15-multi-intent-hitl.md) +- 设计: [../../system-design/2026-09-15-multi-intent-hitl-plan.md](../../system-design/2026-09-15-multi-intent-hitl-plan.md) +- Checklist: [../checklists/2026-09-15-multi-intent-u1-contracts.md](../checklists/2026-09-15-multi-intent-u1-contracts.md) +- Test report: [../../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md](../../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md) + +## 证据 / 备注 + +- 事件登记窗口同时占用 `events` mutex;U1 不改 intent 代码。 +- 生产者含 `events`:StaticContracts 字面量落在 `internal/events/registry.go`,与现有 document.* 先例一致。 +- CSRF:Bearer / `X-Session-Token` + CORS allowlist,U5 非 blocker。 +- 冒烟:`go vet ./...`、`go test -short ./...`、`./scripts/arch-check.sh --mode block` PASS。U1 无 schema/容器变更,集成槽位保持 idle。 +- 提交:`bdccb4d` docs(ops);`aefb1e4` feat(events);`9364da4` feat(intentflow);随后 docs(status)。 diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 2c0b6493..cdd1445d 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -15,7 +15,8 @@ | 模块 | 能力 | 状态 | 证据 | 缺口/后续 | mutex | |------|------|------|------|-----------|-------| -| 意图识别 L1 | 18 类 Intent + 三分支消解 | partial | [system-design/intent-recognition](../system-design/intent-recognition/);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md);[运行态报告](../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md);[双标签活验](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/live.json);[多意图复核方案](../system-design/2026-09-15-multi-intent-hitl-plan.md) | 明确写动作与只读总结已分流;双标签 `DOC_BUSY` 已签(项目 6275)。多意图持久计划仅完成方案复核,核心 U1–U8 与扩面 U9–U12 均未实施;intent 纯依赖边界不得回退 | `intent` | +| 意图识别 L1 | 18 类 Intent + 三分支消解 | partial | [system-design/intent-recognition](../system-design/intent-recognition/);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md);[运行态报告](../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md);[双标签活验](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/live.json);[多意图复核方案](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1 契约](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md) | 明确写动作与只读总结已分流;双标签 `DOC_BUSY` 已签(项目 6275)。多意图 U1 契约已冻(intentflow 边界/事件/BatchActionPolicy/legacy tier);U2–U8 未实施;intent 纯依赖边界不得回退 | `intent` | +| 多意图持久计划 | app/intentflow 生命周期与 HITL 契约 | planned | [设计](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1 报告](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U1 任务](../ops/short-term/2026-09-15-multi-intent-u1-contracts.md) | U1 仅冻结合约。U2 规则分段 shadow;U3 PG/CAS;U4 投影;U5–U8 决策/Web/只读执行/task.generate | `intent-plan` | | 对话 / ctxmgr | 多轮上下文、记忆、摘要 | partial | [system-design/multi-turn-dialogue](../system-design/multi-turn-dialogue/);[刷新恢复专项审计](../audit/2026-09-13-chat-history-refresh-disappears.md);[U10 端口化](../archive/ops/2026-09-15-chatlog-contextasm-u10.md);[U10 报告](../test-reports/2026-09-15-chatlog-contextasm-u10-test-report.md) | `CHAT-HISTORY-REFRESH` 闲聊刷新活验已补。U10 原地端口 **停止扩面**(未物理拆包)。语义摘要(D5 backlog) | `chat` | | 智能体小队 | 角色-任务-工作区-黑板 | done | [system-design/agent-squad](../system-design/agent-squad/);[test 2026-09-07](../test-reports/2026-09-07-dynamic-squad-planning-test-report.md) | 非关键步骤降级(A5/A6 backlog) | `squad` | | 工作流治理 | 三正式工作流 + 阶段门禁 | partial | [remediation/three-workflows](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[test 2026-09-12](../test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md);[冻结态/职责可选整改](../remediation/2026-09-14-workflow-plan-409-frozen-and-optional-duties-remediation.md);[Wave 5 页面矩阵](../test-reports/2026-09-14-workflow-plan-409-frozen-and-optional-duties-test-report.md);[职责-动作鉴权](../test-reports/2026-09-14-governance-action-level-authz-test-report.md) | 设置页/DutyMode + 动作级鉴权/未激活 202-blocked/关闭创建直通已落地。后续:none 文档真实 PI 审批长链路、CI 三工作流 E2E | `workflow` | diff --git a/.doc/system-design/README.md b/.doc/system-design/README.md index 6f41f87f..c0f539e2 100644 --- a/.doc/system-design/README.md +++ b/.doc/system-design/README.md @@ -126,6 +126,7 @@ | 主方案 | [2026-09-15-multi-intent-hitl-plan.md](2026-09-15-multi-intent-hitl-plan.md) | | 复核 | [../audit/2026-09-15-multi-intent-hitl-plan-review.md](../audit/2026-09-15-multi-intent-hitl-plan-review.md) | | 任务总览 | [../ops/short-term/2026-09-15-multi-intent-hitl.md](../ops/short-term/2026-09-15-multi-intent-hitl.md) | +| U1 契约 | [../ops/short-term/2026-09-15-multi-intent-u1-contracts.md](../ops/short-term/2026-09-15-multi-intent-u1-contracts.md) | | 一句话总结 | intent 保持纯规则,intentflow 持久化计划并统一确认、授权、幂等、恢复和逐动作执行 | **硬边界**:确认不等于授权;PG 是计划真相,卡片/SSE 是投影;写动作按 owner 端口逐项接入;LLM 只能提出不可信分段建议。 diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index c6c25629..e834ad06 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -65,6 +65,7 @@ imports: - app/docflow - app/docnotify - app/docproject + - app/intentflow - app/stageflow - app/uow - artifactvault @@ -113,6 +114,10 @@ imports: - docquery - docrevision - events + app/intentflow: + - app/uow + - events + - intent app/uow: [] app/stageflow: - bizdev @@ -304,6 +309,12 @@ table_owners: - dataset dataset_destroy_requests: - dataset + intent_plans: + - app/intentflow + intent_plan_steps: + - app/intentflow + intent_plan_decisions: + - app/intentflow path_owners: output.md: @@ -348,6 +359,15 @@ event_producers: - taskrun fav.artifact_staged: - app/docflow + intent.plan.awaiting_input: + - app/intentflow + - events + intent.plan.step_changed: + - app/intentflow + - events + intent.plan.completed: + - app/intentflow + - events event_consumers: outbox.notify: @@ -371,6 +391,15 @@ event_consumers: - notify fav.artifact_staged: - fav + intent.plan.awaiting_input: + - projection + - notify + intent.plan.step_changed: + - projection + - notify + intent.plan.completed: + - projection + - notify unwrap_owners: - docgov @@ -380,6 +409,7 @@ unwrap_owners: - events - artifactvault - ctxmgr + - app/intentflow exceptions: - kind: sql_write diff --git a/.doc/test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md b/.doc/test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md new file mode 100644 index 00000000..39d410df --- /dev/null +++ b/.doc/test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md @@ -0,0 +1,105 @@ +# 多意图 HITL U1 契约冻结 测试报告 + +## 一、 测试背景 (Background) + +多意图识别目前最多拆两段且写意图直接丢弃;确认只是 coordinator 文本提示。方案已复核为「持久计划深模块」,U1 必须先冻结边界、事件、状态机和 BatchActionPolicy,避免后续单元向 `intent` / coordinator 堆叠职责。 + +## 二、 测试目标 (Test Objectives) + +- `app/intentflow` 进入 import allowlist;三张计划表 owner 仅为 `app/intentflow` +- 三条 `intent.plan.*` 事实事件 YAML ↔ `StaticContracts` 对等,payload 不含 prompt/slots/权限 +- 默认 `MULTI_INTENT_PLAN_TIER=legacy`;未知 policy key fail closed +- ActionRegistry 不出现 batch/tier/adapter 字段 +- `intent -> maturity` 与全仓 short 测试无回归 +- 无运行时路由变更,因此不做真实 PI / 浏览器验收 + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 项 | 值 | +|----|-----| +| OS | Linux 7.0.0-31-generic | +| Go | go1.27.1 linux/amd64 | +| 分支 | `feature/multi-intent-u1-contracts`(从本地 `dev@9874237` 检出) | +| Schema | **43**(本单元不迁移) | +| 容器 | 不 rebuild | +| 项目 143 | **只读** | +| 证据 | `.doc/test-reports/assets/2026-09-15-multi-intent-u1-contracts/` | + +测试层级:archtest / events parity / intentflow 单测 / `go test -short ./...`。无 PG 集成、无真实 PI。 + +## 四、 预期结果 (Expected Results) + +| 用例 | 判定 | +|------|------| +| YAML ↔ StaticContracts | 三条 `intent.plan.*` 生产者含 `app/intentflow`+`events`,消费者 `projection`+`notify` | +| 未知 action key | `Lookup` 失败;构造期缺行/多行失败 | +| `task.run` | 批量必须确认,`OpenFromTier=confirm` | +| `regulation.query` | 不要求批量确认,`OpenFromTier=auto_read` | +| 默认 tier | `legacy`;`MULTI_INTENT_ENABLED=true` 不升档 | +| ActionSpec | 无 batch/tier/adapter 字段 | +| arch-check block | production gate passed | +| 全仓 short | PASS | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 事件 parity 与安全 payload + +- **测试输入 (Input)**:`go test -count=1 ./internal/events -run 'RegistryParity|IntentPlan'` +- **实际输出 (Actual Output)**: + +``` +ok chat-agent-system/internal/events 0.004s +``` + +(完整包输出见 [unit.txt](assets/2026-09-15-multi-intent-u1-contracts/unit.txt)) + +- **评价判定 (Evaluation / Verdict)**:PASS。`TestRegistryParityWithYAML`、`TestIntentPlanContractsOmitSensitivePayload` 绿。 +- **性能与耗时 (Duration & Latency)**:约 5ms + +### 5.2 archtest / arch-check + +- **测试输入**:`./scripts/arch-check.sh --mode block`;`go test -count=1 ./internal/archtest` +- **实际输出**: + +``` +arch-check mode=block +ok chat-agent-system/internal/archtest 0.173s +ok chat-agent-system/internal/archtest 0.154s +arch-check block: production gate passed +``` + +- **评价判定**:PASS。`intent.plan.*` 字面量可被扫描;`events` 已登记为生产者,registry.go 不误报。 +- **性能与耗时**:约 1.1s + +### 5.3 intentflow 政策与 tier + +- **测试输入**:`go test -count=1 ./internal/app/intentflow` +- **实际输出**:`ok chat-agent-system/internal/app/intentflow 0.002s` +- **评价判定**:PASS。未知 key fail closed;`task.run` 批量确认;查询不要求确认;ActionSpec 无 HITL 字段;默认 legacy。 +- **性能与耗时**:约 2ms + +### 5.4 全仓 short 回归 + +- **测试输入**:`unset TEST_DATABASE_URL; go test -short -count=1 ./...`;`go vet ./...`;`git diff --check` +- **实际输出**:见 [short.txt](assets/2026-09-15-multi-intent-u1-contracts/short.txt)。`internal/server` 9.920s;全包 ok。`go vet` 无输出。`git diff --check` 无空白错误。 +- **评价判定**:PASS。单意图路径无运行时改动,`internal/intent` / `internal/coordinator` / `internal/chat` 均绿。 +- **性能与耗时**:约 12s + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 命令 | 结果 | +|------|------| +| `go vet ./...` | PASS | +| `go test -short -count=1 ./internal/app/intentflow ./internal/events ./internal/archtest ./internal/intent` | PASS | +| `go test -short -count=1 ./...` | PASS | +| `./scripts/arch-check.sh --mode block` | PASS | + +通过率 100%。未跑 PG / 真实 PI(本单元无副作用路径)。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +U1 达到合入标准:契约冻结完成,默认 legacy,确认前零副作用保持不变。 + +后续:U2 在独立分支 `feature/multi-intent-u2-segmenter` 做规则分段 shadow;不得把 HITL/tier 回灌 ActionRegistry。 + +CSRF:现有 Bearer / `X-Session-Token` + CORS allowlist 对 U5 足够,不是 blocker。nonce 不替代认证。 diff --git a/.doc/test-reports/assets/2026-09-15-multi-intent-u1-contracts/short.txt b/.doc/test-reports/assets/2026-09-15-multi-intent-u1-contracts/short.txt new file mode 100644 index 00000000..5adc5e92 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-multi-intent-u1-contracts/short.txt @@ -0,0 +1,61 @@ +ok chat-agent-system/cmd/bizdev-cli 2.374s +? chat-agent-system/cmd/dataset [no test files] +ok chat-agent-system/cmd/document-content-reconcile 0.003s +? chat-agent-system/cmd/document-revision-backfill [no test files] +? chat-agent-system/cmd/eventmode [no test files] +? chat-agent-system/cmd/events [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 3.653s +ok chat-agent-system/internal/app 0.016s +ok chat-agent-system/internal/app/chatflow 0.005s +ok chat-agent-system/internal/app/docadvance 0.003s +ok chat-agent-system/internal/app/docflow 0.004s +ok chat-agent-system/internal/app/docnotify 0.004s +ok chat-agent-system/internal/app/docproject 0.004s +ok chat-agent-system/internal/app/intentflow 0.002s +ok chat-agent-system/internal/app/stageflow 0.004s +ok chat-agent-system/internal/app/uow 0.004s +ok chat-agent-system/internal/archtest 0.172s +ok chat-agent-system/internal/artifactvault 0.004s +ok chat-agent-system/internal/auth 0.821s +ok chat-agent-system/internal/authz 0.004s +ok chat-agent-system/internal/bizdev 0.165s +ok chat-agent-system/internal/chat 0.014s +ok chat-agent-system/internal/config 0.002s +ok chat-agent-system/internal/coordinator 0.010s +ok chat-agent-system/internal/ctxmgr 0.083s +ok chat-agent-system/internal/dataset 0.003s +ok chat-agent-system/internal/db 0.003s +ok chat-agent-system/internal/doccontent 0.002s +ok chat-agent-system/internal/docduty 0.005s +ok chat-agent-system/internal/docgov 0.016s +ok chat-agent-system/internal/docquery 0.003s +ok chat-agent-system/internal/docrevision 0.007s +ok chat-agent-system/internal/events 0.005s +ok chat-agent-system/internal/freedoc 0.023s +ok chat-agent-system/internal/fsutil 0.002s +ok chat-agent-system/internal/httpx 0.003s +ok chat-agent-system/internal/intent 0.011s +ok chat-agent-system/internal/kb 0.005s +ok chat-agent-system/internal/maturity 0.002s +ok chat-agent-system/internal/notification 0.068s +? chat-agent-system/internal/observability [no test files] +ok chat-agent-system/internal/projectcontext 0.004s +ok chat-agent-system/internal/projectinstance 0.004s +ok chat-agent-system/internal/projectworkflow 0.003s +ok chat-agent-system/internal/quote 0.007s +ok chat-agent-system/internal/replyexport 0.002s +ok chat-agent-system/internal/server 9.920s +ok chat-agent-system/internal/squad 0.030s +ok chat-agent-system/internal/storage 0.016s +ok chat-agent-system/internal/stream 0.003s +ok chat-agent-system/internal/stream/transport 0.002s +ok chat-agent-system/internal/taskrun 0.142s +ok chat-agent-system/internal/template 0.785s +ok chat-agent-system/internal/upload 0.002s +? chat-agent-system/internal/workflow [no test files] +ok chat-agent-system/internal/workflow/runtime 0.003s +ok chat-agent-system/internal/workflowdef 0.003s +ok chat-agent-system/internal/workspace 0.003s +? chat-agent-system/templates [no test files] diff --git a/.doc/test-reports/assets/2026-09-15-multi-intent-u1-contracts/unit.txt b/.doc/test-reports/assets/2026-09-15-multi-intent-u1-contracts/unit.txt new file mode 100644 index 00000000..69152e06 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-multi-intent-u1-contracts/unit.txt @@ -0,0 +1,4 @@ +ok chat-agent-system/internal/app/intentflow 0.002s +ok chat-agent-system/internal/events 0.004s +ok chat-agent-system/internal/archtest 0.161s +ok chat-agent-system/internal/intent 0.010s diff --git a/internal/app/intentflow/doc.go b/internal/app/intentflow/doc.go new file mode 100644 index 00000000..e02249e1 --- /dev/null +++ b/internal/app/intentflow/doc.go @@ -0,0 +1,13 @@ +// Package intentflow owns the multi-intent plan lifecycle. +// +// It is the only application entry for proposing, confirming, recovering, and +// advancing persistent plans. Callers (chat/server) must depend on the Service +// interface and PlanView projection, not on the store, pool, or action-owner +// engines. +// +// Hard rules: +// - Confirmation is not authorization; each step re-checks the current principal. +// - PostgreSQL plan/step/decision rows are authoritative; cards and SSE are projections. +// - internal/intent stays a pure rule module (intent -> maturity). +// - This package must not import docgov, taskrun, coordinator, server, or pgx. +package intentflow diff --git a/internal/app/intentflow/errors.go b/internal/app/intentflow/errors.go new file mode 100644 index 00000000..6a7ccfc2 --- /dev/null +++ b/internal/app/intentflow/errors.go @@ -0,0 +1,39 @@ +package intentflow + +import "errors" + +// Sentinel errors use the public API error codes from the design §9. +var ( + ErrPlanNotFoundOrHidden = errors.New("PLAN_NOT_FOUND_OR_HIDDEN") + ErrPlanStaleRevision = errors.New("PLAN_STALE_REVISION") + ErrPlanExpired = errors.New("PLAN_EXPIRED") + ErrPlanDecisionConflict = errors.New("PLAN_DECISION_CONFLICT") + ErrPlanDependencyInvalid = errors.New("PLAN_DEPENDENCY_INVALID") + ErrPlanActionForbidden = errors.New("PLAN_ACTION_FORBIDDEN") + ErrPlanActionVersionConflict = errors.New("PLAN_ACTION_VERSION_CONFLICT") + ErrUnauthenticated = errors.New("ERR_UNAUTHENTICATED") +) + +// ErrorCode returns the stable machine code for a sentinel, or empty. +func ErrorCode(err error) string { + switch { + case errors.Is(err, ErrPlanNotFoundOrHidden): + return "PLAN_NOT_FOUND_OR_HIDDEN" + case errors.Is(err, ErrPlanStaleRevision): + return "PLAN_STALE_REVISION" + case errors.Is(err, ErrPlanExpired): + return "PLAN_EXPIRED" + case errors.Is(err, ErrPlanDecisionConflict): + return "PLAN_DECISION_CONFLICT" + case errors.Is(err, ErrPlanDependencyInvalid): + return "PLAN_DEPENDENCY_INVALID" + case errors.Is(err, ErrPlanActionForbidden): + return "PLAN_ACTION_FORBIDDEN" + case errors.Is(err, ErrPlanActionVersionConflict): + return "PLAN_ACTION_VERSION_CONFLICT" + case errors.Is(err, ErrUnauthenticated): + return "ERR_UNAUTHENTICATED" + default: + return "" + } +} diff --git a/internal/app/intentflow/flags.go b/internal/app/intentflow/flags.go new file mode 100644 index 00000000..b50d494d --- /dev/null +++ b/internal/app/intentflow/flags.go @@ -0,0 +1,89 @@ +package intentflow + +import ( + "os" + "strings" + "sync" +) + +// PlanTier is the intentflow rollout switch. It must not be stored on +// intent.ActionSpec or intent.FeatureFlags. +type PlanTier string + +const ( + TierLegacy PlanTier = "legacy" + TierShadow PlanTier = "shadow" + TierAutoRead PlanTier = "auto_read" + TierConfirm PlanTier = "confirm" + TierFull PlanTier = "full" +) + +type FeatureFlags struct { + PlanTier PlanTier +} + +var ( + flagsMu sync.RWMutex + activeFlags = defaultFeatureFlags() +) + +func defaultFeatureFlags() FeatureFlags { + return FeatureFlags{PlanTier: parsePlanTier(os.Getenv("MULTI_INTENT_PLAN_TIER"))} +} + +func parsePlanTier(raw string) PlanTier { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "legacy": + return TierLegacy + case "shadow": + return TierShadow + case "auto_read", "autoread": + return TierAutoRead + case "confirm": + return TierConfirm + case "full": + return TierFull + default: + return TierLegacy + } +} + +func CurrentFeatureFlags() FeatureFlags { + flagsMu.RLock() + defer flagsMu.RUnlock() + return activeFlags +} + +func SetFeatureFlagsForTest(f FeatureFlags) { + flagsMu.Lock() + activeFlags = f + flagsMu.Unlock() +} + +func ResetFeatureFlagsForTest() { + flagsMu.Lock() + activeFlags = defaultFeatureFlags() + flagsMu.Unlock() +} + +// Allows reports whether the current (or supplied) tier may activate a policy row. +func (t PlanTier) Allows(min PlanTier) bool { + return tierRank(t) >= tierRank(min) +} + +func tierRank(t PlanTier) int { + switch t { + case TierLegacy: + return 0 + case TierShadow: + return 1 + case TierAutoRead: + return 2 + case TierConfirm: + return 3 + case TierFull: + return 4 + default: + return 0 + } +} diff --git a/internal/app/intentflow/flags_test.go b/internal/app/intentflow/flags_test.go new file mode 100644 index 00000000..53496635 --- /dev/null +++ b/internal/app/intentflow/flags_test.go @@ -0,0 +1,82 @@ +package intentflow + +import ( + "testing" +) + +func TestDefaultPlanTierIsLegacy(t *testing.T) { + t.Setenv("MULTI_INTENT_PLAN_TIER", "") + ResetFeatureFlagsForTest() + if got := CurrentFeatureFlags().PlanTier; got != TierLegacy { + t.Fatalf("default tier = %s, want legacy", got) + } +} + +func TestUnknownPlanTierFailsClosedToLegacy(t *testing.T) { + if parsePlanTier("definitely-not-a-tier") != TierLegacy { + t.Fatal("unknown MULTI_INTENT_PLAN_TIER must fail closed to legacy") + } +} + +func TestPlanTierIndependentOfLegacyMultiIntentFlag(t *testing.T) { + t.Setenv("MULTI_INTENT_ENABLED", "true") + t.Setenv("MULTI_INTENT_PLAN_TIER", "") + ResetFeatureFlagsForTest() + if CurrentFeatureFlags().PlanTier != TierLegacy { + t.Fatal("MULTI_INTENT_ENABLED must not promote plan tier") + } +} + +func TestPlanTierEnv(t *testing.T) { + if parsePlanTier("shadow") != TierShadow { + t.Fatal("shadow") + } + if parsePlanTier("auto_read") != TierAutoRead { + t.Fatal("auto_read") + } + if parsePlanTier("confirm") != TierConfirm { + t.Fatal("confirm") + } + if parsePlanTier("full") != TierFull { + t.Fatal("full") + } + if !TierConfirm.Allows(TierAutoRead) || TierShadow.Allows(TierConfirm) { + t.Fatal("tier rank") + } +} + +func TestErrorCodes(t *testing.T) { + if ErrorCode(ErrPlanNotFoundOrHidden) != "PLAN_NOT_FOUND_OR_HIDDEN" { + t.Fatal(ErrorCode(ErrPlanNotFoundOrHidden)) + } + if ErrorCode(ErrPlanStaleRevision) != "PLAN_STALE_REVISION" { + t.Fatal("stale") + } + if ErrorCode(ErrPlanExpired) != "PLAN_EXPIRED" { + t.Fatal("expired") + } + if ErrorCode(ErrPlanDecisionConflict) != "PLAN_DECISION_CONFLICT" { + t.Fatal("conflict") + } + if ErrorCode(ErrPlanDependencyInvalid) != "PLAN_DEPENDENCY_INVALID" { + t.Fatal("dep") + } + if ErrorCode(ErrPlanActionForbidden) != "PLAN_ACTION_FORBIDDEN" { + t.Fatal("forbidden") + } + if ErrorCode(ErrPlanActionVersionConflict) != "PLAN_ACTION_VERSION_CONFLICT" { + t.Fatal("version") + } +} + +func TestRESTPathsFrozen(t *testing.T) { + if PathActivePlan != "/api/v1/intent-plans/active" { + t.Fatal(PathActivePlan) + } + if PathPlanByID != "/api/v1/intent-plans/{plan_id}" { + t.Fatal(PathPlanByID) + } + if PathPlanDecisions != "/api/v1/intent-plans/{plan_id}/decisions" { + t.Fatal(PathPlanDecisions) + } +} diff --git a/internal/app/intentflow/policy.go b/internal/app/intentflow/policy.go new file mode 100644 index 00000000..1a9e1caf --- /dev/null +++ b/internal/app/intentflow/policy.go @@ -0,0 +1,137 @@ +package intentflow + +import ( + "fmt" + + "chat-agent-system/internal/intent" +) + +const PolicyVersion = "batch-action-policy.v1" + +// ActionBatchPolicy is the intentflow-owned batch row. It must not copy +// Risk, RequiredSlots, CapabilityKey, or AllowedStates from ActionRegistry. +type ActionBatchPolicy struct { + ActionKey string + RequiresBatchConfirm bool + OpenFromTier PlanTier + AllowedModes []ExecutionMode + RejectPlan bool +} + +// BatchActionPolicy is the versioned fail-closed matrix keyed by ActionSpec.Key. +type BatchActionPolicy struct { + Version string + byKey map[string]ActionBatchPolicy +} + +func DefaultBatchActionPolicy() *BatchActionPolicy { + p, err := NewBatchActionPolicy(defaultBatchRows(), intent.DefaultRegistry()) + if err != nil { + panic(err) + } + return p +} + +func NewBatchActionPolicy(rows []ActionBatchPolicy, registry *intent.ActionRegistry) (*BatchActionPolicy, error) { + if registry == nil { + return nil, fmt.Errorf("intentflow: action registry is required") + } + byKey := make(map[string]ActionBatchPolicy, len(rows)) + for _, row := range rows { + if row.ActionKey == "" { + return nil, fmt.Errorf("intentflow: empty policy action key") + } + if _, exists := byKey[row.ActionKey]; exists { + return nil, fmt.Errorf("intentflow: duplicate policy key %q", row.ActionKey) + } + if _, ok := registry.Get(row.ActionKey); !ok { + return nil, fmt.Errorf("intentflow: unknown policy action key %q", row.ActionKey) + } + byKey[row.ActionKey] = row + } + for _, spec := range registry.All() { + if spec == nil { + continue + } + if _, ok := byKey[spec.Key]; !ok { + return nil, fmt.Errorf("intentflow: action %q missing batch policy", spec.Key) + } + } + return &BatchActionPolicy{Version: PolicyVersion, byKey: byKey}, nil +} + +func (p *BatchActionPolicy) Lookup(key string) (ActionBatchPolicy, error) { + if p == nil { + return ActionBatchPolicy{}, fmt.Errorf("intentflow: policy is nil") + } + row, ok := p.byKey[key] + if !ok { + return ActionBatchPolicy{}, fmt.Errorf("intentflow: unknown action key %q", key) + } + return row, nil +} + +func (p *BatchActionPolicy) RequiresBatchConfirm(key string) bool { + row, err := p.Lookup(key) + if err != nil { + return true + } + return row.RequiresBatchConfirm || row.RejectPlan +} + +func defaultBatchRows() []ActionBatchPolicy { + autoRead := []string{ + "regulation.query", + "case.query", + "permission.query", + "document.reject_reason.query", + "workflow.status.query", + "document.status.query", + "run.status.query", + "project.advisory", + "conversation.help", + } + confirmGenerate := []string{"task.run", "task.run.adhoc"} + rows := make([]ActionBatchPolicy, 0, len(intent.DefaultRegistry().All())) + seen := map[string]struct{}{} + add := func(row ActionBatchPolicy) { + rows = append(rows, row) + seen[row.ActionKey] = struct{}{} + } + for _, key := range autoRead { + add(ActionBatchPolicy{ + ActionKey: key, + RequiresBatchConfirm: false, + OpenFromTier: TierAutoRead, + AllowedModes: []ExecutionMode{ModeLive, ModeValidatedOnly}, + }) + } + for _, key := range confirmGenerate { + add(ActionBatchPolicy{ + ActionKey: key, + RequiresBatchConfirm: true, + OpenFromTier: TierConfirm, + AllowedModes: []ExecutionMode{ModeValidatedOnly, ModeLive}, + }) + } + add(ActionBatchPolicy{ + ActionKey: "security.forbidden", + RejectPlan: true, + OpenFromTier: TierFull, + }) + for _, spec := range intent.DefaultRegistry().All() { + if spec == nil { + continue + } + if _, ok := seen[spec.Key]; ok { + continue + } + add(ActionBatchPolicy{ + ActionKey: spec.Key, + RequiresBatchConfirm: true, + OpenFromTier: TierFull, + AllowedModes: []ExecutionMode{ModeValidatedOnly}, + }) + } + return rows +} diff --git a/internal/app/intentflow/policy_test.go b/internal/app/intentflow/policy_test.go new file mode 100644 index 00000000..33abe8b0 --- /dev/null +++ b/internal/app/intentflow/policy_test.go @@ -0,0 +1,96 @@ +package intentflow + +import ( + "reflect" + "strings" + "testing" + + "chat-agent-system/internal/intent" +) + +func TestDefaultPolicyCoversRegistryAndFailClosed(t *testing.T) { + p := DefaultBatchActionPolicy() + if p.Version != PolicyVersion { + t.Fatalf("version = %s", p.Version) + } + for _, spec := range intent.DefaultRegistry().All() { + if _, err := p.Lookup(spec.Key); err != nil { + t.Fatalf("registry key %s missing from policy: %v", spec.Key, err) + } + } + if _, err := p.Lookup("not.a.real.action"); err == nil { + t.Fatal("unknown key must fail closed") + } +} + +func TestBatchConfirmMatrix(t *testing.T) { + p := DefaultBatchActionPolicy() + row, err := p.Lookup("task.run") + if err != nil { + t.Fatal(err) + } + if !row.RequiresBatchConfirm || row.OpenFromTier != TierConfirm { + t.Fatalf("task.run policy = %+v", row) + } + adhoc, err := p.Lookup("task.run.adhoc") + if err != nil { + t.Fatal(err) + } + if !adhoc.RequiresBatchConfirm { + t.Fatal("task.run.adhoc must require batch confirm") + } + query, err := p.Lookup("regulation.query") + if err != nil { + t.Fatal(err) + } + if query.RequiresBatchConfirm || query.OpenFromTier != TierAutoRead { + t.Fatalf("regulation.query policy = %+v", query) + } + forbidden, err := p.Lookup("security.forbidden") + if err != nil { + t.Fatal(err) + } + if !forbidden.RejectPlan { + t.Fatal("security.forbidden must reject the whole plan") + } +} + +func TestUnknownPolicyKeyRejectedAtConstruction(t *testing.T) { + rows := append(defaultBatchRows(), ActionBatchPolicy{ActionKey: "invented.action"}) + _, err := NewBatchActionPolicy(rows, intent.DefaultRegistry()) + if err == nil { + t.Fatal("expected unknown policy key to fail") + } +} + +func TestMissingRegistryKeyRejectedAtConstruction(t *testing.T) { + var trimmed []ActionBatchPolicy + for _, row := range defaultBatchRows() { + if row.ActionKey == "regulation.query" { + continue + } + trimmed = append(trimmed, row) + } + _, err := NewBatchActionPolicy(trimmed, intent.DefaultRegistry()) + if err == nil { + t.Fatal("expected missing regulation.query policy to fail") + } +} + +func TestActionSpecHasNoBatchOrTierFields(t *testing.T) { + rt := reflect.TypeOf(intent.ActionSpec{}) + for i := 0; i < rt.NumField(); i++ { + name := strings.ToLower(rt.Field(i).Name) + for _, banned := range []string{"batch", "tier", "adapter", "hitl"} { + if strings.Contains(name, banned) { + t.Fatalf("ActionSpec.%s smuggles %s into L1", rt.Field(i).Name, banned) + } + } + } +} + +func TestValidatedOnlyIsNotSuccess(t *testing.T) { + if ModeValidatedOnly == ExecutionMode(PlanCompleted) || ModeValidatedOnly == ExecutionMode(StepSucceeded) { + t.Fatal("validated_only must stay distinct from succeeded/completed") + } +} diff --git a/internal/app/intentflow/service.go b/internal/app/intentflow/service.go new file mode 100644 index 00000000..9e595839 --- /dev/null +++ b/internal/app/intentflow/service.go @@ -0,0 +1,14 @@ +package intentflow + +import "context" + +// Service is the only surface chat/server may call. Implementations live in +// later units; U1 freezes the signature so HTTP and chat cannot grow a second +// plan owner. +type Service interface { + HandleTurn(ctx context.Context, in TurnInput) (*TurnResult, error) + DecidePlan(ctx context.Context, in DecisionCommand) (*PlanView, error) + GetPlan(ctx context.Context, principal Principal, planID string) (*PlanView, error) + // GetActivePlan returns (nil, nil) when the principal has no visible active plan. + GetActivePlan(ctx context.Context, principal Principal) (*PlanView, error) +} diff --git a/internal/app/intentflow/types.go b/internal/app/intentflow/types.go new file mode 100644 index 00000000..d8160f9a --- /dev/null +++ b/internal/app/intentflow/types.go @@ -0,0 +1,121 @@ +package intentflow + +import "time" + +// PlanStatus is the authoritative plan state machine (design §5.1). +type PlanStatus string + +const ( + PlanProposed PlanStatus = "proposed" + PlanAwaitingClarification PlanStatus = "awaiting_clarification" + PlanAwaitingConfirmation PlanStatus = "awaiting_confirmation" + PlanExecuting PlanStatus = "executing" + PlanPartiallyCompleted PlanStatus = "partially_completed" + PlanCompleted PlanStatus = "completed" + PlanFailed PlanStatus = "failed" + PlanCancelled PlanStatus = "cancelled" + PlanExpired PlanStatus = "expired" + PlanSuperseded PlanStatus = "superseded" +) + +// StepStatus is the authoritative step state machine (design §5.2). +type StepStatus string + +const ( + StepPending StepStatus = "pending" + StepEligible StepStatus = "eligible" + StepRunning StepStatus = "running" + StepSucceeded StepStatus = "succeeded" + StepFailed StepStatus = "failed" + StepDenied StepStatus = "denied" + StepConflicted StepStatus = "conflicted" + StepBlockedDependency StepStatus = "blocked_dependency" + StepSkipped StepStatus = "skipped" + StepCancelled StepStatus = "cancelled" +) + +// ExecutionMode distinguishes dry-run precheck from a real action. +// validated_only must never be written as succeeded/completed. +type ExecutionMode string + +const ( + ModeValidatedOnly ExecutionMode = "validated_only" + ModeLive ExecutionMode = "live" +) + +// DecisionKind is the unified server command for buttons and natural language. +type DecisionKind string + +const ( + DecisionConfirm DecisionKind = "confirm" + DecisionCancel DecisionKind = "cancel" + DecisionExclude DecisionKind = "exclude" + DecisionClarify DecisionKind = "clarify" + DecisionEdit DecisionKind = "edit" + DecisionConfirmRetry DecisionKind = "confirm_retry" + DecisionContinue DecisionKind = "continue" + DecisionReplace DecisionKind = "replace" +) + +// Principal is taken only from the authenticated session. Callers must not +// copy user/project/session identifiers from the request body into these fields. +type Principal struct { + UserID int64 + Environment string + ProjectID int64 + SessionID string +} + +// TurnInput is one user utterance plus client idempotency. +type TurnInput struct { + Principal Principal + Text string + ClientTurnID string + RequestDigest string +} + +// TurnResult is the HandleTurn projection. No plan is a documented empty state. +type TurnResult struct { + Plan *PlanView +} + +// DecisionCommand is the single write path for confirm/cancel/edit/exclude. +type DecisionCommand struct { + Principal Principal + PlanID string + ExpectedRevision int64 + DecisionNonce string + DecisionDigest string + Kind DecisionKind + StepIDs []string + ExcludeUnselected bool +} + +// PlanView is the desensitized UI/API projection. It must not return internal +// payloads, permission snapshots, or full intermediate results. +type PlanView struct { + PlanID string `json:"plan_id"` + Revision int64 `json:"revision"` + Status PlanStatus `json:"status"` + ExecutionMode ExecutionMode `json:"execution_mode"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Steps []StepView `json:"steps,omitempty"` + SafeSummary string `json:"safe_summary,omitempty"` +} + +// StepView is the desensitized step projection. +type StepView struct { + StepID string `json:"step_id"` + ActionKey string `json:"action_key"` + Status StepStatus `json:"status"` + ExecutionMode ExecutionMode `json:"execution_mode"` + DependsOn []string `json:"depends_on,omitempty"` + SafeSummary string `json:"safe_summary,omitempty"` +} + +// REST paths are frozen here; U5 mounts them on the authenticated server. +const ( + PathActivePlan = "/api/v1/intent-plans/active" + PathPlanByID = "/api/v1/intent-plans/{plan_id}" + PathPlanDecisions = "/api/v1/intent-plans/{plan_id}/decisions" +) diff --git a/internal/archtest/scan.go b/internal/archtest/scan.go index 6f26471a..1b11f31c 100644 --- a/internal/archtest/scan.go +++ b/internal/archtest/scan.go @@ -14,8 +14,8 @@ import ( const internalPrefix = "chat-agent-system/internal/" var ( - sqlWriteRe = regexp.MustCompile(`(?is)\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+([a-zA-Z_][a-zA-Z0-9_]*)`) - eventNameRe = regexp.MustCompile(`document\.[a-z0-9_.]+`) + sqlWriteRe = regexp.MustCompile(`(?is)\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+([a-zA-Z_][a-zA-Z0-9_]*)`) + eventNameRe = regexp.MustCompile(`(?:document|intent\.plan)\.[a-z0-9_.]+`) ) type fileClass int diff --git a/internal/events/registry.go b/internal/events/registry.go index 9028f1d5..f615123f 100644 --- a/internal/events/registry.go +++ b/internal/events/registry.go @@ -12,14 +12,17 @@ const ( ConsumerWorkflowAdvance = "workflow_advance" ConsumerFAV = "fav" - EventCandidateReady = "document.agent_candidate_ready" - EventVersionCommitted = "document.agent_version_committed" - EventDownstreamImpact = "document.downstream_impact_detected" - EventChangeReportDegraded = "document.change_report_degraded" - EventApproved = "document.approved" - EventTaskRunCompleted = "taskrun.completed" - EventTaskRunCancelled = "taskrun.cancelled" - EventFAVArtifactStaged = "fav.artifact_staged" + EventCandidateReady = "document.agent_candidate_ready" + EventVersionCommitted = "document.agent_version_committed" + EventDownstreamImpact = "document.downstream_impact_detected" + EventChangeReportDegraded = "document.change_report_degraded" + EventApproved = "document.approved" + EventTaskRunCompleted = "taskrun.completed" + EventTaskRunCancelled = "taskrun.cancelled" + EventFAVArtifactStaged = "fav.artifact_staged" + EventIntentPlanAwaiting = "intent.plan.awaiting_input" + EventIntentPlanStepChanged = "intent.plan.step_changed" + EventIntentPlanCompleted = "intent.plan.completed" ) // Contract is the static allowlist row for one event name. @@ -44,6 +47,9 @@ func StaticContracts() []Contract { {EventName: EventTaskRunCompleted, SchemaVersion: 1, Enabled: true, Producers: []string{"taskrun"}, Consumers: []string{ConsumerNotify}, PayloadFields: []string{"run_id"}}, {EventName: EventTaskRunCancelled, SchemaVersion: 1, Enabled: true, Producers: []string{"taskrun"}, Consumers: []string{ConsumerNotify}, PayloadFields: []string{"run_id"}}, {EventName: EventFAVArtifactStaged, SchemaVersion: 1, Enabled: false, Producers: []string{"app/docflow"}, Consumers: []string{ConsumerFAV}, PayloadFields: []string{"doc_id", "snapshot_id"}}, + {EventName: EventIntentPlanAwaiting, SchemaVersion: 1, Enabled: true, Producers: []string{"app/intentflow", "events"}, Consumers: []string{ConsumerProjection, ConsumerNotify}, PayloadFields: []string{"plan_id", "revision", "status", "environment", "project_id", "user_id", "session_id", "summary_ref"}}, + {EventName: EventIntentPlanStepChanged, SchemaVersion: 1, Enabled: true, Producers: []string{"app/intentflow", "events"}, Consumers: []string{ConsumerProjection, ConsumerNotify}, PayloadFields: []string{"plan_id", "revision", "step_id", "status", "environment", "project_id", "user_id", "session_id", "summary_ref"}}, + {EventName: EventIntentPlanCompleted, SchemaVersion: 1, Enabled: true, Producers: []string{"app/intentflow", "events"}, Consumers: []string{ConsumerProjection, ConsumerNotify}, PayloadFields: []string{"plan_id", "revision", "status", "environment", "project_id", "user_id", "session_id", "summary_ref"}}, } } diff --git a/internal/events/registry_test.go b/internal/events/registry_test.go index bca117cd..6dfa8ae3 100644 --- a/internal/events/registry_test.go +++ b/internal/events/registry_test.go @@ -49,6 +49,31 @@ func TestManifestJSONIncludesDisabledFAV(t *testing.T) { } } +func TestIntentPlanContractsOmitSensitivePayload(t *testing.T) { + forbidden := []string{"prompt", "slots", "permissions", "result", "input"} + for _, c := range StaticContracts() { + if !strings.HasPrefix(c.EventName, "intent.plan.") { + continue + } + if !c.Enabled { + t.Fatalf("%s must be enabled for U4 append", c.EventName) + } + if !containsName(c.Producers, "app/intentflow") || !containsName(c.Producers, "events") { + t.Fatalf("%s producers = %v", c.EventName, c.Producers) + } + if !containsName(c.Consumers, ConsumerProjection) || !containsName(c.Consumers, ConsumerNotify) { + t.Fatalf("%s consumers = %v", c.EventName, c.Consumers) + } + for _, field := range c.PayloadFields { + for _, bad := range forbidden { + if field == bad { + t.Fatalf("%s payload contains %s", c.EventName, field) + } + } + } + } +} + func TestRegistryParityWithYAML(t *testing.T) { root := moduleRoot(t) b, err := archtest.LoadBoundaries(archtest.DefaultBoundariesPath(root))