From dbae19e80dc1c467211e25d9fb54d42dece403fc Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 23 Sep 2026 15:44:48 +0000 Subject: [PATCH] fix(intentflow): bind resource versions for U10/U11 at plan create Mutate/docgov live Authorize requires ExpectedResourceVersion; populate it (and frame slots) when HandleTurn drafts confirm-class steps so confirm no longer fails closed with PLAN_ACTION_VERSION_CONFLICT. --- internal/app/app.go | 3 +- internal/app/docgov_owner.go | 13 ++---- internal/app/intentflow/plans.go | 34 ++++++++++---- internal/app/intentflow/turn.go | 46 +++++++++++++++++-- internal/app/resource_version_binder.go | 59 +++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 23 deletions(-) create mode 100644 internal/app/resource_version_binder.go diff --git a/internal/app/app.go b/internal/app/app.go index 91d1e88a..bcbe5e58 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -306,7 +306,8 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { taskOwner, liveOwners, ), - Events: events.PGSink{}, + Events: events.PGSink{}, + ResourceVersionBinder: newResourceVersionBinder(projectBlackboardPort(pool, parts.Ctx), parts.Gov), }) } svc.SetAgentMaterializer(rt.Docflow) diff --git a/internal/app/docgov_owner.go b/internal/app/docgov_owner.go index 54bfa8c6..4f3fde86 100644 --- a/internal/app/docgov_owner.go +++ b/internal/app/docgov_owner.go @@ -2,10 +2,8 @@ package app import ( "context" - "encoding/json" "fmt" "strconv" - "strings" "sync" "chat-agent-system/internal/app/intentflow" @@ -148,15 +146,10 @@ func (d *DocGovOwner) LookupReceipt(ctx context.Context, idempotencyKey string) } func docIDFromExec(exec intentflow.StepExec) string { - if len(exec.Slots) > 0 { - var slots map[string]string - if json.Unmarshal(exec.Slots, &slots) == nil { - if id := strings.TrimSpace(slots["doc_id"]); id != "" { - return id - } - } + if id := docIDFromSlotsOrSummary(exec.Slots, exec.SafeSummary); id != "" { + return id } - return strings.TrimSpace(exec.SafeSummary) + return "" } func docVersion(doc *docgov.DocumentRecord) string { diff --git a/internal/app/intentflow/plans.go b/internal/app/intentflow/plans.go index 7cfa90ce..c74923ab 100644 --- a/internal/app/intentflow/plans.go +++ b/internal/app/intentflow/plans.go @@ -24,20 +24,35 @@ type Plans struct { owner ActionOwner authz StepAuthorizer events EventSink + bind ResourceVersionBinder now func() time.Time newID func(prefix string) string lease time.Duration ttl time.Duration } +// ResourceVersionBindInput carries enough context to resolve optimistic versions +// for mutate/docgov steps at plan-create time (U10/U11). +type ResourceVersionBindInput struct { + Principal Principal + ActionKey string + SafeSummary string + Slots []byte +} + +// ResourceVersionBinder returns the expected resource version for a draft step. +// Empty string means the action key does not require a version binding. +type ResourceVersionBinder func(ctx context.Context, in ResourceVersionBindInput) (string, error) + type Options struct { - Owner ActionOwner - Authorizer StepAuthorizer - Events EventSink - Now func() time.Time - NewID func(prefix string) string - Lease time.Duration - PlanTTL time.Duration + Owner ActionOwner + Authorizer StepAuthorizer + Events EventSink + ResourceVersionBinder ResourceVersionBinder + Now func() time.Time + NewID func(prefix string) string + Lease time.Duration + PlanTTL time.Duration } func NewPlans(unit uow.UnitOfWork, store Store, opt Options) *Plans { @@ -63,7 +78,10 @@ func NewPlans(unit uow.UnitOfWork, store Store, opt Options) *Plans { authz = a } } - return &Plans{uow: unit, store: store, owner: opt.Owner, authz: authz, events: opt.Events, now: now, newID: newID, lease: lease, ttl: ttl} + return &Plans{ + uow: unit, store: store, owner: opt.Owner, authz: authz, events: opt.Events, + bind: opt.ResourceVersionBinder, now: now, newID: newID, lease: lease, ttl: ttl, + } } func (p *Plans) stepAuthorizer() StepAuthorizer { diff --git a/internal/app/intentflow/turn.go b/internal/app/intentflow/turn.go index 8c894843..819fbbf6 100644 --- a/internal/app/intentflow/turn.go +++ b/internal/app/intentflow/turn.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "strings" @@ -118,11 +119,27 @@ func (p *Plans) handleTurnElevated(ctx context.Context, in TurnInput) (*TurnResu if tier == TierShadow { mode = ModeValidatedOnly } + slots := slotsJSONFromSegment(seg) + expectedRev := "" + if p != nil && p.bind != nil && mode == ModeLive { + bound, bindErr := p.bind(ctx, ResourceVersionBindInput{ + Principal: in.Principal, + ActionKey: key, + SafeSummary: summary, + Slots: slots, + }) + if bindErr != nil { + return &TurnResult{}, nil + } + expectedRev = bound + } drafts = append(drafts, StepDraft{ - ActionKey: key, - ActionDigest: fmt.Sprintf("%s:%d:%s", proposal.Digest, i, key), - SafeSummary: summary, - ExecutionMode: mode, + ActionKey: key, + ActionDigest: fmt.Sprintf("%s:%d:%s", proposal.Digest, i, key), + SafeSummary: summary, + ExecutionMode: mode, + Slots: slots, + ExpectedResourceVersion: expectedRev, }) } turnID := in.ClientTurnID @@ -202,6 +219,27 @@ func counterfactualRoute(proposal *intent.PlanProposal) string { } } +func slotsJSONFromSegment(seg intent.ProposedSegment) []byte { + if seg.Frame == nil || len(seg.Frame.Slots) == 0 { + return nil + } + out := make(map[string]string, len(seg.Frame.Slots)) + for k, sv := range seg.Frame.Slots { + v := strings.TrimSpace(sv.Value) + if v != "" { + out[k] = v + } + } + if len(out) == 0 { + return nil + } + b, err := json.Marshal(out) + if err != nil { + return nil + } + return b +} + func shortDigest(text string) string { sum := sha256.Sum256([]byte(text)) return hex.EncodeToString(sum[:8]) diff --git a/internal/app/resource_version_binder.go b/internal/app/resource_version_binder.go new file mode 100644 index 00000000..8f9ac9f6 --- /dev/null +++ b/internal/app/resource_version_binder.go @@ -0,0 +1,59 @@ +package app + +import ( + "context" + "encoding/json" + "strings" + + "chat-agent-system/internal/app/intentflow" + "chat-agent-system/internal/docgov" +) + +// newResourceVersionBinder binds optimistic versions for U10/U11 live keys at +// plan-create time so confirm-time Authorize sees ExpectedResourceVersion. +func newResourceVersionBinder(bb ProjectBlackboardPort, gov *docgov.GovernanceEngine) intentflow.ResourceVersionBinder { + return func(ctx context.Context, in intentflow.ResourceVersionBindInput) (string, error) { + switch in.ActionKey { + case mutateActionKey: + if bb.CurrentVersion == nil { + return "0", nil + } + return bb.CurrentVersion(ctx, in.Principal.ProjectID) + case "document.claim", "document.review.submit": + if gov == nil { + return "", intentflow.ErrPlanActionForbidden + } + docID := docIDFromSlotsOrSummary(in.Slots, in.SafeSummary) + if docID == "" { + return "", intentflow.ErrPlanActionForbidden + } + doc, err := gov.GetDocument(ctx, docID) + if err != nil || doc == nil { + return "", intentflow.ErrPlanActionForbidden + } + return docVersion(doc), nil + default: + return "", nil + } + } +} + +func docIDFromSlotsOrSummary(slots []byte, summary string) string { + if len(slots) > 0 { + var m map[string]string + if json.Unmarshal(slots, &m) == nil { + if id := strings.TrimSpace(m["doc_id"]); id != "" { + return id + } + } + } + summary = strings.TrimSpace(summary) + // Prefer explicit doc_proj* tokens embedded in the utterance summary. + for _, tok := range strings.Fields(summary) { + tok = strings.Trim(tok, ",。,.;;::") + if strings.HasPrefix(tok, "doc_") { + return tok + } + } + return summary +}