diff --git a/docs/evidence/saas-dispatcher-implementation.md b/docs/evidence/saas-dispatcher-implementation.md index 389596c..5ef80d9 100644 --- a/docs/evidence/saas-dispatcher-implementation.md +++ b/docs/evidence/saas-dispatcher-implementation.md @@ -94,6 +94,10 @@ - 本机 HTTPS Mock 信任:Agent 上传及 Dispatcher 配置读取使用现有 mTLS CA 信任束校验证书,不放宽主机名或证书验证,仍拒绝非本机目标及跨域重定向。测试确认已批准的本机 HTTPS 证书可访问,未知证书与无效信任束均失败;未使用 `InsecureSkipVerify`。这不是对真实 OSS 的验证。 - 已验证:`go test ./... -count=1`、`go test -race ./... -count=1`、`go vet ./...`、`go build ./...`、`PATH=/tmp/sip-go-agent-tools/bin:$PATH bash scripts/check-current-contracts.sh`、`PATH=/tmp/sip-go-agent-tools/bin:$PATH bash scripts/check-proto.sh`、`git diff --check`。P06 的本机隔离正向、失败注入和重启防重复场景已分别通过;根命令在 MQ 长时故障后的完整恢复仍需 P08 验证,真实外部服务、SaaS 应用收讫与线路均未验收,不冒充生产通过。 +## P07 单一入口清理(执行中) + +- 数据归属根因:现行 SQLite 的 `CloseAdmission` 原本只按传入的 Dispatcher ID 插入或更新状态;把已有未投递 outbox 的 D1 数据库交给 D2 启动时,会错误建立第二个归属状态。新测试先证实该问题,再要求同一事务内检查既有归属,冲突时返回身份错误且保留原归属和未确认 outbox;本轮不迁移、不删除任何既有数据库。旧入口及名称残留仍须分批清理,不能因该测试通过便宣称 P07 完成。 + ## 验收台账 -A01–A12 的行为验证及 K01–K16 的运行时验证待 P03–P08 逐项填充;不得用本地 Mock 冒充外部签收。 +P01–P06 的项目内隔离证据见上;P07 全仓命名与唯一入口尚在清理,A01–A12 和 K01–K16 的最终对照仍待 P08。不得用本地 Mock 冒充外部签收。 diff --git a/internal/store/current.go b/internal/store/current.go index 9d34ef1..1bdcf31 100644 --- a/internal/store/current.go +++ b/internal/store/current.go @@ -193,10 +193,26 @@ func (s *CurrentStore) CloseAdmission(dispatcherID string) error { if dispatcherID == "" { return errors.New("dispatcher ID is required") } - if _, err := s.db.Exec(`INSERT INTO dispatcher_state(dispatcher_id,discovery_ready) VALUES(?,0) + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("begin Dispatcher admission fence: %w", err) + } + defer tx.Rollback() + var boundID string + checkErr := tx.QueryRow(`SELECT dispatcher_id FROM dispatcher_state WHERE dispatcher_id<>? LIMIT 1`, dispatcherID).Scan(&boundID) + if checkErr == nil { + return fmt.Errorf("%w: stored=%s requested=%s", ErrDispatcherIdentityMismatch, boundID, dispatcherID) + } + if !errors.Is(checkErr, sql.ErrNoRows) { + return fmt.Errorf("read SQLite Dispatcher owner: %w", checkErr) + } + if _, err := tx.Exec(`INSERT INTO dispatcher_state(dispatcher_id,discovery_ready) VALUES(?,0) ON CONFLICT(dispatcher_id) DO UPDATE SET discovery_ready=0`, dispatcherID); err != nil { return fmt.Errorf("close task admission: %w", err) } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit Dispatcher admission fence: %w", err) + } return nil } diff --git a/internal/store/current_identity_test.go b/internal/store/current_identity_test.go new file mode 100644 index 0000000..65744e1 --- /dev/null +++ b/internal/store/current_identity_test.go @@ -0,0 +1,53 @@ +package store + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestCurrentStoreRejectsForeignDispatcherWithoutTouchingOutbox(t *testing.T) { + const foreignID = "862c8e9b-26b8-4642-9fac-c7d85f487a30" + const eventID = "retained-outbox-event" + const originalBody = `{"proof":"original"}` + path := filepath.Join(t.TempDir(), "dispatcher.sqlite") + owner, err := OpenCurrent(path) + if err != nil { + t.Fatal(err) + } + if err := owner.CloseAdmission(currentDispatcherID); err != nil { + t.Fatal(err) + } + if _, err := owner.db.Exec(`INSERT INTO dispatcher_outbox(dispatcher_id,event_id,event_type,routing_key,body,confirmed) + VALUES(?,?,?,?,?,0)`, currentDispatcherID, eventID, "call.execute.result", "agent-call.saas.events.v1", []byte(originalBody)); err != nil { + t.Fatal(err) + } + if err := owner.Close(); err != nil { + t.Fatal(err) + } + + foreign, err := OpenCurrent(path) + if err != nil { + t.Fatal(err) + } + defer foreign.Close() + if err := foreign.CloseAdmission(foreignID); !errors.Is(err, ErrDispatcherIdentityMismatch) { + t.Fatalf("foreign Dispatcher admitted to an existing database: %v", err) + } + var count int + var survivingID string + if err := foreign.db.QueryRow(`SELECT COUNT(*), MIN(dispatcher_id) FROM dispatcher_state`).Scan(&count, &survivingID); err != nil { + t.Fatal(err) + } + if count != 1 || survivingID != currentDispatcherID { + t.Fatalf("foreign startup changed the original Dispatcher identity: count=%d identity=%q", count, survivingID) + } + var body []byte + var confirmed int + if err := foreign.db.QueryRow(`SELECT body,confirmed FROM dispatcher_outbox WHERE dispatcher_id=? AND event_id=?`, currentDispatcherID, eventID).Scan(&body, &confirmed); err != nil { + t.Fatal(err) + } + if string(body) != originalBody || confirmed != 0 { + t.Fatal("foreign startup changed an unconfirmed original outbox record") + } +}