From 301dbdcfb23a52673c3c8ce6847e9ffb14c9f285 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 29 Aug 2026 02:17:36 +0300 Subject: [PATCH] GEN2-004: add ContextScope and FocusStack V2 --- adapters/postgres/situation_repository.go | 33 ++++ contracts/situation/focus_v2.go | 141 ++++++++++++++++++ contracts/situation/focus_v2_test.go | 22 +++ .../migrations/047_situation_focus_v2.sql | 47 ++++++ dev/ACTIVE_TASK.json | 6 +- modules/situation/domain/focus_v2.go | 86 +++++++++++ modules/situation/domain/focus_v2_test.go | 36 +++++ 7 files changed, 366 insertions(+), 5 deletions(-) create mode 100644 contracts/situation/focus_v2.go create mode 100644 contracts/situation/focus_v2_test.go create mode 100644 database/migrations/047_situation_focus_v2.sql create mode 100644 modules/situation/domain/focus_v2.go create mode 100644 modules/situation/domain/focus_v2_test.go diff --git a/adapters/postgres/situation_repository.go b/adapters/postgres/situation_repository.go index 773ad36..8437f67 100644 --- a/adapters/postgres/situation_repository.go +++ b/adapters/postgres/situation_repository.go @@ -2,6 +2,7 @@ package postgres import ( "context" + "encoding/json" "strings" "git.cin.su/m/Assistant/contracts/identity" @@ -18,6 +19,38 @@ func OpenSituation(ctx context.Context, config ContinuityConfig) (*SituationRepo } return &SituationRepository{pool: pool}, nil } + +func (r *SituationRepository) SaveContextScope(ctx context.Context, scope situation.ContextScope) error { + if err := scope.Validate(); err != nil { + return err + } + bindings, err := json.Marshal(scope.Bindings) + if err != nil { + return err + } + _, err = r.pool.Exec(ctx, `INSERT INTO situation.context_scopes(context_scope_id,principal_id,revision,bindings,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5) ON CONFLICT(context_scope_id) DO UPDATE SET revision=EXCLUDED.revision, bindings=EXCLUDED.bindings, recorded_at_unix_millis=EXCLUDED.recorded_at_unix_millis`, scope.ID, scope.PrincipalID, scope.Revision, bindings, scope.RecordedAt) + return err +} + +func (r *SituationRepository) SaveFocusFrame(ctx context.Context, frame situation.FocusFrame) error { + if err := frame.Validate(); err != nil { + return err + } + ref, err := json.Marshal(frame.ContextScopeRef) + if err != nil { + return err + } + _, err = r.pool.Exec(ctx, `INSERT INTO situation.focus_frames(focus_frame_id,principal_id,actor_id,revision,authority_scope_ref,context_scope_ref,goal_ref,task_ref,source_utterance_ref,provenance,confidence_millis,state,parent_frame_ref,activated_at_unix_millis,suspended_at_unix_millis,expired_at_unix_millis,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) ON CONFLICT(focus_frame_id) DO UPDATE SET revision=EXCLUDED.revision, context_scope_ref=EXCLUDED.context_scope_ref, state=EXCLUDED.state, suspended_at_unix_millis=EXCLUDED.suspended_at_unix_millis, expired_at_unix_millis=EXCLUDED.expired_at_unix_millis, recorded_at_unix_millis=EXCLUDED.recorded_at_unix_millis`, frame.ID, frame.PrincipalID, frame.ActorID, frame.Revision, frame.AuthorityScopeRef, ref, frame.GoalRef, frame.TaskRef, frame.SourceUtteranceRef, frame.Provenance, frame.ConfidenceMillis, frame.State, frame.ParentFrameRef, frame.ActivatedAt, frame.SuspendedAt, frame.ExpiredAt, frame.RecordedAt) + return err +} + +func (r *SituationRepository) AppendFocusTransition(ctx context.Context, transition situation.FocusTransition) error { + if err := transition.Validate(); err != nil { + return err + } + _, err := r.pool.Exec(ctx, `INSERT INTO situation.focus_transitions(transition_id,focus_frame_id,from_revision,to_revision,operation,cause_ref,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7)`, transition.TransitionID, transition.FrameID, transition.FromRevision, transition.ToRevision, transition.Operation, transition.CauseRef, transition.RecordedAt) + return err +} func (r *SituationRepository) Close() { r.pool.Close() } func (r *SituationRepository) Save(ctx context.Context, binding situation.FocusBinding) error { _, err := r.pool.Exec(ctx, `INSERT INTO situation.focus_bindings (focus_binding_id, actor_id, session_id, workspace_id, project_id, goal_id, scope_id, active) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (focus_binding_id) DO UPDATE SET active=EXCLUDED.active`, binding.ID, binding.ActorID, binding.SessionID, binding.Workspace, binding.ProjectID, binding.GoalID, binding.ScopeID, binding.Active) diff --git a/contracts/situation/focus_v2.go b/contracts/situation/focus_v2.go new file mode 100644 index 0000000..937f6e8 --- /dev/null +++ b/contracts/situation/focus_v2.go @@ -0,0 +1,141 @@ +package situation + +import ( + "fmt" + + "git.cin.su/m/Assistant/contracts/authority" + "git.cin.su/m/Assistant/contracts/identity" + "git.cin.su/m/Assistant/contracts/work" + "git.cin.su/m/Assistant/platform/clock" + "git.cin.su/m/Assistant/platform/ids" +) + +type ContextScopeID ids.StableID +type FocusFrameID ids.StableID +type FocusTransitionID ids.StableID +type FocusRevision ids.Revision + +type RoleKey string +type MatchPolicy string +type FocusFrameState string +type FocusProvenance string +type FocusTransitionOperation string + +const ( + MatchRequired MatchPolicy = "REQUIRED" + MatchPreferred MatchPolicy = "PREFERRED" + MatchRelated MatchPolicy = "RELATED" + MatchExcluded MatchPolicy = "EXCLUDED" + + RolePerson RoleKey = "PERSON" + RoleOrganization RoleKey = "ORGANIZATION" + RoleFamily RoleKey = "FAMILY" + RoleEnvironment RoleKey = "ENVIRONMENT" + RoleLocation RoleKey = "LOCATION" + RoleSystem RoleKey = "SYSTEM" + RoleDatabase RoleKey = "DATABASE" + RoleRepository RoleKey = "REPOSITORY" + RoleBranch RoleKey = "BRANCH" + RoleVersion RoleKey = "VERSION" + RoleActivity RoleKey = "ACTIVITY" + RoleProject RoleKey = "PROJECT" + RoleActiveEntity RoleKey = "ACTIVE_ENTITY" + + FocusCandidate FocusFrameState = "CANDIDATE" + FocusActive FocusFrameState = "ACTIVE" + FocusSuspended FocusFrameState = "SUSPENDED" + FocusExpired FocusFrameState = "EXPIRED" + FocusSuperseded FocusFrameState = "SUPERSEDED" + + FocusExplicitHuman FocusProvenance = "EXPLICIT_HUMAN" + FocusUserConfirmed FocusProvenance = "USER_CONFIRMED" + FocusInherited FocusProvenance = "INHERITED" + FocusEntityReference FocusProvenance = "ENTITY_REFERENCE" + FocusTaskReference FocusProvenance = "TASK_REFERENCE" + FocusRepositoryReference FocusProvenance = "REPOSITORY_REFERENCE" + FocusEnvironmentObservation FocusProvenance = "ENVIRONMENT_OBSERVATION" + FocusInferred FocusProvenance = "INFERRED" + + TransitionActivate FocusTransitionOperation = "ACTIVATE" + TransitionSuspend FocusTransitionOperation = "SUSPEND" + TransitionResume FocusTransitionOperation = "RESUME" + TransitionSupersede FocusTransitionOperation = "SUPERSEDE" + TransitionExpire FocusTransitionOperation = "EXPIRE" +) + +type ContextBinding struct { + RoleKey RoleKey + TargetRef SemanticReference + MatchPolicy MatchPolicy + Provenance string + ConfidenceMillis int64 + SourceRef *SemanticReference +} + +type ContextScope struct { + ID ContextScopeID + PrincipalID identity.PrincipalID + Revision FocusRevision + Bindings []ContextBinding + RecordedAt clock.Instant +} + +type FocusFrame struct { + ID FocusFrameID + PrincipalID identity.PrincipalID + ActorID identity.ActorID + Revision FocusRevision + AuthorityScopeRef authority.ScopeID + ContextScopeRef SemanticReference + GoalRef *work.GoalID + TaskRef *work.TaskID + SourceUtteranceRef string + Provenance FocusProvenance + ConfidenceMillis int64 + State FocusFrameState + ParentFrameRef *FocusFrameID + ActivatedAt clock.Instant + SuspendedAt *clock.Instant + ExpiredAt *clock.Instant + RecordedAt clock.Instant +} + +type FocusTransition struct { + TransitionID FocusTransitionID + FrameID FocusFrameID + FromRevision FocusRevision + ToRevision FocusRevision + Operation FocusTransitionOperation + CauseRef string + RecordedAt clock.Instant +} + +func (s ContextScope) Validate() error { + if s.ID == "" || s.PrincipalID == "" || !ids.Revision(s.Revision).Valid() || !s.RecordedAt.Valid() { + return fmt.Errorf("invalid context scope") + } + for _, b := range s.Bindings { + if b.RoleKey == "" || !b.TargetRef.Valid() || !validMatchPolicy(b.MatchPolicy) { + return fmt.Errorf("invalid context binding") + } + } + return nil +} + +func (f FocusFrame) Validate() error { + if f.ID == "" || f.PrincipalID == "" || f.ActorID == "" || !ids.Revision(f.Revision).Valid() || !f.ContextScopeRef.Valid() || f.State == "" || f.Provenance == "" || !f.RecordedAt.Valid() { + return fmt.Errorf("invalid focus frame") + } + return nil +} + +func (t FocusTransition) Validate() error { + if t.TransitionID == "" || t.FrameID == "" || !ids.Revision(t.FromRevision).Valid() || !ids.Revision(t.ToRevision).Valid() || t.Operation == "" || !t.RecordedAt.Valid() { + return fmt.Errorf("invalid focus transition") + } + return nil +} + +func validMatchPolicy(p MatchPolicy) bool { + return p == MatchRequired || p == MatchPreferred || p == MatchRelated || p == MatchExcluded +} diff --git a/contracts/situation/focus_v2_test.go b/contracts/situation/focus_v2_test.go new file mode 100644 index 0000000..3745d49 --- /dev/null +++ b/contracts/situation/focus_v2_test.go @@ -0,0 +1,22 @@ +package situation_test + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/identity" + contract "git.cin.su/m/Assistant/contracts/situation" + "git.cin.su/m/Assistant/platform/clock" + "git.cin.su/m/Assistant/platform/ids" +) + +func TestFocusV2ContractsValidateTypedScopeAndFrame(t *testing.T) { + rev := ids.Revision(1) + scope := contract.ContextScope{ID: "scope", PrincipalID: identity.PrincipalID("company-a"), Revision: contract.FocusRevision(rev), RecordedAt: clock.Instant(1), Bindings: []contract.ContextBinding{{RoleKey: contract.RoleOrganization, TargetRef: contract.SemanticReference{OwnerNamespace: "world", SemanticKind: "organization", StableID: "a"}, MatchPolicy: contract.MatchRequired}}} + if err := scope.Validate(); err != nil { + t.Fatal(err) + } + frame := contract.FocusFrame{ID: "frame", PrincipalID: scope.PrincipalID, ActorID: "actor", Revision: 1, ContextScopeRef: contract.SemanticReference{OwnerNamespace: "situation", SemanticKind: "context_scope", StableID: ids.StableID(scope.ID), Revision: &rev}, State: contract.FocusActive, Provenance: contract.FocusExplicitHuman, RecordedAt: 1} + if err := frame.Validate(); err != nil { + t.Fatal(err) + } +} diff --git a/database/migrations/047_situation_focus_v2.sql b/database/migrations/047_situation_focus_v2.sql new file mode 100644 index 0000000..ef45857 --- /dev/null +++ b/database/migrations/047_situation_focus_v2.sql @@ -0,0 +1,47 @@ +BEGIN; + +SET ROLE situation_role; + +CREATE TABLE IF NOT EXISTS situation.context_scopes ( + context_scope_id TEXT PRIMARY KEY, + principal_id TEXT NOT NULL, + revision BIGINT NOT NULL CHECK (revision > 0), + bindings JSONB NOT NULL, + recorded_at_unix_millis BIGINT NOT NULL CHECK (recorded_at_unix_millis > 0) +); + +CREATE TABLE IF NOT EXISTS situation.focus_frames ( + focus_frame_id TEXT PRIMARY KEY, + principal_id TEXT NOT NULL, + actor_id TEXT NOT NULL, + revision BIGINT NOT NULL CHECK (revision > 0), + authority_scope_ref TEXT NOT NULL, + context_scope_ref JSONB NOT NULL, + goal_ref TEXT, + task_ref TEXT, + source_utterance_ref TEXT, + provenance TEXT NOT NULL, + confidence_millis BIGINT NOT NULL CHECK (confidence_millis >= 0), + state TEXT NOT NULL, + parent_frame_ref TEXT, + activated_at_unix_millis BIGINT NOT NULL CHECK (activated_at_unix_millis > 0), + suspended_at_unix_millis BIGINT, + expired_at_unix_millis BIGINT, + recorded_at_unix_millis BIGINT NOT NULL CHECK (recorded_at_unix_millis > 0) +); + +CREATE TABLE IF NOT EXISTS situation.focus_transitions ( + transition_id TEXT PRIMARY KEY, + focus_frame_id TEXT NOT NULL, + from_revision BIGINT NOT NULL CHECK (from_revision > 0), + to_revision BIGINT NOT NULL CHECK (to_revision > 0), + operation TEXT NOT NULL, + cause_ref TEXT NOT NULL, + recorded_at_unix_millis BIGINT NOT NULL CHECK (recorded_at_unix_millis > 0) +); + +CREATE INDEX IF NOT EXISTS focus_transitions_frame_order ON situation.focus_transitions(focus_frame_id, recorded_at_unix_millis, transition_id); +GRANT SELECT, INSERT, UPDATE ON situation.context_scopes, situation.focus_frames, situation.focus_transitions TO situation_role; + +RESET ROLE; +COMMIT; diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 8f5e6f2..92f7e23 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1 @@ -{ - "task_file": "dev/tasks/GEN2-003-add-narrow-world-owner-and-typed-vocabulary.json", - "task_revision": 1, - "task_id": "GEN2-003" -} \ No newline at end of file +{"task_file":"dev/tasks/GEN2-004-replace-project-centric-focus-with-contextscope-focusframe-focusstack.json","task_id":"GEN2-004","task_revision":1} \ No newline at end of file diff --git a/modules/situation/domain/focus_v2.go b/modules/situation/domain/focus_v2.go new file mode 100644 index 0000000..2d3e8e5 --- /dev/null +++ b/modules/situation/domain/focus_v2.go @@ -0,0 +1,86 @@ +package domain + +import ( + "fmt" + + contract "git.cin.su/m/Assistant/contracts/situation" +) + +// FocusStack is a rebuildable, situation-owned projection. Push suspends the +// previous active frame; Pop resumes its parent and never deletes a frame. +type FocusStack struct{ frames []contract.FocusFrame } + +func NewFocusStack(frames []contract.FocusFrame) (FocusStack, error) { + stack := FocusStack{frames: append([]contract.FocusFrame(nil), frames...)} + for _, frame := range stack.frames { + if err := frame.Validate(); err != nil { + return FocusStack{}, err + } + } + return stack, nil +} + +func (s FocusStack) Frames() []contract.FocusFrame { + return append([]contract.FocusFrame(nil), s.frames...) +} + +func (s FocusStack) Current() (contract.FocusFrame, error) { + for i := len(s.frames) - 1; i >= 0; i-- { + if s.frames[i].State == contract.FocusActive { + return s.frames[i], nil + } + } + return contract.FocusFrame{}, ErrNoActiveFocus +} + +func (s FocusStack) Push(frame contract.FocusFrame) (FocusStack, error) { + if err := frame.Validate(); err != nil { + return FocusStack{}, err + } + if frame.State != contract.FocusActive { + return FocusStack{}, fmt.Errorf("pushed frame must be active") + } + frames := s.Frames() + if len(frames) > 0 { + current, err := s.Current() + if err == nil { + for i := range frames { + if frames[i].ID == current.ID { + frames[i].State = contract.FocusSuspended + break + } + } + frame.ParentFrameRef = ¤t.ID + } + } + frames = append(frames, frame) + return FocusStack{frames: frames}, nil +} + +func (s FocusStack) Pop() (FocusStack, contract.FocusFrame, error) { + current, err := s.Current() + if err != nil { + return FocusStack{}, contract.FocusFrame{}, err + } + frames := s.Frames() + for i := range frames { + if frames[i].ID == current.ID { + frames[i].State = contract.FocusSuspended + break + } + } + if current.ParentFrameRef != nil { + for i := range frames { + if frames[i].ID == *current.ParentFrameRef { + frames[i].State = contract.FocusActive + break + } + } + } + return FocusStack{frames: frames}, current, nil +} + +// ProjectFocusProjection retains the V1 API as a read projection only. +func ProjectFocusProjection(frame contract.FocusFrame, project contract.ProjectDescriptor) contract.ActiveProjectFocus { + return contract.ActiveProjectFocus{ActorID: frame.ActorID, PrincipalID: frame.PrincipalID, ProjectID: contract.ProjectID(project.ID), ProjectDisplayName: project.DisplayName, Provenance: contract.ProjectResolutionProvenance(frame.Provenance), ConfidenceMillis: frame.ConfidenceMillis, SourceUtteranceID: frame.SourceUtteranceRef, UpdatedAtUnixMillis: int64(frame.RecordedAt)} +} diff --git a/modules/situation/domain/focus_v2_test.go b/modules/situation/domain/focus_v2_test.go new file mode 100644 index 0000000..d035675 --- /dev/null +++ b/modules/situation/domain/focus_v2_test.go @@ -0,0 +1,36 @@ +package domain_test + +import ( + "testing" + + contract "git.cin.su/m/Assistant/contracts/situation" + "git.cin.su/m/Assistant/modules/situation/domain" +) + +func TestFocusStackPushPopKeepsParentAndSeparatesCompanies(t *testing.T) { + base := contract.FocusFrame{ID: "company-a", PrincipalID: "principal-a", ActorID: "actor", Revision: 1, ContextScopeRef: contract.SemanticReference{OwnerNamespace: "situation", SemanticKind: "context_scope", StableID: "scope-a"}, State: contract.FocusActive, Provenance: contract.FocusExplicitHuman, RecordedAt: 1} + stack, err := domain.NewFocusStack([]contract.FocusFrame{base}) + if err != nil { + t.Fatal(err) + } + child := base + child.ID = "company-b" + child.PrincipalID = "principal-b" + child.ContextScopeRef.StableID = "scope-b" + stack, err = stack.Push(child) + if err != nil { + t.Fatal(err) + } + current, _ := stack.Current() + if current.ID != "company-b" || current.ParentFrameRef == nil || *current.ParentFrameRef != "company-a" { + t.Fatalf("unexpected current %#v", current) + } + stack, _, err = stack.Pop() + if err != nil { + t.Fatal(err) + } + current, _ = stack.Current() + if current.ID != "company-a" { + t.Fatalf("parent was not resumed: %#v", current) + } +}