diff --git a/internal/app/app.go b/internal/app/app.go index 711e95a..1fda861 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -707,6 +707,9 @@ func Run(opt Options) error { } srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost) + srv.SetProjectCommandPreflight(func(ctx context.Context, messageID, action string) (extension.ProjectWorkDecision, error) { + return preflightProjectMessage(ctx, st, workObserverSlot, projectSlot.work(), messageID, action) + }) projectSlot.wire(srv, caps) // EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。 srv.SetProjectAuthorizer(authzSlot.Authorize) diff --git a/internal/app/surface_dispatch.go b/internal/app/surface_dispatch.go index 7a8137a..7540747 100644 --- a/internal/app/surface_dispatch.go +++ b/internal/app/surface_dispatch.go @@ -2,6 +2,7 @@ package app import ( "context" + "fmt" "agent-system-backend/internal/cmdbus" "agent-system-backend/internal/surfacesvc" @@ -11,6 +12,9 @@ import ( // caller. The button is acting for that user, so both Actor and Owner must be // populated before the command bus writes its audit envelope. func dispatchSurfaceAction(ctx context.Context, bus *cmdbus.Bus, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) { + if cmdbus.RequiresProjectWorkDecision(d.CommandID) { + return surfacesvc.ActionOutcome{}, fmt.Errorf("project formal action requires a committed conversation source preflight") + } resp := bus.Dispatch(ctx, cmdbus.Request{ID: d.CommandID, Params: d.Params, Entrance: "button", Actor: d.Owner, Owner: d.Owner, ConversationID: d.ConversationID}) state := "committed" diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index b3b7621..fe2b15c 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -106,6 +106,31 @@ type Request struct { HumanGrant *HumanGrant } +// RequiresProjectWorkDecision identifies formal RDM writes that must be bound +// to an analyzed committed-message manifest before dispatch. Read views and +// the separate exact-card structure issue/decision path are excluded. +func RequiresProjectWorkDecision(id string) bool { + for _, prefix := range []string{ + "rdm.reqs.", "rdm.review.", "rdm.draft.", "rdm.cnode.", "rdm.cedge.", + "rdm.conclusion.", "rdm.section.", "rdm.stage.", "rdm.change.", + } { + if strings.HasPrefix(id, prefix) { + for _, read := range []string{"rdm.reqs.list", "rdm.reqs.exports", "rdm.reqs.underived", "rdm.review.status", "rdm.review.doc.status", "rdm.draft.proposals", "rdm.conclusion.list", "rdm.change.list"} { + if id == read { + return false + } + } + return true + } + } + switch id { + case "rdm.structure.propose", "rdm.structure.revise", "rdm.docs.instantiate", "rdm.ingest", "rdm.upload.confirm", "rdm.knowledge.publish", "rdm.trace.confirm": + return true + default: + return false + } +} + // HumanGrant ties this dispatch to one persisted human response. // It is not a secret token and is not read from command params. type HumanGrant struct { diff --git a/internal/cmdbus/project_work_command_test.go b/internal/cmdbus/project_work_command_test.go new file mode 100644 index 0000000..a39e8ca --- /dev/null +++ b/internal/cmdbus/project_work_command_test.go @@ -0,0 +1,16 @@ +package cmdbus + +import "testing" + +func TestFormalRDMWritesRequireProjectWorkDecision(t *testing.T) { + for _, id := range []string{"rdm.reqs.add", "rdm.docs.instantiate", "rdm.review.doc.approve", "rdm.structure.propose", "rdm.trace.confirm"} { + if !RequiresProjectWorkDecision(id) { + t.Errorf("formal write %s bypassed project source preflight", id) + } + } + for _, id := range []string{"rdm.inspect.tree", "rdm.reqs.list", "rdm.structure.proposals", "rdm.structure.issue", "rdm.structure.decide"} { + if RequiresProjectWorkDecision(id) { + t.Errorf("read or separately proven command %s was misclassified", id) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c6573be..3cd5345 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -79,7 +79,8 @@ type Server struct { // 全部动作权威;未注册时 read 回落 ProjectAuthz(兼容),其余动作一律 // 默认拒绝——旧只读适配器不得自动获得写入能力。公开契约的版本化扩展 // 计划见 docs/guides/team-storage.md(KTD13 一节)。 - ProjectAuthzActions func(owner string, projectID core.ID, action ProjectAction) (bool, error) + ProjectAuthzActions func(owner string, projectID core.ID, action ProjectAction) (bool, error) + ProjectCommandPreflight func(context.Context, string, string) (extension.ProjectWorkDecision, error) // CompiledPlugins 编译进二进制的进程内插件摘要(组合根在 applyCompiled // 期间记录注册面生成;nil/空 = 无编译插件,清单行为不变)。 CompiledPlugins []CompiledPlugin @@ -113,6 +114,10 @@ func (s *Server) SetProjectActionAuthorizer(fn func(owner string, projectID core s.ProjectAuthzActions = fn } +func (s *Server) SetProjectCommandPreflight(fn func(context.Context, string, string) (extension.ProjectWorkDecision, error)) { + s.ProjectCommandPreflight = fn +} + // authorizeProjectAction 动作矩阵唯一判定(KTD13): // - action-aware 适配器已注册 → 权威(出错/拒绝一律 false,fail-closed); // - 未注册 → read 回落旧只读 ProjectAuthz(兼容 EXT-8 现网),其余动作 @@ -1280,6 +1285,18 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) { if body.Params == nil { body.Params = map[string]any{} } + commandID := r.PathValue("id") + if cmdbus.RequiresProjectWorkDecision(commandID) { + if body.ConversationID == "" || body.AnchorMessageID == "" || s.ProjectCommandPreflight == nil { + http.Error(w, "project command requires a committed source message", http.StatusConflict) + return + } + decision, err := s.ProjectCommandPreflight(r.Context(), body.AnchorMessageID, "project-command:"+commandID) + if err != nil || decision.State != extension.ProjectWorkProceed || decision.DecisionID == "" { + http.Error(w, "project command source analysis is not complete", http.StatusConflict) + return + } + } var hostProject string if body.ConversationID != "" { conv, err := s.st.GetConversation(s.owner(r), body.ConversationID) @@ -1298,7 +1315,7 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) { } dispatch := cmdbus.Request{ - ID: r.PathValue("id"), Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r), + ID: commandID, Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r), Owner: s.owner(r), ConversationID: body.ConversationID, AnchorMessageID: body.AnchorMessageID, HostProjectID: hostProject, }