From 74d98dfa8b6e793ba2fdc39f9a8f28fe76c7c350 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Sun, 6 Sep 2026 23:11:38 +0200 Subject: [PATCH 01/11] sdd: signal tactical Pre-flight can fail with a JSON parse error when an entry ... SDD-Mutation: entry-20260906-231136-s-tac-ynt --- .sdd/graph/2026/09/06-231136-s-tac-ynt.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .sdd/graph/2026/09/06-231136-s-tac-ynt.md diff --git a/.sdd/graph/2026/09/06-231136-s-tac-ynt.md b/.sdd/graph/2026/09/06-231136-s-tac-ynt.md new file mode 100644 index 00000000..9de67202 --- /dev/null +++ b/.sdd/graph/2026/09/06-231136-s-tac-ynt.md @@ -0,0 +1,13 @@ +--- +type: signal +layer: tactical +kind: gap +participants: + - Christopher +confidence: high +topics: + - reliability/pre-flight +summary: 'Pre-flight validation can fail with a JSON parse error when an entry description contains a brace literal such as `Options{Zebra: true}`, as reported in GitHub issue #6. This is a tactical gap signal with high confidence, noting that braces in a description should not trigger a validation error or prevent entry capture.' +--- + +Pre-flight can fail with a JSON parse error when an entry description contains a brace literal such as `Options{Zebra: true}`, as reported in [GitHub issue #6](https://github.com/networkteam/sdd/issues/6). Braces in a description should not cause a validation error or prevent capture. From d734a396236ad8f5252a8d60e9b81b8976878ae1 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Sun, 6 Sep 2026 23:41:00 +0200 Subject: [PATCH 02/11] sdd: decision tactical SDD will consolidate application graph reads around one acquired, ... SDD-Mutation: entry-20260906-234056-d-tac-bae --- .sdd/graph/2026/09/06-234056-d-tac-bae.md | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .sdd/graph/2026/09/06-234056-d-tac-bae.md diff --git a/.sdd/graph/2026/09/06-234056-d-tac-bae.md b/.sdd/graph/2026/09/06-234056-d-tac-bae.md new file mode 100644 index 00000000..9280b4c3 --- /dev/null +++ b/.sdd/graph/2026/09/06-234056-d-tac-bae.md @@ -0,0 +1,42 @@ +--- +type: decision +layer: tactical +kind: directive +refs: + - id: 20260906-121218-d-tac-ccm + kind: builds-on + - id: 20260717-144536-s-tac-h3q + kind: builds-on + - id: 20260830-114446-d-cpt-xc3 + kind: grounded-in +participants: + - Christopher +confidence: high +intent: pending +topics: + - portability/runtime + - cli/architecture + - collaboration/concurrent-work + - reliability/testing +summary: SDD commits to consolidating all application graph reads around a single acquired, revision-fixed source per operation — covering ordinary reads, workflow graph access, attachment reads, configuration, search, and mutation preparation — with strict revision selection semantics, per-operation authorization, and explicit cleanup of resource owners. It builds on the fixed search sources and exact entry indexing (20260906-121218-d-tac-ccm), preserves the artifact-branch read behavior (20260717-144536-s-tac-h3q), and follows the application-package single composition root (20260830-114446-d-cpt-xc3), removing current inconsistencies between materialized graphs, attachment bytes, and branch selection without a new graph-manager hierarchy. +--- + +SDD will consolidate application graph reads around one acquired, revision-fixed source per operation, extending pinned search and indexing to ordinary reads, workflow graph access and mutation preparation. + +This builds on the fixed search sources and exact entry indexing in 20260906-121218-d-tac-ccm, preserves the artifact-branch behavior delivered in 20260717-144536-s-tac-h3q, and follows the single application composition root in 20260830-114446-d-cpt-xc3. Current reads can combine a materialized graph with later attachment bytes or route read-only branch selection through mutation acquisition. One acquired source removes those inconsistencies without another public graph-manager hierarchy or search prepare/execute protocol. + +Keep SnapshotReadQuery and SnapshotReader. An empty branch selects current authority, never the write-routing DefaultBranch. A named branch resolves once; ExactRevision selects precisely that revision or fails; IncludesRevision requires causal inclusion on the selected authority. Exact and including revisions remain exclusive. Read selection requires read authorization and must not depend on TargetAcquirer. Writes retain target acquisition and finalizers. + +Add Branch and BranchFromSession to ReadAttachmentRequest and forward session branch selection from MCP. Resolve entry membership and page bytes through the same acquired source. Pages are independent operations; no revision token spanning pages is added. This supports immutable attachment identities without promising consistency across pages when existing editing features replace bytes. + +Add optional Config *ProjectConfig to AcquiredSnapshot. A nonnil value supplies committed configuration from the same revision as graph and attachments. Derive effective language and dependency declarations once for the current operation; never overwrite shared ProjectRuntime configuration or mutate cached source configuration. AcquiredSnapshot.Config is the application configuration authority. SnapshotData.Config remains stored document data, not a second independently interpreted source of effective settings; document that relationship explicitly. Empty source dependency lists mean no dependencies. Authorization remains independent and current for every selected project. Configuration cannot grant permission. + +Config == nil explicitly selects compatibility with runtime configuration. It is never recovery from a failed source-config read. Revision-bound adapters must return errors for missing or malformed source configuration. Preserve the distinction between committed configuration and runtime composition, including local overrides, credentials, providers and index settings. Source DefaultBranch must not silently reroute writes. Document local configuration precedence and any changed local behavior in adoption guidance. + +Migrate Show, View, attachment reads, procedure discovery, CurrentSnapshot, workflow graph access, dependency expansion, ReconcileSearchIndex and mutation preparation through shared acquisition where they need source access. Each independent session operation acquires anew. Materialized immutable values may survive release; attachment readers and lazy dependencies may not silently outlive their source. Search acquires authorized home and dependency sources before preparation and uses those same sources for coverage and retrieval. No atomic snapshot across projects is promised. + +GraphStore implementations without SnapshotReader retain current-authority reads as explicitly unpinned compatibility. Reject named-branch, exact and causal requests requiring acquired semantics. Never fall back after an acquisition error. Migrate supported local adapters in this change, preserving branch-scoped validation and working ordinary branch reads without mutation acquisition. Resource owners release on success, operation failure, cancellation and partial acquisition failure, joining cleanup errors rather than hiding them. Iterator ownership must make early stop and lifetime limits explicit. + +Exact-source indexing and discovery continuations retain reproducible revisions and descriptors rather than live leases. Missing exact sources fail; completed publication still skips source acquisition and embedding. Preparation uses a fixed base revision and Apply checks that expected revision so concurrent advancement conflicts at the boundary. Existing recovery remains separate. + +Implementation must demonstrate concurrent branch readers, branch advancement between and during operations, independent authorization, source configuration isolation, dependency and attachment consistency, exact-source restart or unavailability, iterator termination, cleanup errors and preparation/apply conflicts. Keep batching, publication, discovery identity and search preparation behavior intact. Run repository and nested-example tests, vet, lint and build. Delivery includes the API delta, caller/lifetime audit, regression evidence, consumer adoption guidance and separate commit, merge and release status. Consumer Git caches, eviction, durable retention and scheduling are outside this change; broader public changes return to dialogue. From 824402aff11853ee02acc92a632d64e4f44b2703 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Sun, 6 Sep 2026 23:41:51 +0200 Subject: [PATCH 03/11] sdd: wip start 20260906-234056-d-tac-bae (Christopher) SDD-Mutation: wip-start-20260906-234150-christopher --- .sdd/graph/wip/20260906-234150-christopher.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .sdd/graph/wip/20260906-234150-christopher.md diff --git a/.sdd/graph/wip/20260906-234150-christopher.md b/.sdd/graph/wip/20260906-234150-christopher.md new file mode 100644 index 00000000..3c21606f --- /dev/null +++ b/.sdd/graph/wip/20260906-234150-christopher.md @@ -0,0 +1,7 @@ +--- +entry: 20260906-234056-d-tac-bae +participant: Christopher +exclusive: true +--- + +Unify acquired graph reads and operation-scoped configuration From ce4bc5a0e641395b88e4b38407dfe74dd7780525 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Sun, 6 Sep 2026 23:48:56 +0200 Subject: [PATCH 04/11] sdd: decision tactical The acquired graph reads implementation preserves current write ... SDD-Mutation: entry-20260906-234852-d-tac-vdu --- .sdd/graph/2026/09/06-234852-d-tac-vdu.md | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .sdd/graph/2026/09/06-234852-d-tac-vdu.md diff --git a/.sdd/graph/2026/09/06-234852-d-tac-vdu.md b/.sdd/graph/2026/09/06-234852-d-tac-vdu.md new file mode 100644 index 00000000..04635445 --- /dev/null +++ b/.sdd/graph/2026/09/06-234852-d-tac-vdu.md @@ -0,0 +1,26 @@ +--- +type: decision +layer: tactical +kind: directive +refs: + - id: 20260906-234056-d-tac-bae + kind: refines + - id: 20260718-185125-s-tac-ddd + kind: grounded-in + - id: 20260730-171311-d-cpt-0cv + kind: related +participants: + - Christopher +confidence: high +intent: pending +topics: + - portability/runtime + - collaboration/concurrent-work +summary: 'This tactical directive commits to implementing the acquired-graph read consolidation while preserving the existing write semantics: fresh-read revalidation, three-attempt retry, and merge behavior for concurrent unrelated appends, with no new write validations or rejection rules. It corrects the preparation-revision wording in 20260906-234056-d-tac-bae as a provenance detail rather than a write precondition, grounds the preserved apply behavior in 20260718-185125-s-tac-ddd, and keeps the delivery-redesign direction of 20260730-171311-d-cpt-0cv out of scope, since document-specific preconditions would change the external write contract. All read-API and related commitments from 20260906-234056-d-tac-bae remain in force.' +--- + +The acquired graph reads implementation preserves current write behavior and the external consumer's GraphStore.Apply contract, correcting the preparation-revision wording in 20260906-234056-d-tac-bae. + +Preparation reads a fixed source, but its revision remains provenance rather than a new write precondition. Preserve the existing fresh-read, revalidation and retry behavior documented in 20260718-185125-s-tac-ddd. Concurrent unrelated appends must retain today's merge behavior. Introduce no new write validations or configuration-based rejection rules; keep existing genuine conflict handling, retry limits and recovery semantics. + +The broader delivery redesign in 20260730-171311-d-cpt-0cv remains outside this implementation because removing whole-graph CAS and adding document-specific preconditions changes the external write contract. Its direction remains active. This scope correction prevents a read-consistency change from becoming a write-semantics migration. All accepted read API, configuration, local adapter and source-lifetime commitments in 20260906-234056-d-tac-bae still apply, and this refinement closes with that implementation. From 0701f2889cb611f7ec25db05a898db74860c7455 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Mon, 7 Sep 2026 00:13:53 +0200 Subject: [PATCH 05/11] feat(reads): unify acquired graph views Implements 20260906-234056-d-tac-bae and 20260906-234852-d-tac-vdu. --- cmd/sdd/serve.go | 37 +- internal/proctest/proctest.go | 15 + pkg/application/acquired_reads_test.go | 347 ++++++++++++++++++ pkg/application/application.go | 143 +++++--- pkg/application/branch_read_fixture_test.go | 47 +++ pkg/application/doc.go | 64 ++++ pkg/application/read_api.go | 10 +- pkg/application/read_snapshot.go | 74 +++- pkg/application/read_snapshot_example_test.go | 49 +++ pkg/application/search_index.go | 10 +- pkg/application/search_preparation.go | 4 + .../session_branch_binding_test.go | 9 +- pkg/application/session_runtime_test.go | 4 +- pkg/application/snapshot.go | 8 +- pkg/application/target.go | 4 +- pkg/application/transition.go | 4 +- .../vector_search_persistent_test.go | 2 +- pkg/application/workflow.go | 90 +++-- ...workflow_branch_target_integration_test.go | 4 +- pkg/application/workflow_project.go | 22 +- pkg/application/workflow_registry.go | 27 +- ...=> workflow_target_graph_internal_test.go} | 36 +- pkg/application/write_api.go | 10 +- pkg/application/write_focus_test.go | 2 +- pkg/application/write_supersede_test.go | 2 +- pkg/local/git_read_test.go | 73 ++++ pkg/local/git_target.go | 28 +- pkg/mcpapp/request_identity_test.go | 2 +- pkg/mcpapp/root_runtime_test.go | 2 +- pkg/mcpapp/server_test.go | 22 +- pkg/mcpapp/tools.go | 4 +- 31 files changed, 973 insertions(+), 182 deletions(-) create mode 100644 pkg/application/acquired_reads_test.go create mode 100644 pkg/application/branch_read_fixture_test.go create mode 100644 pkg/application/read_snapshot_example_test.go rename pkg/application/{workflow_target_graph_test.go => workflow_target_graph_internal_test.go} (93%) create mode 100644 pkg/local/git_read_test.go diff --git a/cmd/sdd/serve.go b/cmd/sdd/serve.go index bb6a1b59..f6ea0fda 100644 --- a/cmd/sdd/serve.go +++ b/cmd/sdd/serve.go @@ -299,7 +299,7 @@ func buildLocalApplication(ctx context.Context, cmd *cli.Command, graphDir, sddD } runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ Project: sdd.ProjectRef{ID: project, DisplayName: displayName}, DefaultBranch: cfg.DefaultBranch, Language: language, - Dependencies: dependencies, Graph: graph, Targets: targets, Branches: targets, + Dependencies: dependencies, Graph: localBranchReadStore{GraphStore: graph, branches: targets}, Targets: targets, Branches: targets, Recovery: sdd.RecoveryAuthorizerFunc(func(_ context.Context, request sdd.RecoveryAccessRequest) error { if request.Actor.Subject != request.OriginalSubject { return &sdd.ApplicationError{Code: sdd.ErrorWriteDenied, Message: "cross-principal recovery is not authorized by the local runtime"} @@ -409,9 +409,44 @@ func collectSessions(ctx context.Context, application *sdd.Application, retentio } } +type localBranchReadStore struct { + sdd.GraphStore + branches sdd.SnapshotReader +} + +func (s localBranchReadStore) AcquireSnapshot(ctx context.Context, q sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + if q.Branch != "" { + return s.branches.AcquireSnapshot(ctx, q) + } + return s.GraphStore.(sdd.SnapshotReader).AcquireSnapshot(ctx, q) +} + func newLocalMutationTargets(project sdd.ProjectID, serverCheckout string) (*localadapter.GitWorktreeAcquirer, error) { return localadapter.NewGitWorktreeAcquirer(localadapter.GitWorktreeAcquirerOptions{ Project: project, ServerCheckout: serverCheckout, + ReadFactory: func(ctx context.Context, checkout string, q sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + cfg, err := resolveConfigAt(filepath.Join(checkout, model.SDDDirName)) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, fmt.Errorf("read checkout %q has no SDD configuration", checkout) + } + id := sdd.ProjectID(cfg.RepoID) + if id == "" { + id = "local" + } + if id != project { + return nil, fmt.Errorf("read checkout %q does not contain project %s", checkout, project) + } + graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{ + Project: project, GraphDir: meta.ResolveGraphDir(checkout, cfg), Branch: q.Branch, + }) + if err != nil { + return nil, err + } + return graph.AcquireSnapshot(ctx, q) + }, Factory: func(_ context.Context, checkout string, target sdd.MutationTarget) (sdd.GraphStore, []sdd.MutationFinalizer, func() error, error) { targetCfg, cfgErr := resolveConfigAt(filepath.Join(checkout, model.SDDDirName)) if cfgErr != nil { diff --git a/internal/proctest/proctest.go b/internal/proctest/proctest.go index 7c376ac4..53d3fc9d 100644 --- a/internal/proctest/proctest.go +++ b/internal/proctest/proctest.go @@ -179,6 +179,20 @@ func (b branchTargets) Acquire(_ context.Context, target sdd.MutationTarget) (*s return &sdd.AcquiredTarget{Target: target, Graph: graph, Release: func() error { return nil }}, nil } +type branchReadStore struct { + sdd.GraphStore + targets branchTargets +} + +func (s branchReadStore) AcquireSnapshot(ctx context.Context, q sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + graph := s.targets.graphs[q.Branch] + if graph == nil { + graph = s.GraphStore + } + q.Branch = "" + return graph.(sdd.SnapshotReader).AcquireSnapshot(ctx, q) +} + // World is one project: a real application over temp stores with a scripted // LLM. GraphDir is the on-disk graph — real writes land there. type World struct { @@ -259,6 +273,7 @@ func NewWorld(t *testing.T, opts ...Option) *World { targets.graphs[branch] = store } options.Targets = targets + options.Graph = branchReadStore{GraphStore: graph, targets: targets} options.Branches = sdd.BranchValidatorFunc(func(context.Context, sdd.MutationTarget) error { return nil }) } runtime, err := sdd.NewProjectRuntime(options) diff --git a/pkg/application/acquired_reads_test.go b/pkg/application/acquired_reads_test.go new file mode 100644 index 00000000..ac782078 --- /dev/null +++ b/pkg/application/acquired_reads_test.go @@ -0,0 +1,347 @@ +package application_test + +import ( + "context" + "errors" + "fmt" + "strings" + "sync/atomic" + "testing" + + sdd "github.com/networkteam/sdd/pkg/application" + "github.com/networkteam/sdd/pkg/llm" + "github.com/networkteam/sdd/pkg/local" +) + +type acquiredReadStore struct { + sdd.GraphStore + acquire func(context.Context, sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) +} + +func (s acquiredReadStore) AcquireSnapshot(ctx context.Context, q sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + return s.acquire(ctx, q) +} +func (s acquiredReadStore) Current(context.Context) (*sdd.Snapshot, error) { + return nil, fmt.Errorf("unpinned Current must not run") +} +func (s acquiredReadStore) ReadAttachmentPage(context.Context, string, string, int64, int) (sdd.AttachmentPage, error) { + return sdd.AttachmentPage{}, fmt.Errorf("live attachment reader must not run") +} + +type attachmentPageFunc func(context.Context, string, string, int64, int) (sdd.AttachmentPage, error) + +func (f attachmentPageFunc) ReadAttachmentPage(ctx context.Context, id, name string, offset int64, limit int) (sdd.AttachmentPage, error) { + return f(ctx, id, name, offset, limit) +} + +func acquiredRuntime(t *testing.T, project string, graph sdd.GraphStore, dependencies ...string) *sdd.ProjectRuntime { + t.Helper() + runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ + Project: sdd.ProjectRef{ID: sdd.ProjectID(project)}, Graph: graph, DefaultBranch: "write-default", Language: "runtime-language", Dependencies: dependencies, + Targets: sdd.TargetAcquirerFunc(func(context.Context, sdd.MutationTarget) (*sdd.AcquiredTarget, error) { + return nil, fmt.Errorf("mutation acquisition denied") + }), + LLM: llm.RunnerFunc(func(context.Context, llm.Request) (llm.Result, error) { return llm.Result{}, nil }), + }) + if err != nil { + t.Fatal(err) + } + return runtime +} +func acquiredSnapshot(t *testing.T, project, revision, body string) *sdd.Snapshot { + t.Helper() + s, err := sdd.BuildSnapshot(t.Context(), sdd.SnapshotData{Project: sdd.ProjectID(project), Revision: revision, + Config: sdd.ProjectConfigDocument{Fields: map[string]any{"language": "must-not-be-interpreted", "dependencies": []string{"wrong"}}}, + Entries: []sdd.EntryDocument{{LogicalPath: "2026/01/01-100000-s-tac-aaa.md", Frontmatter: map[string]any{"type": "signal", "kind": "gap", "layer": "tactical", "summary": body}, Body: body}}, + }) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestAcquiredReadsUseReadAuthorityAndRelease(t *testing.T) { + snapshot := acquiredSnapshot(t, "base", "r1", "Selected source body") + var releases atomic.Int32 + store := acquiredReadStore{acquire: func(_ context.Context, q sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + if q.Branch != "work" && q.Branch != "" { + t.Errorf("branch=%q", q.Branch) + } + return &sdd.AcquiredSnapshot{Snapshot: snapshot, Attachments: staticGraphStore{attachment: "pinned bytes"}, Config: &sdd.ProjectConfig{Language: "source-language"}, Release: func() error { releases.Add(1); return nil }}, nil + }} + app := preparationApp(t, acquiredRuntime(t, "base", store), nil, nil) + identity := sdd.RequestIdentity{Subject: "reader"} + for name, run := range map[string]func() error{ + "show": func() error { + r, e := app.Show(t.Context(), identity, "base", sdd.ShowRequest{Branch: "work", IDs: []string{"20260101-100000-s-tac-aaa"}}) + if e == nil && !strings.Contains(r.Entries, "Selected source body") { + t.Error(r) + } + return e + }, + "view": func() error { + _, e := app.View(t.Context(), identity, "base", sdd.ViewRequest{Branch: "work", Layout: "as-list", OmitRecovery: true}) + return e + }, + "text search": func() error { + _, e := app.Search(t.Context(), identity, "base", sdd.SearchRequest{Branch: "work", SyncMode: sdd.SearchSyncNone, Terms: []string{"Selected"}}) + return e + }, + "attachment": func() error { + r, e := app.ReadAttachment(t.Context(), identity, "base", sdd.ReadAttachmentRequest{Branch: "work", EntryID: "20260101-100000-s-tac-aaa", Filename: "note.md", MaxBytes: 100}) + if e == nil && string(r.Page.Content) != "pinned bytes" { + t.Error(r) + } + return e + }, + "snapshot": func() error { _, e := app.CurrentSnapshot(t.Context(), identity, "base"); return e }, + "procedures": func() error { + _, e := app.Procedures(t.Context(), identity, "base", sdd.ProcedureListRequest{}) + return e + }, + "info": func() error { + r, e := app.Info(t.Context(), identity, "base", sdd.InfoRequest{}) + if e == nil && r.Language != "source-language" { + t.Error(r) + } + return e + }, + } { + t.Run(name, func(t *testing.T) { + before := releases.Load() + if err := run(); err != nil { + t.Fatal(err) + } + if releases.Load() != before+1 { + t.Fatalf("releases=%d", releases.Load()) + } + }) + } +} + +func TestAcquiredConfigurationIsPerOperationAndHasOneAuthority(t *testing.T) { + base := acquiredSnapshot(t, "base", "r1", "sourcehometoken") + dep := acquiredSnapshot(t, "dep", "d1", "sourcedeptoken") + sourceConfig := &sdd.ProjectConfig{Language: "source-language", Dependencies: []string{}} + var compatibility atomic.Bool + var calls atomic.Int32 + store := acquiredReadStore{acquire: func(context.Context, sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + calls.Add(1) + config := sourceConfig + if compatibility.Load() { + config = nil + } + return &sdd.AcquiredSnapshot{Snapshot: base, Config: config, Attachments: staticGraphStore{}, Release: func() error { return nil }}, nil + }} + runtime := acquiredRuntime(t, "base", store, "dep") + dependency := acquiredRuntime(t, "dep", staticGraphStore{snapshot: dep}) + app := preparationApp(t, runtime, dependency, nil) + id := sdd.RequestIdentity{Subject: "reader"} + for _, legacy := range []bool{false, true, false} { + compatibility.Store(legacy) + info, err := app.Info(t.Context(), id, "base", sdd.InfoRequest{}) + if err != nil { + t.Fatal(err) + } + wantLanguage := "source-language" + wantEntries := 1 + if legacy { + wantLanguage = "runtime-language" + wantEntries = 2 + } + if info.Language != wantLanguage { + t.Fatalf("language=%s", info.Language) + } + r, err := app.Search(t.Context(), id, "base", sdd.SearchRequest{AllRepos: true, SyncMode: sdd.SearchSyncNone, Terms: []string{"sourcehometoken|sourcedeptoken"}}) + if err != nil { + t.Fatal(err) + } + if len(r.EntryIDs) != wantEntries { + t.Fatalf("legacy=%v entries=%v", legacy, r.EntryIDs) + } + } + if sourceConfig.Language != "source-language" || len(sourceConfig.Dependencies) != 0 { + t.Fatal("shared config mutated") + } + if calls.Load() != 6 { + t.Fatalf("acquisitions=%d", calls.Load()) + } +} + +func TestAttachmentOperationPinsSourceAcrossBranchAdvance(t *testing.T) { + first := acquiredSnapshot(t, "base", "r1", "Old") + second := acquiredSnapshot(t, "base", "r2", "New") + var advanced atomic.Bool + entered := make(chan struct{}) + resume := make(chan struct{}) + var releases atomic.Int32 + store := acquiredReadStore{acquire: func(_ context.Context, q sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + snapshot := first + body := "old bytes" + if advanced.Load() { + snapshot = second + body = "new bytes" + } + reader := attachmentPageFunc(func(ctx context.Context, _ string, name string, _ int64, _ int) (sdd.AttachmentPage, error) { + if snapshot == first { + close(entered) + select { + case <-resume: + case <-ctx.Done(): + return sdd.AttachmentPage{}, ctx.Err() + } + } + return sdd.AttachmentPage{Filename: name, Content: []byte(body)}, nil + }) + return &sdd.AcquiredSnapshot{Snapshot: snapshot, Attachments: reader, Release: func() error { releases.Add(1); return nil }}, nil + }} + app := preparationApp(t, acquiredRuntime(t, "base", store), nil, nil) + request := sdd.ReadAttachmentRequest{Branch: "main", EntryID: "20260101-100000-s-tac-aaa", Filename: "note.md", MaxBytes: 100} + type outcome struct { + result sdd.ReadAttachmentResult + err error + } + done := make(chan outcome, 1) + go func() { + r, e := app.ReadAttachment(t.Context(), sdd.RequestIdentity{Subject: "first"}, "base", request) + done <- outcome{r, e} + }() + <-entered + advanced.Store(true) + newer, err := app.ReadAttachment(t.Context(), sdd.RequestIdentity{Subject: "second"}, "base", request) + if err != nil { + t.Fatal(err) + } + close(resume) + older := <-done + if older.err != nil { + t.Fatal(older.err) + } + if string(older.result.Page.Content) != "old bytes" || string(newer.Page.Content) != "new bytes" { + t.Fatal("read sources mixed") + } + if releases.Load() != 2 { + t.Fatalf("releases=%d", releases.Load()) + } +} + +func TestAcquiredReadFailuresKeepCleanupErrors(t *testing.T) { + snapshot := acquiredSnapshot(t, "base", "r1", "Body") + operationErr := errors.New("source config missing or malformed") + cleanupErr := errors.New("cleanup failed") + for _, partial := range []bool{false, true} { + t.Run(fmt.Sprint(partial), func(t *testing.T) { + released := 0 + store := acquiredReadStore{acquire: func(context.Context, sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + source := &sdd.AcquiredSnapshot{Snapshot: snapshot, Attachments: attachmentPageFunc(func(context.Context, string, string, int64, int) (sdd.AttachmentPage, error) { + return sdd.AttachmentPage{}, operationErr + }), Release: func() error { released++; return cleanupErr }} + if partial { + return source, operationErr + } + return source, nil + }} + app := preparationApp(t, acquiredRuntime(t, "base", store), nil, nil) + _, err := app.ReadAttachment(t.Context(), sdd.RequestIdentity{Subject: "reader"}, "base", sdd.ReadAttachmentRequest{EntryID: "20260101-100000-s-tac-aaa"}) + if !errors.Is(err, operationErr) || !errors.Is(err, cleanupErr) || released != 1 { + t.Fatalf("err=%v released=%d", err, released) + } + }) + } +} + +func TestLegacyReadsRejectBranchAndCausalSelection(t *testing.T) { + snapshot := acquiredSnapshot(t, "base", "r1", "Body") + app := preparationApp(t, acquiredRuntime(t, "base", staticGraphStore{snapshot: snapshot}), nil, nil) + id := sdd.RequestIdentity{Subject: "reader"} + if _, err := app.Show(t.Context(), id, "base", sdd.ShowRequest{IDs: []string{"20260101-100000-s-tac-aaa"}}); err != nil { + t.Fatal(err) + } + if _, err := app.Show(t.Context(), id, "base", sdd.ShowRequest{Branch: "main", IDs: []string{"20260101-100000-s-tac-aaa"}}); err == nil { + t.Fatal("legacy branch read accepted") + } + if _, err := app.Search(t.Context(), id, "base", sdd.SearchRequest{IncludesRevision: "r1", SyncMode: sdd.SearchSyncNone, Terms: []string{"Body"}}); err == nil { + t.Fatal("legacy causal read accepted") + } +} + +func TestAcquiredReadCancellationReleasesSource(t *testing.T) { + snapshot := acquiredSnapshot(t, "base", "r1", "Body") + ctx, cancel := context.WithCancel(t.Context()) + released := 0 + store := acquiredReadStore{acquire: func(context.Context, sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + return &sdd.AcquiredSnapshot{Snapshot: snapshot, Attachments: attachmentPageFunc(func(ctx context.Context, _ string, _ string, _ int64, _ int) (sdd.AttachmentPage, error) { + cancel() + return sdd.AttachmentPage{}, ctx.Err() + }), Release: func() error { released++; return nil }}, nil + }} + app := preparationApp(t, acquiredRuntime(t, "base", store), nil, nil) + _, err := app.ReadAttachment(ctx, sdd.RequestIdentity{Subject: "reader"}, "base", sdd.ReadAttachmentRequest{EntryID: "20260101-100000-s-tac-aaa"}) + if !errors.Is(err, context.Canceled) || released != 1 { + t.Fatalf("err=%v releases=%d", err, released) + } +} + +func TestSourceConfigurationCannotGrantDependencyAccess(t *testing.T) { + snapshot := acquiredSnapshot(t, "base", "r1", "Body") + released := 0 + config := &sdd.ProjectConfig{Dependencies: []string{"dep"}} + store := acquiredReadStore{acquire: func(context.Context, sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + return &sdd.AcquiredSnapshot{Snapshot: snapshot, Config: config, Attachments: staticGraphStore{}, Release: func() error { released++; return nil }}, nil + }} + access := &multiAccessResolver{base: acquiredRuntime(t, "base", store), deny: true} + app, err := sdd.NewApplication(sdd.ApplicationOptions{Access: access, Sessions: noSessionStore{}, StagedBlobs: noBlobStore{}}) + if err != nil { + t.Fatal(err) + } + _, err = app.Search(t.Context(), sdd.RequestIdentity{Subject: "reader"}, "base", sdd.SearchRequest{AllRepos: true, SyncMode: sdd.SearchSyncNone, Terms: []string{"Body"}}) + if err == nil || released != 1 { + t.Fatalf("err=%v releases=%d", err, released) + } + if len(config.Dependencies) != 1 || config.Dependencies[0] != "dep" { + t.Fatal("source config mutated") + } +} + +func TestWorkflowServeUsesOneViewAndRefreshesNextOperation(t *testing.T) { + first := acquiredSnapshot(t, "base", "r1", "First revision") + second := acquiredSnapshot(t, "base", "r2", "Second revision") + var calls atomic.Int32 + var revision atomic.Int32 + store := acquiredReadStore{acquire: func(context.Context, sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + calls.Add(1) + snapshot, language := first, "first-language" + if revision.Load() > 0 { + snapshot, language = second, "second-language" + } + revision.Store(1) + return &sdd.AcquiredSnapshot{Snapshot: snapshot, Config: &sdd.ProjectConfig{Language: language}, Attachments: staticGraphStore{}, Release: func() error { return nil }}, nil + }} + sessions, err := local.NewFilesystemSessionStoreAt(t.TempDir()) + if err != nil { + t.Fatal(err) + } + app, err := sdd.NewApplication(sdd.ApplicationOptions{Access: &multiAccessResolver{base: acquiredRuntime(t, "base", store)}, Sessions: sessions, StagedBlobs: noBlobStore{}}) + if err != nil { + t.Fatal(err) + } + identity := sdd.RequestIdentity{Subject: "reader"} + workflow, _, err := app.OpenWorkflow(t.Context(), identity, "base", sdd.WorkflowOpenRequest{}) + if err != nil { + t.Fatal(err) + } + revision.Store(0) + for _, want := range []string{"first-language", "second-language"} { + calls.Store(0) + blocks, err := workflow.Framing(t.Context(), identity) + if err != nil { + t.Fatal(err) + } + if calls.Load() != 1 { + t.Fatalf("one framing acquired %d sources", calls.Load()) + } + if !strings.Contains(strings.Join(blocks, "\n"), want) { + t.Fatalf("framing missed %s", want) + } + } +} diff --git a/pkg/application/application.go b/pkg/application/application.go index e95a0aac..6e7fdd88 100644 --- a/pkg/application/application.go +++ b/pkg/application/application.go @@ -81,10 +81,22 @@ func NewApplication(options ApplicationOptions) (*Application, error) { func (a *Application) now() time.Time { return a.clock.Now() } func (a *Application) Info(ctx context.Context, identity RequestIdentity, project ProjectID, _ InfoRequest) (InfoResult, error) { + return a.infoForBranch(ctx, identity, project, "") +} + +func (a *Application) infoForBranch(ctx context.Context, identity RequestIdentity, project ProjectID, branch string) (InfoResult, error) { principal, runtime, err := a.resolve(ctx, identity, project, AccessRead) if err != nil { return InfoResult{}, err } + _, runtime, err = readMaterializedSnapshot(ctx, runtime, branch) + if err != nil { + return InfoResult{}, err + } + return a.infoFromRuntime(ctx, principal, runtime) +} + +func (a *Application) infoFromRuntime(ctx context.Context, principal Principal, runtime *ProjectRuntime) (InfoResult, error) { search := "text" if runtime.options.Embedder != nil && runtime.options.SearchIndex != nil { search = "vector,text" @@ -167,6 +179,7 @@ func (a *Application) Lint(ctx context.Context, identity RequestIdentity, projec return nil, err } defer selected.releaseInto(&err) + runtime = selected.runtime registry, err := ProcedureRegistry() if err != nil { return nil, err @@ -188,7 +201,11 @@ func (a *Application) View(ctx context.Context, identity RequestIdentity, projec return ViewResult{}, withSessionBindingTargetError(request.Branch, request.BranchFromSession, err) } defer selected.releaseInto(&err) - snapshot := selected.snapshot + runtime = selected.runtime + return a.viewFromSnapshot(ctx, identity, runtime, selected.snapshot, request) +} + +func (a *Application) viewFromSnapshot(ctx context.Context, identity RequestIdentity, runtime *ProjectRuntime, snapshot *Snapshot, request ViewRequest) (ViewResult, error) { layout, err := query.ParseLayout(request.Layout) if err != nil { return ViewResult{}, err @@ -217,9 +234,9 @@ func (a *Application) View(ctx context.Context, identity RequestIdentity, projec if err != nil { return ViewResult{}, err } - member, err := dependency.options.Graph.Current(ctx) + member, _, err := readMaterializedSnapshot(ctx, dependency, "") if err != nil { - return ViewResult{}, dependencyUnavailable() + return ViewResult{}, err } memberResult, err := member.finder.View(query.ViewQuery{Layout: layout}) if err != nil { @@ -267,11 +284,16 @@ func (a *Application) Show(ctx context.Context, identity RequestIdentity, projec return ShowResult{}, withSessionBindingTargetError(request.Branch, request.BranchFromSession, err) } defer selected.releaseInto(&err) + runtime = selected.runtime local := selected.snapshot snapshot, err := a.snapshotWithDependenciesFrom(ctx, identity, runtime, local) if err != nil { return ShowResult{}, err } + return showFromSnapshot(runtime, snapshot, request) +} + +func showFromSnapshot(runtime *ProjectRuntime, snapshot *Snapshot, request ShowRequest) (ShowResult, error) { up, down := request.UpDepth, request.DownDepth if up < 0 || down < 0 { return ShowResult{}, fmt.Errorf("sdd: show depths cannot be negative") @@ -315,6 +337,7 @@ func (a *Application) Search(ctx context.Context, identity RequestIdentity, proj return SearchResult{}, withSessionBindingTargetError(request.Branch, request.BranchFromSession, err) } defer selected.releaseInto(&err) + runtime = selected.runtime snapshot := selected.snapshot filter, err := publicGraphFilter(request) if err != nil { @@ -377,75 +400,58 @@ func (a *Application) Search(ctx context.Context, identity RequestIdentity, proj type readSnapshotSelection struct { snapshot *Snapshot store GraphStore + runtime *ProjectRuntime release func() error branch string } -// acquireSnapshotForReadBranch selects only the local project's read -// authority. Empty means the runtime's current graph, not DefaultBranch: -// DefaultBranch is a write-routing fallback and may intentionally point at a -// different checkout. A concrete branch stays acquired until the caller has -// finished reading both the snapshot and its attachments. +// Empty branch keeps current read authority, independently of write routing. func acquireSnapshotForReadBranch(ctx context.Context, runtime *ProjectRuntime, branch string) (*readSnapshotSelection, error) { - if branch == "" { - snapshot, err := runtime.options.Graph.Current(ctx) - if err != nil { - return nil, err - } - return &readSnapshotSelection{snapshot: snapshot, store: runtime.options.Graph}, nil - } - target, err := resolveMutationTarget(runtime, MutationTarget{Project: runtime.options.Project.ID, Branch: branch}) - if err != nil { - return nil, err - } - acquired, err := runtime.acquire(ctx, target) - if err != nil { - return nil, err - } - snapshot, err := acquired.Graph.Current(ctx) - if err != nil { - releaseErr := acquired.Release() - if releaseErr != nil { - releaseErr = fmt.Errorf("releasing read target %s after snapshot failure: %w", branch, releaseErr) - } - return nil, errors.Join(err, releaseErr) - } - return &readSnapshotSelection{snapshot: snapshot, store: acquired.Graph, release: acquired.Release, branch: branch}, nil + return acquireSnapshotSelection(ctx, runtime, SnapshotReadQuery{Branch: branch}) } func (s *readSnapshotSelection) releaseInto(errp *error) { if s == nil || s.release == nil { return } - if releaseErr := s.release(); releaseErr != nil { + release := s.release + s.release = nil + if releaseErr := snapshotRelease(release); releaseErr != nil { *errp = errors.Join(*errp, fmt.Errorf("releasing read target %s: %w", s.branch, releaseErr)) } } -func (a *Application) ReadAttachment(ctx context.Context, identity RequestIdentity, project ProjectID, request ReadAttachmentRequest) (ReadAttachmentResult, error) { +func (a *Application) ReadAttachment(ctx context.Context, identity RequestIdentity, project ProjectID, request ReadAttachmentRequest) (result ReadAttachmentResult, err error) { _, runtime, err := a.resolve(ctx, identity, project, AccessRead) if err != nil { return ReadAttachmentResult{}, err } + selected, err := acquireSnapshotForReadBranch(ctx, runtime, request.Branch) + if err != nil { + return ReadAttachmentResult{}, withSessionBindingTargetError(request.Branch, request.BranchFromSession, err) + } + defer selected.releaseInto(&err) + runtime = selected.runtime entryID := request.EntryID - store := runtime.options.Graph if repoID, memberID, qualified := model.SplitCrossRepoID(request.EntryID); qualified { dependency, depErr := a.dependency(ctx, identity, runtime, repoID) if depErr != nil { return ReadAttachmentResult{}, depErr } - store = dependency.options.Graph + member, depErr := acquireSnapshotForReadBranch(ctx, dependency, "") + if depErr != nil { + return ReadAttachmentResult{}, depErr + } + defer member.releaseInto(&err) + selected = member entryID = memberID } - snapshot, err := store.Current(ctx) - if err != nil { - return ReadAttachmentResult{}, err - } + snapshot := selected.snapshot entry, ok := snapshot.graph.ByID[entryID] if !ok { return ReadAttachmentResult{}, fmt.Errorf("entry not found: %s", entryID) } - page, err := store.ReadAttachmentPage(ctx, entryID, request.Filename, request.Offset, request.MaxBytes) + page, err := selected.store.ReadAttachmentPage(ctx, entryID, request.Filename, request.Offset, request.MaxBytes) if err != nil { return ReadAttachmentResult{}, err } @@ -461,12 +467,16 @@ func (a *Application) Procedures(ctx context.Context, identity RequestIdentity, if err != nil { return ProcedureListResult{}, err } - snapshot, err := runtime.options.Graph.Current(ctx) + snapshot, _, err := readMaterializedSnapshot(ctx, runtime, "") if err != nil { return ProcedureListResult{}, err } + return ProcedureListResult{Project: runtime.options.Project, Procedures: renderProcedureList(snapshot.graph)}, nil +} + +func renderProcedureList(graph *model.Graph) string { var lines []string - for _, chain := range snapshot.graph.ProcedureChains() { + for _, chain := range graph.ProcedureChains() { head := chain.Head if head == nil || head.Canonical == "" || head.IsShellProcedure() || head.IsTaskProcedure() || len(chain.LiveHeads) == 0 { continue @@ -481,7 +491,7 @@ func (a *Application) Procedures(ctx context.Context, identity RequestIdentity, lines = append(lines, fmt.Sprintf("- %s%s — %s", head.Canonical, signature, head.FirstSummarySentence())) } sort.Strings(lines) - return ProcedureListResult{Project: runtime.options.Project, Procedures: strings.Join(lines, "\n")}, nil + return strings.Join(lines, "\n") } func (a *Application) resolvePrincipal(ctx context.Context, identity RequestIdentity) (Principal, error) { @@ -585,7 +595,7 @@ func (r *ProjectRuntime) searchSnapshot(ctx context.Context, snapshot *Snapshot, } func (a *Application) snapshotWithDependencies(ctx context.Context, identity RequestIdentity, runtime *ProjectRuntime) (*Snapshot, error) { - base, err := runtime.options.Graph.Current(ctx) + base, runtime, err := readMaterializedSnapshot(ctx, runtime, "") if err != nil { return nil, err } @@ -593,6 +603,10 @@ func (a *Application) snapshotWithDependencies(ctx context.Context, identity Req } func (a *Application) snapshotWithDependenciesFrom(ctx context.Context, identity RequestIdentity, runtime *ProjectRuntime, base *Snapshot) (*Snapshot, error) { + return a.snapshotWithDependencyPolicy(ctx, identity, runtime, base, true) +} + +func (a *Application) snapshotWithDependencyPolicy(ctx context.Context, identity RequestIdentity, runtime *ProjectRuntime, base *Snapshot, requireSources bool) (*Snapshot, error) { if len(runtime.options.Dependencies) == 0 { return base, nil } @@ -601,19 +615,40 @@ func (a *Application) snapshotWithDependenciesFrom(ctx context.Context, identity return nil, err } local := model.NewGraph(append([]*model.Entry(nil), base.graph.Entries...)) - model.NewMultiGraph(local, append([]string(nil), runtime.options.Dependencies...), func(repoID string) (*model.Graph, error) { - if !slices.Contains(runtime.options.Dependencies, repoID) { - return nil, dependencyUnavailable() + type memberResult struct { + graph *model.Graph + err error + } + members := make(map[string]memberResult, len(runtime.options.Dependencies)) + for _, repoID := range runtime.options.Dependencies { + dependency, depErr := a.access.ResolveDependency(ctx, principal, runtime.options.Project.ID, repoID) + if depErr != nil || dependency == nil { + members[repoID] = memberResult{err: dependencyUnavailable()} + continue } - dependency, err := a.access.ResolveDependency(ctx, principal, runtime.options.Project.ID, repoID) - if err != nil || dependency == nil { - return nil, dependencyUnavailable() + selected, readErr := acquireSnapshotForReadBranch(ctx, dependency, "") + if readErr != nil { + var cleanup *snapshotReleaseError + if requireSources || errors.As(readErr, &cleanup) { + return nil, readErr + } + // A write's existing ref validation decides whether an unavailable + // dependency matters; eagerly loading it adds no new write gate. + members[repoID] = memberResult{err: dependencyUnavailable()} + continue } - snapshot, err := dependency.options.Graph.Current(ctx) - if err != nil { + members[repoID] = memberResult{graph: model.NewGraph(append([]*model.Entry(nil), selected.snapshot.graph.Entries...))} + selected.releaseInto(&readErr) + if readErr != nil { + return nil, readErr + } + } + model.NewMultiGraph(local, append([]string(nil), runtime.options.Dependencies...), func(repoID string) (*model.Graph, error) { + member, ok := members[repoID] + if !ok { return nil, dependencyUnavailable() } - return model.NewGraph(append([]*model.Entry(nil), snapshot.graph.Entries...)), nil + return member.graph, member.err }) clone := *base clone.graph = local diff --git a/pkg/application/branch_read_fixture_test.go b/pkg/application/branch_read_fixture_test.go new file mode 100644 index 00000000..4db39cb0 --- /dev/null +++ b/pkg/application/branch_read_fixture_test.go @@ -0,0 +1,47 @@ +package application_test + +import ( + "context" + "errors" + "fmt" + + sdd "github.com/networkteam/sdd/pkg/application" +) + +// Existing branch fixtures share their adapter bookkeeping between read and +// write ports. Read-only authorization is exercised separately. +type branchReadFixture struct { + sdd.GraphStore + targets sdd.TargetAcquirer + project sdd.ProjectID +} + +func (s branchReadFixture) AcquireSnapshot(ctx context.Context, q sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + if q.Branch == "" { + if reader, ok := s.GraphStore.(sdd.SnapshotReader); ok { + return reader.AcquireSnapshot(ctx, q) + } + snapshot, err := s.Current(ctx) + return &sdd.AcquiredSnapshot{Snapshot: snapshot, Attachments: s.GraphStore, Release: func() error { return nil }}, err + } + target, err := s.targets.Acquire(ctx, sdd.MutationTarget{Project: s.project, Branch: q.Branch}) + if err != nil { + return nil, err + } + if target == nil || target.Graph == nil || target.Release == nil { + return nil, fmt.Errorf("incomplete fixture source") + } + // The fixture selected the branch above, independently of the scoped store. + q.Branch = "" + if reader, ok := target.Graph.(sdd.SnapshotReader); ok { + source, err := reader.AcquireSnapshot(ctx, q) + if err != nil { + return nil, errors.Join(err, target.Release()) + } + release := source.Release + source.Release = func() error { return errors.Join(release(), target.Release()) } + return source, nil + } + snapshot, err := target.Graph.Current(ctx) + return &sdd.AcquiredSnapshot{Snapshot: snapshot, Attachments: target.Graph, Release: target.Release}, err +} diff --git a/pkg/application/doc.go b/pkg/application/doc.go index 073d349d..8eaafbae 100644 --- a/pkg/application/doc.go +++ b/pkg/application/doc.go @@ -1,6 +1,62 @@ // Package application owns SDD's protocol-neutral runtime, public request and // result types, and the infrastructure ports implemented by consumers. // +// # Acquired graph reads +// +// Each application read acquires its source through SnapshotReader when the +// graph store implements it. Branch selection never calls TargetAcquirer. +// An empty branch selects the runtime's current authority, independently of +// DefaultBranch. Show, View and Search preserve their request signatures; +// ReadAttachmentRequest also carries Branch and BranchFromSession. MCP forwards +// the session branch for attachment pages. Each page is an independent read. +// +// AcquiredSnapshot.Config is the sole source of effective read configuration. +// A nonnil value supplies committed language and dependency declarations from +// the acquired revision. Application copies those settings for the operation; +// it never mutates the source configuration or the shared ProjectRuntime. +// SnapshotData.Config is retained document data, not an alternative effective +// configuration input. Hosts populating both must derive them from one source; +// application does not interpret SnapshotData.Config as runtime settings. +// +// Nil Config explicitly retains runtime configuration, including local +// overrides. It never represents a configuration read failure. Revision-backed +// hosts read their selected tree with ReadProjectConfigFS and return missing or +// malformed configuration as an acquisition error. Credentials, providers, index +// configuration and write routing remain runtime composition. Read declarations +// never grant access: each project and dependency is authorized independently. +// +// Stores lacking SnapshotReader retain unpinned current-authority reads only. +// Named-branch, exact and causal selections fail rather than silently weakening +// their guarantees. Acquisition failures never trigger a Current fallback. +// Search preparation still requires pinned sources for all selected projects. +// +// Application owns acquired resources until their last source-dependent read, +// joining release failures with operation errors. Returned canonical snapshots +// are materialized immutable values and need no live lease. Show/workflow +// dependency graphs are materialized before they escape acquisition. Discovery +// iterators instead borrow the caller's lease until iteration finishes or stops. +// SearchTarget must be consumed within PrepareSearch; retain descriptors for +// jobs, never a target or its iterator. Exact indexing retains its existing +// publication shortcut and fails when the recorded source cannot be acquired. +// +// Writes retain the existing GraphStore.Apply contract, fresh-read revalidation, +// retry limit and recovery behavior. The preparation revision remains provenance, +// not a newly imposed write precondition. Acquired read configuration introduces +// no new write validations or changes to write-time configuration precedence. +// +// # Local composition +// +// FilesystemGraphStore pins graph and attachment bytes in memory and deliberately +// returns nil Config: language/dependency settings and local overrides continue +// to come from runtime composition. Its revision covers the graph directory, +// not committed repository configuration. Named local branches resolve registered +// worktrees through GitWorktreeAcquirer.ReadFactory without mutation acquisition; +// the local CLI uses its existing configuration resolver for graph-directory +// overrides and validates project identity. No local configuration precedence +// changes. An unscoped FilesystemGraphStore still rejects a nonempty branch. +// Local exact sources survive while retained by that store instance, not process +// restart. Durable consumers must provide their own reproducible source access. +// // SearchRequest.SyncMode is required. Without ApplicationOptions.PrepareSearch, // SearchSyncNone skips maintenance, SearchSyncLocal reconciles the selected // home snapshot, and SearchSyncAll also reconciles searched dependencies. @@ -26,6 +82,14 @@ // Compose authorized project runtimes through the existing access resolver and // register PrepareSearch once. MCP uses the same application. Every selected // project needs SnapshotReader and SearchIndexEntryStore for custom preparation. +// For branch-aware hosts, implement SnapshotReader on the runtime's GraphStore +// for current, named, exact and causal selections; no read factory belongs in +// the mutation port. Return immutable Config from the same selected source. +// Acquire graph and attachment access together; an operation's release must +// not evict resources used by another lease. Cached graphs may be shared by +// project/revision, but authorization and operation configuration stay separate. +// Changes to loader settings require a distinct cache identity. Keep source +// availability for queued work independently of active leases. // Preserve SDD's Coverage and readable Notice in the consumer's search response. // // In the mutation finalizer or graph-write/recovery adapter, call diff --git a/pkg/application/read_api.go b/pkg/application/read_api.go index 82d126dd..1ba6c656 100644 --- a/pkg/application/read_api.go +++ b/pkg/application/read_api.go @@ -98,10 +98,12 @@ type SearchResult struct { } type ReadAttachmentRequest struct { - EntryID string - Filename string - Offset int64 - MaxBytes int + Branch string + BranchFromSession bool + EntryID string + Filename string + Offset int64 + MaxBytes int } type ReadAttachmentResult struct { diff --git a/pkg/application/read_snapshot.go b/pkg/application/read_snapshot.go index c15bb551..c1c712f0 100644 --- a/pkg/application/read_snapshot.go +++ b/pkg/application/read_snapshot.go @@ -6,7 +6,7 @@ import ( "fmt" ) -// AttachmentPageReader is the read portion of GraphStore used by indexing. +// AttachmentPageReader reads attachment bytes from one acquired source. type AttachmentPageReader interface { ReadAttachmentPage(context.Context, string, string, int64, int) (AttachmentPage, error) } @@ -26,7 +26,11 @@ type SnapshotReadQuery struct { type AcquiredSnapshot struct { Snapshot *Snapshot Attachments AttachmentPageReader - Release func() error + // Config is immutable committed configuration from this source revision. + // Nil explicitly uses runtime configuration; configuration read failures + // must be returned by the adapter, never converted to nil. + Config *ProjectConfig + Release func() error } // SnapshotReader is an optional GraphStore capability for pinned reads. Hosts @@ -62,7 +66,7 @@ func acquireReadSnapshot(ctx context.Context, graph GraphStore, project ProjectI } if err != nil { if source != nil && source.Release != nil { - err = errors.Join(err, source.Release()) + err = errors.Join(err, snapshotRelease(source.Release)) } return nil, err } @@ -80,26 +84,56 @@ func (s pinnedGraphStore) ReadAttachmentPage(ctx context.Context, entry, name st } func acquireSnapshotForSearch(ctx context.Context, runtime *ProjectRuntime, branch, includes string) (*readSnapshotSelection, error) { - selected, err := acquireSnapshotForReadBranch(ctx, runtime, branch) + return acquireSnapshotSelection(ctx, runtime, SnapshotReadQuery{Branch: branch, IncludesRevision: includes}) +} + +func acquireSnapshotSelection(ctx context.Context, runtime *ProjectRuntime, q SnapshotReadQuery) (*readSnapshotSelection, error) { + if _, ok := runtime.options.Graph.(SnapshotReader); !ok { + if q.Branch != "" || q.ExactRevision != "" || q.IncludesRevision != "" { + return nil, markTargetAcquisitionError(MutationTarget{Project: runtime.Project().ID, Branch: q.Branch}, fmt.Errorf("sdd: graph store does not support selected snapshot reads")) + } + snapshot, err := runtime.options.Graph.Current(ctx) + if err != nil { + return nil, err + } + return &readSnapshotSelection{snapshot: snapshot, store: runtime.options.Graph, runtime: runtime}, nil + } + source, err := acquireReadSnapshot(ctx, runtime.options.Graph, runtime.Project().ID, q) if err != nil { - return nil, err + return nil, markTargetAcquisitionError(MutationTarget{Project: runtime.Project().ID, Branch: q.Branch}, err) } - if _, ok := selected.store.(SnapshotReader); !ok { - if includes == "" { - return selected, nil - } - err := fmt.Errorf("sdd: source cannot establish read-your-writes freshness") - selected.releaseInto(&err) - return nil, err + return &readSnapshotSelection{ + snapshot: source.Snapshot, store: pinnedGraphStore{GraphStore: runtime.options.Graph, source: source}, + runtime: runtime.withReadConfig(source.Config), branch: q.Branch, release: source.Release, + }, nil +} + +func (r *ProjectRuntime) withReadConfig(config *ProjectConfig) *ProjectRuntime { + if config == nil { + return r } - source, err := acquireReadSnapshot(ctx, selected.store, runtime.Project().ID, SnapshotReadQuery{Branch: branch, IncludesRevision: includes}) + clone := *r + clone.options.Language = config.Language + clone.options.Dependencies = append([]string(nil), config.Dependencies...) + return &clone +} + +func readMaterializedSnapshot(ctx context.Context, runtime *ProjectRuntime, branch string) (snapshot *Snapshot, effective *ProjectRuntime, err error) { + selected, err := acquireSnapshotForReadBranch(ctx, runtime, branch) if err != nil { - selected.releaseInto(&err) - return nil, err + return nil, nil, err } - return &readSnapshotSelection{snapshot: source.Snapshot, store: pinnedGraphStore{GraphStore: selected.store, source: source}, branch: branch, release: func() error { - err := source.Release() - selected.releaseInto(&err) - return err - }}, nil + defer selected.releaseInto(&err) + return selected.snapshot, selected.runtime, nil +} + +type snapshotReleaseError struct{ cause error } + +func (e *snapshotReleaseError) Error() string { return "releasing snapshot: " + e.cause.Error() } +func (e *snapshotReleaseError) Unwrap() error { return e.cause } +func snapshotRelease(release func() error) error { + if err := release(); err != nil { + return &snapshotReleaseError{cause: err} + } + return nil } diff --git a/pkg/application/read_snapshot_example_test.go b/pkg/application/read_snapshot_example_test.go new file mode 100644 index 00000000..a235f1f8 --- /dev/null +++ b/pkg/application/read_snapshot_example_test.go @@ -0,0 +1,49 @@ +package application_test + +import ( + "context" + "fmt" + "io/fs" + "testing/fstest" + + sdd "github.com/networkteam/sdd/pkg/application" +) + +type exampleTreeAttachments struct { + tree fs.FS + graphDir string +} + +func (r exampleTreeAttachments) ReadAttachmentPage(_ context.Context, entry, name string, offset int64, limit int) (sdd.AttachmentPage, error) { + return sdd.PageAttachment(r.tree, r.graphDir, entry, name, offset, limit) +} + +func ExampleAcquiredSnapshot() { + // A revision-backed adapter obtains this immutable tree from its storage. + tree := fstest.MapFS{ + ".sdd/config.yaml": {Data: []byte("repo_id: example\nlanguage: en\n")}, + ".sdd/graph/2026/01/01-100000-s-tac-aaa.md": {Data: []byte("---\ntype: signal\nkind: fact\nlayer: tactical\nsummary: Source fixture.\n---\n\nSource fixture.")}, + ".sdd/graph/2026/01/01-100000-s-tac-aaa/note.txt": {Data: []byte("Same revision.")}, + } + config, err := sdd.ReadProjectConfigFS(tree) + if err != nil { + panic(err) + } + snapshot, err := sdd.LoadSnapshotFS(context.Background(), "example", "R1", tree, config.GraphDir) + if err != nil { + panic(err) + } + source := &sdd.AcquiredSnapshot{ + Snapshot: snapshot, Config: &config, Attachments: exampleTreeAttachments{tree: tree, graphDir: config.GraphDir}, + Release: func() error { return nil }, + } + page, err := source.Attachments.ReadAttachmentPage(context.Background(), "20260101-100000-s-tac-aaa", "note.txt", 0, 100) + if err != nil { + panic(err) + } + if err := source.Release(); err != nil { + panic(err) + } + fmt.Println(source.Snapshot.Revision(), source.Config.Language, string(page.Content)) + // Output: R1 en Same revision. +} diff --git a/pkg/application/search_index.go b/pkg/application/search_index.go index 3fc7c6d1..0e69eec3 100644 --- a/pkg/application/search_index.go +++ b/pkg/application/search_index.go @@ -20,20 +20,22 @@ const ( // ReconcileSearchIndex maintains the runtime's current graph index. The host // authorizes the call; this operation does not resolve a request identity. -func (r *ProjectRuntime) ReconcileSearchIndex(ctx context.Context, cmd ReconcileSearchIndexCmd) error { +func (r *ProjectRuntime) ReconcileSearchIndex(ctx context.Context, cmd ReconcileSearchIndexCmd) (err error) { namespace, err := r.indexNamespace() if err != nil { return err } - snapshot, err := r.options.Graph.Current(ctx) + selected, err := acquireSnapshotForReadBranch(ctx, r, "") if err != nil { return err } - hashes, err := r.currentEntryHashes(ctx, snapshot.graph.Entries, r.options.Graph) + defer selected.releaseInto(&err) + snapshot := selected.snapshot + hashes, err := r.currentEntryHashes(ctx, snapshot.graph.Entries, selected.store) if err != nil { return err } - return r.reconcileSearchSnapshot(ctx, snapshot, r.options.Graph, namespace, hashes, cmd) + return r.reconcileSearchSnapshot(ctx, snapshot, selected.store, namespace, hashes, cmd) } func (r *ProjectRuntime) indexNamespace() (IndexNamespace, error) { diff --git a/pkg/application/search_preparation.go b/pkg/application/search_preparation.go index 651b8b98..1f08621c 100644 --- a/pkg/application/search_preparation.go +++ b/pkg/application/search_preparation.go @@ -66,6 +66,10 @@ func (t SearchTarget) Entries(ctx context.Context) iter.Seq2[SearchEntryRequirem if !yield(item, err) || err != nil { return } + if t.state.closed.Load() { + yield(SearchEntryRequirement{}, fmt.Errorf("sdd: search target is no longer available")) + return + } } } } diff --git a/pkg/application/session_branch_binding_test.go b/pkg/application/session_branch_binding_test.go index 4df4784c..d1689fc2 100644 --- a/pkg/application/session_branch_binding_test.go +++ b/pkg/application/session_branch_binding_test.go @@ -36,11 +36,12 @@ func newBranchBindingApplicationWithStore(t *testing.T, validator sdd.BranchVali if wrap != nil { sessionStore = wrap(sessionStore) } + targets := sdd.TargetAcquirerFunc(func(_ context.Context, target sdd.MutationTarget) (*sdd.AcquiredTarget, error) { + return &sdd.AcquiredTarget{Target: target, Graph: graph, Release: func() error { return nil }}, nil + }) runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ - Project: sdd.ProjectRef{ID: "example"}, Graph: graph, Branches: validator, - Targets: sdd.TargetAcquirerFunc(func(_ context.Context, target sdd.MutationTarget) (*sdd.AcquiredTarget, error) { - return &sdd.AcquiredTarget{Target: target, Graph: graph, Release: func() error { return nil }}, nil - }), + Project: sdd.ProjectRef{ID: "example"}, Graph: branchReadFixture{GraphStore: graph, targets: targets, project: "example"}, Branches: validator, + Targets: targets, LLM: pkgllm.RunnerFunc(func(context.Context, pkgllm.Request) (pkgllm.Result, error) { return pkgllm.Result{Identity: pkgllm.Identity{Provider: "test", Model: "test"}}, nil }), diff --git a/pkg/application/session_runtime_test.go b/pkg/application/session_runtime_test.go index 669cef01..d41a273b 100644 --- a/pkg/application/session_runtime_test.go +++ b/pkg/application/session_runtime_test.go @@ -387,7 +387,7 @@ func TestCreateEntryResolvesConcreteDefaultWithoutCWDAndReleasesAroundLLM(t *tes } llmCalls := 0 runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ - Project: sdd.ProjectRef{ID: "example"}, DefaultBranch: "main", Graph: graph, Targets: targets, + Project: sdd.ProjectRef{ID: "example"}, DefaultBranch: "main", Graph: branchReadFixture{GraphStore: graph, targets: targets, project: "example"}, Targets: targets, LLM: pkgllm.RunnerFunc(func(_ context.Context, request pkgllm.Request) (pkgllm.Result, error) { identity := pkgllm.Identity{Provider: "test", Model: "test"} if targets.isActive() { @@ -840,7 +840,7 @@ func newDurableApplicationWithHomeAndTargets(t *testing.T, home sdd.GraphStore, } blobs := &trackingBlobStore{StagedBlobStore: baseBlobs} runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ - Project: sdd.ProjectRef{ID: "example"}, DefaultBranch: "main", Graph: home, Targets: targets, + Project: sdd.ProjectRef{ID: "example"}, DefaultBranch: "main", Graph: branchReadFixture{GraphStore: home, targets: targets, project: "example"}, Targets: targets, Recovery: sdd.RecoveryAuthorizerFunc(func(context.Context, sdd.RecoveryAccessRequest) error { return nil }), LLM: pkgllm.RunnerFunc(func(context.Context, pkgllm.Request) (pkgllm.Result, error) { return pkgllm.Result{Identity: pkgllm.Identity{Provider: "test", Model: "test"}}, nil diff --git a/pkg/application/snapshot.go b/pkg/application/snapshot.go index 36cc68e4..1faa34a1 100644 --- a/pkg/application/snapshot.go +++ b/pkg/application/snapshot.go @@ -48,9 +48,11 @@ func (s *Snapshot) Revision() string { type SnapshotData struct { Project ProjectID Revision string - Config ProjectConfigDocument - Entries []EntryDocument - WIP []WIPDocument + // Config retains stored document data only. Effective read configuration + // comes exclusively from AcquiredSnapshot.Config or explicit runtime compatibility. + Config ProjectConfigDocument + Entries []EntryDocument + WIP []WIPDocument // Unreadable records documents a store could not decode into structured // form — a file whose YAML frontmatter would not parse, for example. They // are carried as data rather than aborting the load: BuildSnapshot turns diff --git a/pkg/application/target.go b/pkg/application/target.go index f2800446..442e1458 100644 --- a/pkg/application/target.go +++ b/pkg/application/target.go @@ -8,8 +8,8 @@ import ( ) // MutationTarget is the immutable canonical authority for one graph -// mutation. Project identifies the session project in this delivery; Branch -// names the concrete Git branch whose registered checkout owns the write. +// mutation. Project identifies the project; Branch names its logical write +// authority. Adapters resolve that authority to their storage. type MutationTarget struct { Project ProjectID `json:"project"` Branch string `json:"branch"` diff --git a/pkg/application/transition.go b/pkg/application/transition.go index 5b81c752..23d6ef13 100644 --- a/pkg/application/transition.go +++ b/pkg/application/transition.go @@ -118,7 +118,9 @@ func (a *Application) applyOnAcquired(ctx context.Context, runtime *ProjectRunti var apply ApplyResult var applyErr error for attempt := 1; ; attempt++ { - snapshot, readErr := acquired.Graph.Current(ctx) + snapshotRuntime := *runtime + snapshotRuntime.options.Graph = acquired.Graph + snapshot, _, readErr := readMaterializedSnapshot(ctx, &snapshotRuntime, "") if readErr != nil { return TransitionResult{Project: runtime.options.Project, Binding: binding, Apply: ApplyResult{State: MutationUnknown}}, &ApplicationError{Code: ErrorRecoveryRequired, Message: "reading mutation target before apply failed", Cause: readErr} } diff --git a/pkg/application/vector_search_persistent_test.go b/pkg/application/vector_search_persistent_test.go index fad0330a..98bf78cb 100644 --- a/pkg/application/vector_search_persistent_test.go +++ b/pkg/application/vector_search_persistent_test.go @@ -382,7 +382,7 @@ func newBranchCounterApp(t *testing.T, base sdd.GraphStore, targets sdd.TargetAc } runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ Project: sdd.ProjectRef{ID: counterProject, DisplayName: "Counter"}, DefaultBranch: "main", - Graph: base, Targets: targets, + Graph: branchReadFixture{GraphStore: base, targets: targets, project: counterProject}, Targets: targets, Embedder: embeddings, SearchIndex: localadapter.NewPersistentSearchIndexStore(counterProject, cacheRoot, "counter/branch"), LLM: pkgllm.RunnerFunc(func(context.Context, pkgllm.Request) (pkgllm.Result, error) { diff --git a/pkg/application/workflow.go b/pkg/application/workflow.go index 8082bb8d..cd391e4d 100644 --- a/pkg/application/workflow.go +++ b/pkg/application/workflow.go @@ -811,7 +811,7 @@ func (w *WorkflowSession) LogRead(ctx context.Context, identity RequestIdentity, // alone; there is no Go-constant fallback. func (w *WorkflowSession) Framing(ctx context.Context, identity RequestIdentity) ([]string, error) { w.setOperation(ctx, identity) - info, err := w.app.Info(ctx, identity, w.project, InfoRequest{}) + info, err := w.readInfo() if err != nil { return nil, err } @@ -1064,6 +1064,9 @@ func (b *bufferSink) Append(event engine.Event) error { func (w *WorkflowSession) setOperation(ctx context.Context, identity RequestIdentity) { w.ctx = ctx w.identity = identity + if w.graphs != nil { + w.graphs.Invalidate() + } } func (w *WorkflowSession) setLabel(label string) error { @@ -1142,82 +1145,73 @@ func (w *WorkflowSession) publicServe(serve *engine.Serve) *WorkflowServe { return result } +type materializedGraphView struct { + snapshot *Snapshot + runtime *ProjectRuntime +} + type workflowGraphs struct { workflow *WorkflowSession - snapshot *Snapshot - targets map[MutationTarget]*Snapshot + views map[MutationTarget]*materializedGraphView } func (g *workflowGraphs) Current() (*model.Graph, error) { - if g.snapshot != nil { - return g.snapshot.graph, nil - } - _, runtime, err := g.workflow.app.resolve(g.workflow.ctx, g.workflow.identity, g.workflow.project, AccessRead) + view, err := g.targetView(MutationTarget{Project: g.workflow.project, Branch: g.workflow.branch}, g.workflow.branch != "") if err != nil { return nil, err } - snapshot, err := g.workflow.app.snapshotWithDependencies(g.workflow.ctx, g.workflow.identity, runtime) + return view.snapshot.graph, nil +} + +func (g *workflowGraphs) CurrentFor(store *engine.Store) (*model.Graph, error) { + view, err := g.viewFor(store) if err != nil { return nil, err } - g.snapshot = snapshot - return snapshot.graph, nil + return view.snapshot.graph, nil } -// CurrentFor resolves the graph authority carried by a procedure instance: the -// project it targets, on the branch its state or the session binding names. -func (g *workflowGraphs) CurrentFor(store *engine.Store) (*model.Graph, error) { +func (g *workflowGraphs) viewFor(store *engine.Store) (*materializedGraphView, error) { target, fromBinding := g.workflow.effectiveTarget(store) - if target.Branch == "" && target.Project == g.workflow.project { - return g.Current() - } - return g.currentTarget(target, fromBinding) + return g.targetView(target, fromBinding) } -func (g *workflowGraphs) currentTarget(target MutationTarget, fromBinding bool) (*model.Graph, error) { +func (g *workflowGraphs) targetView(target MutationTarget, fromBinding bool) (*materializedGraphView, error) { runtime, err := g.workflow.targetRuntime(target.Project, AccessRead) if err != nil { return nil, err } - if snapshot := g.targets[target]; snapshot != nil { - return snapshot.graph, nil + if view := g.views[target]; view != nil { + return view, nil } - var snapshot *Snapshot - if target.Branch == "" { - // Another project's view on its configured default: its graph as the - // store serves it, no acquisition — reads in it need no write authority. - snapshot, err = runtime.options.Graph.Current(g.workflow.ctx) - if err != nil { - return nil, err - } - } else { - resolved, err := resolveMutationTarget(runtime, target) - if err != nil { - return nil, err - } - // A cache miss deliberately uses the same short-lived acquisition as a - // write snapshot. Local acquisition is a checkout lookup; remote target - // acquirers may clone, so remote compositions should accelerate this seam - // with their read cache while preserving explicit target authority. - snapshot, err = snapshotMutationTarget(g.workflow.ctx, runtime, resolved) - if err != nil { - return nil, withSessionBindingTargetError(g.workflow.branch, fromBinding, err) - } + snapshot, runtime, err := readMaterializedSnapshot(g.workflow.ctx, runtime, target.Branch) + if err != nil { + return nil, withSessionBindingTargetError(g.workflow.branch, fromBinding, err) } snapshot, err = g.workflow.app.snapshotWithDependenciesFrom(g.workflow.ctx, g.workflow.identity, runtime, snapshot) if err != nil { return nil, err } - if g.targets == nil { - g.targets = make(map[MutationTarget]*Snapshot) + if g.views == nil { + g.views = make(map[MutationTarget]*materializedGraphView) } - g.targets[target] = snapshot - return snapshot.graph, nil + view := &materializedGraphView{snapshot: snapshot, runtime: runtime} + g.views[target] = view + return view, nil } -func (g *workflowGraphs) Invalidate() { - g.snapshot = nil - g.targets = nil +func (g *workflowGraphs) Invalidate() { g.views = nil } + +func (w *WorkflowSession) readInfo() (InfoResult, error) { + view, err := w.graphs.targetView(MutationTarget{Project: w.project, Branch: w.branch}, w.branch != "") + if err != nil { + return InfoResult{}, err + } + principal, err := w.app.resolvePrincipal(w.ctx, w.identity) + if err != nil { + return InfoResult{}, err + } + return w.app.infoFromRuntime(w.ctx, principal, view.runtime) } type workflowSink struct{ workflow *WorkflowSession } diff --git a/pkg/application/workflow_branch_target_integration_test.go b/pkg/application/workflow_branch_target_integration_test.go index cab2ab5e..238a77f3 100644 --- a/pkg/application/workflow_branch_target_integration_test.go +++ b/pkg/application/workflow_branch_target_integration_test.go @@ -85,7 +85,7 @@ Branch-targeted workflow reads need to follow the written artifact. runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ Project: sdd.ProjectRef{ID: "example"}, DefaultBranch: "main", - Graph: base, + Graph: branchReadFixture{GraphStore: base, project: "example", targets: workflowBranchTargets{graphs: map[string]sdd.GraphStore{"main": base, "work": work, "explicit": explicit}}}, Targets: workflowBranchTargets{graphs: map[string]sdd.GraphStore{ "main": base, "work": work, @@ -288,7 +288,7 @@ This reference exists only on the bound work branch. targetGraphs := map[string]sdd.GraphStore{"main": mainGraph, "work": workGraph} rejectNextPreflight := false runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ - Project: sdd.ProjectRef{ID: "example"}, DefaultBranch: "main", Graph: currentGraph, + Project: sdd.ProjectRef{ID: "example"}, DefaultBranch: "main", Graph: branchReadFixture{GraphStore: currentGraph, project: "example", targets: workflowBranchTargets{graphs: targetGraphs}}, Targets: workflowBranchTargets{graphs: targetGraphs}, Branches: sdd.BranchValidatorFunc(func(_ context.Context, target sdd.MutationTarget) error { if targetGraphs[target.Branch] == nil { diff --git a/pkg/application/workflow_project.go b/pkg/application/workflow_project.go index ea5e31b9..8e143468 100644 --- a/pkg/application/workflow_project.go +++ b/pkg/application/workflow_project.go @@ -107,7 +107,7 @@ func (w *WorkflowSession) targetRuntime(project ProjectID, required Access) (*Pr _, runtime, err := w.app.resolve(w.ctx, w.identity, w.project, required) return runtime, err } - _, runtime, err := w.app.resolveTargetProject(w.ctx, w.identity, home, project, required) + _, runtime, err := w.app.resolveTargetProject(w.ctx, w.identity, home, project, required, w.branch) return runtime, err } @@ -143,7 +143,7 @@ func (w *WorkflowSession) ReadScope(ctx context.Context, identity RequestIdentit // principal — and the principal is a member of the target, asked with the // access the operation needs. Reading a dependency inside the home view and // being in it are different questions; this is the second. -func (a *Application) resolveTargetProject(ctx context.Context, identity RequestIdentity, home *ProjectRuntime, target ProjectID, required Access) (Principal, *ProjectRuntime, error) { +func (a *Application) resolveTargetProject(ctx context.Context, identity RequestIdentity, home *ProjectRuntime, target ProjectID, required Access, homeBranch string) (Principal, *ProjectRuntime, error) { principal, err := a.resolvePrincipal(ctx, identity) if err != nil { return Principal{}, nil, err @@ -152,7 +152,14 @@ func (a *Application) resolveTargetProject(ctx context.Context, identity Request runtime, err := a.resolveProject(ctx, principal, home.options.Project.ID, required) return principal, runtime, err } - if err := a.inDependencyClosure(ctx, principal, home, target); err != nil { + if required == AccessRead { + _, selected, err := readMaterializedSnapshot(ctx, home, homeBranch) + if err != nil { + return Principal{}, nil, err + } + home = selected + } + if err := a.inDependencyClosure(ctx, principal, home, target, required == AccessRead); err != nil { return Principal{}, nil, err } runtime, err := a.resolveProject(ctx, principal, target, required) @@ -168,12 +175,19 @@ func (a *Application) resolveTargetProject(ctx context.Context, identity Request // behind it is a valid target. Membership is a property of the resolved // project, never of the declared string: a declaration names a repo ID, and // only the composition knows which project carries it. -func (a *Application) inDependencyClosure(ctx context.Context, principal Principal, home *ProjectRuntime, target ProjectID) error { +func (a *Application) inDependencyClosure(ctx context.Context, principal Principal, home *ProjectRuntime, target ProjectID, readConfig bool) error { seen := map[ProjectID]bool{home.options.Project.ID: true} queue := []*ProjectRuntime{home} for len(queue) > 0 { current := queue[0] queue = queue[1:] + if readConfig && current != home { + _, selected, err := readMaterializedSnapshot(ctx, current, "") + if err != nil { + return err + } + current = selected + } for _, dependency := range current.options.Dependencies { runtime, err := a.access.ResolveDependency(ctx, principal, current.options.Project.ID, dependency) if err != nil || runtime == nil { diff --git a/pkg/application/workflow_registry.go b/pkg/application/workflow_registry.go index ea92921d..13d5dfdd 100644 --- a/pkg/application/workflow_registry.go +++ b/pkg/application/workflow_registry.go @@ -69,7 +69,7 @@ func (w *WorkflowSession) registerWorkflowQueries(registry *engine.Registry) err Doc: engine.FuncDoc{Name: "sessionInfo", Doc: "Session framing: local participant, configured language, available search modes, and actionable recovery notices."}, ServeSafe: true, Fn: func(*engine.Context, map[string]any) (any, error) { - info, err := w.app.Info(w.ctx, w.identity, w.project, InfoRequest{}) + info, err := w.readInfo() if err != nil { return nil, err } @@ -112,10 +112,13 @@ func (w *WorkflowSession) registerWorkflowQueries(registry *engine.Registry) err if rec, ok := args["recovery"].(bool); ok && !rec { omitRecovery = true } - target, fromBinding := w.effectiveTarget(ctx.Store) - result, err := w.app.View(w.ctx, w.identity, target.Project, ViewRequest{Layout: layout, Branch: target.Branch, Budget: servedViewBudget, OmitRecovery: omitRecovery}) + view, err := w.graphs.viewFor(ctx.Store) if err != nil { - return nil, w.withSessionBindingTargetError(err, fromBinding) + return nil, err + } + result, err := w.app.viewFromSnapshot(w.ctx, w.identity, view.runtime, view.snapshot, ViewRequest{Layout: layout, Budget: servedViewBudget, OmitRecovery: omitRecovery}) + if err != nil { + return nil, err } return result.Sections, nil }, @@ -163,14 +166,16 @@ func (w *WorkflowSession) registerWorkflowQueries(registry *engine.Registry) err } } } - target, fromBinding := w.effectiveTarget(ctx.Store) - result, err := w.app.Show(w.ctx, w.identity, target.Project, ShowRequest{ + view, err := w.graphs.viewFor(ctx.Store) + if err != nil { + return nil, err + } + result, err := showFromSnapshot(view.runtime, view.snapshot, ShowRequest{ IDs: ids, UpDepth: workflowIntArg(args, "up", query.DefaultUpDepth), DownDepth: workflowIntArg(args, "down", query.DefaultDownDepth), - Branch: target.Branch, Budget: serveChainBudget, }) if err != nil { - return nil, w.withSessionBindingTargetError(err, fromBinding) + return nil, err } w.session.LogRead("inject:entryChains", result.FullIDs, result.SummaryIDs) return result.Entries, nil @@ -192,11 +197,7 @@ func (w *WorkflowSession) registerWorkflowQueries(registry *engine.Registry) err Doc: engine.FuncDoc{Name: "procedureList", Doc: "The live playbook moves, one line each: canonical, a compact signature of its accepted start params, and the first sentence of the head entry's summary. Shell-class procedures are excluded — they enter through start_session."}, ServeSafe: true, Fn: func(ctx *engine.Context, _ map[string]any) (any, error) { - result, err := w.app.Procedures(w.ctx, w.identity, w.projectFor(ctx.Store), ProcedureListRequest{}) - if err != nil { - return nil, err - } - return result.Procedures, nil + return renderProcedureList(ctx.Graph), nil }, }); err != nil { return err diff --git a/pkg/application/workflow_target_graph_test.go b/pkg/application/workflow_target_graph_internal_test.go similarity index 93% rename from pkg/application/workflow_target_graph_test.go rename to pkg/application/workflow_target_graph_internal_test.go index 20a7afc2..275c3664 100644 --- a/pkg/application/workflow_target_graph_test.go +++ b/pkg/application/workflow_target_graph_internal_test.go @@ -11,7 +11,29 @@ import ( "github.com/networkteam/sdd/internal/model" ) -type workflowTargetGraphStore struct{ snapshot *Snapshot } +type workflowTargetGraphStore struct { + snapshot *Snapshot + reads *workflowTargetAcquirer +} + +func (s workflowTargetGraphStore) AcquireSnapshot(ctx context.Context, q SnapshotReadQuery) (*AcquiredSnapshot, error) { + if q.Branch == "" { + return &AcquiredSnapshot{Snapshot: s.snapshot, Attachments: s, Release: func() error { return nil }}, nil + } + if s.reads == nil { + return nil, fmt.Errorf("read branch unavailable") + } + s.reads.acquisitions++ + if s.reads.err != nil { + return nil, s.reads.err + } + graph := s.reads.graphs[q.Branch] + if graph == nil { + return nil, fmt.Errorf("incomplete acquired snapshot") + } + snapshot, err := graph.Current(ctx) + return &AcquiredSnapshot{Snapshot: snapshot, Attachments: graph, Release: func() error { s.reads.releases++; return nil }}, err +} func (s workflowTargetGraphStore) Current(context.Context) (*Snapshot, error) { return s.snapshot, nil } func (workflowTargetGraphStore) Apply(context.Context, string, MutationBatch, StagedBlobReader) (ApplyResult, error) { @@ -93,7 +115,7 @@ func TestWorkflowContextUsesBranchTargetForSummaryAndPredicates(t *testing.T) { }} runtime := &ProjectRuntime{options: ProjectRuntimeOptions{ Project: ProjectRef{ID: "example"}, DefaultBranch: "main", - Graph: workflowTargetGraphStore{snapshot: base}, Targets: targets, + Graph: workflowTargetGraphStore{snapshot: base, reads: targets}, Targets: targets, }} app := &Application{access: workflowTargetAccess{runtime: runtime}} workflow := &WorkflowSession{ @@ -186,7 +208,7 @@ func TestWorkflowEffectiveTargetPrecedenceIsSharedByReadsAndWrites(t *testing.T) current := workflowTargetSnapshot(t, "current-r1", []EntryDocument{workflowBranchMarker("2026/07/22-120000-s-tac-cur.md")}) runtime := &ProjectRuntime{options: ProjectRuntimeOptions{ Project: ProjectRef{ID: "example"}, DefaultBranch: "main", - Graph: workflowTargetGraphStore{snapshot: current}, Targets: &workflowTargetAcquirer{graphs: graphStores}, + Graph: workflowTargetGraphStore{snapshot: current, reads: &workflowTargetAcquirer{graphs: graphStores}}, Targets: &workflowTargetAcquirer{graphs: graphStores}, }} app := &Application{access: workflowTargetAccess{runtime: runtime}} @@ -290,7 +312,7 @@ func TestWorkflowGraphCacheInvalidatesAcrossRebindingAndClear(t *testing.T) { }} runtime := &ProjectRuntime{options: ProjectRuntimeOptions{ Project: ProjectRef{ID: "example"}, DefaultBranch: "main", - Graph: workflowTargetGraphStore{snapshot: base}, Targets: targets, + Graph: workflowTargetGraphStore{snapshot: base, reads: targets}, Targets: targets, }} app := &Application{access: workflowTargetAccess{runtime: runtime}} workflow := &WorkflowSession{ @@ -322,7 +344,7 @@ func TestWorkflowSessionBindingDriftProvenanceOnlyForBindingTargets(t *testing.T targets := &workflowTargetAcquirer{graphs: map[string]GraphStore{}, err: driftCause} runtime := &ProjectRuntime{options: ProjectRuntimeOptions{ Project: ProjectRef{ID: "example"}, DefaultBranch: "main", - Graph: workflowTargetGraphStore{snapshot: base}, Targets: targets, + Graph: workflowTargetGraphStore{snapshot: base, reads: targets}, Targets: targets, }} workflow := &WorkflowSession{ app: &Application{access: workflowTargetAccess{runtime: runtime}}, project: "example", @@ -379,7 +401,7 @@ func TestWorkflowSessionBindingDriftProvenanceOnlyForBindingTargets(t *testing.T incompleteTargets := &workflowTargetAcquirer{graphs: map[string]GraphStore{"drifted": nil}} incompleteRuntime := &ProjectRuntime{options: ProjectRuntimeOptions{ Project: ProjectRef{ID: "example"}, DefaultBranch: "main", - Graph: workflowTargetGraphStore{snapshot: base}, Targets: incompleteTargets, + Graph: workflowTargetGraphStore{snapshot: base, reads: incompleteTargets}, Targets: incompleteTargets, }} incompleteWorkflow := &WorkflowSession{ app: &Application{access: workflowTargetAccess{runtime: incompleteRuntime}}, project: "example", @@ -388,7 +410,7 @@ func TestWorkflowSessionBindingDriftProvenanceOnlyForBindingTargets(t *testing.T _, incompleteErr := (&workflowGraphs{workflow: incompleteWorkflow}).CurrentFor(workflowTargetStore(t, nil)) if incompleteErr == nil || !strings.Contains(incompleteErr.Error(), `session is bound to branch "drifted"`) || - !strings.Contains(incompleteErr.Error(), "target acquisition returned an incomplete runtime") || + !strings.Contains(incompleteErr.Error(), "incomplete acquired snapshot") || strings.Contains(incompleteErr.Error(), "no longer resolves to a checkout") { t.Fatalf("incomplete binding target error = %v", incompleteErr) } diff --git a/pkg/application/write_api.go b/pkg/application/write_api.go index c574b0db..47419845 100644 --- a/pkg/application/write_api.go +++ b/pkg/application/write_api.go @@ -114,7 +114,8 @@ func (a *Application) CurrentSnapshot(ctx context.Context, identity RequestIdent if err != nil { return nil, err } - return runtime.options.Graph.Current(ctx) + snapshot, _, err := readMaterializedSnapshot(ctx, runtime, "") + return snapshot, err } // StageBlob resolves current read access before placing immutable bytes in @@ -159,7 +160,7 @@ func (a *Application) CreateEntry(ctx context.Context, identity RequestIdentity, if err != nil { return CreateEntryResult{}, err } - snapshot, err := a.snapshotWithDependenciesFrom(ctx, identity, runtime, targetSnapshot) + snapshot, err := a.snapshotWithDependencyPolicy(ctx, identity, runtime, targetSnapshot, false) if err != nil { return CreateEntryResult{}, err } @@ -422,7 +423,10 @@ func snapshotMutationTarget(ctx context.Context, runtime *ProjectRuntime, target err = errors.Join(err, fmt.Errorf("releasing mutation target %s after snapshot: %w", target.Branch, releaseErr)) } }() - return acquired.Graph.Current(ctx) + selectedRuntime := *runtime + selectedRuntime.options.Graph = acquired.Graph + snapshot, _, err = readMaterializedSnapshot(ctx, &selectedRuntime, "") + return snapshot, err } func newMutationID(prefix string) (string, error) { diff --git a/pkg/application/write_focus_test.go b/pkg/application/write_focus_test.go index 0510d56b..1e8f95c3 100644 --- a/pkg/application/write_focus_test.go +++ b/pkg/application/write_focus_test.go @@ -169,7 +169,7 @@ func TestWorkflowCaptureFocusPersistsThroughRealNewEntry(t *testing.T) { dir := t.TempDir() writeFocusWorkflowTarget(t, dir, targetID) - graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: dir}) + graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: dir, Branch: "main"}) if err != nil { t.Fatal(err) } diff --git a/pkg/application/write_supersede_test.go b/pkg/application/write_supersede_test.go index 5851f220..e3527f63 100644 --- a/pkg/application/write_supersede_test.go +++ b/pkg/application/write_supersede_test.go @@ -54,7 +54,7 @@ func runSupersedeCapture(t *testing.T, supersedes []any) *model.Entry { writeSupersedeWorkflowTarget(t, dir, supersedeSecondID, "The other earlier reading this capture replaces.") writeSupersedeWorkflowTarget(t, dir, supersedeGroundID, supersedeGroundRef) - graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: dir}) + graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: dir, Branch: "main"}) if err != nil { t.Fatal(err) } diff --git a/pkg/local/git_read_test.go b/pkg/local/git_read_test.go new file mode 100644 index 00000000..35d9c494 --- /dev/null +++ b/pkg/local/git_read_test.go @@ -0,0 +1,73 @@ +package local_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + sdd "github.com/networkteam/sdd/pkg/application" + "github.com/networkteam/sdd/pkg/local" +) + +func TestGitWorktreeReadAcquisitionWithoutMutationFactory(t *testing.T) { + root := t.TempDir() + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", root}, args...)...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v %s", args, err, out) + } + } + git("init", "-b", "main") + git("config", "user.name", "Test") + git("config", "user.email", "test@example.invalid") + git("config", "commit.gpgsign", "false") + if err := os.WriteFile(filepath.Join(root, "README"), []byte("test"), 0600); err != nil { + t.Fatal(err) + } + git("add", "README") + git("commit", "-m", "test: seed repository") + work := filepath.Join(t.TempDir(), "work") + git("worktree", "add", "-b", "work", work) + acquirer, err := local.NewGitWorktreeAcquirer(local.GitWorktreeAcquirerOptions{Project: "example", ServerCheckout: root, + ReadFactory: func(ctx context.Context, checkout string, q sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + selected, _ := filepath.EvalSymlinks(checkout) + want := root + if q.Branch == "work" { + want = work + } + want, _ = filepath.EvalSymlinks(want) + if selected != want { + t.Fatalf("checkout=%q want %q", selected, want) + } + graph, err := local.NewFilesystemGraphStore(local.FilesystemGraphStoreOptions{Project: "example", GraphDir: filepath.Join(checkout, ".sdd", "graph"), Branch: q.Branch}) + if err != nil { + return nil, err + } + return graph.AcquireSnapshot(ctx, q) + }}) + if err != nil { + t.Fatal(err) + } + for _, branch := range []string{"", "main", "work"} { + source, err := acquirer.AcquireSnapshot(t.Context(), sdd.SnapshotReadQuery{Branch: branch}) + if err != nil { + t.Fatal(err) + } + if source.Snapshot.Project() != "example" { + t.Fatal("wrong project") + } + if err := source.Release(); err != nil { + t.Fatal(err) + } + } + if _, err := acquirer.AcquireSnapshot(t.Context(), sdd.SnapshotReadQuery{Branch: "missing"}); err == nil { + t.Fatal("unregistered branch accepted") + } + if _, err := acquirer.Acquire(t.Context(), sdd.MutationTarget{Project: "example", Branch: "work"}); err == nil || !strings.Contains(err.Error(), "not configured") { + t.Fatalf("mutation acquisition=%v", err) + } +} diff --git a/pkg/local/git_target.go b/pkg/local/git_target.go index 44868f86..aad28fe8 100644 --- a/pkg/local/git_target.go +++ b/pkg/local/git_target.go @@ -17,6 +17,8 @@ type GitWorktreeAcquirerOptions struct { Project app.ProjectID ServerCheckout string Factory TargetRuntimeFactory + // ReadFactory opens a read source without constructing mutation adapters. + ReadFactory func(context.Context, string, app.SnapshotReadQuery) (*app.AcquiredSnapshot, error) } // GitWorktreeAcquirer rediscoveries one registered checkout for every @@ -25,18 +27,19 @@ type GitWorktreeAcquirer struct { project app.ProjectID serverCheckout string factory TargetRuntimeFactory + readFactory func(context.Context, string, app.SnapshotReadQuery) (*app.AcquiredSnapshot, error) runGit func(context.Context, ...string) ([]byte, error) } func NewGitWorktreeAcquirer(options GitWorktreeAcquirerOptions) (*GitWorktreeAcquirer, error) { - if options.Project == "" || strings.TrimSpace(options.ServerCheckout) == "" || options.Factory == nil { + if options.Project == "" || strings.TrimSpace(options.ServerCheckout) == "" || (options.Factory == nil && options.ReadFactory == nil) { return nil, fmt.Errorf("sdd: local target project, server checkout, and factory are required") } root, err := filepath.Abs(options.ServerCheckout) if err != nil { return nil, err } - return &GitWorktreeAcquirer{project: options.Project, serverCheckout: root, factory: options.Factory, runGit: runGitTargetCommand}, nil + return &GitWorktreeAcquirer{project: options.Project, serverCheckout: root, factory: options.Factory, readFactory: options.ReadFactory, runGit: runGitTargetCommand}, nil } func runGitTargetCommand(ctx context.Context, args ...string) ([]byte, error) { @@ -44,6 +47,9 @@ func runGitTargetCommand(ctx context.Context, args ...string) ([]byte, error) { } func (a *GitWorktreeAcquirer) Acquire(ctx context.Context, target app.MutationTarget) (*app.AcquiredTarget, error) { + if a.factory == nil { + return nil, fmt.Errorf("sdd: mutation acquisition is not configured") + } checkout, err := a.resolveCheckout(ctx, target) if err != nil { return nil, err @@ -58,6 +64,24 @@ func (a *GitWorktreeAcquirer) Acquire(ctx context.Context, target app.MutationTa return &app.AcquiredTarget{Target: target, Graph: graph, Finalizers: finalizers, Release: release}, nil } +// AcquireSnapshot resolves a registered read branch without mutation acquisition. +// Empty branch reads the server checkout. Exact-source availability belongs to +// ReadFactory; this resolver does not retain historical revisions. +func (a *GitWorktreeAcquirer) AcquireSnapshot(ctx context.Context, q app.SnapshotReadQuery) (*app.AcquiredSnapshot, error) { + if a.readFactory == nil { + return nil, fmt.Errorf("sdd: snapshot acquisition is not configured") + } + checkout := a.serverCheckout + if q.Branch != "" { + var err error + checkout, err = a.resolveCheckout(ctx, app.MutationTarget{Project: a.project, Branch: q.Branch}) + if err != nil { + return nil, err + } + } + return a.readFactory(ctx, checkout, q) +} + // ValidateBranch applies the live acquisition rule without opening graph // adapters or finalizers. func (a *GitWorktreeAcquirer) ValidateBranch(ctx context.Context, target app.MutationTarget) error { diff --git a/pkg/mcpapp/request_identity_test.go b/pkg/mcpapp/request_identity_test.go index ac3f716f..bc1eadb9 100644 --- a/pkg/mcpapp/request_identity_test.go +++ b/pkg/mcpapp/request_identity_test.go @@ -217,7 +217,7 @@ The HTTP identity test anchors its real mutation here. `), 0o644); err != nil { t.Fatal(err) } - graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "identity-test", GraphDir: graphDir}) + graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "identity-test", GraphDir: graphDir, Branch: "main"}) if err != nil { t.Fatal(err) } diff --git a/pkg/mcpapp/root_runtime_test.go b/pkg/mcpapp/root_runtime_test.go index e9b22065..20596e9d 100644 --- a/pkg/mcpapp/root_runtime_test.go +++ b/pkg/mcpapp/root_runtime_test.go @@ -41,7 +41,7 @@ func (rootAccess) ResolveDependency(context.Context, sdd.Principal, sdd.ProjectI func TestPublicMCPApplicationRunsStatefulWorkflowOnRootRuntime(t *testing.T) { graphDir := writeFixtureGraph(t) - graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "root-test", GraphDir: graphDir}) + graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "root-test", GraphDir: graphDir, Branch: "main"}) if err != nil { t.Fatal(err) } diff --git a/pkg/mcpapp/server_test.go b/pkg/mcpapp/server_test.go index d7cc6e33..f37f7066 100644 --- a/pkg/mcpapp/server_test.go +++ b/pkg/mcpapp/server_test.go @@ -280,6 +280,26 @@ func (t *testBranchTargets) Acquire(_ context.Context, target sdd.MutationTarget return &sdd.AcquiredTarget{Target: target, Graph: graph, Release: func() error { return nil }}, nil } +type testBranchReadStore struct { + sdd.GraphStore + targets *testBranchTargets +} + +func (s testBranchReadStore) AcquireSnapshot(ctx context.Context, q sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + s.targets.mu.RLock() + err := s.targets.errors[q.Branch] + graph := s.targets.graphs[q.Branch] + if graph == nil { + graph = s.GraphStore + } + s.targets.mu.RUnlock() + if err != nil { + return nil, err + } + q.Branch = "" + return graph.(sdd.SnapshotReader).AcquireSnapshot(ctx, q) +} + func (t *testBranchTargets) set(branch string, graph sdd.GraphStore) { t.mu.Lock() t.graphs[branch] = graph @@ -332,7 +352,7 @@ func newTestServerConfig(t *testing.T, findings []query.Finding, graphDir, sessi now := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(testRuntimeGeneration.Add(1)) * time.Hour) runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ Project: sdd.ProjectRef{ID: "test", DisplayName: "Test"}, DefaultBranch: "main", Language: language, - Graph: graph, Targets: targets, + Graph: testBranchReadStore{GraphStore: graph, targets: targets}, Targets: targets, Branches: sdd.BranchValidatorFunc(func(_ context.Context, target sdd.MutationTarget) error { if target.Project != "test" { return fmt.Errorf("unexpected branch project %q", target.Project) diff --git a/pkg/mcpapp/tools.go b/pkg/mcpapp/tools.go index 8e0ea7e4..d1fb9a20 100644 --- a/pkg/mcpapp/tools.go +++ b/pkg/mcpapp/tools.go @@ -1049,11 +1049,11 @@ func (s *Server) readAttachment(ctx context.Context, req *mcp.CallToolRequest, a TotalBytes: page.TotalSize, More: page.More, Available: staged, }, nil } - project, _, _, err := s.readScope(ctx, req, ss, args.Project) + project, branch, branchFromSession, err := s.readScope(ctx, req, ss, args.Project) if err != nil { return nil, ReadAttachmentResult{}, err } - result, err := s.app.ReadAttachment(ctx, s.requestIdentity(req), project, sdd.ReadAttachmentRequest{EntryID: args.ID, Filename: args.Name, Offset: args.Offset, MaxBytes: maxBytes}) + result, err := s.app.ReadAttachment(ctx, s.requestIdentity(req), project, sdd.ReadAttachmentRequest{Branch: branch, BranchFromSession: branchFromSession, EntryID: args.ID, Filename: args.Name, Offset: args.Offset, MaxBytes: maxBytes}) if err != nil { return nil, ReadAttachmentResult{}, err } From d5620ca69d8d5b3ff9ccd4a8395d4b059bf892ce Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Mon, 7 Sep 2026 10:22:59 +0200 Subject: [PATCH 06/11] sdd: signal tactical Implemented operation-scoped acquired graph reads with authoritative ... SDD-Mutation: entry-20260907-102247-s-tac-0br --- .sdd/graph/2026/09/07-102247-s-tac-0br.md | 22 ++++++ .../acquired-views-validation.md | 67 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 .sdd/graph/2026/09/07-102247-s-tac-0br.md create mode 100644 .sdd/graph/2026/09/07-102247-s-tac-0br/acquired-views-validation.md diff --git a/.sdd/graph/2026/09/07-102247-s-tac-0br.md b/.sdd/graph/2026/09/07-102247-s-tac-0br.md new file mode 100644 index 00000000..a0a8e95f --- /dev/null +++ b/.sdd/graph/2026/09/07-102247-s-tac-0br.md @@ -0,0 +1,22 @@ +--- +type: signal +layer: tactical +kind: done +closes: + - 20260906-234056-d-tac-bae + - 20260906-234852-d-tac-vdu +participants: + - Christopher +confidence: high +topics: + - implementation/engine + - implementation/mcp + - implementation/search +summary: 'Implemented operation-scoped acquired graph reads with authoritative source configuration, branch-aware attachments, and read-only local branch acquisition in commit `0701f288`. This closes the acquired-graph read consolidation directive (20260906-234056-d-tac-bae) together with the write-preservation refinement (20260906-234852-d-tac-vdu): callers share and release the selected source per operation, and existing write revalidation and retry behavior is unchanged. A validation record covers acceptance criteria, caller audit, and concurrency evidence; the work is committed on `codex/acquired-graph-views` but not yet merged or released.' +--- + +Implemented operation-scoped acquired graph reads with authoritative source configuration, branch-aware attachments and read-only local branch acquisition in commit `0701f288`. + +This completes 20260906-234056-d-tac-bae with the write-preservation refinement in 20260906-234852-d-tac-vdu. Read callers share the selected source and release it after materialization or consumption; configuration stays operation-local. Local configuration overrides retain their existing precedence. Current write validation, revalidation and retry behavior remain unchanged. + +The [validation and adoption record](./07-102247-s-tac-0br/acquired-views-validation.md) carries the acceptance-criteria coverage, caller audit, concurrency and cleanup evidence, and adapter migration guidance. The implementation is committed on `codex/acquired-graph-views`; local merge and release have not occurred. diff --git a/.sdd/graph/2026/09/07-102247-s-tac-0br/acquired-views-validation.md b/.sdd/graph/2026/09/07-102247-s-tac-0br/acquired-views-validation.md new file mode 100644 index 00000000..bf179dbe --- /dev/null +++ b/.sdd/graph/2026/09/07-102247-s-tac-0br/acquired-views-validation.md @@ -0,0 +1,67 @@ +# Acquired graph views: delivery evidence + +Implements 20260906-234056-d-tac-bae with the write-preservation refinement 20260906-234852-d-tac-vdu. + +## API and configuration + +- `AcquiredSnapshot.Config *ProjectConfig` supplies immutable committed configuration for one source revision. The application copies language and dependency declarations into an operation-local runtime value. It does not modify the shared runtime or the supplied configuration. +- `SnapshotData.Config` remains stored document data. It is not interpreted as another effective configuration source. An adapter populating both representations must derive them from the same source. +- `Config == nil` explicitly selects runtime configuration compatibility. Source configuration errors propagate; they do not select compatibility. Runtime credentials, providers, index settings and write routing remain composition concerns. +- `ReadAttachmentRequest` adds `Branch` and `BranchFromSession`. MCP forwards both. Paging has no new revision parameter. +- `SnapshotReader`, `SnapshotReadQuery`, `GraphStore.Apply`, Show/View/Search signatures and consumer search preparation remain intact. +- The local `GitWorktreeAcquirer` gains a `ReadFactory` and implements `AcquireSnapshot`. A read-only composition can omit its mutation factory. The CLI composes this reader with its current filesystem store, preserving current-authority selection and registered-worktree branch validation. + +## Caller and lifetime audit + +| Caller | Source ownership | +| --- | --- | +| Show, View, Lint, Search | Shared acquisition helper; release after operation, including error paths. | +| Attachment read | Home source establishes effective dependency scope; selected owner source supplies both entry membership and page bytes; release after paging. | +| CurrentSnapshot, Info, Procedures | Acquire, materialize immutable value or operation configuration, release. | +| Workflow graph access | Cache materialized views only within an operation; next operation invalidates the cache. View/entry-chain/procedure injections use the selected graph instead of reacquiring. | +| Show/workflow dependency graphs | Materialize authorized dependency graphs before the resulting resolver can escape acquisition. No live source resources hide in lazy graph resolution. | +| Read-side dependency closure | Use acquired home configuration on the session branch and acquired intermediate configuration; check access separately. | +| ReconcileSearchIndex | Graph, hashing and attachment reads share the lease through reconciliation. | +| Search preparation, coverage and retrieval | Same authorized acquired home and dependency sources, with callback-scoped target access. A resumed iterator checks expiry before the next source read. | +| DiscoverSearchEntries | Existing caller-owned lease; caller consumes or stops the iterator before releasing it. | +| IndexSearchEntry | Existing exact-revision acquisition and already-published shortcut retained. | +| Mutation preparation | Acquire the target snapshot and release its storage resources once materialized. Preparation revision remains provenance. | +| Apply retries | Each attempt obtains a fresh materialized snapshot through the helper. Existing revalidation, expected-revision apply, three-attempt retry and recovery logic remain. | + +The only application `GraphStore.Current` call outside a pinned wrapper is the explicit legacy current-authority compatibility branch. Named-branch and causal requests reject legacy stores; exact indexing also requires SnapshotReader. An acquisition failure does not fall back to Current. + +## Regression evidence + +- `TestAcquiredReadsUseReadAuthorityAndRelease`: Show, View, text search, attachment, snapshot, procedure and info reads use acquired sources. The mutation acquisition port refuses access and live Current/attachment reads fail if called. +- `TestAcquiredConfigurationIsPerOperationAndHasOneAuthority`: acquired configuration overrides read settings, empty dependencies remove the runtime list, nil compatibility restores runtime settings, and conflicting SnapshotData.Config does not become another authority. +- `TestSourceConfigurationCannotGrantDependencyAccess`: a source dependency declaration cannot override an access denial. +- `TestAttachmentOperationPinsSourceAcrossBranchAdvance`: two readers observe their own revision and attachment bytes while the branch advances. +- `TestWorkflowServeUsesOneViewAndRefreshesNextOperation`: one framing uses one source; the next operation sees the newer source and configuration. +- `TestAcquiredReadFailuresKeepCleanupErrors`, `TestAcquiredReadCancellationReleasesSource`: operation errors, partial acquisition and cancellation release their source and preserve cleanup errors. +- `TestLegacyReadsRejectBranchAndCausalSelection`: current reads remain available while unsupported guarantees fail explicitly. +- `TestGitWorktreeReadAcquisitionWithoutMutationFactory`: real registered branches remain readable with no mutation factory; unregistered branches fail. +- Existing search/index tests cover fixed preparation targets, exact-source publication, retained-source lifetime, restart and publication shortcuts, indexing interruption, cursor scope and incomplete coverage. +- Existing interleaved-capture, prepared-transition, retry-exhaustion and summary-replacement tests preserve today's write behavior. Unused unavailable dependencies do not become a new write gate. +- `ExampleAcquiredSnapshot` demonstrates loading graph and committed configuration from one immutable filesystem tree and pairing its attachment reader with the acquired snapshot. + +## Adoption + +Hosted compositions implement SnapshotReader on the runtime graph store, return configuration from the selected source, preserve branch/exact/causal selection and retain source availability independently of active leases. Cache loaded immutable graphs by project/revision and loader configuration, never by session; keep authorization separate. Released leases must not invalidate another reader. + +Local language and dependency configuration, including local overrides, keep their runtime precedence through explicit nil Config. Named-branch graph-directory lookup continues using the local configuration resolver. The local filesystem revision pins graph/attachment bytes, not committed repository configuration, and historical sources are not durable across process restart. No local configuration precedence change is introduced. + +The broader whole-graph CAS removal is not part of this delivery. Consumer write semantics, provider batching, publication identity and scheduling responsibilities remain unchanged. + +## Verification and status + +Implementation commit: `0701f288` on `codex/acquired-graph-views`. + +- `devbox run test`: passed root module and `examples/extendingsdd`. +- `go vet ./...` through Devbox: passed. +- `devbox run lint`: passed; only existing test-package convention warnings. +- Focused `go test -race` for acquired reads, source configuration, concurrent attachment reads, workflow view reuse, local branch reads and interleaved captures: passed. +- `devbox run build`: passed. +- Fresh binary `sdd view --layout 'rank(by(date)):n(3):brief:as-list'`: passed. +- `git diff --check`: passed. + +At completion preparation, the implementation is committed locally, not merged, pushed or released. The implementation procedure owns the subsequent landing status. From 6aa697d0fb9183264d549b32c0afb16b3d7cac0f Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Mon, 7 Sep 2026 10:26:58 +0200 Subject: [PATCH 07/11] fix(reads): reuse workflow dependency source selections --- pkg/application/application.go | 2 +- pkg/application/workflow.go | 28 ++++++- pkg/application/workflow_project.go | 16 ++-- .../workflow_target_graph_internal_test.go | 84 +++++++++++++++++++ 4 files changed, 118 insertions(+), 12 deletions(-) diff --git a/pkg/application/application.go b/pkg/application/application.go index 6e7fdd88..c6d4ad27 100644 --- a/pkg/application/application.go +++ b/pkg/application/application.go @@ -365,7 +365,7 @@ func (a *Application) Search(ctx context.Context, identity RequestIdentity, proj return SearchResult{}, err } defer member.releaseInto(&err) - target.state.members = append(target.state.members, &searchTargetMember{runtime: dependency, selected: member, repoID: repoID}) + target.state.members = append(target.state.members, &searchTargetMember{runtime: member.runtime, selected: member, repoID: repoID}) } searchResult, coverage, err := a.searchTarget(ctx, target, q) if err != nil { diff --git a/pkg/application/workflow.go b/pkg/application/workflow.go index cd391e4d..3fa20959 100644 --- a/pkg/application/workflow.go +++ b/pkg/application/workflow.go @@ -1153,6 +1153,7 @@ type materializedGraphView struct { type workflowGraphs struct { workflow *WorkflowSession views map[MutationTarget]*materializedGraphView + sources map[MutationTarget]*materializedGraphView } func (g *workflowGraphs) Current() (*model.Graph, error) { @@ -1184,11 +1185,12 @@ func (g *workflowGraphs) targetView(target MutationTarget, fromBinding bool) (*m if view := g.views[target]; view != nil { return view, nil } - snapshot, runtime, err := readMaterializedSnapshot(g.workflow.ctx, runtime, target.Branch) + source, err := g.sourceView(runtime, target.Branch) if err != nil { return nil, withSessionBindingTargetError(g.workflow.branch, fromBinding, err) } - snapshot, err = g.workflow.app.snapshotWithDependenciesFrom(g.workflow.ctx, g.workflow.identity, runtime, snapshot) + runtime = source.runtime + snapshot, err := g.workflow.app.snapshotWithDependenciesFrom(g.workflow.ctx, g.workflow.identity, runtime, source.snapshot) if err != nil { return nil, err } @@ -1200,7 +1202,27 @@ func (g *workflowGraphs) targetView(target MutationTarget, fromBinding bool) (*m return view, nil } -func (g *workflowGraphs) Invalidate() { g.views = nil } +func (g *workflowGraphs) sourceView(runtime *ProjectRuntime, branch string) (*materializedGraphView, error) { + target := MutationTarget{Project: runtime.options.Project.ID, Branch: branch} + if view := g.sources[target]; view != nil { + return view, nil + } + snapshot, selected, err := readMaterializedSnapshot(g.workflow.ctx, runtime, branch) + if err != nil { + return nil, err + } + if g.sources == nil { + g.sources = make(map[MutationTarget]*materializedGraphView) + } + view := &materializedGraphView{snapshot: snapshot, runtime: selected} + g.sources[target] = view + return view, nil +} + +func (g *workflowGraphs) Invalidate() { + g.views = nil + g.sources = nil +} func (w *WorkflowSession) readInfo() (InfoResult, error) { view, err := w.graphs.targetView(MutationTarget{Project: w.project, Branch: w.branch}, w.branch != "") diff --git a/pkg/application/workflow_project.go b/pkg/application/workflow_project.go index 8e143468..017319ac 100644 --- a/pkg/application/workflow_project.go +++ b/pkg/application/workflow_project.go @@ -107,7 +107,7 @@ func (w *WorkflowSession) targetRuntime(project ProjectID, required Access) (*Pr _, runtime, err := w.app.resolve(w.ctx, w.identity, w.project, required) return runtime, err } - _, runtime, err := w.app.resolveTargetProject(w.ctx, w.identity, home, project, required, w.branch) + _, runtime, err := w.app.resolveTargetProject(w.ctx, w.identity, home, project, required, w.branch, w.graphs.sourceView) return runtime, err } @@ -143,7 +143,7 @@ func (w *WorkflowSession) ReadScope(ctx context.Context, identity RequestIdentit // principal — and the principal is a member of the target, asked with the // access the operation needs. Reading a dependency inside the home view and // being in it are different questions; this is the second. -func (a *Application) resolveTargetProject(ctx context.Context, identity RequestIdentity, home *ProjectRuntime, target ProjectID, required Access, homeBranch string) (Principal, *ProjectRuntime, error) { +func (a *Application) resolveTargetProject(ctx context.Context, identity RequestIdentity, home *ProjectRuntime, target ProjectID, required Access, homeBranch string, sourceView func(*ProjectRuntime, string) (*materializedGraphView, error)) (Principal, *ProjectRuntime, error) { principal, err := a.resolvePrincipal(ctx, identity) if err != nil { return Principal{}, nil, err @@ -153,13 +153,13 @@ func (a *Application) resolveTargetProject(ctx context.Context, identity Request return principal, runtime, err } if required == AccessRead { - _, selected, err := readMaterializedSnapshot(ctx, home, homeBranch) + selected, err := sourceView(home, homeBranch) if err != nil { return Principal{}, nil, err } - home = selected + home = selected.runtime } - if err := a.inDependencyClosure(ctx, principal, home, target, required == AccessRead); err != nil { + if err := a.inDependencyClosure(ctx, principal, home, target, required == AccessRead, sourceView); err != nil { return Principal{}, nil, err } runtime, err := a.resolveProject(ctx, principal, target, required) @@ -175,18 +175,18 @@ func (a *Application) resolveTargetProject(ctx context.Context, identity Request // behind it is a valid target. Membership is a property of the resolved // project, never of the declared string: a declaration names a repo ID, and // only the composition knows which project carries it. -func (a *Application) inDependencyClosure(ctx context.Context, principal Principal, home *ProjectRuntime, target ProjectID, readConfig bool) error { +func (a *Application) inDependencyClosure(ctx context.Context, principal Principal, home *ProjectRuntime, target ProjectID, readConfig bool, sourceView func(*ProjectRuntime, string) (*materializedGraphView, error)) error { seen := map[ProjectID]bool{home.options.Project.ID: true} queue := []*ProjectRuntime{home} for len(queue) > 0 { current := queue[0] queue = queue[1:] if readConfig && current != home { - _, selected, err := readMaterializedSnapshot(ctx, current, "") + selected, err := sourceView(current, "") if err != nil { return err } - current = selected + current = selected.runtime } for _, dependency := range current.options.Dependencies { runtime, err := a.access.ResolveDependency(ctx, principal, current.options.Project.ID, dependency) diff --git a/pkg/application/workflow_target_graph_internal_test.go b/pkg/application/workflow_target_graph_internal_test.go index 275c3664..a364e69c 100644 --- a/pkg/application/workflow_target_graph_internal_test.go +++ b/pkg/application/workflow_target_graph_internal_test.go @@ -563,3 +563,87 @@ Target-aware graph test procedure. } return store } + +type workflowClosureStore struct { + workflowTargetGraphStore + dependencies []string + acquisitions int +} + +func (s *workflowClosureStore) AcquireSnapshot(_ context.Context, q SnapshotReadQuery) (*AcquiredSnapshot, error) { + s.acquisitions++ + return &AcquiredSnapshot{Snapshot: s.snapshot, Config: &ProjectConfig{Dependencies: append([]string(nil), s.dependencies...)}, Attachments: s, Release: func() error { return nil }}, nil +} + +type workflowClosureAccess struct { + workflowTargetAccess + projects map[ProjectID]*ProjectRuntime + denied bool + checks int +} + +func (a *workflowClosureAccess) ResolveProject(_ context.Context, _ Principal, id ProjectID, _ Access) (*ProjectRuntime, error) { + a.checks++ + if a.denied && id == "target" { + return nil, errors.New("access revoked") + } + return a.projects[id], nil +} +func (a *workflowClosureAccess) ResolveDependency(_ context.Context, _ Principal, _ ProjectID, dependency string) (*ProjectRuntime, error) { + return a.projects[ProjectID(dependency)], nil +} + +// The operation boundary and repeated graph requests are internal engine seams. +func TestWorkflowDependencyClosureReusesOperationSources(t *testing.T) { + for _, changed := range []ProjectID{"home", "middle"} { + t.Run(string(changed), func(t *testing.T) { + stores := map[ProjectID]*workflowClosureStore{} + access := &workflowClosureAccess{projects: map[ProjectID]*ProjectRuntime{}} + for _, id := range []ProjectID{"home", "middle", "target"} { + snapshot, err := BuildSnapshot(t.Context(), SnapshotData{Project: id, Revision: string(id) + "-r1"}) + if err != nil { + t.Fatal(err) + } + store := &workflowClosureStore{workflowTargetGraphStore: workflowTargetGraphStore{snapshot: snapshot}} + stores[id] = store + access.projects[id] = &ProjectRuntime{options: ProjectRuntimeOptions{Project: ProjectRef{ID: id}, Graph: store}} + } + stores["home"].dependencies = []string{"middle"} + stores["middle"].dependencies = []string{"target"} + w := &WorkflowSession{app: &Application{access: access}, project: "home", branch: "work"} + w.graphs = &workflowGraphs{workflow: w} + w.setOperation(t.Context(), RequestIdentity{Subject: "reader"}) + target := MutationTarget{Project: "target"} + first, err := w.graphs.targetView(target, false) + if err != nil { + t.Fatal(err) + } + stores[changed].dependencies = nil + checks := access.checks + second, err := w.graphs.targetView(target, false) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatal("target view changed within operation") + } + if access.checks <= checks { + t.Fatal("cached view bypassed authorization") + } + for id, store := range stores { + if store.acquisitions != 1 { + t.Fatalf("%s acquired %d times", id, store.acquisitions) + } + } + access.denied = true + if _, err := w.graphs.targetView(target, false); err == nil { + t.Fatal("cached view bypassed revoked authorization") + } + access.denied = false + w.setOperation(t.Context(), RequestIdentity{Subject: "reader"}) + if _, err := w.graphs.targetView(target, false); err == nil || !strings.Contains(err.Error(), "dependency closure") { + t.Fatalf("next operation ignored configuration change: %v", err) + } + }) + } +} From 7cdf3c1ae302b13f609fdbb79a27ef2c470ef9c6 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Mon, 7 Sep 2026 10:48:14 +0200 Subject: [PATCH 08/11] fix(reads): preserve branch info and source-owned attachment paths --- cmd/sdd/serve.go | 9 +--- pkg/application/acquired_reads_test.go | 31 ++++++++++++ pkg/application/application.go | 5 +- pkg/application/doc.go | 10 ++++ pkg/application/graphstore.go | 3 ++ pkg/application/read_api.go | 7 ++- pkg/local/local_graphstore.go | 6 ++- pkg/local/read_snapshot.go | 22 ++++++-- pkg/mcpapp/server.go | 31 +++++------- pkg/mcpapp/server_test.go | 70 +++++++++++++++++++++++--- pkg/mcpapp/tools.go | 12 ++--- 11 files changed, 159 insertions(+), 47 deletions(-) diff --git a/cmd/sdd/serve.go b/cmd/sdd/serve.go index f6ea0fda..7d6da7ef 100644 --- a/cmd/sdd/serve.go +++ b/cmd/sdd/serve.go @@ -85,14 +85,7 @@ func serveCmd() *cli.Command { Application: application, LocalIdentity: identity, LocalClient: transport == "stdio", - LocalAttachmentPath: func(entryID, filename string) (string, error) { - attachDir, pathErr := sdd.AttachmentDirRelPath(entryID) - if pathErr != nil { - return "", pathErr - } - return filepath.Abs(filepath.Join(dir, attachDir, filename)) - }, - Version: version, + Version: version, }) if err != nil { return err diff --git a/pkg/application/acquired_reads_test.go b/pkg/application/acquired_reads_test.go index ac782078..2512b0ea 100644 --- a/pkg/application/acquired_reads_test.go +++ b/pkg/application/acquired_reads_test.go @@ -345,3 +345,34 @@ func TestWorkflowServeUsesOneViewAndRefreshesNextOperation(t *testing.T) { } } } + +func TestInfoSelectsRequestedAuthorityWithoutFallback(t *testing.T) { + snapshot := acquiredSnapshot(t, "base", "r1", "Info source") + var branches []string + store := acquiredReadStore{acquire: func(_ context.Context, q sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + branches = append(branches, q.Branch) + if q.Branch == "missing" { + return nil, errors.New("source unavailable") + } + language := "en" + if q.Branch == "work" { + language = "de" + } + return &sdd.AcquiredSnapshot{Snapshot: snapshot, Config: &sdd.ProjectConfig{Language: language}, Attachments: staticGraphStore{}, Release: func() error { return nil }}, nil + }} + app := preparationApp(t, acquiredRuntime(t, "base", store), nil, nil) + identity := sdd.RequestIdentity{Subject: "reader"} + for _, tc := range []struct{ branch, language string }{{"", "en"}, {"work", "de"}} { + info, err := app.Info(t.Context(), identity, "base", sdd.InfoRequest{Branch: tc.branch}) + if err != nil || info.Language != tc.language { + t.Fatalf("info=%+v err=%v", info, err) + } + } + _, err := app.Info(t.Context(), identity, "base", sdd.InfoRequest{Branch: "missing", BranchFromSession: true}) + if err == nil || !strings.Contains(err.Error(), "session is bound") { + t.Fatalf("missing branch error: %v", err) + } + if strings.Join(branches, ",") != ",work,missing" { + t.Fatalf("unexpected acquisitions: %v", branches) + } +} diff --git a/pkg/application/application.go b/pkg/application/application.go index c6d4ad27..7c407492 100644 --- a/pkg/application/application.go +++ b/pkg/application/application.go @@ -80,8 +80,9 @@ func NewApplication(options ApplicationOptions) (*Application, error) { func (a *Application) now() time.Time { return a.clock.Now() } -func (a *Application) Info(ctx context.Context, identity RequestIdentity, project ProjectID, _ InfoRequest) (InfoResult, error) { - return a.infoForBranch(ctx, identity, project, "") +func (a *Application) Info(ctx context.Context, identity RequestIdentity, project ProjectID, request InfoRequest) (InfoResult, error) { + result, err := a.infoForBranch(ctx, identity, project, request.Branch) + return result, withSessionBindingTargetError(request.Branch, request.BranchFromSession, err) } func (a *Application) infoForBranch(ctx context.Context, identity RequestIdentity, project ProjectID, branch string) (InfoResult, error) { diff --git a/pkg/application/doc.go b/pkg/application/doc.go index 8eaafbae..b49ed486 100644 --- a/pkg/application/doc.go +++ b/pkg/application/doc.go @@ -44,6 +44,16 @@ // not a newly imposed write precondition. Acquired read configuration introduces // no new write validations or changes to write-time configuration precedence. // +// InfoRequest.Branch selects the read authority; an empty branch intentionally +// selects current authority. MCP forwards the session binding for Info. +// +// AttachmentPage.LocalPath is optional source-provided metadata for clients +// sharing the adapter filesystem. Filesystem sources return their own checkout +// path under attachment immutability; the hint does not retain that checkout. +// MCP forwards it only to local clients and never constructs filesystem paths. +// Compositions using mcpapp.Options.LocalAttachmentPath must remove that callback +// and supply paths from their attachment reader instead. +// // # Local composition // // FilesystemGraphStore pins graph and attachment bytes in memory and deliberately diff --git a/pkg/application/graphstore.go b/pkg/application/graphstore.go index f495cf64..5872767b 100644 --- a/pkg/application/graphstore.go +++ b/pkg/application/graphstore.go @@ -81,6 +81,9 @@ type AttachmentMaterialization struct { } type AttachmentPage struct { + // LocalPath is an optional absolute path supplied by the attachment source + // for clients sharing its filesystem. It does not extend source retention. + LocalPath string Filename string Content []byte Offset int64 diff --git a/pkg/application/read_api.go b/pkg/application/read_api.go index 1ba6c656..7e35c2ea 100644 --- a/pkg/application/read_api.go +++ b/pkg/application/read_api.go @@ -12,7 +12,12 @@ const ( DefaultShowDownDepth = query.DefaultDownDepth ) -type InfoRequest struct{} +// InfoRequest selects current read authority when Branch is empty. +// A selected branch that cannot be acquired returns an error. +type InfoRequest struct { + Branch string + BranchFromSession bool +} type InfoResult struct { Project ProjectRef diff --git a/pkg/local/local_graphstore.go b/pkg/local/local_graphstore.go index 27230179..cacc7f50 100644 --- a/pkg/local/local_graphstore.go +++ b/pkg/local/local_graphstore.go @@ -422,7 +422,11 @@ func (s *FilesystemGraphStore) ReadAttachmentPage(_ context.Context, entryID, fi if err := s.recoverPendingTransactionsLocked(); err != nil { return app.AttachmentPage{}, err } - return app.PageAttachment(os.DirFS(s.dir), ".", entryID, filename, offset, maxBytes) + page, err := app.PageAttachment(os.DirFS(s.dir), ".", entryID, filename, offset, maxBytes) + if err != nil { + return app.AttachmentPage{}, err + } + return attachmentPageWithLocalPath(page, s.dir, entryID) } func (s *FilesystemGraphStore) lock() (*flock.Flock, error) { diff --git a/pkg/local/read_snapshot.go b/pkg/local/read_snapshot.go index e2bff84f..ae125b6a 100644 --- a/pkg/local/read_snapshot.go +++ b/pkg/local/read_snapshot.go @@ -20,13 +20,20 @@ type retainedSnapshot struct { leases int } -type snapshotAttachments struct{ files fs.FS } +type snapshotAttachments struct { + files fs.FS + dir string +} func (s snapshotAttachments) ReadAttachmentPage(ctx context.Context, entry, name string, offset int64, limit int) (app.AttachmentPage, error) { if err := ctx.Err(); err != nil { return app.AttachmentPage{}, err } - return app.PageAttachment(s.files, ".", entry, name, offset, limit) + page, err := app.PageAttachment(s.files, ".", entry, name, offset, limit) + if err != nil { + return app.AttachmentPage{}, err + } + return attachmentPageWithLocalPath(page, s.dir, entry) } // AcquireSnapshot retains immutable graph and attachment bytes while a lease @@ -101,7 +108,7 @@ func (s *FilesystemGraphStore) AcquireSnapshot(ctx context.Context, q app.Snapsh func (s *FilesystemGraphStore) leaseSnapshot(retained *retainedSnapshot) *app.AcquiredSnapshot { retained.leases++ var once sync.Once - return &app.AcquiredSnapshot{Snapshot: retained.snapshot, Attachments: snapshotAttachments{files: retained.files}, Release: func() error { + return &app.AcquiredSnapshot{Snapshot: retained.snapshot, Attachments: snapshotAttachments{files: retained.files, dir: s.dir}, Release: func() error { once.Do(func() { s.mu.Lock() defer s.mu.Unlock() @@ -188,3 +195,12 @@ func freezeGraphFS(ctx context.Context, dir string) (*zip.Reader, error) { } return zip.NewReader(bytes.NewReader(buffer.Bytes()), int64(buffer.Len())) } + +func attachmentPageWithLocalPath(page app.AttachmentPage, dir, entry string) (app.AttachmentPage, error) { + relative, err := app.AttachmentDirRelPath(entry) + if err != nil { + return app.AttachmentPage{}, err + } + page.LocalPath, err = filepath.Abs(filepath.Join(dir, relative, page.Filename)) + return page, err +} diff --git a/pkg/mcpapp/server.go b/pkg/mcpapp/server.go index 833ab394..40ae7be0 100644 --- a/pkg/mcpapp/server.go +++ b/pkg/mcpapp/server.go @@ -30,22 +30,18 @@ type Options struct { // (stdio transport). Local clients get absolute paths in read results // (read_attachment) so they can read files directly instead of paging. LocalClient bool - // LocalAttachmentPath optionally adds the local-only path hint to - // read_attachment results. Canonical attachment reads remain path-free. - LocalAttachmentPath func(entryID, filename string) (string, error) - Version string + Version string } // Server wires the MCP protocol surface to the engine and the SDD read and // write layers. type Server struct { - mcp *mcp.Server - app *sdd.Application - searchSyncMode sdd.SearchSyncMode - localIdentity sdd.RequestIdentity - local bool - localAttachmentPath func(string, string) (string, error) - version string + mcp *mcp.Server + app *sdd.Application + searchSyncMode sdd.SearchSyncMode + localIdentity sdd.RequestIdentity + local bool + version string // sessions caches loaded sessions by ID. The transport holds no state // about a dialogue: nothing is keyed to a connection, and no connection // event acts on a session (d-cpt-aen). @@ -68,13 +64,12 @@ func New(opts Options) (*Server, error) { return nil, errors.New("mcpapp: Application is required") } s := &Server{ - app: opts.Application, - searchSyncMode: opts.SearchSyncMode, - localIdentity: opts.LocalIdentity, - local: opts.LocalClient, - localAttachmentPath: opts.LocalAttachmentPath, - version: opts.Version, - sessions: newSessionCache(), + app: opts.Application, + searchSyncMode: opts.SearchSyncMode, + localIdentity: opts.LocalIdentity, + local: opts.LocalClient, + version: opts.Version, + sessions: newSessionCache(), } s.mcp = mcp.NewServer(&mcp.Implementation{ Name: "sdd", diff --git a/pkg/mcpapp/server_test.go b/pkg/mcpapp/server_test.go index f37f7066..e8719c9a 100644 --- a/pkg/mcpapp/server_test.go +++ b/pkg/mcpapp/server_test.go @@ -384,13 +384,6 @@ func newTestServerConfig(t *testing.T, findings []query.Finding, graphDir, sessi } opts := mcpserver.Options{SearchSyncMode: sdd.SearchSyncAll, Application: application, LocalIdentity: sdd.RequestIdentity{Subject: "tester"}, Version: "test", - LocalAttachmentPath: func(entryID, filename string) (string, error) { - dir, pathErr := model.AttachDirRelPath(entryID) - if pathErr != nil { - return "", pathErr - } - return filepath.Abs(filepath.Join(graphDir, dir, filename)) - }, } for _, m := range mutate { m(&opts) @@ -3496,3 +3489,66 @@ func TestEmbeddedCatchupProcedure(t *testing.T) { t.Fatalf("pursue should complete the check-in, got %s at %q", serve.Status, serve.Step) } } + +type configuredReadStore struct { + sdd.GraphStore + language string +} + +func (s configuredReadStore) AcquireSnapshot(ctx context.Context, q sdd.SnapshotReadQuery) (*sdd.AcquiredSnapshot, error) { + source, err := s.GraphStore.(sdd.SnapshotReader).AcquireSnapshot(ctx, q) + if err == nil { + source.Config = &sdd.ProjectConfig{Language: s.language} + } + return source, err +} + +func TestBoundInfoAndAttachmentPathUseSelectedSource(t *testing.T) { + env := newTestServer(t, nil, "", "", func(o *mcpserver.Options) { o.LocalClient = true }) + branchDir := t.TempDir() + relative, err := sdd.AttachmentDirRelPath(fixtureGapID) + if err != nil { + t.Fatal(err) + } + entryRelative := relative + ".md" + content, err := os.ReadFile(filepath.Join(env.graphDir, entryRelative)) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(branchDir, relative), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(branchDir, entryRelative), content, 0644); err != nil { + t.Fatal(err) + } + attachment := filepath.Join(branchDir, relative, "branch-only.txt") + if err := os.WriteFile(attachment, []byte("Branch-only immutable attachment"), 0644); err != nil { + t.Fatal(err) + } + graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "test", GraphDir: branchDir}) + if err != nil { + t.Fatal(err) + } + env.targets.set("work", configuredReadStore{GraphStore: graph, language: "de"}) + cs := connect(t, env.srv) + session := openSession(t, cs).Session + var binding mcpserver.BindBranchResult + call(t, cs, "bind_branch", map[string]any{"session": session, "branch": "work"}, &binding) + var info mcpserver.InfoResult + call(t, cs, "info", map[string]any{"session": session}, &info) + if info.Language != "de" { + t.Fatalf("branch language lost: %+v", info) + } + var page mcpserver.ReadAttachmentResult + call(t, cs, "read_attachment", map[string]any{"session": session, "id": fixtureGapID, "name": "branch-only.txt"}, &page) + if page.Path != attachment { + t.Fatalf("path=%q want %q", page.Path, attachment) + } + bytes, err := os.ReadFile(page.Path) + if err != nil || string(bytes) != page.Content { + t.Fatalf("path does not identify returned content: %q %v", bytes, err) + } + if _, err := os.Stat(filepath.Join(env.graphDir, relative, "branch-only.txt")); !os.IsNotExist(err) { + t.Fatalf("attachment unexpectedly exists in base: %v", err) + } +} diff --git a/pkg/mcpapp/tools.go b/pkg/mcpapp/tools.go index d1fb9a20..a0849c1b 100644 --- a/pkg/mcpapp/tools.go +++ b/pkg/mcpapp/tools.go @@ -1065,12 +1065,10 @@ func (s *Server) readAttachment(ctx context.Context, req *mcp.CallToolRequest, a Name: page.Filename, Content: string(page.Content), Offset: page.Offset, NextOffset: page.NextOffset, TotalBytes: page.TotalSize, More: page.More, Available: result.Available, } - if s.local && s.localAttachmentPath != nil { - output.Path, err = s.localAttachmentPath(args.ID, page.Filename) - if err != nil { - return nil, ReadAttachmentResult{}, err - } + if s.local { + output.Path = page.LocalPath } + return nil, output, nil } @@ -1081,11 +1079,11 @@ func (s *Server) info(ctx context.Context, req *mcp.CallToolRequest, args InfoAr return nil, InfoResult{}, err } defer ss.mu.Unlock() - project, _, _, err := s.readScope(ctx, req, ss, args.Project) + project, branch, branchFromSession, err := s.readScope(ctx, req, ss, args.Project) if err != nil { return nil, InfoResult{}, err } - info, err := s.app.Info(ctx, s.requestIdentity(req), project, sdd.InfoRequest{}) + info, err := s.app.Info(ctx, s.requestIdentity(req), project, sdd.InfoRequest{Branch: branch, BranchFromSession: branchFromSession}) if err != nil { return nil, InfoResult{}, err } From 90d4c7c4d244ae9ca88b09db4f8dd7762bdc1521 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Mon, 7 Sep 2026 10:48:40 +0200 Subject: [PATCH 09/11] sdd: signal tactical Should local MCP attachment reads retain filesystem paths alongside ... SDD-Mutation: entry-20260907-104837-s-tac-jlh --- .sdd/graph/2026/09/07-104837-s-tac-jlh.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .sdd/graph/2026/09/07-104837-s-tac-jlh.md diff --git a/.sdd/graph/2026/09/07-104837-s-tac-jlh.md b/.sdd/graph/2026/09/07-104837-s-tac-jlh.md new file mode 100644 index 00000000..93a89979 --- /dev/null +++ b/.sdd/graph/2026/09/07-104837-s-tac-jlh.md @@ -0,0 +1,21 @@ +--- +type: signal +layer: tactical +kind: question +refs: + - id: 20260906-234056-d-tac-bae + kind: surfaced-by + - id: 20260606-004059-d-tac-d21 + kind: related +participants: + - Christopher +confidence: high +topics: + - agent/ux + - portability/mcp +summary: Question of whether local MCP attachment reads should retain filesystem paths alongside paged content, since direct file access lets agents extract information via scripts with potentially fewer tokens and tool turns. It surfaced from the branch-read review in 20260906-234056-d-tac-bae and relates to the CLI-owned attachment read surface split between local paths and remote paged content in 20260606-004059-d-tac-d21. Comparing representative extraction tasks could settle whether the benefit justifies the local-only behavior; reconsidering falls outside the acquired-read implementation. +--- + +Should local MCP attachment reads retain filesystem paths alongside paged content? + +The branch-read review of 20260906-234056-d-tac-bae raised this question about the local and remote read distinction in 20260606-004059-d-tac-d21. Direct files let agents extract information with scripts and programs, potentially using fewer tokens and tool turns than paged MCP reads. Comparing representative extraction tasks could establish whether that benefit warrants the local-only behavior. Reconsidering it is outside the acquired-read implementation. From 49c8b4e79cda95ee780bd5cb5f08326370303e85 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Mon, 7 Sep 2026 11:41:36 +0200 Subject: [PATCH 10/11] sdd: signal tactical Should the local filesystem adapter enforce immutability of ... SDD-Mutation: entry-20260907-114132-s-tac-r25 --- .sdd/graph/2026/09/07-114132-s-tac-r25.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .sdd/graph/2026/09/07-114132-s-tac-r25.md diff --git a/.sdd/graph/2026/09/07-114132-s-tac-r25.md b/.sdd/graph/2026/09/07-114132-s-tac-r25.md new file mode 100644 index 00000000..c027b547 --- /dev/null +++ b/.sdd/graph/2026/09/07-114132-s-tac-r25.md @@ -0,0 +1,21 @@ +--- +type: signal +layer: tactical +kind: question +refs: + - id: 20260906-234056-d-tac-bae + kind: surfaced-by + - id: 20260409-113337-d-tac-04t + kind: grounded-in +participants: + - Christopher +confidence: high +topics: + - type-system/immutability + - portability/runtime +summary: Asks whether the local filesystem adapter should enforce immutability of published attachments by rejecting writes of different bytes to an existing attachment path while allowing identical-byte retries. The question was surfaced by reviewing the acquired-read consolidation decision (20260906-234056-d-tac-bae), whose normal application operations create attachments under new entry IDs but accept overwriting writes, and it is grounded in the attachment immutability rule established in the attachment mechanism directive (20260409-113337-d-tac-04t). Enforcing this would change write acceptance rules and lies outside the scope of the acquired-read implementation. +--- + +Should the local filesystem adapter enforce immutability of published attachments? + +The review of 20260906-234056-d-tac-bae surfaced that normal application operations create attachments under new entry IDs, but the adapter accepts writes that replace existing attachment bytes. No ordinary MCP workflow exercising that replacement has been identified. The attachment immutability rule in 20260409-113337-d-tac-04t therefore raises an enforcement question: should the adapter reject different bytes at an existing attachment path while allowing identical-byte retries? This would change write acceptance rules and remains outside the acquired-read implementation in 20260906-234056-d-tac-bae. From 300fbceaa1dfc67f0f5a96c3d38813736749f363 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Mon, 7 Sep 2026 11:41:49 +0200 Subject: [PATCH 11/11] sdd: summarize 20260907-114132-s-tac-r25 (manual) SDD-Mutation: summary-20260907-114132-s-tac-r25-c391eeebf95e63ca --- .sdd/graph/2026/09/07-114132-s-tac-r25.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.sdd/graph/2026/09/07-114132-s-tac-r25.md b/.sdd/graph/2026/09/07-114132-s-tac-r25.md index c027b547..728fc68a 100644 --- a/.sdd/graph/2026/09/07-114132-s-tac-r25.md +++ b/.sdd/graph/2026/09/07-114132-s-tac-r25.md @@ -13,7 +13,7 @@ confidence: high topics: - type-system/immutability - portability/runtime -summary: Asks whether the local filesystem adapter should enforce immutability of published attachments by rejecting writes of different bytes to an existing attachment path while allowing identical-byte retries. The question was surfaced by reviewing the acquired-read consolidation decision (20260906-234056-d-tac-bae), whose normal application operations create attachments under new entry IDs but accept overwriting writes, and it is grounded in the attachment immutability rule established in the attachment mechanism directive (20260409-113337-d-tac-04t). Enforcing this would change write acceptance rules and lies outside the scope of the acquired-read implementation. +summary: Asks whether the local filesystem adapter should reject different bytes at an existing published attachment path while allowing identical-byte retries. Review of the acquired-read work (20260906-234056-d-tac-bae) found that the adapter permits replacement, although normal application operations create attachments under new entry IDs and no ordinary MCP replacement workflow was identified. Grounded in the attachment immutability rule (20260409-113337-d-tac-04t), the question defers any change to write acceptance rules beyond that implementation. --- Should the local filesystem adapter enforce immutability of published attachments?