From c1479e7da2f4c43d17e9a44f90598d15d7182b7e Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 23 Sep 2026 00:44:16 +0200 Subject: [PATCH 01/12] feat(engine): complete the answered option's transition on retry A mutation dispatched from a chooser option records the transition the option owes; the retry of that intent finishes it instead of re-serving the answered chooser (s-tac-do6). Co-Authored-By: Claude Fable 5.1 --- internal/cliapp/recover_internal_test.go | 58 ------- internal/engine/engine_test.go | 4 +- internal/engine/instance.go | 18 ++- internal/engine/mutation.go | 15 +- internal/engine/session.go | 2 +- pkg/local/local_graphstore_atomic_test.go | 181 ---------------------- 6 files changed, 27 insertions(+), 251 deletions(-) delete mode 100644 internal/cliapp/recover_internal_test.go delete mode 100644 pkg/local/local_graphstore_atomic_test.go diff --git a/internal/cliapp/recover_internal_test.go b/internal/cliapp/recover_internal_test.go deleted file mode 100644 index 4fac523b..00000000 --- a/internal/cliapp/recover_internal_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package cliapp - -import ( - "slices" - "testing" - - sdd "github.com/networkteam/sdd/pkg/application" -) - -func TestRecoveryInteractiveSelectionReconcilesUnknownBeforeOfferingVerbs(t *testing.T) { - unknown := sdd.RecoveryItem{State: sdd.RecoveryPending, Reason: sdd.RecoveryReasonOutcomeUnknown} - if !recoveryNeedsReconciliation(unknown, "") { - t.Fatal("interactive unknown recovery must reconcile before verb selection") - } - if recoveryNeedsReconciliation(unknown, "apply") { - t.Fatal("an explicit non-interactive verb reconciles inside RecoverMutation") - } - if recoveryNeedsReconciliation(sdd.RecoveryItem{State: sdd.RecoveryPending, Reason: sdd.RecoveryReasonOutcomeUnknown, LegacyUnroutable: true}, "") { - t.Fatal("legacy recovery must bind its target before reconciliation") - } - // A pending item whose outcome is already definitive needs no reconciliation. - if recoveryNeedsReconciliation(sdd.RecoveryItem{State: sdd.RecoveryPending, Reason: sdd.RecoveryReasonFinalizationOwed}, "") { - t.Fatal("a definitive outcome must not be reconciled again before verb selection") - } -} - -func TestRecoveryVerbMenusCoverEveryActionableProjection(t *testing.T) { - tests := []struct { - name string - item sdd.RecoveryItem - want []sdd.RecoveryVerb - }{ - {name: "not applied", item: sdd.RecoveryItem{State: sdd.RecoveryPending, Reason: sdd.RecoveryReasonNotApplied}, want: []sdd.RecoveryVerb{sdd.RecoveryApply, sdd.RecoveryDiscard}}, - {name: "finalization owed", item: sdd.RecoveryItem{State: sdd.RecoveryPending, Reason: sdd.RecoveryReasonFinalizationOwed}, want: []sdd.RecoveryVerb{sdd.RecoveryFinalizeRetry}}, - {name: "unknown evidence", item: sdd.RecoveryItem{State: sdd.RecoveryPending, Reason: sdd.RecoveryReasonOutcomeUnknown}, want: []sdd.RecoveryVerb{sdd.RecoveryAbandonUnknown}}, - {name: "legacy", item: sdd.RecoveryItem{State: sdd.RecoveryPending, Reason: sdd.RecoveryReasonOutcomeUnknown, LegacyUnroutable: true}, want: []sdd.RecoveryVerb{sdd.RecoveryBindTarget}}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if got := recoveryVerbs(test.item); !slices.Equal(got, test.want) { - t.Fatalf("verbs = %v, want %v", got, test.want) - } - }) - } -} - -func TestParseRecoveryVerbCoversEveryPublicVerb(t *testing.T) { - for _, verb := range []sdd.RecoveryVerb{ - sdd.RecoveryApply, sdd.RecoveryDiscard, sdd.RecoveryFinalizeRetry, sdd.RecoveryAbandonUnknown, sdd.RecoveryBindTarget, - } { - if got, err := parseRecoveryVerb(string(verb)); err != nil || got != verb { - t.Fatalf("parse %q = %q, %v", verb, got, err) - } - } - if _, err := parseRecoveryVerb(string(sdd.RecoveryReconcile)); err == nil { - t.Fatal("reconcile-only refresh must not be exposed as a user recovery verb") - } -} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 124682b0..706fd1f5 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -627,7 +627,7 @@ func TestRunCommandDiscardsStoreWritesOnError(t *testing.T) { t.Fatal(err) } beforeEvents := len(env.sink.events) - if err := env.session.runCommand(inst, "failAfterWrite"); err == nil || !strings.Contains(err.Error(), "injected command failure") { + if err := env.session.runCommand(inst, "failAfterWrite", ""); err == nil || !strings.Contains(err.Error(), "injected command failure") { t.Fatalf("runCommand error = %v", err) } if marker, _ := inst.Store.Get("marker"); marker != "original" { @@ -660,7 +660,7 @@ func TestRunCommandCannotWriteReportState(t *testing.T) { } inst, _ := env.session.Instance(sv.Instance) beforeEvents := len(env.sink.events) - if err := env.session.runCommand(inst, "attemptStateWrite"); err != nil { + if err := env.session.runCommand(inst, "attemptStateWrite", ""); err != nil { t.Fatal(err) } if writeErr == nil || !strings.Contains(writeErr.Error(), "only through WriteEngine") { diff --git a/internal/engine/instance.go b/internal/engine/instance.go index 77d88ec5..fb31587c 100644 --- a/internal/engine/instance.go +++ b/internal/engine/instance.go @@ -245,7 +245,7 @@ func (s *Session) cascade(inst *Instance) error { } if step.Op != "" && !inst.opDone { - if err := s.runCommand(inst, step.Op); err != nil { + if err := s.runCommand(inst, step.Op, ""); err != nil { return err } inst.opDone = true @@ -319,8 +319,10 @@ func (s *Session) reopenStalePlayback(inst *Instance, failing []FailedPredicate) } // runCommand executes a registry command at the instance's current step and -// logs its engine writes as an op_result event. -func (s *Session) runCommand(inst *Instance, name string) error { +// logs its engine writes as an op_result event. A non-empty to names the +// transition the dispatching option owes after the command, recorded with the +// intent so a retry completes it. +func (s *Session) runCommand(inst *Instance, name, to string) error { if err := s.checkSink(); err != nil { return err } @@ -335,13 +337,15 @@ func (s *Session) runCommand(inst *Instance, name string) error { if err != nil { return fmt.Errorf("preparing command %q: %w", name, err) } - position := s.appendEvent(inst.ID, EventMutationIntent, map[string]any{ - "step": inst.Step, "fn": name, "values": values, - }) + data := map[string]any{"step": inst.Step, "fn": name, "values": values} + if to != "" { + data["to"] = to + } + position := s.appendEvent(inst.ID, EventMutationIntent, data) if err := s.checkSink(); err != nil { return err } - s.intent = &MutationIntent{Ref: position, Instance: inst.ID, Step: inst.Step, Command: name, Values: values} + s.intent = &MutationIntent{Ref: position, Instance: inst.ID, Step: inst.Step, Command: name, Values: values, To: to} s.intentStore = inst.Store.Clone() s.intentDone = false s.cancelled = nil diff --git a/internal/engine/mutation.go b/internal/engine/mutation.go index dc04e20e..05db5760 100644 --- a/internal/engine/mutation.go +++ b/internal/engine/mutation.go @@ -14,6 +14,10 @@ type MutationIntent struct { Step string Command string Values map[string]string + // To is the transition the dispatching chooser option owes once the + // command finishes; a retry completes it, so the instance never re-serves + // the answered chooser (s-tac-do6). Empty for a step op. + To string } // OperationError is a recorded invocation that did not finish; the intent stays @@ -189,10 +193,16 @@ func (s *Session) Retry(instance string, ref uint64) (*Serve, error) { return nil, fmt.Errorf("instance %q not found", instance) } if !s.intentDone { - if err := s.runCommand(inst, s.intent.Command); err != nil { + to := s.intent.To + if err := s.runCommand(inst, s.intent.Command, to); err != nil { return nil, err } inst.opDone = true + if to != "" { + if err := s.transitionTo(inst, to, false); err != nil { + return nil, err + } + } } if err := s.cascade(inst); err != nil { return nil, err @@ -255,6 +265,7 @@ func (s *Session) restoreIntent(event Event) error { Step string `json:"step"` Command string `json:"fn"` Values map[string]string `json:"values"` + To string `json:"to"` } if err := json.Unmarshal(raw, &data); err != nil { return err @@ -262,7 +273,7 @@ func (s *Session) restoreIntent(event Event) error { if event.Position == 0 || data.Step != inst.Step || data.Command == "" { return fmt.Errorf("mutation intent has an invalid position or invocation") } - s.intent = &MutationIntent{Ref: event.Position, Instance: inst.ID, Step: data.Step, Command: data.Command, Values: data.Values} + s.intent = &MutationIntent{Ref: event.Position, Instance: inst.ID, Step: data.Step, Command: data.Command, Values: data.Values, To: data.To} s.intentStore = inst.Store.Clone() s.intentDone = false s.cancelled = nil diff --git a/internal/engine/session.go b/internal/engine/session.go index 910f6b99..30978cde 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -689,7 +689,7 @@ func (s *Session) Answer(instanceID, chooser, choice string, fields map[string]a recordDispatchSeed(inst, opt) if opt.Call != "" { - if err := s.runCommand(inst, opt.Call); err != nil { + if err := s.runCommand(inst, opt.Call, opt.To); err != nil { return nil, err } } diff --git a/pkg/local/local_graphstore_atomic_test.go b/pkg/local/local_graphstore_atomic_test.go deleted file mode 100644 index fe29270a..00000000 --- a/pkg/local/local_graphstore_atomic_test.go +++ /dev/null @@ -1,181 +0,0 @@ -package local - -import ( - "errors" - "os" - "path/filepath" - "testing" - "time" - - app "github.com/networkteam/sdd/pkg/application" -) - -func TestFilesystemGraphStoreRollsBackMidBatchFailure(t *testing.T) { - store, initialRevision, batch, originals := atomicGraphFixture(t) - store.beforeApplyOperation = func(index int) error { - if index == 1 { - return errors.New("injected second operation failure") - } - return nil - } - - result, err := store.Apply(t.Context(), initialRevision, batch, nil) - if err == nil || result.State != app.MutationNotApplied { - t.Fatalf("Apply = %+v, %v; want not_applied with injected error", result, err) - } - assertAtomicFiles(t, store.dir, originals) - current, err := store.Current(t.Context()) - if err != nil { - t.Fatal(err) - } - if current.Revision() != initialRevision { - t.Fatalf("revision after rollback = %q, want %q", current.Revision(), initialRevision) - } - reconciled, err := store.Reconcile(t.Context(), batch.ID, batch.Digest) - if err != nil || reconciled.State != app.MutationNotApplied || reconciled.Revision != initialRevision { - t.Fatalf("Reconcile = %+v, %v", reconciled, err) - } -} - -func TestFilesystemGraphStoreRestartRecoversInterruptedRollback(t *testing.T) { - store, initialRevision, batch, originals := atomicGraphFixture(t) - store.beforeApplyOperation = func(index int) error { - if index == 1 { - return errors.New("injected process interruption") - } - return nil - } - store.beforeRollbackOperation = func(int) error { - return errors.New("injected rollback interruption") - } - - result, err := store.Apply(t.Context(), initialRevision, batch, nil) - if err == nil || result.State != app.MutationUnknown { - t.Fatalf("Apply = %+v, %v; want unknown interrupted transaction", result, err) - } - firstPath := filepath.Join(store.dir, filepath.FromSlash(batch.Changes[0].LogicalPath)) - first, err := os.ReadFile(firstPath) - if err != nil { - t.Fatal(err) - } - if string(first) == string(originals[batch.Changes[0].LogicalPath]) { - t.Fatal("fault did not leave the first operation applied for restart recovery") - } - - restarted, err := NewFilesystemGraphStore(FilesystemGraphStoreOptions{Project: "atomic", GraphDir: store.dir}) - if err != nil { - t.Fatal(err) - } - current, err := restarted.Current(t.Context()) - if err != nil { - t.Fatalf("Current after restart recovery: %v", err) - } - assertAtomicFiles(t, restarted.dir, originals) - if current.Revision() != initialRevision { - t.Fatalf("revision after restart rollback = %q, want %q", current.Revision(), initialRevision) - } - reconciled, err := restarted.Reconcile(t.Context(), batch.ID, batch.Digest) - if err != nil || reconciled.State != app.MutationNotApplied || reconciled.Revision != initialRevision { - t.Fatalf("Reconcile after restart = %+v, %v", reconciled, err) - } - transactionDir := filepath.Join(restarted.dir, ".sdd-runtime", "transactions", batch.ID) - if _, err := os.Stat(transactionDir); !os.IsNotExist(err) { - t.Fatalf("transaction directory remains after recovery: %v", err) - } -} - -func TestFilesystemGraphStoreCurrentCannotObservePartialBatch(t *testing.T) { - store, initialRevision, batch, originals := atomicGraphFixture(t) - firstApplied := make(chan struct{}) - releaseFailure := make(chan struct{}) - store.beforeApplyOperation = func(index int) error { - if index != 1 { - return nil - } - close(firstApplied) - <-releaseFailure - return errors.New("injected second operation failure") - } - applyDone := make(chan app.ApplyResult, 1) - go func() { - result, _ := store.Apply(t.Context(), initialRevision, batch, nil) - applyDone <- result - }() - <-firstApplied - - reader, err := NewFilesystemGraphStore(FilesystemGraphStoreOptions{Project: "atomic", GraphDir: store.dir}) - if err != nil { - t.Fatal(err) - } - currentDone := make(chan *app.Snapshot, 1) - go func() { - snapshot, _ := reader.Current(t.Context()) - currentDone <- snapshot - }() - select { - case <-currentDone: - t.Fatal("Current returned while a partial batch held the graph lock") - case <-time.After(20 * time.Millisecond): - } - close(releaseFailure) - if result := <-applyDone; result.State != app.MutationNotApplied { - t.Fatalf("Apply state = %s, want not_applied", result.State) - } - current := <-currentDone - if current == nil || current.Revision() != initialRevision { - t.Fatalf("Current after rollback = %#v, want revision %s", current, initialRevision) - } - assertAtomicFiles(t, store.dir, originals) -} - -func atomicGraphFixture(t *testing.T) (*FilesystemGraphStore, string, app.MutationBatch, map[string][]byte) { - t.Helper() - dir := canonicalTempDir(t) - originals := map[string][]byte{ - "2026/07/13-070000-s-tac-one.md": atomicEntry("First original summary.", "First original body."), - "2026/07/13-070100-s-tac-two.md": atomicEntry("Second original summary.", "Second original body."), - } - for logicalPath, data := range originals { - path := filepath.Join(dir, filepath.FromSlash(logicalPath)) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, data, 0o644); err != nil { - t.Fatal(err) - } - } - store, err := NewFilesystemGraphStore(FilesystemGraphStoreOptions{Project: "atomic", GraphDir: dir}) - if err != nil { - t.Fatal(err) - } - initial, err := store.Current(t.Context()) - if err != nil { - t.Fatal(err) - } - batch := app.MutationBatch{ID: "atomic-mid-batch", Changes: []app.DocumentChange{ - {LogicalPath: "2026/07/13-070000-s-tac-one.md", CanonicalBytes: atomicEntry("First replacement summary.", "First replacement body.")}, - {LogicalPath: "2026/07/13-070100-s-tac-two.md", CanonicalBytes: atomicEntry("Second replacement summary.", "Second replacement body.")}, - }} - batch.Digest, err = app.MutationBatchDigest(batch) - if err != nil { - t.Fatal(err) - } - return store, initial.Revision(), batch, originals -} - -func atomicEntry(summary, body string) []byte { - return []byte("---\ntype: signal\nkind: gap\nlayer: tactical\nconfidence: high\nsummary: " + summary + "\n---\n\n" + body + "\n") -} - -func assertAtomicFiles(t *testing.T, root string, originals map[string][]byte) { - t.Helper() - for logicalPath, want := range originals { - got, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(logicalPath))) - if err != nil { - t.Fatal(err) - } - if string(got) != string(want) { - t.Errorf("%s contains a partial batch result\ngot: %q\nwant: %q", logicalPath, got, want) - } - } -} From ae330df3b32a13d32dc9ca700b09ba67a18fae91 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 23 Sep 2026 00:44:17 +0200 Subject: [PATCH 02/12] feat(application): publish summary and WIP writes under the recorded intent replaceSummary, wipStart, wipDone and wipRemove allocate their identity and precondition before the intent, publish once under the intent's key through the store's document publication, and report their effects. A summary replacement conditions on the document it replaces. The prepared-write path it replaces goes: ApplyPrepared, revalidation, the recovery projections and notices, the recover command, and the store's apply and reconcile ports. Delivers the summary and WIP requirements of 20260914-113911-d-tac-wgw. Co-Authored-By: Claude Fable 5.1 --- README.md | 3 - docs/local-mutation-recovery.md | 55 -- examples/extendingsdd/adapters_test.go | 6 - .../entries/20260703-194500-d-prc-cat.md | 10 +- .../entries/20260704-100000-d-prc-dlg.md | 12 +- internal/cliapp/app.go | 1 - internal/cliapp/local_store_application.go | 79 ++ internal/cliapp/recover.go | 313 -------- internal/cliapp/serve.go | 6 - internal/proctest/document_writes_test.go | 236 ++++++ pkg/application/acquired_reads_test.go | 2 +- pkg/application/application.go | 19 +- .../application_dependencies_test.go | 6 - pkg/application/capture.go | 18 +- pkg/application/capture_test.go | 20 + pkg/application/document_publication.go | 275 +++++++ pkg/application/errors.go | 15 +- pkg/application/graphstore.go | 87 ++- pkg/application/merge_apply_test.go | 199 +---- pkg/application/read_api.go | 5 - pkg/application/recovery.go | 698 ------------------ .../recovery_projection_integration_test.go | 133 ---- pkg/application/recovery_projection_test.go | 272 ------- pkg/application/revalidation.go | 104 --- pkg/application/runtime.go | 1 - pkg/application/search_preparation_test.go | 19 +- pkg/application/session_runtime_test.go | 668 +---------------- pkg/application/target.go | 32 - pkg/application/transition.go | 270 ------- pkg/application/workflow.go | 12 +- pkg/application/workflow_capture.go | 4 +- pkg/application/workflow_document.go | 191 +++++ pkg/application/workflow_registry.go | 108 +-- .../workflow_target_graph_internal_test.go | 8 +- pkg/application/write_api.go | 123 --- pkg/application/write_fixture_test.go | 2 +- pkg/local/capture_publication.go | 170 +++++ pkg/local/document_publication_test.go | 132 ++++ pkg/local/git_finalizer.go | 69 +- pkg/local/local_adapters_test.go | 33 +- pkg/local/local_graphstore.go | 565 +------------- pkg/local/publish.go | 26 - pkg/local/read_snapshot.go | 58 +- pkg/local/store_root.go | 13 - .../.snapshots/TestToolContractSnapshot.json | 4 - pkg/mcpapp/tools.go | 3 +- pkg/sddtest/conformance.go | 129 +++- 47 files changed, 1430 insertions(+), 3784 deletions(-) delete mode 100644 docs/local-mutation-recovery.md create mode 100644 internal/cliapp/local_store_application.go delete mode 100644 internal/cliapp/recover.go create mode 100644 internal/proctest/document_writes_test.go create mode 100644 pkg/application/document_publication.go delete mode 100644 pkg/application/recovery.go delete mode 100644 pkg/application/recovery_projection_integration_test.go delete mode 100644 pkg/application/recovery_projection_test.go delete mode 100644 pkg/application/revalidation.go delete mode 100644 pkg/application/transition.go create mode 100644 pkg/application/workflow_document.go create mode 100644 pkg/local/document_publication_test.go diff --git a/README.md b/README.md index e4875791..36437cb1 100644 --- a/README.md +++ b/README.md @@ -518,8 +518,6 @@ sdd show d-cpt-vr4 # the entry plus its grounding and consumers sdd show d-cpt-vr4 --up 4 --down 3 # widen the neighborhood ``` -**`sdd recover`** — inspect a durable write whose outcome needs an explicit decision. The interactive command reconciles the concrete branch target before offering a valid action; it never replays pending work automatically. `sdd recover --history` shows terminal audit history. See [local mutation targets and recovery](docs/local-mutation-recovery.md). - For the full CLI surface, run `sdd --help`. ## Directory layout @@ -549,7 +547,6 @@ Registering the MCP server does land in your project tree, so engine mode works - [docs/signal-dialogue-decision.md](docs/signal-dialogue-decision.md) — framework model - [docs/story.md](docs/story.md) — a fictional story (Kōgen Coffee) of what SDD could become; the vision that sparked the design - [docs/signals.md](docs/signals.md) — open design signals for the framework itself -- [docs/local-mutation-recovery.md](docs/local-mutation-recovery.md) — explicit branch authority, durable apply, and recovery states - [CLAUDE.md](CLAUDE.md) — guidance for Claude Code working on SDD itself ## Star the repo diff --git a/docs/local-mutation-recovery.md b/docs/local-mutation-recovery.md deleted file mode 100644 index 7e21be29..00000000 --- a/docs/local-mutation-recovery.md +++ /dev/null @@ -1,55 +0,0 @@ -# Local mutation targets and recovery - -Engine writes carry an immutable project-and-branch authority. The project is the workflow session's project; the branch is concrete before durable intent is recorded. Ordinary captures use the committed `default_branch` setting. Implementation workflows instead carry explicit `baseBranch` and `workBranch` values: WIP coordination targets base, while implementation captures and the closing done target work. - -The local adapter never treats process cwd as authority. For each short graph operation it validates the branch, exact-matches it to one entry from `git worktree list --porcelain -z`, rechecks symbolic HEAD, loads that checkout's SDD configuration, and constructs checkout-scoped graph and Git finalizer adapters. SDD does not create, switch, merge, or remove branches or worktrees. - -## Durable apply lifecycle - -A prepared intent retains the concrete target, structured entry documents, canonical bytes, graph revision, mutation batch identity and digest, and staged-blob ownership. Target acquisition is released before pre-flight and summary model calls. After intent persistence, the engine reacquires the target, validates the retained structured facts against its fresh graph, and calls the storage-neutral `GraphStore.Apply` CAS operation. Target-scoped finalizers are idempotent. - -Pending intent never runs automatically during startup, session resume, orientation, view, catch-up, or an unrelated write. Read surfaces only report that action is available. - -## Recovery states and actions - -The projection answers three separate questions in three fields, so none of them -has to encode the others. - -**State** answers only whether delivery was reached, and has three values: - -- `delivered`: the batch landed and finalization is proven — at least one recorded finalizer outcome, all successful. Nothing is owed; -- `pending`: delivery is not proven. This is exactly the actionable condition; nothing else is actionable and no pending item is not; -- `abandoned`: a participant decided to stop pursuing delivery. - -**Reason** qualifies a state that does not explain itself. For `pending` it names -what delivery waits on; for `abandoned`, which decision ended it; for `delivered` -it is empty. - -- `outcome-unknown`: no definitive canonical outcome and no reconciliation establishing one; -- `not-applied`: the batch is definitively absent; -- `finalization-owed`: the batch landed, but either a recorded finalizer outcome failed or no finalizer outcome is recorded at all, which means none ran; -- `discarded`: abandoned as definitively absent; -- `abandoned-unknown`: abandoned without claiming absence. - -Applied state itself comes from the recorded outcome: the canonical apply outcome -when it is definitive, otherwise a recovery attempt's reconciliation. - -**Recovered** is provenance, not state: it records that recovery machinery — a -reconciliation or a verb — touched this mutation. A write that simply succeeded is -`delivered` with the flag unset; one that needed help is `delivered` with it set. -Both are equally delivered, which is why this is a flag and not a state. - -Every recovery action resolves current authorization using the actor, original owner and session, concrete target, and a distinct verb. It then reacquires and reconciles batch ID plus digest before acting: - -- `reconcile` is a nonterminal refresh used by interactive clients before they present a verb; it records current evidence but never applies, finalizes, discards, abandons, or binds; -- `apply` revalidates the retained structured facts and retries CAS only from definitely not applied; -- `discard` terminally releases a definitely absent batch; -- `finalize-retry` retries unfinished idempotent finalizers only after application is established; -- `abandon-unknown` is allowed only after recorded failed acquisition or non-definitive reconciliation; -- `bind-target` is reserved for legacy version-1 intent. Intent without sufficient structured facts fails with migration-required and must be explicitly recaptured. - -Terminal audit records retain the original owner/session, recovery actor, target, batch identity and digest, verb, reason, and reconciliation evidence. Abandoned entry identities remain available for a future grooming lens; recovery never creates graph entries itself. - -## Local command - -Run `sdd recover` in a terminal to inspect actionable items and choose an allowed recovery verb with explicit confirmation. An unknown item is reconciled first so the menu reflects current evidence; `reconcile` itself is not exposed as a user-selectable terminal action. `sdd recover --history` shows closed audit history. Non-interactive recovery requires `--session`, `--mutation`, `--verb`, and `--yes`; `bind-target` additionally requires `--branch`. diff --git a/examples/extendingsdd/adapters_test.go b/examples/extendingsdd/adapters_test.go index d823c476..33730d2f 100644 --- a/examples/extendingsdd/adapters_test.go +++ b/examples/extendingsdd/adapters_test.go @@ -13,12 +13,6 @@ import ( type graphStore struct{} func (graphStore) Current(context.Context) (*sdd.Snapshot, error) { return &sdd.Snapshot{}, nil } -func (graphStore) Apply(context.Context, string, sdd.MutationBatch, sdd.StagedBlobReader) (sdd.ApplyResult, error) { - return sdd.ApplyResult{State: sdd.MutationApplied, Revision: "r2"}, nil -} -func (graphStore) Reconcile(context.Context, string, string) (sdd.ApplyResult, error) { - return sdd.ApplyResult{State: sdd.MutationApplied, Revision: "r2"}, nil -} func (graphStore) ReadAttachmentPage(context.Context, string, string, int64, int) (sdd.AttachmentPage, error) { return sdd.AttachmentPage{}, nil } diff --git a/internal/baseprocedures/entries/20260703-194500-d-prc-cat.md b/internal/baseprocedures/entries/20260703-194500-d-prc-cat.md index 207e7d77..15d57d8c 100644 --- a/internal/baseprocedures/entries/20260703-194500-d-prc-cat.md +++ b/internal/baseprocedures/entries/20260703-194500-d-prc-cat.md @@ -17,11 +17,11 @@ state: steps: - id: compose inject: - - {id: focus, fn: viewLayout, args: {layout: 'focus:brief', recovery: false}, maxBytes: 4000} - - {id: recentDone, fn: viewLayout, args: {layout: 'kind(done):rank(by(date)):n(10):name("Recent done"):brief:as-list', recovery: false}, maxBytes: 4000} - - {id: activeHot, fn: viewLayout, args: {layout: 'kind(plan,activity,directive):active:not(intent(guiding)):rank(heat(exp-7d)):n(8):expand(refs(inactive)):name("Active and hot"):brief:as-list', recovery: false}, maxBytes: 6000} - - {id: openLoops, fn: viewLayout, args: {layout: 'kind(plan,activity,directive,gap,question):active:not(intent(guiding)):rank(coldness(exp-30d)):n(8):expand(refs):name("Open loops"):brief:as-list', recovery: false}, maxBytes: 6000} - - {id: openWarm, fn: viewLayout, args: {layout: 'kind(gap,question,insight):active:rank(heat(exp-14d)):n(15):name("Open and warm"):brief:as-list', recovery: false}, maxBytes: 5000} + - {id: focus, fn: viewLayout, args: {layout: 'focus:brief'}, maxBytes: 4000} + - {id: recentDone, fn: viewLayout, args: {layout: 'kind(done):rank(by(date)):n(10):name("Recent done"):brief:as-list'}, maxBytes: 4000} + - {id: activeHot, fn: viewLayout, args: {layout: 'kind(plan,activity,directive):active:not(intent(guiding)):rank(heat(exp-7d)):n(8):expand(refs(inactive)):name("Active and hot"):brief:as-list'}, maxBytes: 6000} + - {id: openLoops, fn: viewLayout, args: {layout: 'kind(plan,activity,directive,gap,question):active:not(intent(guiding)):rank(coldness(exp-30d)):n(8):expand(refs):name("Open loops"):brief:as-list'}, maxBytes: 6000} + - {id: openWarm, fn: viewLayout, args: {layout: 'kind(gap,question,insight):active:rank(heat(exp-14d)):n(15):name("Open and warm"):brief:as-list'}, maxBytes: 5000} - {id: wip, fn: viewLayout, args: {layout: 'wip'}, maxBytes: 2000} collect: [briefing] transitions: diff --git a/internal/baseprocedures/entries/20260704-100000-d-prc-dlg.md b/internal/baseprocedures/entries/20260704-100000-d-prc-dlg.md index 517f9e34..73b49375 100644 --- a/internal/baseprocedures/entries/20260704-100000-d-prc-dlg.md +++ b/internal/baseprocedures/entries/20260704-100000-d-prc-dlg.md @@ -28,12 +28,12 @@ steps: transitions: - to: end(completed) framing: - - {id: principles, fn: viewLayout, args: {layout: 'active:kind(fact):topic("principles/interactive"):as-bodies:name("Working principles")', recovery: false}} - - {id: aspirations, fn: viewLayout, args: {layout: 'aspirations:rank(heat(exp-14d)):n(8):brief', recovery: false}} - - {id: directives, fn: viewLayout, args: {layout: 'kind(directive):intent(guiding):active:rank(heat(exp-14d)):n(10):name("Guiding directives"):brief:as-list', recovery: false}} - - {id: focus, fn: viewLayout, args: {layout: 'focus:brief', recovery: false}} - - {id: participants, fn: viewLayout, args: {layout: 'participants:brief', recovery: false}} - - {id: recentMovement, fn: viewLayout, args: {layout: 'rank(by(date)):n(10):brief:as-list:name("Recent graph movement")', recovery: false}, maxBytes: 2500} + - {id: principles, fn: viewLayout, args: {layout: 'active:kind(fact):topic("principles/interactive"):as-bodies:name("Working principles")'}} + - {id: aspirations, fn: viewLayout, args: {layout: 'aspirations:rank(heat(exp-14d)):n(8):brief'}} + - {id: directives, fn: viewLayout, args: {layout: 'kind(directive):intent(guiding):active:rank(heat(exp-14d)):n(10):name("Guiding directives"):brief:as-list'}} + - {id: focus, fn: viewLayout, args: {layout: 'focus:brief'}} + - {id: participants, fn: viewLayout, args: {layout: 'participants:brief'}} + - {id: recentMovement, fn: viewLayout, args: {layout: 'rank(by(date)):n(10):brief:as-list:name("Recent graph movement")'}, maxBytes: 2500} --- The user-dialogue shell is the base procedure a session opens with: one long-lived instance whose pending junction is where free dialogue happens, whose serves are where every move lands when it ends, and whose conclude path is the deliberate way out. The agent is never goalless — before the first move, between moves, and after the last one, this instance's serve names where the dialogue stands. diff --git a/internal/cliapp/app.go b/internal/cliapp/app.go index 34f4a089..29285b08 100644 --- a/internal/cliapp/app.go +++ b/internal/cliapp/app.go @@ -392,7 +392,6 @@ func New(options Options) *cli.Command { searchCmd(), serveCmd(), sessionsCmd(), - recoverCmd(), syncCmd(), repoCmd(), statsCmd(), diff --git a/internal/cliapp/local_store_application.go b/internal/cliapp/local_store_application.go new file mode 100644 index 00000000..11058efd --- /dev/null +++ b/internal/cliapp/local_store_application.go @@ -0,0 +1,79 @@ +package cliapp + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/urfave/cli/v3" + + "github.com/networkteam/sdd/internal/repos" + sdd "github.com/networkteam/sdd/pkg/application" + pkgllm "github.com/networkteam/sdd/pkg/llm" + localadapter "github.com/networkteam/sdd/pkg/local" +) + +// buildLocalStoreApplication composes the application over the local checkout +// for commands that inspect session state without running language models. +func buildLocalStoreApplication(ctx context.Context, cmd *cli.Command) (*sdd.Application, sdd.ProjectID, sdd.RequestIdentity, error) { + graphDir, err := resolveGraphDir(cmd) + if err != nil { + return nil, "", sdd.RequestIdentity{}, err + } + sddDir, err := resolveSDDDir() + if err != nil { + return nil, "", sdd.RequestIdentity{}, err + } + cfg, err := loadConfig() + if err != nil { + return nil, "", sdd.RequestIdentity{}, err + } + if cfg == nil || cfg.DefaultBranch == "" { + return nil, "", sdd.RequestIdentity{}, fmt.Errorf("default_branch is required in .sdd/config.yaml") + } + locations, err := repos.DefaultLocations() + if err != nil { + return nil, "", sdd.RequestIdentity{}, err + } + storeLocations, err := resolveSessionLocations(sddDir, cfg, locations) + if err != nil { + return nil, "", sdd.RequestIdentity{}, err + } + project := sessionStoreProject(cfg) + graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: project, GraphDir: graphDir}) + if err != nil { + return nil, "", sdd.RequestIdentity{}, err + } + sessions, err := localadapter.NewFilesystemSessionStore(storeLocations...) + if err != nil { + return nil, "", sdd.RequestIdentity{}, err + } + blobs, err := localadapter.NewFilesystemStagedBlobStore(storeLocations...) + if err != nil { + return nil, "", sdd.RequestIdentity{}, err + } + targets, err := localadapter.NewRepositoryTargets(project, filepath.Dir(sddDir), locations.ConfigPath) + if err != nil { + return nil, "", sdd.RequestIdentity{}, err + } + runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ + Project: sdd.ProjectRef{ID: project, DisplayName: filepath.Base(filepath.Dir(sddDir))}, DefaultBranch: cfg.DefaultBranch, + Graph: graph, Targets: targets, + LLM: pkgllm.RunnerFunc(func(context.Context, pkgllm.Request) (pkgllm.Result, error) { + return pkgllm.Result{}, fmt.Errorf("session inspection does not execute language models") + }), + }) + if err != nil { + return nil, "", sdd.RequestIdentity{}, err + } + access := &localRuntimeAccess{project: project, participant: cfg.Participant, runtime: runtime, dependencies: map[string]*sdd.ProjectRuntime{}} + application, err := sdd.NewApplication(sdd.ApplicationOptions{Access: access, Sessions: sessions, StagedBlobs: blobs}) + if err != nil { + return nil, "", sdd.RequestIdentity{}, err + } + identity := sdd.RequestIdentity{Subject: "local"} + if _, err := application.Info(ctx, identity, project, sdd.InfoRequest{}); err != nil { + return nil, "", sdd.RequestIdentity{}, err + } + return application, project, identity, nil +} diff --git a/internal/cliapp/recover.go b/internal/cliapp/recover.go deleted file mode 100644 index 87a9e535..00000000 --- a/internal/cliapp/recover.go +++ /dev/null @@ -1,313 +0,0 @@ -package cliapp - -import ( - "bufio" - "context" - "fmt" - "io" - "path/filepath" - "strconv" - "strings" - - "github.com/urfave/cli/v3" - - "github.com/networkteam/sdd/internal/cliout" - "github.com/networkteam/sdd/internal/repos" - sdd "github.com/networkteam/sdd/pkg/application" - pkgllm "github.com/networkteam/sdd/pkg/llm" - localadapter "github.com/networkteam/sdd/pkg/local" -) - -func recoverCmd() *cli.Command { - return &cli.Command{ - Name: "recover", - Usage: "Inspect and explicitly recover durable pending writes", - Flags: []cli.Flag{ - &cli.BoolFlag{Name: "history", Usage: "Show actionable and closed recovery audit history without taking action"}, - &cli.StringFlag{Name: "session", Usage: "Session containing the pending mutation"}, - &cli.StringFlag{Name: "mutation", Usage: "Pending mutation ID"}, - &cli.StringFlag{Name: "verb", Usage: "Recovery verb: apply, discard, finalize-retry, abandon-unknown, or bind-target"}, - &cli.StringFlag{Name: "branch", Usage: "Concrete branch for bind-target"}, - &cli.StringFlag{Name: "reason", Usage: "Reason recorded in the immutable recovery audit"}, - &cli.BoolFlag{Name: "yes", Usage: "Confirm the explicitly selected recovery verb non-interactively"}, - }, - Action: withWriteGate(func(ctx context.Context, cmd *cli.Command) error { - application, project, identity, err := buildLocalStoreApplication(ctx, cmd) - if err != nil { - return err - } - list, err := application.ListRecoveries(ctx, identity, project, cmd.Bool("history")) - if err != nil { - return err - } - if cmd.Bool("history") { - renderRecoveryItems(cmd.Writer, list.Items) - return nil - } - if len(list.Items) == 0 { - fmt.Fprintln(cmd.Writer, "No pending writes await recovery.") - return nil - } - item, err := selectRecoveryItem(list.Items, cmd) - if err != nil { - return err - } - if recoveryNeedsReconciliation(item, cmd.String("verb")) { - refreshed, err := application.ReconcileMutation(ctx, identity, sdd.RecoveryReconcileRequest{ - Session: item.Session, MutationID: item.MutationID, - }) - if err != nil { - return err - } - item = refreshed.Item - } - verb, err := selectRecoveryVerb(item, cmd) - if err != nil { - return err - } - target := sdd.MutationTarget{} - if verb == sdd.RecoveryBindTarget { - branch := strings.TrimSpace(cmd.String("branch")) - if branch == "" { - if !cliout.IsTerminalReader(cmd.Reader) { - return fmt.Errorf("bind-target requires --branch in non-interactive mode") - } - branch, err = readRecoveryLine(cmd.Reader, cmd.Writer, "Concrete target branch: ") - if err != nil { - return err - } - } - target = sdd.MutationTarget{Project: project, Branch: branch} - } - reason := strings.TrimSpace(cmd.String("reason")) - if reason == "" && cliout.IsTerminalReader(cmd.Reader) { - reason, err = readRecoveryLine(cmd.Reader, cmd.Writer, "Audit reason: ") - if err != nil { - return err - } - } - if !cmd.Bool("yes") { - if !cliout.IsTerminalReader(cmd.Reader) { - return fmt.Errorf("recovery requires explicit confirmation; pass --yes with --session, --mutation, and --verb") - } - confirmed, err := promptConfirmation(cmd, fmt.Sprintf("Run %s for %s on %s?", verb, item.MutationID, recoveryTargetLabel(item))) - if err != nil { - return err - } - if !confirmed { - return fmt.Errorf("recovery cancelled") - } - } - result, err := application.RecoverMutation(ctx, identity, sdd.RecoveryRequest{ - Session: item.Session, MutationID: item.MutationID, Verb: verb, Reason: reason, Target: target, - }) - if err != nil { - return err - } - fmt.Fprintf(cmd.Writer, "Recovery recorded: %s · %s · %s\n", item.MutationID, verb, recoveryStateLabel(result.Item)) - return nil - }), - } -} - -// buildLocalStoreApplication composes the application over the project's -// stores alone — no LLM, no index — for the maintenance commands that act on -// sessions and pending writes. -func buildLocalStoreApplication(ctx context.Context, cmd *cli.Command) (*sdd.Application, sdd.ProjectID, sdd.RequestIdentity, error) { - graphDir, err := resolveGraphDir(cmd) - if err != nil { - return nil, "", sdd.RequestIdentity{}, err - } - sddDir, err := resolveSDDDir() - if err != nil { - return nil, "", sdd.RequestIdentity{}, err - } - cfg, err := loadConfig() - if err != nil { - return nil, "", sdd.RequestIdentity{}, err - } - if cfg == nil || cfg.DefaultBranch == "" { - return nil, "", sdd.RequestIdentity{}, fmt.Errorf("default_branch is required in .sdd/config.yaml") - } - locations, err := repos.DefaultLocations() - if err != nil { - return nil, "", sdd.RequestIdentity{}, err - } - storeLocations, err := resolveSessionLocations(sddDir, cfg, locations) - if err != nil { - return nil, "", sdd.RequestIdentity{}, err - } - project := sessionStoreProject(cfg) - graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: project, GraphDir: graphDir}) - if err != nil { - return nil, "", sdd.RequestIdentity{}, err - } - sessions, err := localadapter.NewFilesystemSessionStore(storeLocations...) - if err != nil { - return nil, "", sdd.RequestIdentity{}, err - } - blobs, err := localadapter.NewFilesystemStagedBlobStore(storeLocations...) - if err != nil { - return nil, "", sdd.RequestIdentity{}, err - } - targets, err := localadapter.NewRepositoryTargets(project, filepath.Dir(sddDir), locations.ConfigPath) - if err != nil { - return nil, "", sdd.RequestIdentity{}, err - } - runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ - Project: sdd.ProjectRef{ID: project, DisplayName: filepath.Base(filepath.Dir(sddDir))}, DefaultBranch: cfg.DefaultBranch, - Graph: graph, Targets: 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"} - } - return nil - }), - LLM: pkgllm.RunnerFunc(func(context.Context, pkgllm.Request) (pkgllm.Result, error) { - return pkgllm.Result{}, fmt.Errorf("recovery does not execute language models") - }), - }) - if err != nil { - return nil, "", sdd.RequestIdentity{}, err - } - access := &localRuntimeAccess{project: project, participant: cfg.Participant, runtime: runtime, dependencies: map[string]*sdd.ProjectRuntime{}} - application, err := sdd.NewApplication(sdd.ApplicationOptions{Access: access, Sessions: sessions, StagedBlobs: blobs}) - if err != nil { - return nil, "", sdd.RequestIdentity{}, err - } - identity := sdd.RequestIdentity{Subject: "local"} - if _, err := application.Info(ctx, identity, project, sdd.InfoRequest{}); err != nil { - return nil, "", sdd.RequestIdentity{}, err - } - return application, project, identity, nil -} - -func renderRecoveryItems(writer io.Writer, items []sdd.RecoveryItem) { - if len(items) == 0 { - fmt.Fprintln(writer, "No recovery history.") - return - } - for index, item := range items { - fmt.Fprintf(writer, "%d. %s · %s · %s · owner %s · session %s\n", index+1, item.MutationID, recoveryStateLabel(item), recoveryTargetLabel(item), item.OriginalSubject, item.Session) - if item.LastEvidence != "" { - fmt.Fprintf(writer, " evidence: %s\n", item.LastEvidence) - } - } -} - -// recoveryStateLabel renders delivery state, qualified by the reason where one -// applies, and marks the items a recovery verb actually touched — so a write that -// simply succeeded never reads as recovered. -func recoveryStateLabel(item sdd.RecoveryItem) string { - label := string(item.State) - if item.Reason != "" { - label += " (" + string(item.Reason) + ")" - } - if item.Recovered { - label += " · recovered" - } - return label -} - -func selectRecoveryItem(items []sdd.RecoveryItem, cmd *cli.Command) (sdd.RecoveryItem, error) { - session, mutation := sdd.SessionID(strings.TrimSpace(cmd.String("session"))), strings.TrimSpace(cmd.String("mutation")) - if session != "" || mutation != "" { - if session == "" || mutation == "" { - return sdd.RecoveryItem{}, fmt.Errorf("--session and --mutation must be supplied together") - } - for _, item := range items { - if item.Session == session && item.MutationID == mutation { - return item, nil - } - } - return sdd.RecoveryItem{}, fmt.Errorf("pending mutation %s in session %s was not found", mutation, session) - } - if !cliout.IsTerminalReader(cmd.Reader) { - return sdd.RecoveryItem{}, fmt.Errorf("multiple pending writes require --session and --mutation in non-interactive mode") - } - renderRecoveryItems(cmd.Writer, items) - choice, err := readRecoveryChoice(cmd.Reader, cmd.Writer, "Select pending write: ", len(items)) - if err != nil { - return sdd.RecoveryItem{}, err - } - return items[choice-1], nil -} - -func selectRecoveryVerb(item sdd.RecoveryItem, cmd *cli.Command) (sdd.RecoveryVerb, error) { - if raw := strings.TrimSpace(cmd.String("verb")); raw != "" { - return parseRecoveryVerb(raw) - } - if !cliout.IsTerminalReader(cmd.Reader) { - return "", fmt.Errorf("--verb is required in non-interactive mode") - } - verbs := recoveryVerbs(item) - for index, verb := range verbs { - fmt.Fprintf(cmd.Writer, "%d. %s\n", index+1, verb) - } - choice, err := readRecoveryChoice(cmd.Reader, cmd.Writer, "Select recovery action: ", len(verbs)) - if err != nil { - return "", err - } - return verbs[choice-1], nil -} - -func recoveryNeedsReconciliation(item sdd.RecoveryItem, explicitVerb string) bool { - return strings.TrimSpace(explicitVerb) == "" && !item.LegacyUnroutable && item.Reason == sdd.RecoveryReasonOutcomeUnknown -} - -// recoveryVerbs offers the actions that answer what a pending item is waiting -// on, so it keys on the reason rather than on delivery state. -func recoveryVerbs(item sdd.RecoveryItem) []sdd.RecoveryVerb { - if item.LegacyUnroutable { - return []sdd.RecoveryVerb{sdd.RecoveryBindTarget} - } - switch item.Reason { - case sdd.RecoveryReasonNotApplied: - return []sdd.RecoveryVerb{sdd.RecoveryApply, sdd.RecoveryDiscard} - case sdd.RecoveryReasonFinalizationOwed: - return []sdd.RecoveryVerb{sdd.RecoveryFinalizeRetry} - default: - return []sdd.RecoveryVerb{sdd.RecoveryAbandonUnknown} - } -} - -func parseRecoveryVerb(raw string) (sdd.RecoveryVerb, error) { - verb := sdd.RecoveryVerb(raw) - switch verb { - case sdd.RecoveryApply, sdd.RecoveryDiscard, sdd.RecoveryFinalizeRetry, sdd.RecoveryAbandonUnknown, sdd.RecoveryBindTarget: - return verb, nil - default: - return "", fmt.Errorf("invalid recovery verb %q", raw) - } -} - -func recoveryTargetLabel(item sdd.RecoveryItem) string { - if item.LegacyUnroutable { - return "target binding required" - } - if item.Target.Project == "" && item.Target.Branch == "" { - return "no recorded target" - } - return fmt.Sprintf("%s@%s", item.Target.Project, item.Target.Branch) -} - -func readRecoveryChoice(reader io.Reader, writer io.Writer, prompt string, count int) (int, error) { - line, err := readRecoveryLine(reader, writer, prompt) - if err != nil { - return 0, err - } - choice, err := strconv.Atoi(strings.TrimSpace(line)) - if err != nil || choice < 1 || choice > count { - return 0, fmt.Errorf("choice must be between 1 and %d", count) - } - return choice, nil -} - -func readRecoveryLine(reader io.Reader, writer io.Writer, prompt string) (string, error) { - fmt.Fprint(writer, prompt) - line, err := bufio.NewReader(reader).ReadString('\n') - if err != nil { - return "", err - } - return strings.TrimSpace(line), nil -} diff --git a/internal/cliapp/serve.go b/internal/cliapp/serve.go index adafcbe4..259b1207 100644 --- a/internal/cliapp/serve.go +++ b/internal/cliapp/serve.go @@ -308,12 +308,6 @@ 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: 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"} - } - return nil - }), Embedder: embeddings, SearchIndex: optionalSearchIndex(embeddings, baseIndex), LLM: runner, }) diff --git a/internal/proctest/document_writes_test.go b/internal/proctest/document_writes_test.go new file mode 100644 index 00000000..2eba0fa5 --- /dev/null +++ b/internal/proctest/document_writes_test.go @@ -0,0 +1,236 @@ +package proctest_test + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/networkteam/sdd/internal/model" + "github.com/networkteam/sdd/internal/proctest" + sdd "github.com/networkteam/sdd/pkg/application" +) + +// failingFinalizer fails the n-th completion it is asked for and succeeds on +// every other call, so one publication's completion can be interrupted while +// the ones before it land. +type failingFinalizer struct { + failCall int + calls int +} + +func (*failingFinalizer) Name() string { return "interrupted-completion" } +func (f *failingFinalizer) Finalize(context.Context, sdd.AppliedMutation) error { + f.calls++ + if f.calls == f.failCall { + return errors.New("completion unavailable after publication") + } + return nil +} + +// A summary correction is a recorded write like capture: when its completion +// fails, the position is served pending; the retry publishes nothing twice and +// completes the transition the verifySummary answer owed (s-tac-do6), and the +// corrected summary is what the entry carries. +func TestCapture_SummaryCorrectionRetryCompletesTheAnsweredTransition(t *testing.T) { + finalizer := &failingFinalizer{failCall: 2} + world, session := newCaptureWorld(t, "summary-correction", proctest.WithFinalizers(finalizer)) + before := entryIDsOnDisk(t, world.GraphDir) + serve := session.Start(t, "capture", nil) + instance := serve.Instance + session.Report(t, instance, captureDraft()) + serve = session.Answer(t, instance, "playback", "confirm", nil, "publish the observation") + proctest.RequireStep(t, serve, "verifySummary") + entryID := writtenEntryID(t, world.GraphDir, before) + + failed, err := session.AnswerErr(t, instance, "verifySummary", "drifted", map[string]any{"correctedSummary": "The corrected summary."}, "") + if err != nil { + t.Fatal(err) + } + if failed.PendingOperation == nil || failed.PendingOperation.Command != "replaceSummary" || failed.PendingOperation.Values["entryId"] != entryID { + t.Fatalf("a failed summary correction must serve its pending position: %+v", failed.PendingOperation) + } + if got := proctest.LoadEntry(t, world.GraphDir, entryID).Summary; got != "The corrected summary." { + t.Fatalf("the replacement was written before its completion failed; summary = %q", got) + } + if finalizer.calls != 2 { + t.Fatalf("finalizer calls = %d, want 2 (capture, then the failed correction)", finalizer.calls) + } + + retried, err := session.WF.Advance(t.Context(), world.Identity, sdd.WorkflowAdvanceRequest{Instance: instance, RetryRef: failed.PendingOperation.RetryRef}) + if err != nil { + t.Fatal(err) + } + proctest.RequireStatus(t, retried, "completed") + if finalizer.calls != 3 { + t.Fatalf("retry must complete the publication once more, calls = %d", finalizer.calls) + } + if got := proctest.LoadEntry(t, world.GraphDir, entryID).Summary; got != "The corrected summary." { + t.Fatalf("summary after retry = %q", got) + } + if world.LLM.Calls("summarize") != 1 { + t.Fatalf("a correction must not regenerate the summary; summarize calls = %d", world.LLM.Calls("summarize")) + } +} + +// A summary correction conditions on the document it was read from: a +// document that moved meanwhile is refused as a conflict naming the current +// summary, and nothing is written. +func TestApplication_SummaryCorrectionRefusesAMovedDocument(t *testing.T) { + world, session := newCaptureWorld(t, "summary-conflict") + before := entryIDsOnDisk(t, world.GraphDir) + serve := session.Start(t, "capture", nil) + instance := serve.Instance + session.Report(t, instance, captureDraft()) + serve = session.Answer(t, instance, "playback", "confirm", nil, "publish the observation") + proctest.RequireStep(t, serve, "verifySummary") + entryID := writtenEntryID(t, world.GraphDir, before) + binding := session.WF.Binding() + + _, err := world.App.ReplaceSummary(t.Context(), world.Identity, "proctest", binding, sdd.SummaryReplacement{ + Publication: sdd.PublicationKey{Session: session.ID, Sequence: 99, Discriminator: "replaceSummary:" + entryID}, + EntryID: entryID, + ExpectedBlob: sdd.GitBlobID([]byte("the document as some earlier read saw it")), + Summary: "A late correction.", + }) + var appErr *sdd.ApplicationError + if !errors.As(err, &appErr) || appErr.Code != sdd.ErrorGraphConflict { + t.Fatalf("stale correction = %v, want %s", err, sdd.ErrorGraphConflict) + } + current := proctest.LoadEntry(t, world.GraphDir, entryID).Summary + if current != "A generated summary." || !containsAll(appErr.Message, current) { + t.Fatalf("conflict must leave the summary and name it: summary=%q message=%q", current, appErr.Message) + } +} + +// A WIP marker's identity is allocated before the intent, so a retry after a +// failed completion publishes the same marker once and lands on the step the +// setup answer owed. +func TestImplementation_WIPStartRetryPublishesOneMarker(t *testing.T) { + finalizer := &failingFinalizer{failCall: 1} + world := proctest.NewWorld(t, proctest.WithEntries(implAnchorEntry()), proctest.WithFinalizers(finalizer)) + session := world.Open(t, "wip-retry") + serve := implToSetup(t, session, map[string]any{"anchor": implAnchorID}, "main") + failed, err := session.AnswerErr(t, serve.Instance, "setup", "inPlace", map[string]any{"wipDescription": "implement the anchor"}, "in place") + if err != nil { + t.Fatal(err) + } + if failed.PendingOperation == nil || failed.PendingOperation.Command != "wipStart" { + t.Fatalf("a failed marker publication must serve its pending position: %+v", failed.PendingOperation) + } + marker := requireSingleMarker(t, world.GraphDir) + if marker.ID != failed.PendingOperation.Values["markerId"] { + t.Fatalf("marker on disk %s, intent recorded %s", marker.ID, failed.PendingOperation.Values["markerId"]) + } + retried, err := session.WF.Advance(t.Context(), world.Identity, sdd.WorkflowAdvanceRequest{Instance: serve.Instance, RetryRef: failed.PendingOperation.RetryRef}) + if err != nil { + t.Fatal(err) + } + proctest.RequireStep(t, retried, "workTarget") + if again := requireSingleMarker(t, world.GraphDir); again.ID != marker.ID { + t.Fatalf("retry published another marker: %s then %s", marker.ID, again.ID) + } + if finalizer.calls != 2 { + t.Fatalf("finalizer calls = %d, want the failed and the completing one", finalizer.calls) + } +} + +// Removing a marker that is already gone succeeds: the landing still closes +// the run, and no other marker is touched. +func TestImplementation_LandingRemovesAnAbsentMarkerWithoutError(t *testing.T) { + world := proctest.NewWorld(t, proctest.WithEntries(implAnchorEntry())) + session := world.Open(t, "wip-absent") + serve := startImplementationAtWork(t, session) + instance := serve.Instance + marker := requireSingleMarker(t, world.GraphDir) + other := writeWIPMarker(t, world.GraphDir, "20260601-130000-someone-else", implAnchorID) + if err := os.Remove(filepath.Join(world.GraphDir, "wip", marker.ID+".md")); err != nil { + t.Fatal(err) + } + + serve = session.Answer(t, instance, "work", "conclude", nil, "done") + proctest.RequireStep(t, serve, "record") + doneID := captureDone(t, session, instance) + serve = session.Report(t, instance, map[string]any{"doneEntry": doneID}) + proctest.RequireStep(t, serve, "landing") + serve = session.Answer(t, instance, "landing", "landed", nil, "merged") + proctest.RequireStep(t, serve, "closeout") + if ids := wipMarkerIDs(t, world.GraphDir); len(ids) != 1 || ids[0] != "20260601-130000-someone-else" { + t.Fatalf("markers after landing = %v, want only the other participant's", ids) + } + if _, err := os.Stat(other); err != nil { + t.Fatalf("another participant's marker was removed: %v", err) + } +} + +// entryIDsOnDisk lists the entries a graph directory holds. +func entryIDsOnDisk(t *testing.T, graphDir string) map[string]bool { + t.Helper() + ids := map[string]bool{} + err := filepath.WalkDir(graphDir, func(name string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if entry.Name() == "wip" || entry.Name() == ".sdd-runtime" { + return filepath.SkipDir + } + return nil + } + if filepath.Ext(name) != ".md" { + return nil + } + rel, err := filepath.Rel(graphDir, name) + if err != nil { + return err + } + if id, err := model.RelPathToID(filepath.ToSlash(rel)); err == nil { + ids[id] = true + } + return nil + }) + if err != nil { + t.Fatal(err) + } + return ids +} + +// writtenEntryID returns the one entry written since before was taken. +func writtenEntryID(t *testing.T, graphDir string, before map[string]bool) string { + t.Helper() + var found string + for id := range entryIDsOnDisk(t, graphDir) { + if before[id] { + continue + } + if found != "" { + t.Fatalf("more than one written entry: %s and %s", found, id) + } + found = id + } + if found == "" { + t.Fatal("no entry was written") + } + return found +} + +func containsAll(haystack string, needles ...string) bool { + for _, needle := range needles { + if needle == "" { + continue + } + found := false + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + found = true + break + } + } + if !found { + return false + } + } + return true +} diff --git a/pkg/application/acquired_reads_test.go b/pkg/application/acquired_reads_test.go index 2512b0ea..5da69893 100644 --- a/pkg/application/acquired_reads_test.go +++ b/pkg/application/acquired_reads_test.go @@ -80,7 +80,7 @@ func TestAcquiredReadsUseReadAuthorityAndRelease(t *testing.T) { return e }, "view": func() error { - _, e := app.View(t.Context(), identity, "base", sdd.ViewRequest{Branch: "work", Layout: "as-list", OmitRecovery: true}) + _, e := app.View(t.Context(), identity, "base", sdd.ViewRequest{Branch: "work", Layout: "as-list"}) return e }, "text search": func() error { diff --git a/pkg/application/application.go b/pkg/application/application.go index 136388b3..eb0e26fb 100644 --- a/pkg/application/application.go +++ b/pkg/application/application.go @@ -111,17 +111,13 @@ func (a *Application) infoFromRuntime(ctx context.Context, principal Principal, if runtime.options.Embedder != nil && runtime.options.SearchIndex != nil { search = "vector,text" } - recoveries, err := a.listRecoveries(ctx, runtime, false) - if err != nil { - return InfoResult{}, err - } participant, err := a.participantFor(ctx, principal, runtime) if err != nil { return InfoResult{}, err } return InfoResult{ Project: runtime.options.Project, Participant: participant, Language: runtime.options.Language, - Search: search, Recovery: renderRecoveryNotices(recoveries.Items), + Search: search, }, nil } @@ -256,15 +252,6 @@ func (a *Application) viewFromSnapshot(ctx context.Context, identity RequestIden fmt.Fprintf(&rendered, "\n── repo: %s ──\n", repoID) presenters.RenderView(&rendered, memberResult) } - if !request.OmitRecovery { - recoveries, err := a.listRecoveries(ctx, runtime, false) - if err != nil { - return ViewResult{}, err - } - if notices := renderRecoveryNotices(recoveries.Items); notices != "" { - fmt.Fprintf(&rendered, "\n%s\n", notices) - } - } // When a participant filter matched nothing, name the participants the // local graph knows: participant() is an exact canonical match, so an // empty result usually means a wrong spelling rather than genuinely no @@ -566,7 +553,7 @@ func (a *Application) resolveProject(ctx context.Context, principal Principal, p // the ID carries 128 random bits and the caller learns nothing until both // checks pass. Whether an ended session may still be acted on is the // caller's question — recovery reads a concluded log, a move never does. -func (a *Application) resolveSession(ctx context.Context, identity RequestIdentity, id SessionID, required Access) (Principal, *ProjectRuntime, StoredSession, error) { +func (a *Application) resolveSession(ctx context.Context, identity RequestIdentity, id SessionID) (Principal, *ProjectRuntime, StoredSession, error) { if id == "" { return Principal{}, nil, StoredSession{}, &ApplicationError{Code: ErrorInvalidArgument, Message: "session ID is required"} } @@ -587,7 +574,7 @@ func (a *Application) resolveSession(ctx context.Context, identity RequestIdenti }); err != nil { return Principal{}, nil, StoredSession{}, err } - runtime, err := a.resolveProject(ctx, principal, stored.Metadata.Project, required) + runtime, err := a.resolveProject(ctx, principal, stored.Metadata.Project, AccessRead) if err != nil { return Principal{}, nil, StoredSession{}, err } diff --git a/pkg/application/application_dependencies_test.go b/pkg/application/application_dependencies_test.go index 31e7d124..82022f72 100644 --- a/pkg/application/application_dependencies_test.go +++ b/pkg/application/application_dependencies_test.go @@ -16,12 +16,6 @@ type staticGraphStore struct { } func (s staticGraphStore) Current(context.Context) (*sdd.Snapshot, error) { return s.snapshot, nil } -func (staticGraphStore) Apply(context.Context, string, sdd.MutationBatch, sdd.StagedBlobReader) (sdd.ApplyResult, error) { - return sdd.ApplyResult{}, nil -} -func (staticGraphStore) Reconcile(context.Context, string, string) (sdd.ApplyResult, error) { - return sdd.ApplyResult{}, nil -} func (s staticGraphStore) ReadAttachmentPage(_ context.Context, _ string, filename string, offset int64, limit int) (sdd.AttachmentPage, error) { content := []byte(s.attachment) end := int(offset) + limit diff --git a/pkg/application/capture.go b/pkg/application/capture.go index 103d4edb..23750a16 100644 --- a/pkg/application/capture.go +++ b/pkg/application/capture.go @@ -75,9 +75,9 @@ func (a *Application) entryPublicationExists(ctx context.Context, identity Reque return false, err } defer func() { err = errors.Join(err, acquired.Release()) }() - publisher, ok := acquired.Graph.(EntryPublicationStore) - if !ok { - return false, fmt.Errorf("entry publication is not configured for project %s", target.Project) + publisher, err := publicationStoreOf(acquired, target.Project) + if err != nil { + return false, err } _, exists, err = publisher.LookupEntryPublication(ctx, key, entryID) return exists, err @@ -125,9 +125,9 @@ func (a *Application) CreateEntry(ctx context.Context, identity RequestIdentity, err = errors.Join(err, acquired.Release()) } }() - publisher, ok := acquired.Graph.(EntryPublicationStore) - if !ok { - return result, fmt.Errorf("entry publication is not configured for project %s", project) + publisher, err := publicationStoreOf(acquired, project) + if err != nil { + return result, err } publication, exists, err := publisher.LookupEntryPublication(ctx, draft.Publication, draft.EntryID) if err != nil { @@ -177,9 +177,9 @@ func (a *Application) CreateEntry(ctx context.Context, identity RequestIdentity, if err != nil { return result, err } - publisher, ok = acquired.Graph.(EntryPublicationStore) - if !ok { - return result, fmt.Errorf("entry publication is not configured for project %s", project) + publisher, err = publicationStoreOf(acquired, project) + if err != nil { + return result, err } publication, err = publisher.PublishEntry(ctx, draft.Publication, batch, ownedBlobReader{store: a.blobs, ref: SessionRef{Subject: principal.Subject, Session: binding.SessionID}}) if err != nil { diff --git a/pkg/application/capture_test.go b/pkg/application/capture_test.go index f77707a9..3ea5fea9 100644 --- a/pkg/application/capture_test.go +++ b/pkg/application/capture_test.go @@ -3,8 +3,10 @@ package application_test import ( "context" "errors" + "fmt" "path/filepath" "strings" + "sync" "testing" sdd "github.com/networkteam/sdd/pkg/application" @@ -114,3 +116,21 @@ func TestCaptureUsesAcquiredLanguageAndDependencies(t *testing.T) { t.Fatalf("stored summary = %q, want %q", got, created.Summary) } } + +// failOnceFinalizer fails its first call and succeeds afterwards, so a retry +// proves that required finalizers run on every attempt. +type failOnceFinalizer struct { + mu sync.Mutex + calls int +} + +func (*failOnceFinalizer) Name() string { return "fail-once" } +func (f *failOnceFinalizer) Finalize(context.Context, sdd.AppliedMutation) error { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + if f.calls == 1 { + return fmt.Errorf("finalizer failed once") + } + return nil +} diff --git a/pkg/application/document_publication.go b/pkg/application/document_publication.go new file mode 100644 index 00000000..98368bab --- /dev/null +++ b/pkg/application/document_publication.go @@ -0,0 +1,275 @@ +package application + +import ( + "context" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/networkteam/sdd/internal/model" +) + +// DocumentWrite is one entry-less graph write under the session's recorded +// intent: a summary replacement, a WIP marker created or removed. The key is +// the intent's publication identity; the store publishes the mutation once +// under it, and a retry that finds the publication returns it (d-tac-n47). +type DocumentWrite struct { + Target MutationTarget + Publication PublicationKey + Mutation DocumentMutation +} + +// readDocument returns a document's current bytes on the target, or Absent. +func (a *Application) readDocument(ctx context.Context, identity RequestIdentity, target MutationTarget, logicalPath string) (_ DocumentPublication, err error) { + _, runtime, err := a.resolve(ctx, identity, target.Project, AccessRead) + if err != nil { + return DocumentPublication{}, err + } + target, err = resolveMutationTarget(runtime, target) + if err != nil { + return DocumentPublication{}, err + } + acquired, err := runtime.acquire(ctx, target) + if err != nil { + return DocumentPublication{}, err + } + defer func() { err = errors.Join(err, acquired.Release()) }() + publisher, err := publicationStoreOf(acquired, target.Project) + if err != nil { + return DocumentPublication{}, err + } + return publisher.ReadDocument(ctx, logicalPath) +} + +// lookupDocumentPublication reports what a recorded intent's key published for +// a logical path, reading the target's store without changing it. +func (a *Application) lookupDocumentPublication(ctx context.Context, identity RequestIdentity, target MutationTarget, key PublicationKey, logicalPath string) (_ DocumentPublication, _ bool, err error) { + _, runtime, err := a.resolve(ctx, identity, target.Project, AccessRead) + if err != nil { + return DocumentPublication{}, false, err + } + target, err = resolveMutationTarget(runtime, target) + if err != nil { + return DocumentPublication{}, false, err + } + acquired, err := runtime.acquire(ctx, target) + if err != nil { + return DocumentPublication{}, false, err + } + defer func() { err = errors.Join(err, acquired.Release()) }() + publisher, err := publicationStoreOf(acquired, target.Project) + if err != nil { + return DocumentPublication{}, false, err + } + return publisher.LookupDocumentPublication(ctx, key, logicalPath) +} + +// PublishDocument publishes one document write under its recorded key and runs +// the target's finalizers on what it changed. A key that already published +// returns that publication; a removal of an absent document publishes nothing. +func (a *Application) PublishDocument(ctx context.Context, identity RequestIdentity, project ProjectID, binding SessionBinding, write DocumentWrite) (_ DocumentPublication, err error) { + principal, runtime, err := a.resolve(ctx, identity, project, AccessWrite) + if err != nil { + return DocumentPublication{}, err + } + if binding.Subject != principal.Subject { + return DocumentPublication{}, &ApplicationError{Code: ErrorSessionOwnership, Message: "document publication ownership mismatch"} + } + stored, err := a.sessions.Load(ctx, binding.SessionID) + if err != nil { + return DocumentPublication{}, err + } + if err := verifyBinding(stored, binding); err != nil { + return DocumentPublication{}, err + } + if err := write.Publication.Validate(); err != nil { + return DocumentPublication{}, err + } + if write.Publication.Session != binding.SessionID { + return DocumentPublication{}, fmt.Errorf("publication belongs to another session") + } + if write.Mutation.LogicalPath == "" || write.Mutation.LogicalPath != filepath.ToSlash(write.Mutation.LogicalPath) || strings.HasPrefix(write.Mutation.LogicalPath, "/") || strings.Contains(write.Mutation.LogicalPath, "..") { + return DocumentPublication{}, fmt.Errorf("invalid document path %q", write.Mutation.LogicalPath) + } + target, err := resolveMutationTarget(runtime, write.Target) + if err != nil { + return DocumentPublication{}, err + } + if target.Project != runtime.options.Project.ID { + return DocumentPublication{}, &ApplicationError{Code: ErrorWriteDenied, Message: "mutation target project must equal the session project"} + } + acquired, err := runtime.acquire(ctx, target) + if err != nil { + return DocumentPublication{}, err + } + defer func() { err = errors.Join(err, acquired.Release()) }() + publisher, err := publicationStoreOf(acquired, project) + if err != nil { + return DocumentPublication{}, err + } + publication, exists, err := publisher.LookupDocumentPublication(ctx, write.Publication, write.Mutation.LogicalPath) + if err != nil { + return DocumentPublication{}, err + } + if !exists { + publication, err = publisher.PublishDocument(ctx, write.Publication, write.Mutation) + if err != nil { + return DocumentPublication{}, err + } + if write.Mutation.Content == nil && publication.Absent && publication.Revision == "" { + // Removing what was already absent left nothing to commit. + return publication, nil + } + } + batch := MutationBatch{ + ID: write.Publication.String(), + Message: write.Mutation.Message, + Changes: []DocumentChange{{LogicalPath: write.Mutation.LogicalPath, CanonicalBytes: write.Mutation.Content, Delete: write.Mutation.Content == nil}}, + } + for _, finalizer := range acquired.Finalizers { + if err := finalizer.Finalize(ctx, AppliedMutation{Project: project, BatchID: batch.ID, Revision: publication.Revision, Batch: batch}); err != nil { + return DocumentPublication{}, fmt.Errorf("completing document publication (%s): %w", finalizer.Name(), err) + } + } + return publication, nil +} + +// SummaryReplacement is the recorded input of a summary correction: the entry, +// the bytes it was read as (the write's precondition) and the new summary. +type SummaryReplacement struct { + Target MutationTarget + Publication PublicationKey + EntryID string + ExpectedBlob string + Summary string +} + +// ReplaceSummary writes the corrected summary onto the entry's current +// document, conditioned on the document the correction was read from. A +// document that moved meanwhile is a conflict carrying the current summary, so +// the next attempt is a deliberate one (d-tac-wgw). +func (a *Application) ReplaceSummary(ctx context.Context, identity RequestIdentity, project ProjectID, binding SessionBinding, replacement SummaryReplacement) (DocumentPublication, error) { + logicalPath, err := model.IDToRelPath(replacement.EntryID) + if err != nil { + return DocumentPublication{}, err + } + logicalPath = filepath.ToSlash(logicalPath) + target := replacement.Target + if target.Project == "" { + target.Project = project + } + if published, exists, err := a.lookupDocumentPublication(ctx, identity, target, replacement.Publication, logicalPath); err != nil { + return DocumentPublication{}, err + } else if exists { + return published, nil + } + current, err := a.readDocument(ctx, identity, target, logicalPath) + if err != nil { + return DocumentPublication{}, err + } + if current.Absent { + return DocumentPublication{}, &ApplicationError{Code: ErrorInvalidArgument, Message: "entry not found: " + replacement.EntryID} + } + entry, err := model.ParseEntry(replacement.EntryID+".md", string(current.Content)) + if err != nil { + return DocumentPublication{}, err + } + canonical := current.Content + if replacement.ExpectedBlob == "" || GitBlobID(current.Content) == replacement.ExpectedBlob { + entry.Summary = replacement.Summary + canonical = []byte(model.FormatFrontmatter(entry) + "\n" + entry.Content + "\n") + } else if entry.Summary != replacement.Summary { + return DocumentPublication{}, summaryConflict(replacement.EntryID, entry.Summary) + } + // Otherwise an earlier attempt wrote the replacement and its completion did + // not finish: publishing the same bytes is recognized, not repeated. + return a.PublishDocument(ctx, identity, project, binding, DocumentWrite{ + Target: target, Publication: replacement.Publication, + Mutation: DocumentMutation{LogicalPath: logicalPath, Content: canonical, ExpectedBlob: GitBlobID(current.Content), Message: "sdd: summarize " + replacement.EntryID + " (manual)"}, + }) +} + +// summaryConflict is the answer to a replacement whose document moved: the +// current summary and what a deliberate next attempt needs. +func summaryConflict(entryID, current string) error { + return &ApplicationError{Code: ErrorGraphConflict, Message: fmt.Sprintf("the summary of %s changed since it was read and was not replaced; its current summary is %q. Read the entry again before correcting it.", entryID, strings.TrimSpace(current))} +} + +// WIPMarkerWrite is the recorded input of a WIP marker creation. +type WIPMarkerWrite struct { + Target MutationTarget + Publication PublicationKey + MarkerID string + EntryID string + Description string +} + +// StartWIP publishes an exclusive WIP marker for the entry on the target. The +// marker's identity was allocated before the intent, so a retry publishes the +// same marker once. +func (a *Application) StartWIP(ctx context.Context, identity RequestIdentity, project ProjectID, binding SessionBinding, write WIPMarkerWrite) (DocumentPublication, error) { + principal, runtime, err := a.resolve(ctx, identity, project, AccessWrite) + if err != nil { + return DocumentPublication{}, err + } + participant, err := a.participantFor(ctx, principal, runtime) + if err != nil { + return DocumentPublication{}, err + } + if participant == "" { + return DocumentPublication{}, fmt.Errorf("sdd: resolved participant is required to start WIP") + } + marker := &model.WIPMarker{ID: write.MarkerID, Entry: write.EntryID, Participant: participant, Exclusive: true, Content: write.Description, Time: a.now()} + return a.PublishDocument(ctx, identity, project, binding, DocumentWrite{ + Target: write.Target, Publication: write.Publication, + Mutation: DocumentMutation{ + LogicalPath: filepath.ToSlash(model.WIPMarkerPath(write.MarkerID)), Content: []byte(model.FormatWIPMarker(marker)), + Message: fmt.Sprintf("sdd: wip start %s (%s)", write.EntryID, participant), + }, + }) +} + +// FinishWIP removes the named WIP marker from the target. Removing a marker +// that is already absent succeeds and never removes another one. +func (a *Application) FinishWIP(ctx context.Context, identity RequestIdentity, project ProjectID, binding SessionBinding, target MutationTarget, key PublicationKey, markerID string) (DocumentPublication, error) { + return a.PublishDocument(ctx, identity, project, binding, DocumentWrite{ + Target: target, Publication: key, + Mutation: DocumentMutation{LogicalPath: filepath.ToSlash(model.WIPMarkerPath(markerID)), Message: "sdd: wip done " + markerID}, + }) +} + +// WIPMarkerID allocates the identity of a marker the resolved principal starts. +func (a *Application) WIPMarkerID(ctx context.Context, identity RequestIdentity, project ProjectID) (string, error) { + principal, runtime, err := a.resolve(ctx, identity, project, AccessWrite) + if err != nil { + return "", err + } + participant, err := a.participantFor(ctx, principal, runtime) + if err != nil { + return "", err + } + if participant == "" { + return "", fmt.Errorf("sdd: resolved participant is required to start WIP") + } + return model.GenerateWIPMarkerID(participant), nil +} + +func publicationStoreOf(acquired *AcquiredTarget, project ProjectID) (PublicationStore, error) { + publisher, ok := acquired.Graph.(PublicationStore) + if !ok { + return nil, fmt.Errorf("publication is not configured for project %s", project) + } + return publisher, nil +} + +// ownedBlobReader limits a publication to the staged blobs of its own session. +type ownedBlobReader struct { + store StagedBlobStore + ref SessionRef +} + +func (r ownedBlobReader) Open(ctx context.Context, id string) (io.ReadCloser, error) { + return r.store.Open(ctx, r.ref, id) +} diff --git a/pkg/application/errors.go b/pkg/application/errors.go index e14d0268..f944d9cf 100644 --- a/pkg/application/errors.go +++ b/pkg/application/errors.go @@ -30,14 +30,13 @@ const ( ) type ApplicationError struct { - Code ErrorCode - Message string - Project ProjectRef - Action *ProjectAction - ApplyState ApplyState - Revision string - Version uint32 - Cause error + Code ErrorCode + Message string + Project ProjectRef + Action *ProjectAction + Revision string + Version uint32 + Cause error // Ended carries the act that ended the session on an ErrorSessionEnded, so // the caller can be told who/when/why. Ended *SessionEnd diff --git a/pkg/application/graphstore.go b/pkg/application/graphstore.go index d40e6639..97a53a58 100644 --- a/pkg/application/graphstore.go +++ b/pkg/application/graphstore.go @@ -2,21 +2,20 @@ package application import ( "context" + "crypto/sha1" //nolint:gosec // Git blob IDs are SHA-1 by definition; this is an identity, not a security digest. "crypto/sha256" "encoding/hex" - "encoding/json" "fmt" "io" "strconv" "strings" ) -// GraphStore is the canonical graph authority: snapshot reads, atomic -// mutation, reconciliation, and canonical attachment bytes. +// GraphStore is the canonical graph authority: snapshot reads and canonical +// attachment bytes. Writes go through PublicationStore, keyed by the session's +// recorded mutation intent (d-tac-n47). type GraphStore interface { Current(context.Context) (*Snapshot, error) - Apply(context.Context, string, MutationBatch, StagedBlobReader) (ApplyResult, error) - Reconcile(context.Context, string, string) (ApplyResult, error) ReadAttachmentPage(context.Context, string, string, int64, int) (AttachmentPage, error) } @@ -48,18 +47,57 @@ type EntryPublication struct { Document EntryDocument } -// EntryPublicationStore serializes publication lookup and creation without a -// graph-wide revision comparison. PublishEntry accepts one entry and its staged -// attachments. Repeated keys return the original document and revision; a lookup -// failure is never treated as absence. Required storage commits precede success. -type EntryPublicationStore interface { +// DocumentPublication is what one keyed write left for a logical path: the +// revision carrying it and the document's bytes there, or its absence when the +// write removed the document or found nothing to remove. +type DocumentPublication struct { + Revision string + Content []byte + Absent bool +} + +// DocumentMutation is one keyed write of a single graph document without +// attachments: a creation, a replacement conditioned on the document it +// replaces, or a removal. The store publishes it once under its key. +type DocumentMutation struct { + LogicalPath string + // Content is the complete document after the write; nil removes it. + Content []byte + // ExpectedBlob is the Git blob ID of the document a replacement replaces + // (GitBlobID); a mismatch is an ErrorGraphConflict, never a retryable + // condition (d-tac-wgw). Empty for a creation or a removal. + ExpectedBlob string + Message string +} + +// PublicationStore serializes publication lookup and creation without a +// graph-wide revision comparison (d-tac-n47). Repeated keys return the original +// publication; a lookup failure is never treated as absence. PublishEntry +// accepts one entry and its staged attachments; PublishDocument one entry-less +// document change: a WIP marker created or removed, a summary replaced. A +// removal of an absent document succeeds without a publication. Required +// storage commits precede success. +type PublicationStore interface { LookupEntryPublication(context.Context, PublicationKey, string) (EntryPublication, bool, error) PublishEntry(context.Context, PublicationKey, MutationBatch, StagedBlobReader) (EntryPublication, error) + // ReadDocument returns the document's current bytes on the target, or Absent. + ReadDocument(context.Context, string) (DocumentPublication, error) + LookupDocumentPublication(context.Context, PublicationKey, string) (DocumentPublication, bool, error) + PublishDocument(context.Context, PublicationKey, DocumentMutation) (DocumentPublication, error) +} + +// GitBlobID is the Git object ID of content stored as a blob, the precondition +// currency of DocumentMutation: computable by any store, verifiable by a Git +// service against its tree without reading the file. +func GitBlobID(content []byte) string { + h := sha1.New() + fmt.Fprintf(h, "blob %d\x00", len(content)) + h.Write(content) + return hex.EncodeToString(h.Sum(nil)) } type MutationBatch struct { ID string - Digest string Changes []DocumentChange Attachments []AttachmentMaterialization Message string @@ -80,19 +118,6 @@ type Author struct { Email string } -type ApplyState string - -const ( - MutationNotApplied ApplyState = "not_applied" - MutationApplied ApplyState = "applied" - MutationUnknown ApplyState = "unknown" -) - -type ApplyResult struct { - State ApplyState - Revision string -} - type AppliedMutation struct { Project ProjectID BatchID string @@ -133,19 +158,7 @@ type AttachmentPage struct { Digest BlobDigest } -// StagedBlobReader limits Apply to the blobs named by its prepared batch. +// StagedBlobReader limits a publication to the blobs named by its batch. type StagedBlobReader interface { Open(context.Context, string) (io.ReadCloser, error) } - -// MutationBatchDigest returns the SDD-owned digest over a storage-neutral -// batch. The Digest field itself is excluded. -func MutationBatchDigest(batch MutationBatch) (string, error) { - batch.Digest = "" - encoded, err := json.Marshal(batch) - if err != nil { - return "", err - } - sum := sha256.Sum256(encoded) - return "sha256:" + hex.EncodeToString(sum[:]), nil -} diff --git a/pkg/application/merge_apply_test.go b/pkg/application/merge_apply_test.go index 4683da22..b2964614 100644 --- a/pkg/application/merge_apply_test.go +++ b/pkg/application/merge_apply_test.go @@ -3,205 +3,16 @@ package application_test import ( "context" "fmt" - "path/filepath" - "strings" - "sync" "sync/atomic" "testing" - "time" - "github.com/networkteam/sdd/internal/model" sdd "github.com/networkteam/sdd/pkg/application" pkgllm "github.com/networkteam/sdd/pkg/llm" ) -// conflictInjectingStore forces the leading `remaining` Apply calls to return -// the adapter's typed revision conflict without touching the graph, modelling a -// concurrent process that moved the revision between the caller's fresh read -// and its CAS apply. -type conflictInjectingStore struct { - sdd.GraphStore - mu sync.Mutex - remaining int -} - -func (s *conflictInjectingStore) Apply(ctx context.Context, revision string, batch sdd.MutationBatch, blobs sdd.StagedBlobReader) (sdd.ApplyResult, error) { - s.mu.Lock() - force := s.remaining > 0 - if force { - s.remaining-- - } - s.mu.Unlock() - if force { - moved := revision + "-moved" - return sdd.ApplyResult{State: sdd.MutationNotApplied, Revision: moved}, &sdd.ApplicationError{Code: sdd.ErrorGraphConflict, Message: "graph revision changed", Revision: moved} - } - return s.GraphStore.Apply(ctx, revision, batch, blobs) -} - -func (s *conflictInjectingStore) injectConflicts(n int) { - s.mu.Lock() - s.remaining = n - s.mu.Unlock() -} - -func TestApplyPreparedRetriesRevisionConflictThenMerges(t *testing.T) { - var injector *conflictInjectingStore - // Two lost races followed by success proves the engine-internal retry lands - // within its three-attempt bound, invisible to the caller. - application, sessions, graph := newDurableApplication(t, time.Now, func(store sdd.GraphStore) sdd.GraphStore { - injector = &conflictInjectingStore{GraphStore: store, remaining: 2} - return injector - }, nil) - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "retry-merge") - prepared := preparedEntry(t, graph.GraphStore, binding, "retry-merge", "2026/07/13-054000-s-tac-rty.md") - result, err := application.ApplyPrepared(t.Context(), identity, "example", binding, prepared) - if err != nil || result.Apply.State != sdd.MutationApplied { - t.Fatalf("bounded retry apply = %+v, %v", result, err) - } - pending, err := application.ListRecoveries(t.Context(), identity, "example", false) - if err != nil || len(pending.Items) != 0 { - t.Fatalf("retry-merge recovery projection = %+v, %v", pending, err) - } - injector.mu.Lock() - remaining := injector.remaining - injector.mu.Unlock() - if remaining != 0 { - t.Fatalf("retry consumed injected conflicts leaving %d, want 0", remaining) - } -} - -func TestApplyPreparedExhaustedConflictFailsTypedNeverRecovery(t *testing.T) { - // Exactly three injected conflicts exhaust the cap: paired with the two-loss - // merge case (which lands), this pins the retry bound at exactly three. - application, sessions, graph := newDurableApplication(t, time.Now, func(store sdd.GraphStore) sdd.GraphStore { - return &conflictInjectingStore{GraphStore: store, remaining: 3} - }, nil) - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "exhausted") - prepared := preparedEntry(t, graph.GraphStore, binding, "exhausted", "2026/07/13-054100-s-tac-exh.md") - result, err := application.ApplyPrepared(t.Context(), identity, "example", binding, prepared) - if errorCode(err) != sdd.ErrorGraphConflict || result.Apply.State != sdd.MutationNotApplied { - t.Fatalf("exhausted retries = %+v, %v; want typed graph conflict", result, err) - } - if !strings.Contains(err.Error(), "re-try") { - t.Fatalf("exhausted conflict message = %q, want a re-try invitation", err.Error()) - } - // A revision conflict never files a recovery: the contended intent is - // auto-discarded. - pending, err := application.ListRecoveries(t.Context(), identity, "example", false) - if err != nil || len(pending.Items) != 0 { - t.Fatalf("exhausted conflict actionable recovery = %+v, %v; want none", pending, err) - } - history, err := application.ListRecoveries(t.Context(), identity, "example", true) - if err != nil || len(history.Items) != 1 || history.Items[0].State != sdd.RecoveryAbandoned || history.Items[0].Reason != sdd.RecoveryReasonDiscarded || history.Items[0].Actionable() { - t.Fatalf("exhausted conflict terminal history = %+v, %v; want one non-actionable discarded item", history, err) - } -} - -func TestApplyPreparedGenuineWIPMarkerPathCollisionFailsTyped(t *testing.T) { - application, sessions, graph := newDurableApplication(t, time.Now, nil, nil) - identity := sdd.RequestIdentity{Subject: "christopher"} - anchorPath := "2026/07/13-055400-s-tac-anc.md" - anchorID, err := model.RelPathToID(anchorPath) - if err != nil { - t.Fatal(err) - } - writerA := openBinding(t, sessions, identity.Subject, "writer-a") - anchor := preparedEntry(t, graph.GraphStore, writerA, "anchor", anchorPath) - created, err := application.ApplyPrepared(t.Context(), identity, "example", writerA, anchor) - if err != nil || created.Apply.State != sdd.MutationApplied { - t.Fatalf("anchor apply = %+v, %v", created, err) - } - writerB := openBinding(t, sessions, identity.Subject, "writer-b") - - // Two writers start exclusive WIP on the same entry in the same second, so - // they share the deterministic marker ID — hence the same marker path and - // the same batch ID — while describing the work differently. The adapter's - // batch ledger rejects the second write typed; merge retries never clobber. - const markerID = "20260713-055500-christopher" - first := preparedWIP(t, graph.GraphStore, created.Binding, markerID, anchorID, "writer A takes the entry") - firstResult, err := application.ApplyPrepared(t.Context(), identity, "example", created.Binding, first) - if err != nil || firstResult.Apply.State != sdd.MutationApplied { - t.Fatalf("first WIP apply = %+v, %v", firstResult, err) - } - second := preparedWIP(t, graph.GraphStore, writerB, markerID, anchorID, "writer B takes the entry") - result, err := application.ApplyPrepared(t.Context(), identity, "example", writerB, second) - if errorCode(err) != sdd.ErrorRecoveryRequired || result.Apply.State != sdd.MutationNotApplied { - t.Fatalf("same WIP marker path collision = %+v, %v; want typed not-applied", result, err) - } - if !strings.Contains(err.Error(), "reused") { - t.Fatalf("collision error message = %q", err.Error()) - } -} - -func TestReplaceSummaryMergesUnderRetry(t *testing.T) { - var injector *conflictInjectingStore - application, sessions, graph := newDurableApplication(t, time.Now, func(store sdd.GraphStore) sdd.GraphStore { - injector = &conflictInjectingStore{GraphStore: store} - return injector - }, nil) - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "replace-summary") - entryPath := "2026/07/13-055600-s-tac-sum.md" - entryID, err := model.RelPathToID(entryPath) - if err != nil { - t.Fatal(err) - } - prepared := preparedEntry(t, graph.GraphStore, binding, "summary-target", entryPath) - created, err := application.ApplyPrepared(t.Context(), identity, "example", binding, prepared) - if err != nil || created.Apply.State != sdd.MutationApplied { - t.Fatalf("summary target apply = %+v, %v", created, err) - } - // Force two lost races on the summary-replacement apply: it must retry and - // land through the same merge path as capture, not a bespoke apply. - injector.injectConflicts(2) - if _, err := application.ReplaceSummary(t.Context(), identity, "example", created.Binding, sdd.MutationTarget{Project: "example", Branch: "main"}, entryID, "Replacement summary text."); err != nil { - t.Fatalf("ReplaceSummary under retry = %v", err) - } - injector.mu.Lock() - remaining := injector.remaining - injector.mu.Unlock() - if remaining != 0 { - t.Fatalf("ReplaceSummary retry left %d injected conflicts unconsumed, want 0", remaining) - } - pending, err := application.ListRecoveries(t.Context(), identity, "example", false) - if err != nil || len(pending.Items) != 0 { - t.Fatalf("ReplaceSummary recovery projection = %+v, %v; want none", pending, err) - } -} - -// preparedWIP builds a durable intent mirroring what StartWIP produces: a WIP -// marker change at the deterministic marker path with the "wip-start-{id}" -// batch ID, so two writers sharing a marker ID share the batch ID. -func preparedWIP(t *testing.T, graph sdd.GraphStore, binding sdd.SessionBinding, markerID, entryID, description string) sdd.PreparedTransition { - t.Helper() - snapshot, err := graph.Current(t.Context()) - if err != nil { - t.Fatal(err) - } - marker := &model.WIPMarker{ - ID: markerID, Entry: entryID, Participant: "Christopher", Exclusive: true, Content: description, - Time: time.Date(2026, 7, 13, 5, 55, 0, 0, time.UTC), - } - batch := sdd.MutationBatch{ - ID: "wip-start-" + markerID, Message: "sdd: wip start " + entryID, - Changes: []sdd.DocumentChange{{LogicalPath: filepath.ToSlash(model.WIPMarkerPath(markerID)), CanonicalBytes: []byte(model.FormatWIPMarker(marker))}}, - } - digest, err := sdd.MutationBatchDigest(batch) - if err != nil { - t.Fatal(err) - } - batch.Digest = digest - return sdd.PreparedTransition{ - Version: sdd.PreparedTransitionVersion, Target: sdd.MutationTarget{Project: "example", Branch: "main"}, - ExpectedGraphRevision: snapshot.Revision(), Batch: batch, - Staged: sdd.SessionRef{Subject: binding.Subject, Session: binding.SessionID}, - } -} - -func TestInterleavedCapturesBothLandWithoutRecovery(t *testing.T) { +// Two captures interleave inside the same application: each publishes its own +// entry under its own recorded intent, and neither needs recovery. +func TestInterleavedCapturesBothLand(t *testing.T) { const captures = 2 var preflightCalls int64 entered := make(chan struct{}, captures) @@ -258,8 +69,4 @@ func TestInterleavedCapturesBothLandWithoutRecovery(t *testing.T) { if atomic.LoadInt64(&preflightCalls) != captures { t.Fatalf("pre-flight ran %d times, want %d (once per capture)", preflightCalls, captures) } - recoveries, err := f.app.ListRecoveries(t.Context(), f.identity, "example", false) - if err != nil || len(recoveries.Items) != 0 { - t.Fatalf("interleaved capture recovery projection = %+v, %v; want none", recoveries, err) - } } diff --git a/pkg/application/read_api.go b/pkg/application/read_api.go index 7e35c2ea..6c37f020 100644 --- a/pkg/application/read_api.go +++ b/pkg/application/read_api.go @@ -24,7 +24,6 @@ type InfoResult struct { Participant string Language string Search string - Recovery string } type ViewRequest struct { @@ -36,10 +35,6 @@ type ViewRequest struct { // Budget bounds the view's scaling parts on the serve path; the zero // value is unbounded — explicit pulls arrive complete (d-tac-rzi). Budget types.ViewBudget - // OmitRecovery skips the appended recovery notices — for injected lanes - // whose session already carries them via sessionInfo, so a pending - // recovery is served once, not once per lane. - OmitRecovery bool } type ViewResult struct { diff --git a/pkg/application/recovery.go b/pkg/application/recovery.go deleted file mode 100644 index fc6b43c2..00000000 --- a/pkg/application/recovery.go +++ /dev/null @@ -1,698 +0,0 @@ -package application - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - - "github.com/networkteam/sdd/internal/model" - "github.com/networkteam/sdd/internal/serveview" - "github.com/networkteam/sdd/internal/truncate" -) - -const ( - eventRecoveryAttempt = "recovery_attempt" - eventRecoveryTerminal = "recovery_terminal" - eventLegacyTargetBound = "legacy_target_bound" -) - -// recoveryCauseGraphContention marks a terminal the engine recorded itself when -// a write lost every bounded apply retry, distinguishing it structurally from -// an operator's recovery decision (which leaves Cause empty). -const recoveryCauseGraphContention = "graph-contention" - -type RecoveryState string - -// State answers one question — has delivery been reached — so it carries the two -// durable conditions of the delivery contract plus the one outcome that is a -// participant's decision rather than a delivery result. -const ( - // RecoveryDelivered means the write reached its desired state: the batch - // applied and finalization is proven. Nothing is owed. - RecoveryDelivered RecoveryState = "delivered" - // RecoveryPending means delivery is not proven yet. Pending is exactly the - // actionable condition, and Reason names what is owed. - RecoveryPending RecoveryState = "pending" - // RecoveryAbandoned means a participant decided to stop pursuing delivery. - // Reason names the decision. - RecoveryAbandoned RecoveryState = "abandoned" -) - -// RecoveryReason qualifies a state that does not explain itself: what delivery -// is waiting on, or which decision ended it. -type RecoveryReason string - -const ( - RecoveryReasonOutcomeUnknown RecoveryReason = "outcome-unknown" - RecoveryReasonNotApplied RecoveryReason = "not-applied" - RecoveryReasonFinalizationOwed RecoveryReason = "finalization-owed" - RecoveryReasonDiscarded RecoveryReason = "discarded" - RecoveryReasonAbandonedUnknown RecoveryReason = "abandoned-unknown" -) - -type RecoveryItem struct { - Session SessionID - MutationID string - Digest string - Target MutationTarget - OriginalSubject string - State RecoveryState - // Reason qualifies State: what delivery waits on while pending, or which - // decision ended it while abandoned. Empty when delivered. - Reason RecoveryReason - // Recovered records that recovery machinery touched this mutation — a - // reconciliation or a verb. It is provenance, not state: a recovered write is - // delivered exactly like one that never needed help. - Recovered bool - LegacyUnroutable bool - EntryIDs []string - LastEvidence string - // Cause is the terminal's structured cause (e.g. graph-contention for an - // engine-recorded discard), empty for participant decisions and open items. - Cause string -} - -// Actionable reports whether this item awaits a recovery decision. It is derived -// from State rather than stored beside it, so the two cannot disagree. -func (i RecoveryItem) Actionable() bool { return i.State == RecoveryPending } - -type RecoveryList struct { - Project ProjectRef - Items []RecoveryItem -} - -type RecoveryRequest struct { - Session SessionID - MutationID string - Verb RecoveryVerb - Reason string - Target MutationTarget -} - -type RecoveryReconcileRequest struct { - Session SessionID - MutationID string -} - -type RecoveryResult struct { - Project ProjectRef - Item RecoveryItem - Transition TransitionResult -} - -type recoveryAttemptEvent struct { - MutationID string `json:"mutation_id"` - Digest string `json:"digest"` - Target MutationTarget `json:"target"` - OriginalSubject string `json:"original_subject"` - OriginalSession SessionID `json:"original_session"` - Actor string `json:"actor"` - Verb RecoveryVerb `json:"verb"` - Reason string `json:"reason,omitempty"` - Evidence string `json:"evidence"` - Reconciled ApplyResult `json:"reconciled"` -} - -type recoveryTerminalEvent struct { - MutationID string `json:"mutation_id"` - Digest string `json:"digest"` - Target MutationTarget `json:"target"` - OriginalSubject string `json:"original_subject"` - OriginalSession SessionID `json:"original_session"` - Actor string `json:"actor"` - Verb RecoveryVerb `json:"verb"` - Reason string `json:"reason,omitempty"` - Cause string `json:"cause,omitempty"` -} - -type legacyTargetBoundEvent struct { - MutationID string `json:"mutation_id"` - Target MutationTarget `json:"target"` - OriginalSubject string `json:"original_subject"` - OriginalSession SessionID `json:"original_session"` - Actor string `json:"actor"` - Reason string `json:"reason"` -} - -type mutationRecoveryReplay struct { - prepared PreparedTransition - apply ApplyResult - finalizers map[string]FinalizerOutcome - attempt *recoveryAttemptEvent - terminal *recoveryTerminalEvent - bound *legacyTargetBoundEvent -} - -// ListRecoveries is a free read projection. Closed terminal history is -// included only when requested; actionable states never perform acquisition -// or replay. -func (a *Application) ListRecoveries(ctx context.Context, identity RequestIdentity, project ProjectID, includeClosed bool) (RecoveryList, error) { - _, runtime, err := a.resolve(ctx, identity, project, AccessRead) - if err != nil { - return RecoveryList{}, err - } - return a.listRecoveries(ctx, runtime, includeClosed) -} - -func (a *Application) listRecoveries(ctx context.Context, runtime *ProjectRuntime, includeClosed bool) (RecoveryList, error) { - page, err := a.sessions.List(ctx, SessionFilter{Project: runtime.options.Project.ID}) - if err != nil { - return RecoveryList{}, err - } - result := RecoveryList{Project: runtime.options.Project} - for _, stored := range page.Sessions { - ids, err := mutationIDs(stored.Events) - if err != nil { - return RecoveryList{}, err - } - for _, id := range ids { - replay, err := replayRecovery(stored.Events, id) - if err != nil { - return RecoveryList{}, err - } - item := recoveryItem(stored, replay) - if !includeClosed && !item.Actionable() { - continue - } - result.Items = append(result.Items, item) - } - } - sort.Slice(result.Items, func(i, j int) bool { - if result.Items[i].Session != result.Items[j].Session { - return result.Items[i].Session < result.Items[j].Session - } - return result.Items[i].MutationID < result.Items[j].MutationID - }) - return result, nil -} - -func renderRecoveryNotices(items []RecoveryItem) string { - if len(items) == 0 { - return "" - } - lines := make([]string, 0, len(items)) - for _, item := range items { - target := item.Target.Branch - if item.LegacyUnroutable { - target = "target binding required" - } - lines = append(lines, fmt.Sprintf(" a pending write awaits explicit recovery: %s · %s · %s", item.MutationID, item.Reason, target)) - } - bounded := truncate.Items(lines, func(s string) int { return len(s) + 1 }, serveview.Default().Cap(serveview.PartLineList).MaxBytes, "") - rendered := "Recovery\n\n" + strings.Join(bounded.Items, "\n") - if bounded.Cut.Dropped > 0 { - rendered += fmt.Sprintf("\n (+%d more pending writes await recovery)", bounded.Cut.Dropped) - } - return rendered -} - -// ReconcileMutation refreshes one actionable recovery projection without -// choosing a terminal or graph-affecting verb. It exists for interactive -// clients that must present actions from current target evidence instead of -// guessing from a durable projection that may predate reconciliation. -func (a *Application) ReconcileMutation(ctx context.Context, identity RequestIdentity, request RecoveryReconcileRequest) (result RecoveryResult, err error) { - if request.Session == "" || strings.TrimSpace(request.MutationID) == "" { - return RecoveryResult{}, fmt.Errorf("sdd: recovery session and mutation ID are required") - } - principal, runtime, stored, err := a.resolveSession(ctx, identity, request.Session, AccessWrite) - if err != nil { - return RecoveryResult{}, err - } - replay, err := replayRecovery(stored.Events, request.MutationID) - if err != nil { - return RecoveryResult{}, err - } - if replay.terminal != nil { - return RecoveryResult{Project: runtime.options.Project, Item: recoveryItem(stored, replay)}, &ApplicationError{Code: ErrorRecoveryRequired, Message: "mutation recovery is already terminal"} - } - prepared := replay.prepared - if prepared.Version == LegacyPreparedTransitionVersion && replay.bound == nil { - return RecoveryResult{Project: runtime.options.Project, Item: recoveryItem(stored, replay)}, &ApplicationError{Code: ErrorMigrationRequired, Message: "legacy prepared intent needs an explicitly authorized target binding or recapture", Version: prepared.Version} - } - if replay.bound != nil { - prepared.Target = replay.bound.Target - prepared.Version = PreparedTransitionVersion - } - if err := validatePreparedForRecovery(prepared, stored.Metadata); err != nil { - return RecoveryResult{}, err - } - if runtime.options.Recovery == nil { - return RecoveryResult{}, &ApplicationError{Code: ErrorWriteDenied, Message: "project has no recovery authorizer"} - } - if err := runtime.options.Recovery.AuthorizeRecovery(ctx, RecoveryAccessRequest{ - Actor: principal, Target: prepared.Target, Verb: RecoveryReconcile, - OriginalSubject: stored.Metadata.Subject, OriginalSession: stored.Metadata.ID, - }); err != nil { - return RecoveryResult{}, err - } - binding := SessionBinding{SessionID: stored.Metadata.ID, Subject: stored.Metadata.Subject, Project: stored.Metadata.Project, Version: stored.Version} - acquired, acquireErr := runtime.acquire(ctx, prepared.Target) - if acquireErr != nil { - attempt := recoveryAttemptEvent{ - MutationID: prepared.Batch.ID, Digest: prepared.Batch.Digest, Target: prepared.Target, - OriginalSubject: stored.Metadata.Subject, OriginalSession: stored.Metadata.ID, Actor: principal.Subject, - Verb: RecoveryReconcile, Evidence: "target acquisition failed: " + acquireErr.Error(), Reconciled: ApplyResult{State: MutationUnknown}, - } - binding, appendErr := appendRecoveryAttempt(ctx, a.sessions, binding, attempt) - if appendErr != nil { - return RecoveryResult{}, errors.Join(acquireErr, appendErr) - } - replay.attempt = &attempt - return RecoveryResult{ - Project: runtime.options.Project, Item: recoveryItem(stored, replay), - Transition: TransitionResult{Project: runtime.options.Project, Binding: binding, Apply: attempt.Reconciled}, - }, &ApplicationError{Code: ErrorRecoveryRequired, Message: "recovery target acquisition failed; abandon-unknown may acknowledge the recorded evidence", Cause: acquireErr} - } - defer func() { - if releaseErr := acquired.Release(); releaseErr != nil { - err = errors.Join(err, fmt.Errorf("releasing mutation target %s: %w", prepared.Target.Branch, releaseErr)) - } - }() - reconciled, reconcileErr := acquired.Graph.Reconcile(ctx, prepared.Batch.ID, prepared.Batch.Digest) - evidence := "batch ID and digest reconciled" - if reconcileErr != nil { - evidence = "reconciliation failed: " + reconcileErr.Error() - reconciled.State = MutationUnknown - } - attempt := recoveryAttemptEvent{ - MutationID: prepared.Batch.ID, Digest: prepared.Batch.Digest, Target: prepared.Target, - OriginalSubject: stored.Metadata.Subject, OriginalSession: stored.Metadata.ID, Actor: principal.Subject, - Verb: RecoveryReconcile, Evidence: evidence, Reconciled: reconciled, - } - binding, err = appendRecoveryAttempt(ctx, a.sessions, binding, attempt) - if err != nil { - return RecoveryResult{}, err - } - replay.attempt = &attempt - result = RecoveryResult{ - Project: runtime.options.Project, Item: recoveryItem(stored, replay), - Transition: TransitionResult{Project: runtime.options.Project, Binding: binding, Apply: reconciled}, - } - if reconcileErr != nil { - return result, &ApplicationError{Code: ErrorRecoveryRequired, Message: "recovery reconciliation was non-definitive; abandon-unknown may acknowledge the recorded evidence", Cause: reconcileErr} - } - return result, nil -} - -// RecoverMutation performs exactly one explicitly authorized verb. It always -// reconciles a freshly acquired concrete target before any graph-affecting or -// terminal action and never runs from startup, resume, or read surfaces. -func (a *Application) RecoverMutation(ctx context.Context, identity RequestIdentity, request RecoveryRequest) (result RecoveryResult, err error) { - if request.Session == "" || strings.TrimSpace(request.MutationID) == "" { - return RecoveryResult{}, fmt.Errorf("sdd: recovery session and mutation ID are required") - } - principal, runtime, stored, err := a.resolveSession(ctx, identity, request.Session, AccessWrite) - if err != nil { - return RecoveryResult{}, err - } - replay, err := replayRecovery(stored.Events, request.MutationID) - if err != nil { - return RecoveryResult{}, err - } - if replay.terminal != nil { - return RecoveryResult{Project: runtime.options.Project, Item: recoveryItem(stored, replay)}, &ApplicationError{Code: ErrorRecoveryRequired, Message: "mutation recovery is already terminal"} - } - prepared := replay.prepared - if prepared.Version == LegacyPreparedTransitionVersion { - if replay.bound != nil { - prepared.Target = replay.bound.Target - prepared.Version = PreparedTransitionVersion - } else if request.Verb == RecoveryBindTarget { - return a.bindLegacyTarget(ctx, runtime, principal, stored, replay, request) - } else { - return RecoveryResult{Project: runtime.options.Project, Item: recoveryItem(stored, replay)}, &ApplicationError{Code: ErrorMigrationRequired, Message: "legacy prepared intent needs an explicitly authorized target binding or recapture", Version: prepared.Version} - } - } - if request.Verb == RecoveryBindTarget { - return RecoveryResult{}, &ApplicationError{Code: ErrorRecoveryRequired, Message: "bind-target is only valid for legacy prepared intents"} - } - if err := validatePreparedForRecovery(prepared, stored.Metadata); err != nil { - return RecoveryResult{}, err - } - if runtime.options.Recovery == nil { - return RecoveryResult{}, &ApplicationError{Code: ErrorWriteDenied, Message: "project has no recovery authorizer"} - } - access := RecoveryAccessRequest{ - Actor: principal, Target: prepared.Target, Verb: request.Verb, - OriginalSubject: stored.Metadata.Subject, OriginalSession: stored.Metadata.ID, - } - if err := runtime.options.Recovery.AuthorizeRecovery(ctx, access); err != nil { - return RecoveryResult{}, err - } - binding := SessionBinding{SessionID: stored.Metadata.ID, Subject: stored.Metadata.Subject, Project: stored.Metadata.Project, Version: stored.Version} - acquired, acquireErr := runtime.acquire(ctx, prepared.Target) - if acquireErr != nil { - binding, appendErr := appendRecoveryAttempt(ctx, a.sessions, binding, recoveryAttemptEvent{ - MutationID: prepared.Batch.ID, Digest: prepared.Batch.Digest, Target: prepared.Target, - OriginalSubject: stored.Metadata.Subject, OriginalSession: stored.Metadata.ID, Actor: principal.Subject, - Verb: request.Verb, Reason: request.Reason, Evidence: "target acquisition failed: " + acquireErr.Error(), Reconciled: ApplyResult{State: MutationUnknown}, - }) - if appendErr != nil { - return RecoveryResult{}, errors.Join(acquireErr, appendErr) - } - if request.Verb != RecoveryAbandonUnknown { - return RecoveryResult{Project: runtime.options.Project, Transition: TransitionResult{Project: runtime.options.Project, Binding: binding, Apply: ApplyResult{State: MutationUnknown}}}, &ApplicationError{Code: ErrorRecoveryRequired, Message: "recovery target acquisition failed; only abandon-unknown may terminally acknowledge this evidence", Cause: acquireErr} - } - return a.terminalRecovery(ctx, runtime, stored, prepared, binding, principal.Subject, request, RecoveryReasonAbandonedUnknown) - } - release := true - defer func() { - if release { - if releaseErr := acquired.Release(); releaseErr != nil { - err = errors.Join(err, fmt.Errorf("releasing mutation target %s: %w", prepared.Target.Branch, releaseErr)) - } - } - }() - reconciled, reconcileErr := acquired.Graph.Reconcile(ctx, prepared.Batch.ID, prepared.Batch.Digest) - evidence := "batch ID and digest reconciled" - if reconcileErr != nil { - evidence = "reconciliation failed: " + reconcileErr.Error() - reconciled.State = MutationUnknown - } - binding, err = appendRecoveryAttempt(ctx, a.sessions, binding, recoveryAttemptEvent{ - MutationID: prepared.Batch.ID, Digest: prepared.Batch.Digest, Target: prepared.Target, - OriginalSubject: stored.Metadata.Subject, OriginalSession: stored.Metadata.ID, Actor: principal.Subject, - Verb: request.Verb, Reason: request.Reason, Evidence: evidence, Reconciled: reconciled, - }) - if err != nil { - return RecoveryResult{}, err - } - switch request.Verb { - case RecoveryApply: - if reconciled.State != MutationNotApplied || reconcileErr != nil { - return RecoveryResult{}, recoveryStateError(request.Verb, reconciled.State, reconcileErr) - } - release = false - transition, err := a.applyOnAcquired(ctx, runtime, acquired, binding, prepared, replay.finalizers, principal.Subject, RecoveryApply) - return RecoveryResult{Project: runtime.options.Project, Transition: transition}, err - case RecoveryDiscard: - if reconciled.State != MutationNotApplied || reconcileErr != nil { - return RecoveryResult{}, recoveryStateError(request.Verb, reconciled.State, reconcileErr) - } - return a.terminalRecovery(ctx, runtime, stored, prepared, binding, principal.Subject, request, RecoveryReasonDiscarded) - case RecoveryFinalizeRetry: - if reconciled.State != MutationApplied || reconcileErr != nil { - return RecoveryResult{}, recoveryStateError(request.Verb, reconciled.State, reconcileErr) - } - transition, err := a.finishTransition(ctx, runtime, acquired, binding, prepared, reconciled, nil, replay.finalizers, principal.Subject, RecoveryFinalizeRetry) - return RecoveryResult{Project: runtime.options.Project, Transition: transition}, err - case RecoveryAbandonUnknown: - if reconciled.State != MutationUnknown { - return RecoveryResult{}, recoveryStateError(request.Verb, reconciled.State, reconcileErr) - } - return a.terminalRecovery(ctx, runtime, stored, prepared, binding, principal.Subject, request, RecoveryReasonAbandonedUnknown) - default: - return RecoveryResult{}, fmt.Errorf("sdd: unknown recovery verb %q", request.Verb) - } -} - -func (a *Application) terminalRecovery(ctx context.Context, runtime *ProjectRuntime, stored StoredSession, prepared PreparedTransition, binding SessionBinding, actor string, request RecoveryRequest, reason RecoveryReason) (RecoveryResult, error) { - next, err := a.recordRecoveryTerminal(ctx, binding, recoveryTerminalEvent{ - MutationID: prepared.Batch.ID, Digest: prepared.Batch.Digest, Target: prepared.Target, - OriginalSubject: stored.Metadata.Subject, OriginalSession: stored.Metadata.ID, - Actor: actor, Verb: request.Verb, Reason: request.Reason, - }) - if err != nil { - return RecoveryResult{}, err - } - binding.Version = next - item := RecoveryItem{ - Session: stored.Metadata.ID, MutationID: prepared.Batch.ID, Digest: prepared.Batch.Digest, Target: prepared.Target, - OriginalSubject: stored.Metadata.Subject, State: RecoveryAbandoned, Reason: reason, Recovered: true, EntryIDs: mutationEntryIDs(prepared.Batch), - } - return RecoveryResult{Project: runtime.options.Project, Item: item, Transition: TransitionResult{Project: runtime.options.Project, Binding: binding}}, nil -} - -func (a *Application) bindLegacyTarget(ctx context.Context, runtime *ProjectRuntime, principal Principal, stored StoredSession, replay mutationRecoveryReplay, request RecoveryRequest) (RecoveryResult, error) { - prepared := replay.prepared - if err := request.Target.Validate(runtime.options.Project.ID); err != nil { - return RecoveryResult{}, err - } - for _, change := range prepared.Batch.Changes { - if !change.Delete && !strings.HasPrefix(filepathSlash(change.LogicalPath), "wip/") && change.Document == nil { - return RecoveryResult{}, &ApplicationError{Code: ErrorMigrationRequired, Message: "legacy intent lacks structured facts required for safe target binding; recapture explicitly", Version: prepared.Version} - } - } - if runtime.options.Recovery == nil { - return RecoveryResult{}, &ApplicationError{Code: ErrorWriteDenied, Message: "project has no recovery authorizer"} - } - if err := runtime.options.Recovery.AuthorizeRecovery(ctx, RecoveryAccessRequest{ - Actor: principal, Target: request.Target, Verb: RecoveryBindTarget, - OriginalSubject: stored.Metadata.Subject, OriginalSession: stored.Metadata.ID, - }); err != nil { - return RecoveryResult{}, err - } - event, err := storedEvent(eventLegacyTargetBound, legacyTargetBoundEvent{ - MutationID: prepared.Batch.ID, Target: request.Target, OriginalSubject: stored.Metadata.Subject, - OriginalSession: stored.Metadata.ID, Actor: principal.Subject, Reason: request.Reason, - }) - if err != nil { - return RecoveryResult{}, err - } - version, err := a.sessions.Append(ctx, stored.Metadata.ID, stored.Version, SessionAppend{Events: []StoredEvent{event}}) - if err != nil { - return RecoveryResult{}, err - } - // Binding supplies the target a pending legacy intent was missing; delivery is - // still owed, so the item stays pending with its recorded reason intact. - item := recoveryItem(stored, replay) - item.Target = request.Target - item.LegacyUnroutable = false - item.Recovered = true - return RecoveryResult{Project: runtime.options.Project, Item: item, Transition: TransitionResult{Project: runtime.options.Project, Binding: SessionBinding{SessionID: stored.Metadata.ID, Subject: stored.Metadata.Subject, Project: stored.Metadata.Project, Version: version}}}, nil -} - -func validatePreparedForRecovery(prepared PreparedTransition, metadata SessionMetadata) error { - if prepared.Version != PreparedTransitionVersion { - return &ApplicationError{Code: ErrorMigrationRequired, Message: "unsupported prepared transition version", Version: prepared.Version} - } - if err := prepared.Target.Validate(metadata.Project); err != nil { - return err - } - if prepared.Staged.Subject != metadata.Subject || prepared.Staged.Session != metadata.ID { - return &ApplicationError{Code: ErrorSessionOwnership, Message: "prepared transition provenance mismatch"} - } - digest, err := MutationBatchDigest(prepared.Batch) - if err != nil { - return err - } - if prepared.Batch.ID == "" || prepared.Batch.Digest == "" || prepared.Batch.Digest != digest { - return &ApplicationError{Code: ErrorRecoveryRequired, Message: "prepared mutation digest mismatch"} - } - return nil -} - -func appendRecoveryAttempt(ctx context.Context, sessions SessionStore, binding SessionBinding, value recoveryAttemptEvent) (SessionBinding, error) { - event, err := storedEvent(eventRecoveryAttempt, value) - if err != nil { - return binding, err - } - version, err := sessions.Append(ctx, binding.SessionID, binding.Version, SessionAppend{Events: []StoredEvent{event}}) - if err != nil { - return binding, err - } - binding.Version = version - return binding, nil -} - -func appendRecoveryTerminal(ctx context.Context, sessions SessionStore, binding SessionBinding, value recoveryTerminalEvent) (uint64, error) { - event, err := storedEvent(eventRecoveryTerminal, value) - if err != nil { - return binding.Version, err - } - return sessions.Append(ctx, binding.SessionID, binding.Version, SessionAppend{Events: []StoredEvent{event}}) -} - -// recordRecoveryTerminal appends the terminal recovery record and returns its -// event sequence. Staged bytes remain available until the session is collected. -func (a *Application) recordRecoveryTerminal(ctx context.Context, binding SessionBinding, event recoveryTerminalEvent) (uint64, error) { - return appendRecoveryTerminal(ctx, a.sessions, binding, event) -} - -func recoveryStateError(verb RecoveryVerb, state ApplyState, cause error) error { - message := fmt.Sprintf("recovery verb %s is forbidden after reconciliation state %s", verb, state) - return &ApplicationError{Code: ErrorRecoveryRequired, Message: message, ApplyState: state, Cause: cause} -} - -func mutationIDs(events []StoredEvent) ([]string, error) { - seen := map[string]bool{} - var result []string - for _, event := range events { - if event.Code != eventMutationIntent { - continue - } - var value mutationIntentEvent - if err := json.Unmarshal(event.Payload, &value); err != nil { - return nil, err - } - if value.Prepared.Batch.ID != "" && !seen[value.Prepared.Batch.ID] { - seen[value.Prepared.Batch.ID] = true - result = append(result, value.Prepared.Batch.ID) - } - } - return result, nil -} - -func replayRecovery(events []StoredEvent, mutationID string) (mutationRecoveryReplay, error) { - replay := mutationRecoveryReplay{apply: ApplyResult{State: MutationUnknown}, finalizers: map[string]FinalizerOutcome{}} - for _, event := range events { - if !SupportedSessionCodecVersion(event.CodecVersion) { - return mutationRecoveryReplay{}, &ApplicationError{Code: ErrorMigrationRequired, Message: "unsupported session event codec version", Version: event.CodecVersion} - } - switch event.Code { - case eventMutationIntent: - var value mutationIntentEvent - if err := json.Unmarshal(event.Payload, &value); err != nil { - return mutationRecoveryReplay{}, err - } - if value.Prepared.Batch.ID == mutationID { - replay.prepared = value.Prepared - } - case eventMutationOutcome: - var value mutationOutcomeEvent - if err := json.Unmarshal(event.Payload, &value); err != nil { - return mutationRecoveryReplay{}, err - } - if value.MutationID == mutationID { - replay.apply = value.Apply - } - case eventFinalizerOutcome: - var value finalizerOutcomeEvent - if err := json.Unmarshal(event.Payload, &value); err != nil { - return mutationRecoveryReplay{}, err - } - if value.MutationID == mutationID { - replay.finalizers[value.Outcome.Name] = value.Outcome - } - case eventRecoveryAttempt: - var value recoveryAttemptEvent - if err := json.Unmarshal(event.Payload, &value); err != nil { - return mutationRecoveryReplay{}, err - } - if value.MutationID == mutationID { - replay.attempt = &value - } - case eventRecoveryTerminal: - var value recoveryTerminalEvent - if err := json.Unmarshal(event.Payload, &value); err != nil { - return mutationRecoveryReplay{}, err - } - if value.MutationID == mutationID { - replay.terminal = &value - } - case eventLegacyTargetBound: - var value legacyTargetBoundEvent - if err := json.Unmarshal(event.Payload, &value); err != nil { - return mutationRecoveryReplay{}, err - } - if value.MutationID == mutationID { - replay.bound = &value - } - } - } - if replay.prepared.Batch.ID == "" { - return mutationRecoveryReplay{}, fmt.Errorf("sdd: mutation intent %q not found", mutationID) - } - return replay, nil -} - -func recoveryItem(stored StoredSession, replay mutationRecoveryReplay) RecoveryItem { - item := RecoveryItem{ - Session: stored.Metadata.ID, MutationID: replay.prepared.Batch.ID, Digest: replay.prepared.Batch.Digest, - Target: replay.prepared.Target, OriginalSubject: stored.Metadata.Subject, EntryIDs: mutationEntryIDs(replay.prepared.Batch), - } - if replay.bound != nil { - item.Target = replay.bound.Target - } - if replay.attempt != nil { - item.LastEvidence = replay.attempt.Evidence - item.Recovered = true - } - // A terminal is written by the ordinary write path too, so its presence says - // nothing about provenance — only a recorded attempt does. - if replay.terminal != nil { - item.Cause = replay.terminal.Cause - switch replay.terminal.Verb { - case RecoveryDiscard: - item.State, item.Reason = RecoveryAbandoned, RecoveryReasonDiscarded - case RecoveryAbandonUnknown: - item.State, item.Reason = RecoveryAbandoned, RecoveryReasonAbandonedUnknown - default: - item.State = RecoveryDelivered - } - return item - } - // Delivery derives from the recorded outcome, never from a terminal's - // absence: the v1 writer recorded an applied, finalized mutation and never - // wrote a terminal event, so terminal absence alone is not open work. - switch replay.recordedApplyState() { - case MutationApplied: - if replay.finalizationOwed() { - item.State, item.Reason = RecoveryPending, RecoveryReasonFinalizationOwed - } else { - item.State = RecoveryDelivered - } - case MutationNotApplied: - item.State, item.Reason = RecoveryPending, RecoveryReasonNotApplied - default: - item.State, item.Reason = RecoveryPending, RecoveryReasonOutcomeUnknown - } - // An unbound legacy intent is unroutable only while a verb is still owed; - // a write that already landed needs no target to act on. - if item.Actionable() && replay.prepared.Version == LegacyPreparedTransitionVersion && replay.bound == nil { - item.LegacyUnroutable = true - } - return item -} - -// recordedApplyState is the mutation's outcome as the store records it: the -// canonical apply outcome when it is definitive, otherwise the latest recovery -// attempt's reconciliation. Only the two definitive states short-circuit, so an -// absent or unrecognized canonical state still consults the attempt. -func (r mutationRecoveryReplay) recordedApplyState() ApplyState { - if r.apply.State == MutationApplied || r.apply.State == MutationNotApplied { - return r.apply.State - } - if r.attempt != nil { - return r.attempt.Reconciled.State - } - return MutationUnknown -} - -// finalizationOwed reports whether finalization is still owed. Delivery needs -// positive proof — a recorded outcome that succeeded. No recorded outcome at all -// means no finalizer ever reported, so the write landed with its commit still -// owed, and a recorded failure is independently retryable work; both keep an -// applied mutation actionable. A target configuring no finalizers never reaches -// here, because an applied mutation records its terminal regardless of count. -func (r mutationRecoveryReplay) finalizationOwed() bool { - if len(r.finalizers) == 0 { - return true - } - for _, outcome := range r.finalizers { - if !outcome.Succeeded { - return true - } - } - return false -} - -func mutationEntryIDs(batch MutationBatch) []string { - var result []string - for _, change := range batch.Changes { - if change.Document == nil { - continue - } - if id, err := entryIDFromDocument(*change.Document); err == nil { - result = append(result, id) - } - } - return result -} - -func entryIDFromDocument(document EntryDocument) (string, error) { - return model.RelPathToID(document.LogicalPath) -} diff --git a/pkg/application/recovery_projection_integration_test.go b/pkg/application/recovery_projection_integration_test.go deleted file mode 100644 index 2d2cb745..00000000 --- a/pkg/application/recovery_projection_integration_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package application_test - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - sdd "github.com/networkteam/sdd/pkg/application" - pkgllm "github.com/networkteam/sdd/pkg/llm" - localadapter "github.com/networkteam/sdd/pkg/local" -) - -// recoveryFixtureProject is the project the real fixture sessions were recorded -// against; the projection filters sessions by project, so it must match. -const recoveryFixtureProject sdd.ProjectID = "github.com/networkteam/sdd" - -// copyRecoveryFixtureSessions stages the trimmed real session logs in a -// temporary store directory. The store writes lock files beside each session, -// so it must never be pointed at testdata itself. -func copyRecoveryFixtureSessions(t *testing.T, variants ...string) string { - t.Helper() - dir := t.TempDir() - copied := 0 - for _, variant := range variants { - source := filepath.Join("testdata", "recovery", variant) - entries, err := os.ReadDir(source) - if err != nil { - t.Fatal(err) - } - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { - continue - } - content, err := os.ReadFile(filepath.Join(source, entry.Name())) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, entry.Name()), content, 0o600); err != nil { - t.Fatal(err) - } - copied++ - } - } - if copied == 0 { - t.Fatalf("no fixture sessions found for variants %v", variants) - } - return dir -} - -func newRecoveryFixtureApplication(t *testing.T, sessionsDir string) *sdd.Application { - t.Helper() - graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{ - Project: recoveryFixtureProject, GraphDir: t.TempDir(), - }) - if err != nil { - t.Fatal(err) - } - sessions, err := localadapter.NewFilesystemSessionStoreAt(sessionsDir) - if err != nil { - t.Fatal(err) - } - blobs, err := localadapter.NewFilesystemStagedBlobStoreAt(t.TempDir()) - if err != nil { - t.Fatal(err) - } - runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ - Project: sdd.ProjectRef{ID: recoveryFixtureProject, DisplayName: "SDD"}, - Graph: graph, - LLM: pkgllm.RunnerFunc(func(context.Context, pkgllm.Request) (pkgllm.Result, error) { - return pkgllm.Result{Identity: pkgllm.Identity{Provider: "test", Model: "test"}}, nil - }), - }) - if err != nil { - t.Fatal(err) - } - application, err := sdd.NewApplication(sdd.ApplicationOptions{Access: &runtimeAccessResolver{runtime: runtime}, Sessions: sessions, StagedBlobs: blobs}) - if err != nil { - t.Fatal(err) - } - return application -} - -// TestListRecoveriesReportsNoPendingWritesForRealAppliedLegacyStore drives the -// projection over a session store built from three real session logs, each -// carrying one real v1 intent that the store records as applied with the git -// finalizer succeeded. Nothing in that store is a pending write, so the free -// read projection must surface no actionable items and Info must print no -// recovery notice. -func TestListRecoveriesReportsNoPendingWritesForRealAppliedLegacyStore(t *testing.T) { - sessionsDir := copyRecoveryFixtureSessions(t, "sessions") - application := newRecoveryFixtureApplication(t, sessionsDir) - identity := sdd.RequestIdentity{Subject: "local", Scopes: []string{"project:read"}} - - // Guard: the closed projection must actually see the fixture intents, so - // that an empty actionable list cannot pass vacuously on an empty store. - all, err := application.ListRecoveries(t.Context(), identity, recoveryFixtureProject, true) - if err != nil { - t.Fatal(err) - } - if len(all.Items) != 3 { - t.Fatalf("ListRecoveries(includeClosed=true) returned %d items, want the 3 fixture intents: %+v", len(all.Items), all.Items) - } - - open, err := application.ListRecoveries(t.Context(), identity, recoveryFixtureProject, false) - if err != nil { - t.Fatal(err) - } - if len(open.Items) != 0 { - var reported []string - for _, item := range open.Items { - reported = append(reported, string(item.Session)+"/"+item.MutationID+" state="+string(item.State)+" legacyUnroutable="+boolText(item.LegacyUnroutable)) - } - t.Errorf("ListRecoveries(includeClosed=false) returned %d actionable items, want 0; every fixture intent is recorded as applied with the git finalizer succeeded:\n %s", - len(open.Items), strings.Join(reported, "\n ")) - } - - info, err := application.Info(t.Context(), identity, recoveryFixtureProject, sdd.InfoRequest{}) - if err != nil { - t.Fatal(err) - } - if info.Recovery != "" { - t.Errorf("Info().Recovery = %q, want empty: no pending write awaits recovery in this store", info.Recovery) - } -} - -func boolText(value bool) string { - if value { - return "true" - } - return "false" -} diff --git a/pkg/application/recovery_projection_test.go b/pkg/application/recovery_projection_test.go deleted file mode 100644 index c2c8e565..00000000 --- a/pkg/application/recovery_projection_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package application - -import ( - "bufio" - "encoding/json" - "os" - "path/filepath" - "testing" -) - -// Real v1 intents extracted from the live session store; see -// testdata/recovery/README.md for the source logs and the selection rule. -const ( - strandedFixtureSession = "s_20260714-095955-885b3c45" - strandedFixtureMutationID = "entry-20260714-103304-s-tac-rcv" -) - -// recoveryFixtureLine mirrors the store's on-disk session envelope. The test -// reads the envelope only; every projection fact under test still comes from -// replayRecovery and recoveryItem, the real seams. -type recoveryFixtureLine struct { - Version uint64 `json:"version"` - Metadata *SessionMetadata `json:"metadata,omitempty"` - Events []StoredEvent `json:"events,omitempty"` -} - -// loadRecoveryFixture reads the stranded fixture session in the named variant -// directory; each variant holds the same session under a different outcome. -func loadRecoveryFixture(t *testing.T, variant string) StoredSession { - t.Helper() - filename := filepath.Join("testdata", "recovery", variant, strandedFixtureSession+".jsonl") - file, err := os.Open(filename) - if err != nil { - t.Fatal(err) - } - defer func() { _ = file.Close() }() - var stored StoredSession - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) - for number := 1; scanner.Scan(); number++ { - var line recoveryFixtureLine - if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { - t.Fatalf("decoding %s line %d: %v", filename, number, err) - } - if line.Version != uint64(number) { - t.Fatalf("%s line %d carries version %d", filename, number, line.Version) - } - if line.Metadata != nil { - stored.Metadata = *line.Metadata - } - stored.Events = append(stored.Events, line.Events...) - stored.Version = line.Version - } - if err := scanner.Err(); err != nil { - t.Fatal(err) - } - if stored.Metadata.ID == "" { - t.Fatalf("%s carries no session metadata", filename) - } - return stored -} - -// TestRecoveryProjectionClearsAppliedLegacyIntent covers the stranded shape the -// live store holds. A real v1 intent whose recorded apply outcome is applied and -// whose git finalizer succeeded is a finished write: the store plainly records -// the desired state as reached. The projection must not report it as an -// actionable pending write, and must not demand a target binding for a write -// that no longer needs a target. -func TestRecoveryProjectionClearsAppliedLegacyIntent(t *testing.T) { - stored := loadRecoveryFixture(t, "sessions") - replay, err := replayRecovery(stored.Events, strandedFixtureMutationID) - if err != nil { - t.Fatal(err) - } - - // Guard the fixture: this must be the real stranded shape, or the - // assertions below prove nothing. - if replay.prepared.Version != LegacyPreparedTransitionVersion { - t.Fatalf("fixture prepared version = %d, want the legacy version %d", replay.prepared.Version, LegacyPreparedTransitionVersion) - } - if replay.apply.State != MutationApplied { - t.Fatalf("fixture apply state = %q, want %q", replay.apply.State, MutationApplied) - } - if outcome, ok := replay.finalizers["git"]; !ok || !outcome.Succeeded { - t.Fatalf("fixture git finalizer = %+v, present=%t, want a recorded success", outcome, ok) - } - if replay.terminal != nil || replay.bound != nil || replay.attempt != nil { - t.Fatalf("fixture already carries recovery history: terminal=%+v bound=%+v attempt=%+v", replay.terminal, replay.bound, replay.attempt) - } - for _, change := range replay.prepared.Batch.Changes { - if change.Document != nil { - t.Fatalf("fixture change %q carries a structured Document; the real v1 writer wrote none", change.LogicalPath) - } - } - - item := recoveryItem(stored, replay) - if item.Actionable() { - t.Errorf("recoveryItem(%s).Actionable = true, want false: the store records apply=%s with the git finalizer succeeded, so there is no pending write to recover", - strandedFixtureMutationID, replay.apply.State) - } - if item.LegacyUnroutable { - t.Errorf("recoveryItem(%s).LegacyUnroutable = true, want false: an already-applied write needs no target binding", strandedFixtureMutationID) - } - if item.State != RecoveryDelivered { - t.Errorf("recoveryItem(%s).State = %q, want %q: the recorded outcome proves the write reached its desired state", strandedFixtureMutationID, item.State, RecoveryDelivered) - } - if item.Reason != "" { - t.Errorf("recoveryItem(%s).Reason = %q, want empty: a delivered write owes nothing", strandedFixtureMutationID, item.Reason) - } - if item.Recovered { - t.Errorf("recoveryItem(%s).Recovered = true, want false: no recovery verb ever touched this mutation", strandedFixtureMutationID) - } -} - -// TestRecoveryProjectionKeepsAppliedLegacyIntentWithFailedFinalizerActionable -// pins one half of the delivery boundary. The same real applied intent with a -// failed git finalizer is not a notice to clear: the finalizer is independently -// retryable, so the item stays actionable. -func TestRecoveryProjectionKeepsAppliedLegacyIntentWithFailedFinalizerActionable(t *testing.T) { - stored := loadRecoveryFixture(t, "finalizer-failed") - replay, err := replayRecovery(stored.Events, strandedFixtureMutationID) - if err != nil { - t.Fatal(err) - } - - if replay.prepared.Version != LegacyPreparedTransitionVersion { - t.Fatalf("fixture prepared version = %d, want the legacy version %d", replay.prepared.Version, LegacyPreparedTransitionVersion) - } - if replay.apply.State != MutationApplied { - t.Fatalf("fixture apply state = %q, want %q", replay.apply.State, MutationApplied) - } - if outcome, ok := replay.finalizers["git"]; !ok || outcome.Succeeded { - t.Fatalf("fixture git finalizer = %+v, present=%t, want a recorded failure", outcome, ok) - } - - item := recoveryItem(stored, replay) - if !item.Actionable() { - t.Errorf("recoveryItem(%s).Actionable = false, want true: the recorded git finalizer failed, so finalization is still owed", - strandedFixtureMutationID) - } -} - -// TestRecoveryProjectionKeepsAppliedIntentWithoutFinalizerRecordActionable pins -// the other half of the delivery boundary: silence is not proof. An applied -// mutation carrying no finalizer outcome at all landed in the graph with its -// commit still owed — the writer records each finalizer's outcome only after -// running it, so an absent record means none ran. Suppressing that notice would -// strand the write with no way to reach finalize-retry. -// -// The input is the real stranded triple with its finalizer outcome removed, so -// the shape stays derived from what the v1 writer actually produced rather than -// hand-assembled. The live store holds no such session today; this guards the -// case forward. -func TestRecoveryProjectionKeepsAppliedIntentWithoutFinalizerRecordActionable(t *testing.T) { - stored := loadRecoveryFixture(t, "sessions") - withoutFinalizer := make([]StoredEvent, 0, len(stored.Events)) - for _, event := range stored.Events { - if event.Code == eventFinalizerOutcome { - continue - } - withoutFinalizer = append(withoutFinalizer, event) - } - if len(withoutFinalizer) == len(stored.Events) { - t.Fatalf("fixture %s carries no %s event to remove", strandedFixtureSession, eventFinalizerOutcome) - } - stored.Events = withoutFinalizer - - replay, err := replayRecovery(stored.Events, strandedFixtureMutationID) - if err != nil { - t.Fatal(err) - } - if replay.apply.State != MutationApplied { - t.Fatalf("fixture apply state = %q, want %q", replay.apply.State, MutationApplied) - } - if len(replay.finalizers) != 0 { - t.Fatalf("replay recorded %d finalizer outcomes, want none", len(replay.finalizers)) - } - - item := recoveryItem(stored, replay) - if !item.Actionable() { - t.Errorf("recoveryItem(%s).Actionable = false, want true: no finalizer outcome is recorded, so the commit is still owed", - strandedFixtureMutationID) - } - if item.State != RecoveryPending { - t.Errorf("recoveryItem(%s).State = %q, want %q", strandedFixtureMutationID, item.State, RecoveryPending) - } -} - -// TestRecoveryProjectionKeepsReconciledAppliedIntentActionable pins the -// reconciliation path. A mutation whose canonical outcome never became -// definitive, and which a recovery attempt later reconciled to applied, has by -// construction no finalizer outcome: the writer runs finalizers only from a -// definitive apply. It must stay actionable so finalize-retry remains reachable. -func TestRecoveryProjectionKeepsReconciledAppliedIntentActionable(t *testing.T) { - stored := loadRecoveryFixture(t, "sessions") - intentOnly := make([]StoredEvent, 0, len(stored.Events)) - for _, event := range stored.Events { - if event.Code == eventMutationOutcome || event.Code == eventFinalizerOutcome { - continue - } - intentOnly = append(intentOnly, event) - } - stored.Events = intentOnly - - attempt, err := storedEvent(eventRecoveryAttempt, recoveryAttemptEvent{ - MutationID: strandedFixtureMutationID, - Reconciled: ApplyResult{State: MutationApplied, Revision: "sha256:reconciled"}, - }) - if err != nil { - t.Fatal(err) - } - stored.Events = append(stored.Events, attempt) - - replay, err := replayRecovery(stored.Events, strandedFixtureMutationID) - if err != nil { - t.Fatal(err) - } - if replay.apply.State != MutationUnknown { - t.Fatalf("replay apply state = %q, want %q", replay.apply.State, MutationUnknown) - } - if replay.attempt == nil || replay.attempt.Reconciled.State != MutationApplied { - t.Fatalf("replay attempt = %+v, want a reconciliation recording %q", replay.attempt, MutationApplied) - } - - item := recoveryItem(stored, replay) - if !item.Actionable() { - t.Errorf("recoveryItem(%s).Actionable = false, want true: reconciliation proved the apply landed but no finalizer has run", - strandedFixtureMutationID) - } - if item.State != RecoveryPending { - t.Errorf("recoveryItem(%s).State = %q, want %q", strandedFixtureMutationID, item.State, RecoveryPending) - } - if !item.Recovered { - t.Errorf("recoveryItem(%s).Recovered = false, want true: a reconciliation attempt is recorded", strandedFixtureMutationID) - } -} - -// TestRecoveryProjectionDoesNotCallOrdinaryWritesRecovered pins provenance to the -// only evidence that carries it. The ordinary write path closes an applied -// mutation with a terminal whose verb is `apply`, so terminal presence says -// nothing about whether recovery machinery ran — reading it as provenance labels -// every successful write recovered, which is what this guards. -func TestRecoveryProjectionDoesNotCallOrdinaryWritesRecovered(t *testing.T) { - stored := loadRecoveryFixture(t, "sessions") - terminal, err := storedEvent(eventRecoveryTerminal, recoveryTerminalEvent{ - MutationID: strandedFixtureMutationID, - Verb: RecoveryApply, - }) - if err != nil { - t.Fatal(err) - } - stored.Events = append(stored.Events, terminal) - - replay, err := replayRecovery(stored.Events, strandedFixtureMutationID) - if err != nil { - t.Fatal(err) - } - if replay.terminal == nil || replay.attempt != nil { - t.Fatalf("replay terminal=%+v attempt=%+v, want a terminal and no attempt", replay.terminal, replay.attempt) - } - - item := recoveryItem(stored, replay) - if item.State != RecoveryDelivered { - t.Errorf("recoveryItem(%s).State = %q, want %q", strandedFixtureMutationID, item.State, RecoveryDelivered) - } - if item.Recovered { - t.Errorf("recoveryItem(%s).Recovered = true, want false: an ordinary write closes with an apply terminal and no recovery ever ran", strandedFixtureMutationID) - } - if item.Actionable() { - t.Errorf("recoveryItem(%s).Actionable = true, want false", strandedFixtureMutationID) - } -} diff --git a/pkg/application/revalidation.go b/pkg/application/revalidation.go deleted file mode 100644 index b509d3dc..00000000 --- a/pkg/application/revalidation.go +++ /dev/null @@ -1,104 +0,0 @@ -package application - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/networkteam/sdd/internal/model" -) - -// revalidatePreparedTransition proves that the structured facts retained in -// durable intent still form a valid graph against the fresh target snapshot. -// Canonical content is never regenerated during recovery. -func revalidatePreparedTransition(ctx context.Context, snapshot *Snapshot, prepared PreparedTransition) error { - if snapshot == nil || snapshot.Project() != prepared.Target.Project { - return &ApplicationError{Code: ErrorRecoveryRequired, Message: "mutation target snapshot does not match prepared target"} - } - data := cloneSnapshotData(snapshot.data) - for _, change := range prepared.Batch.Changes { - if strings.HasPrefix(filepathSlash(change.LogicalPath), "wip/") { - applyPreparedWIP(&data, change) - continue - } - if _, err := model.RelPathToID(change.LogicalPath); err != nil { - return fmt.Errorf("sdd: prepared mutation path %q is neither an entry nor WIP marker: %w", change.LogicalPath, err) - } - if change.Delete { - removeEntryDocument(&data, change.LogicalPath) - continue - } - if change.Document == nil { - return &ApplicationError{Code: ErrorMigrationRequired, Message: "prepared entry mutation lacks structured document facts", Version: prepared.Version} - } - parsed, err := ParseEntryDocument(change.LogicalPath, change.CanonicalBytes) - if err != nil { - return fmt.Errorf("sdd: validating prepared canonical entry %q: %w", change.LogicalPath, err) - } - equal, err := equalEntryDocuments(parsed, *change.Document) - if err != nil { - return fmt.Errorf("sdd: comparing prepared structured entry %q: %w", change.LogicalPath, err) - } - if !equal { - return &ApplicationError{Code: ErrorRecoveryRequired, Message: "prepared structured entry and canonical bytes diverge"} - } - upsertEntryDocument(&data, parsed) - } - data.Revision = "prepared-revalidation" - if _, err := BuildSnapshot(ctx, data); err != nil { - return fmt.Errorf("sdd: prepared mutation no longer validates against target: %w", err) - } - return nil -} - -func equalEntryDocuments(left, right EntryDocument) (bool, error) { - leftJSON, err := json.Marshal(left) - if err != nil { - return false, err - } - rightJSON, err := json.Marshal(right) - if err != nil { - return false, err - } - return bytes.Equal(leftJSON, rightJSON), nil -} - -func applyPreparedWIP(data *SnapshotData, change DocumentChange) { - for index := range data.WIP { - if data.WIP[index].LogicalPath != change.LogicalPath { - continue - } - if change.Delete { - data.WIP = append(data.WIP[:index], data.WIP[index+1:]...) - } else { - data.WIP[index].Content = string(change.CanonicalBytes) - } - return - } - if !change.Delete { - data.WIP = append(data.WIP, WIPDocument{LogicalPath: change.LogicalPath, Content: string(change.CanonicalBytes)}) - } -} - -func removeEntryDocument(data *SnapshotData, logicalPath string) { - for index := range data.Entries { - if data.Entries[index].LogicalPath == logicalPath { - data.Entries = append(data.Entries[:index], data.Entries[index+1:]...) - return - } - } -} - -func upsertEntryDocument(data *SnapshotData, document EntryDocument) { - for index := range data.Entries { - if data.Entries[index].LogicalPath == document.LogicalPath { - data.Entries[index] = document - return - } - } - data.Entries = append(data.Entries, document) -} - -func filepathSlash(path string) string { return strings.ReplaceAll(path, "\\", "/") } diff --git a/pkg/application/runtime.go b/pkg/application/runtime.go index ac114d56..35a0722b 100644 --- a/pkg/application/runtime.go +++ b/pkg/application/runtime.go @@ -24,7 +24,6 @@ type ProjectRuntimeOptions struct { Graph GraphStore Targets TargetAcquirer Branches BranchValidator - Recovery RecoveryAuthorizer // Embedder and LLM are the two model dependencies, each a pkg/llm port // injected as an instance that arrives already composed — observed, // bounded, and rate-limited by the host's decorators. Routing, deadlines, diff --git a/pkg/application/search_preparation_test.go b/pkg/application/search_preparation_test.go index b9fa2464..893e73da 100644 --- a/pkg/application/search_preparation_test.go +++ b/pkg/application/search_preparation_test.go @@ -169,26 +169,19 @@ func TestSearchReadYourWritesAndLocalSourceLifetime(t *testing.T) { t.Fatal(err) } document := sdd.EntryDocument{LogicalPath: "2026/01/01-100000-s-tac-new.md", Frontmatter: map[string]any{"type": "signal", "kind": "gap", "layer": "tactical", "summary": "New"}, Body: "New write"} - apply := func(id string, doc sdd.EntryDocument) string { - before, err := graph.Current(t.Context()) - if err != nil { - t.Fatal(err) - } - batch := sdd.MutationBatch{ID: id, Changes: []sdd.DocumentChange{{LogicalPath: doc.LogicalPath, Document: &doc, CanonicalBytes: []byte("---\ntype: signal\nkind: gap\nlayer: tactical\nsummary: New\n---\n" + doc.Body)}}} - batch.Digest, err = sdd.MutationBatchDigest(batch) - if err != nil { - t.Fatal(err) - } - result, err := graph.Apply(t.Context(), before.Revision(), batch, nil) + apply := func(sequence uint64, doc sdd.EntryDocument) string { + key := sdd.PublicationKey{Session: "s_search", Sequence: sequence, Discriminator: "newEntry"} + batch := sdd.MutationBatch{ID: key.String(), Changes: []sdd.DocumentChange{{LogicalPath: doc.LogicalPath, Document: &doc, CanonicalBytes: []byte("---\ntype: signal\nkind: gap\nlayer: tactical\nsummary: New\n---\n" + doc.Body)}}} + result, err := graph.PublishEntry(t.Context(), key, batch, nil) if err != nil { t.Fatal(err) } return result.Revision } - written := apply("write-one", document) + written := apply(1, document) document.LogicalPath = "2026/01/01-100000-s-tac-two.md" document.Body = "Second write" - latest := apply("write-two", document) + latest := apply(2, document) app := preparationApp(t, base, nil, func(ctx context.Context, target sdd.SearchTarget) error { for item, err := range target.Entries(ctx) { if err != nil { diff --git a/pkg/application/session_runtime_test.go b/pkg/application/session_runtime_test.go index ea5c233e..8e3d846b 100644 --- a/pkg/application/session_runtime_test.go +++ b/pkg/application/session_runtime_test.go @@ -2,10 +2,7 @@ package application_test import ( "context" - "crypto/sha256" - "encoding/json" "errors" - "fmt" "os" "path/filepath" "strings" @@ -19,52 +16,6 @@ import ( localadapter "github.com/networkteam/sdd/pkg/local" ) -type toggleAppendSessionStore struct { - sdd.SessionStore - mu sync.Mutex - err error -} - -func (s *toggleAppendSessionStore) Append(ctx context.Context, id sdd.SessionID, version uint64, append sdd.SessionAppend) (uint64, error) { - s.mu.Lock() - err := s.err - s.mu.Unlock() - if err != nil { - return 0, err - } - return s.SessionStore.Append(ctx, id, version, append) -} - -func (s *toggleAppendSessionStore) fail(err error) { - s.mu.Lock() - s.err = err - s.mu.Unlock() -} - -type unknownAfterApplyStore struct{ sdd.GraphStore } - -func (s unknownAfterApplyStore) Apply(ctx context.Context, revision string, batch sdd.MutationBatch, blobs sdd.StagedBlobReader) (sdd.ApplyResult, error) { - result, err := s.GraphStore.Apply(ctx, revision, batch, blobs) - if err != nil { - return result, err - } - return sdd.ApplyResult{State: sdd.MutationUnknown, Revision: result.Revision}, errors.New("injected lost apply acknowledgement") -} - -type pendingUnknownStore struct { - sdd.GraphStore - mu sync.Mutex - reconciles int -} - -type mutableTargetAcquirer struct { - mu sync.Mutex - graph sdd.GraphStore - finalizers []sdd.MutationFinalizer - failNext bool - releaseErr error -} - type activityTargetAcquirer struct { mu sync.Mutex graph sdd.GraphStore @@ -99,65 +50,6 @@ func (a *activityTargetAcquirer) isActive() bool { return a.active } -func (a *mutableTargetAcquirer) Acquire(_ context.Context, target sdd.MutationTarget) (*sdd.AcquiredTarget, error) { - a.mu.Lock() - defer a.mu.Unlock() - if a.failNext { - a.failNext = false - return nil, errors.New("injected acquisition failure") - } - releaseErr := a.releaseErr - return &sdd.AcquiredTarget{ - Target: target, Graph: a.graph, Finalizers: append([]sdd.MutationFinalizer(nil), a.finalizers...), - Release: func() error { return releaseErr }, - }, nil -} - -func (a *mutableTargetAcquirer) setReleaseError(err error) { - a.mu.Lock() - a.releaseErr = err - a.mu.Unlock() -} - -func (s *pendingUnknownStore) Apply(context.Context, string, sdd.MutationBatch, sdd.StagedBlobReader) (sdd.ApplyResult, error) { - return sdd.ApplyResult{State: sdd.MutationUnknown}, errors.New("injected unknown apply outcome") -} - -func (s *pendingUnknownStore) Reconcile(context.Context, string, string) (sdd.ApplyResult, error) { - s.mu.Lock() - s.reconciles++ - s.mu.Unlock() - return sdd.ApplyResult{State: sdd.MutationUnknown}, errors.New("injected non-definitive reconciliation") -} - -type failOnceFinalizer struct { - mu sync.Mutex - calls int -} - -type recordingRecoveryAuthorizer struct { - mu sync.Mutex - request sdd.RecoveryAccessRequest -} - -func (a *recordingRecoveryAuthorizer) AuthorizeRecovery(_ context.Context, request sdd.RecoveryAccessRequest) error { - a.mu.Lock() - a.request = request - a.mu.Unlock() - return nil -} - -func (*failOnceFinalizer) Name() string { return "fail-once" } -func (f *failOnceFinalizer) Finalize(context.Context, sdd.AppliedMutation) error { - f.mu.Lock() - defer f.mu.Unlock() - f.calls++ - if f.calls == 1 { - return errors.New("injected finalizer failure") - } - return nil -} - // openBinding creates a durable session with an attachment and returns the // matching write binding. func openBinding(t *testing.T, sessions sdd.SessionStore, subject string, id sdd.SessionID) sdd.SessionBinding { @@ -172,177 +64,6 @@ func openBinding(t *testing.T, sessions sdd.SessionStore, subject string, id sdd return sdd.SessionBinding{SessionID: id, Subject: subject, Project: "example", Version: created.Version} } -// TestIncumbentContinuityHoldsWithoutExpiry proves I3 by construction: with -// arbitrary elapsed time and no competing claim, the driving client's next -// write succeeds — there is no expiry to inject, so the assertion is -// clock-free in the sense that no lease can revoke the incumbent. -func TestIncumbentContinuityHoldsWithoutExpiry(t *testing.T) { - now := time.Date(2026, 7, 13, 5, 0, 0, 0, time.UTC) - application, sessions, graph := newDurableApplication(t, func() time.Time { return now }, nil, nil) - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "incumbent") - first := preparedEntry(t, graph.GraphStore, binding, "incumbent-first", "2026/07/13-050000-s-tac-in1.md") - r1, err := application.ApplyPrepared(t.Context(), identity, "example", binding, first) - if err != nil || r1.Apply.State != sdd.MutationApplied { - t.Fatalf("first apply = %+v, %v", r1, err) - } - now = now.Add(72 * time.Hour) - second := preparedEntry(t, graph.GraphStore, r1.Binding, "incumbent-second", "2026/07/13-050100-s-tac-in2.md") - r2, err := application.ApplyPrepared(t.Context(), identity, "example", r1.Binding, second) - if err != nil || r2.Apply.State != sdd.MutationApplied { - t.Fatalf("incumbent second apply after elapsed time = %+v, %v", r2, err) - } -} - -func TestPreparedTransitionRecoversUnknownApplyAndFinalizer(t *testing.T) { - finalizer := &failOnceFinalizer{} - application, sessions, graph := newDurableApplication(t, time.Now, func(store sdd.GraphStore) sdd.GraphStore { - return unknownAfterApplyStore{GraphStore: store} - }, []sdd.MutationFinalizer{finalizer}) - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "recover") - prepared := preparedEntry(t, graph.GraphStore, binding, "recover-unknown", "2026/07/13-051000-s-tac-rec.md") - unknown, err := application.ApplyPrepared(t.Context(), identity, "example", binding, prepared) - if errorCode(err) != sdd.ErrorRecoveryRequired || unknown.Apply.State != sdd.MutationUnknown { - t.Fatalf("unknown ApplyPrepared = %+v, %v", unknown, err) - } - if _, err := application.RecoverMutation(t.Context(), identity, sdd.RecoveryRequest{Session: unknown.Binding.SessionID, MutationID: prepared.Batch.ID, Verb: sdd.RecoveryDiscard}); errorCode(err) != sdd.ErrorRecoveryRequired { - t.Fatalf("discard after reconciled applied error = %v", err) - } - recoveredResult, err := application.RecoverMutation(t.Context(), identity, sdd.RecoveryRequest{Session: unknown.Binding.SessionID, MutationID: prepared.Batch.ID, Verb: sdd.RecoveryFinalizeRetry}) - recovered := recoveredResult.Transition - if errorCode(err) != sdd.ErrorRecoveryRequired || recovered.Apply.State != sdd.MutationApplied || finalizer.calls != 1 { - t.Fatalf("first recovery = %+v, %v; finalizer calls=%d", recovered, err, finalizer.calls) - } - recoveredResult, err = application.RecoverMutation(t.Context(), identity, sdd.RecoveryRequest{Session: recovered.Binding.SessionID, MutationID: prepared.Batch.ID, Verb: sdd.RecoveryFinalizeRetry}) - recovered = recoveredResult.Transition - if err != nil || recovered.Apply.State != sdd.MutationApplied || finalizer.calls != 2 { - t.Fatalf("second recovery = %+v, %v; finalizer calls=%d", recovered, err, finalizer.calls) - } - history, err := application.ListRecoveries(t.Context(), identity, "example", true) - if err != nil || len(history.Items) != 1 || history.Items[0].State != sdd.RecoveryDelivered || history.Items[0].Actionable() { - t.Fatalf("recovered history = %+v, %v", history, err) - } - restarted, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: graph.dir}) - if err != nil { - t.Fatal(err) - } - reconciled, err := restarted.Reconcile(t.Context(), prepared.Batch.ID, prepared.Batch.Digest) - if err != nil || reconciled.State != sdd.MutationApplied { - t.Fatalf("restart Reconcile = %+v, %v", reconciled, err) - } -} - -func TestPreparedTransitionMergesUnrelatedAppendAndRejectsStaleBinding(t *testing.T) { - application, sessions, graph := newDurableApplication(t, time.Now, nil, nil) - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "merge") - prepared := preparedEntry(t, graph.GraphStore, binding, "merge-revision", "2026/07/13-052000-s-tac-stl.md") - advance := preparedEntry(t, graph.GraphStore, binding, "external", "2026/07/13-052100-s-tac-ext.md") - if _, err := graph.Apply(t.Context(), advance.ExpectedGraphRevision, advance.Batch, nil); err != nil { - t.Fatal(err) - } - // The prepared write pins the pre-advance revision, but an unrelated append - // moved the store. It merges cleanly against the revalidated fresh revision - // instead of failing the stale pin, and never files a recovery. - result, err := application.ApplyPrepared(t.Context(), identity, "example", binding, prepared) - if err != nil || result.Apply.State != sdd.MutationApplied { - t.Fatalf("merge-under-append ApplyPrepared = %+v, %v", result, err) - } - pending, err := application.ListRecoveries(t.Context(), identity, "example", false) - if err != nil || len(pending.Items) != 0 { - t.Fatalf("merge-under-append recovery projection = %+v, %v", pending, err) - } - // The successful write advanced the session; a second write presenting the - // pre-write binding version is fenced as a stale binding. - other := preparedEntry(t, graph.GraphStore, binding, "stale-binding", "2026/07/13-052200-s-tac-bnd.md") - if _, err := application.ApplyPrepared(t.Context(), identity, "example", binding, other); errorCode(err) != sdd.ErrorSessionConflict { - t.Fatalf("stale binding error = %v", err) - } -} - -func TestReconcileMutationRefreshesIntentOnlyProjectionBeforeVerbSelection(t *testing.T) { - graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - targets := &mutableTargetAcquirer{graph: graph, failNext: true} - application, sessions := newDurableApplicationWithTargets(t, graph, targets) - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "intent-only") - prepared := preparedEntry(t, graph, binding, "intent-only", "2026/07/13-052300-s-tac-int.md") - result, err := application.ApplyPrepared(t.Context(), identity, "example", binding, prepared) - if errorCode(err) != sdd.ErrorRecoveryRequired || result.Apply.State != sdd.MutationUnknown { - t.Fatalf("intent-only ApplyPrepared = %+v, %v", result, err) - } - - refreshed, err := application.ReconcileMutation(t.Context(), identity, sdd.RecoveryReconcileRequest{ - Session: result.Binding.SessionID, MutationID: prepared.Batch.ID, - }) - if err != nil || refreshed.Item.State != sdd.RecoveryPending || refreshed.Item.Reason != sdd.RecoveryReasonNotApplied || refreshed.Transition.Apply.State != sdd.MutationNotApplied { - t.Fatalf("ReconcileMutation = %+v, %v", refreshed, err) - } - stored, err := sessions.Load(t.Context(), binding.SessionID) - if err != nil { - t.Fatal(err) - } - found := false - for _, event := range stored.Events { - if event.Code == "recovery_attempt" && strings.Contains(string(event.Payload), `"verb":"reconcile"`) { - found = true - } - } - if !found { - t.Fatal("reconcile-only recovery attempt was not recorded") - } - applied, err := application.RecoverMutation(t.Context(), identity, sdd.RecoveryRequest{ - Session: result.Binding.SessionID, MutationID: prepared.Batch.ID, Verb: sdd.RecoveryApply, - }) - if err != nil || applied.Transition.Apply.State != sdd.MutationApplied { - t.Fatalf("recovery apply = %+v, %v", applied, err) - } - history, err := application.ListRecoveries(t.Context(), identity, "example", true) - if err != nil || len(history.Items) != 1 || history.Items[0].State != sdd.RecoveryDelivered || history.Items[0].Actionable() { - t.Fatalf("recovery apply history = %+v, %v", history, err) - } -} - -func TestPreparedTransitionRejectsEmptyTargetAndStructuredDivergence(t *testing.T) { - application, sessions, graph := newDurableApplication(t, time.Now, nil, nil) - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "validate-prepared") - - emptyTarget := preparedEntry(t, graph.GraphStore, binding, "empty-target", "2026/07/13-052301-s-tac-emp.md") - emptyTarget.Target.Branch = "" - if _, err := application.ApplyPrepared(t.Context(), identity, "example", binding, emptyTarget); errorCode(err) != sdd.ErrorWriteDenied { - t.Fatalf("empty target error = %v", err) - } - stored, err := sessions.Load(t.Context(), binding.SessionID) - if err != nil || len(stored.Events) != 0 { - t.Fatalf("empty target persisted events = %d, %v", len(stored.Events), err) - } - foreignTarget := preparedEntry(t, graph.GraphStore, binding, "foreign-target", "2026/07/13-052306-s-tac-for.md") - foreignTarget.Target.Project = "connected.example/foreign" - if _, err := application.ApplyPrepared(t.Context(), identity, "example", binding, foreignTarget); errorCode(err) != sdd.ErrorWriteDenied { - t.Fatalf("connected target exposure error = %v", err) - } - stored, err = sessions.Load(t.Context(), binding.SessionID) - if err != nil || len(stored.Events) != 0 { - t.Fatalf("foreign target persisted events = %d, %v", len(stored.Events), err) - } - - diverged := preparedEntry(t, graph.GraphStore, binding, "diverged", "2026/07/13-052302-s-tac-div.md") - diverged.Batch.Changes[0].Document.Body = "Different structured body." - diverged.Batch.Digest, err = sdd.MutationBatchDigest(diverged.Batch) - if err != nil { - t.Fatal(err) - } - result, err := application.ApplyPrepared(t.Context(), identity, "example", binding, diverged) - if errorCode(err) != sdd.ErrorRecoveryRequired || result.Apply.State != sdd.MutationNotApplied || !strings.Contains(err.Error(), "structured entry and canonical bytes diverge") { - t.Fatalf("structured divergence = %+v, %v", result, err) - } -} - func TestCreateEntryResolvesConcreteDefaultWithoutCWDAndReleasesAroundLLM(t *testing.T) { graphDir := t.TempDir() graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: graphDir}) @@ -419,318 +140,6 @@ func TestCreateEntryResolvesConcreteDefaultWithoutCWDAndReleasesAroundLLM(t *tes } } -func TestPreparedRevalidationToleratesJSONRoundTripScalarTypes(t *testing.T) { - application, sessions, graph := newDurableApplication(t, time.Now, nil, nil) - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "scalar-round-trip") - prepared := preparedEntry(t, graph.GraphStore, binding, "scalar-round-trip", "2026/07/13-052305-s-tac-sca.md") - prepared.Batch.Changes[0].CanonicalBytes = []byte("---\ntype: signal\nkind: done\nlayer: tactical\nsummary: Durable transition fixture.\ntime: 2026-07-13T05:23:05Z\n---\n\nDurable transition fixture body.\n") - prepared.Batch.Changes[0].Document.Frontmatter["time"] = "2026-07-13T05:23:05Z" - digest, err := sdd.MutationBatchDigest(prepared.Batch) - if err != nil { - t.Fatal(err) - } - prepared.Batch.Digest = digest - result, err := application.ApplyPrepared(t.Context(), identity, "example", binding, prepared) - if err != nil || result.Apply.State != sdd.MutationApplied { - t.Fatalf("scalar round-trip apply = %+v, %v", result, err) - } -} - -func TestPreparedAttachmentCrossesHomeStagingIntoTargetGraph(t *testing.T) { - home, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - target, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - targets := &mutableTargetAcquirer{graph: target} - application, sessions := newDurableApplicationWithHomeAndTargets(t, home, targets) - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "cross-target-attachment") - owner := sdd.SessionRef{Subject: binding.Subject, Session: binding.SessionID} - want := []byte("evidence from the home session\n") - blob, err := application.StageBlob(t.Context(), identity, "example", owner, "evidence.txt", want) - if err != nil { - t.Fatal(err) - } - prepared := preparedEntry(t, target, binding, "cross-target", "2026/07/13-052303-s-tac-att.md") - prepared.Target.Branch = "work" - prepared.BlobIDs = []string{blob.ID} - prepared.Batch.Attachments = []sdd.AttachmentMaterialization{{ - BlobID: blob.ID, Digest: sdd.BlobDigest{Algorithm: "sha256", Value: fmt.Sprintf("%x", sha256.Sum256(want))}, Size: blob.Size, SourceName: blob.Filename, - LogicalPath: "2026/07/13-052303-s-tac-att/evidence.txt", - }} - prepared.Batch.Digest, err = sdd.MutationBatchDigest(prepared.Batch) - if err != nil { - t.Fatal(err) - } - result, err := application.ApplyPrepared(t.Context(), identity, "example", binding, prepared) - if err != nil || result.Apply.State != sdd.MutationApplied { - t.Fatalf("cross-target apply = %+v, %v", result, err) - } - page, err := target.ReadAttachmentPage(t.Context(), "20260713-052303-s-tac-att", "evidence.txt", 0, 1024) - if err != nil || string(page.Content) != string(want) { - t.Fatalf("target attachment = %q, %v", page.Content, err) - } - if _, err := home.ReadAttachmentPage(t.Context(), "20260713-052303-s-tac-att", "evidence.txt", 0, 1024); err == nil { - t.Fatal("cross-target attachment was written to the home graph") - } -} - -func TestLegacyIntentRequiresAuthorizedAuditedTargetBinding(t *testing.T) { - authorizer := &recordingRecoveryAuthorizer{} - application, sessions, graph := newDurableApplication(t, time.Now, nil, nil, authorizer) - metadata := sdd.SessionMetadata{ID: "legacy-v1", Subject: "christopher", Project: "example"} - stored, err := sessions.Create(t.Context(), metadata) - if err != nil { - t.Fatal(err) - } - binding := sdd.SessionBinding{SessionID: metadata.ID, Subject: metadata.Subject, Project: metadata.Project, Version: stored.Version} - prepared := preparedEntry(t, graph.GraphStore, binding, "legacy-v1", "2026/07/13-052304-s-tac-leg.md") - prepared.Version = sdd.LegacyPreparedTransitionVersion - prepared.Target = sdd.MutationTarget{} - payload, err := json.Marshal(map[string]any{"prepared": prepared}) - if err != nil { - t.Fatal(err) - } - if _, err := sessions.Append(t.Context(), metadata.ID, stored.Version, sdd.SessionAppend{Events: []sdd.StoredEvent{{ - CodecVersion: sdd.SessionCodecVersion, Code: "mutation_intent", Payload: payload, - }}}); err != nil { - t.Fatal(err) - } - - list, err := application.ListRecoveries(t.Context(), sdd.RequestIdentity{Subject: "christopher"}, "example", false) - if err != nil || len(list.Items) != 1 || !list.Items[0].LegacyUnroutable || list.Items[0].Target.Branch != "" { - t.Fatalf("legacy projection = %+v, %v", list, err) - } - boundTarget, err := application.RecoverMutation(t.Context(), sdd.RequestIdentity{Subject: "christopher"}, sdd.RecoveryRequest{ - Session: metadata.ID, MutationID: prepared.Batch.ID, Verb: sdd.RecoveryBindTarget, - Target: sdd.MutationTarget{Project: "example", Branch: "main"}, Reason: "operator selected the historical branch", - }) - if err != nil || boundTarget.Item.LegacyUnroutable || boundTarget.Item.Target.Branch != "main" { - t.Fatalf("bind target = %+v, %v", boundTarget, err) - } - authorizer.mu.Lock() - request := authorizer.request - authorizer.mu.Unlock() - if request.Verb != sdd.RecoveryBindTarget || request.Target.Branch != "main" { - t.Fatalf("bind authorization = %+v", request) - } - stored, err = sessions.Load(t.Context(), metadata.ID) - if err != nil { - t.Fatal(err) - } - if !sessionHasEvent(stored.Events, "legacy_target_bound", `"actor":"christopher"`) || !sessionHasEvent(stored.Events, "legacy_target_bound", `"reason":"operator selected the historical branch"`) { - t.Fatalf("legacy binding audit = %+v", stored.Events) - } - refreshed, err := application.ReconcileMutation(t.Context(), sdd.RequestIdentity{Subject: "christopher"}, sdd.RecoveryReconcileRequest{Session: metadata.ID, MutationID: prepared.Batch.ID}) - if err != nil || refreshed.Item.State != sdd.RecoveryPending || refreshed.Item.Reason != sdd.RecoveryReasonNotApplied { - t.Fatalf("bound legacy reconciliation = %+v, %v", refreshed, err) - } -} - -func TestRecoveryNonApplyPathsSurfaceTargetReleaseErrors(t *testing.T) { - t.Run("discard", func(t *testing.T) { - graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - targets := &mutableTargetAcquirer{graph: graph} - application, sessions := newDurableApplicationWithTargets(t, graph, targets) - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "release-discard") - // A structurally diverged intent files a discardable not-applied - // recovery without ever reaching the graph store. - prepared := preparedEntry(t, graph, binding, "release-discard", "2026/07/13-052310-s-tac-dis.md") - prepared.Batch.Changes[0].Document.Body = "Diverged structured body." - prepared.Batch.Digest, err = sdd.MutationBatchDigest(prepared.Batch) - if err != nil { - t.Fatal(err) - } - result, err := application.ApplyPrepared(t.Context(), identity, "example", binding, prepared) - if errorCode(err) != sdd.ErrorRecoveryRequired { - t.Fatalf("diverged apply = %+v, %v", result, err) - } - targets.setReleaseError(errors.New("injected target release failure")) - discarded, err := application.RecoverMutation(t.Context(), identity, sdd.RecoveryRequest{Session: result.Binding.SessionID, MutationID: prepared.Batch.ID, Verb: sdd.RecoveryDiscard}) - if discarded.Item.State != sdd.RecoveryAbandoned || discarded.Item.Reason != sdd.RecoveryReasonDiscarded || err == nil || !strings.Contains(err.Error(), "injected target release failure") { - t.Fatalf("discard = %+v, %v", discarded, err) - } - }) - - t.Run("abandon unknown", func(t *testing.T) { - base, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - graph := &pendingUnknownStore{GraphStore: base} - targets := &mutableTargetAcquirer{graph: graph} - application, sessions := newDurableApplicationWithTargets(t, graph, targets) - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "release-abandon") - prepared := preparedEntry(t, base, binding, "release-abandon", "2026/07/13-052320-s-tac-abn.md") - result, err := application.ApplyPrepared(t.Context(), identity, "example", binding, prepared) - if errorCode(err) != sdd.ErrorRecoveryRequired { - t.Fatalf("unknown apply = %+v, %v", result, err) - } - targets.setReleaseError(errors.New("injected target release failure")) - abandoned, err := application.RecoverMutation(t.Context(), identity, sdd.RecoveryRequest{Session: result.Binding.SessionID, MutationID: prepared.Batch.ID, Verb: sdd.RecoveryAbandonUnknown}) - if abandoned.Item.State != sdd.RecoveryAbandoned || abandoned.Item.Reason != sdd.RecoveryReasonAbandonedUnknown || err == nil || !strings.Contains(err.Error(), "injected target release failure") { - t.Fatalf("abandon = %+v, %v", abandoned, err) - } - }) - - t.Run("finalize retry", func(t *testing.T) { - base, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - graph := unknownAfterApplyStore{GraphStore: base} - finalizer := &failOnceFinalizer{} - targets := &mutableTargetAcquirer{graph: graph, finalizers: []sdd.MutationFinalizer{finalizer}} - application, sessions := newDurableApplicationWithTargets(t, graph, targets) - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "release-finalize") - prepared := preparedEntry(t, base, binding, "release-finalize", "2026/07/13-052330-s-tac-fin.md") - result, err := application.ApplyPrepared(t.Context(), identity, "example", binding, prepared) - if errorCode(err) != sdd.ErrorRecoveryRequired { - t.Fatalf("unknown apply = %+v, %v", result, err) - } - targets.setReleaseError(errors.New("injected target release failure")) - _, err = application.RecoverMutation(t.Context(), identity, sdd.RecoveryRequest{Session: result.Binding.SessionID, MutationID: prepared.Batch.ID, Verb: sdd.RecoveryFinalizeRetry}) - if err == nil || !strings.Contains(err.Error(), "injected target release failure") { - t.Fatalf("finalize retry error = %v", err) - } - }) -} - -func TestReadSurfacesNeverReplayPendingMutation(t *testing.T) { - var pendingStore *pendingUnknownStore - application, sessions, graph := newDurableApplication(t, time.Now, func(store sdd.GraphStore) sdd.GraphStore { - pendingStore = &pendingUnknownStore{GraphStore: store} - return pendingStore - }, nil) - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "no-replay") - prepared := preparedEntry(t, graph.GraphStore, binding, "pending-unknown", "2026/07/13-052500-s-tac-unk.md") - result, err := application.ApplyPrepared(t.Context(), identity, "example", binding, prepared) - if errorCode(err) != sdd.ErrorRecoveryRequired || result.Apply.State != sdd.MutationUnknown { - t.Fatalf("pending ApplyPrepared = %+v, %v", result, err) - } - if _, err := application.ListRecoveries(t.Context(), identity, "example", false); err != nil { - t.Fatal(err) - } - info, err := application.Info(t.Context(), identity, "example", sdd.InfoRequest{}) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(info.Recovery, "pending-unknown") || !strings.Contains(info.Recovery, string(sdd.RecoveryReasonOutcomeUnknown)) { - t.Fatalf("Info recovery notice = %q", info.Recovery) - } - view, err := application.View(t.Context(), identity, "example", sdd.ViewRequest{Layout: "active:as-list"}) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(view.Sections, "pending-unknown") { - t.Fatalf("View recovery notice = %q", view.Sections) - } - pendingStore.mu.Lock() - reconciles := pendingStore.reconciles - pendingStore.mu.Unlock() - if reconciles != 0 { - t.Fatalf("read surfaces reconciled %d times", reconciles) - } - if _, err := application.RecoverMutation(t.Context(), identity, sdd.RecoveryRequest{ - Session: result.Binding.SessionID, MutationID: prepared.Batch.ID, Verb: sdd.RecoveryApply, - }); errorCode(err) != sdd.ErrorRecoveryRequired { - t.Fatalf("apply after unknown reconciliation error = %v", err) - } - if _, err := application.RecoverMutation(t.Context(), identity, sdd.RecoveryRequest{ - Session: result.Binding.SessionID, MutationID: prepared.Batch.ID, Verb: sdd.RecoveryDiscard, - }); errorCode(err) != sdd.ErrorRecoveryRequired { - t.Fatalf("discard after unknown reconciliation error = %v", err) - } - abandoned, err := application.RecoverMutation(t.Context(), identity, sdd.RecoveryRequest{ - Session: result.Binding.SessionID, MutationID: prepared.Batch.ID, Verb: sdd.RecoveryAbandonUnknown, Reason: "operator accepts unknown history", - }) - if err != nil || abandoned.Item.State != sdd.RecoveryAbandoned || abandoned.Item.Reason != sdd.RecoveryReasonAbandonedUnknown { - t.Fatalf("abandon unknown = %+v, %v", abandoned, err) - } -} - -func TestRecoveryAuthorizationReceivesActorOwnerTargetAndDistinctVerb(t *testing.T) { - authorizer := &recordingRecoveryAuthorizer{} - application, sessions, graph := newDurableApplication(t, time.Now, nil, nil, authorizer) - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "authorize-recovery") - // A structurally diverged intent yields an actionable recovery item to - // discard, without depending on a graph revision race. - prepared := preparedEntry(t, graph.GraphStore, binding, "authorize-discard", "2026/07/13-052600-s-tac-aut.md") - prepared.Batch.Changes[0].Document.Body = "Diverged structured body." - digest, err := sdd.MutationBatchDigest(prepared.Batch) - if err != nil { - t.Fatal(err) - } - prepared.Batch.Digest = digest - result, err := application.ApplyPrepared(t.Context(), identity, "example", binding, prepared) - if errorCode(err) != sdd.ErrorRecoveryRequired { - t.Fatalf("diverged apply = %+v, %v", result, err) - } - if _, err := application.RecoverMutation(t.Context(), identity, sdd.RecoveryRequest{ - Session: result.Binding.SessionID, MutationID: prepared.Batch.ID, Verb: sdd.RecoveryDiscard, Reason: "operator chose discard", - }); err != nil { - t.Fatal(err) - } - authorizer.mu.Lock() - request := authorizer.request - authorizer.mu.Unlock() - if request.Actor.Subject != "christopher" || request.OriginalSubject != "christopher" || request.OriginalSession != "authorize-recovery" || request.Target.Branch != "main" || request.Verb != sdd.RecoveryDiscard { - t.Fatalf("recovery authorization request = %+v", request) - } -} - -func TestPreparedTransitionSurfacesIntentAppendFailure(t *testing.T) { - graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: t.TempDir()}) - if err != nil { - t.Fatal(err) - } - baseSessions, err := localadapter.NewFilesystemSessionStoreAt(t.TempDir()) - if err != nil { - t.Fatal(err) - } - sessions := &toggleAppendSessionStore{SessionStore: baseSessions} - baseBlobs, err := localadapter.NewFilesystemStagedBlobStoreAt(t.TempDir()) - if err != nil { - t.Fatal(err) - } - blobs := baseBlobs - runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ - Project: sdd.ProjectRef{ID: "example"}, DefaultBranch: "main", Graph: graph, - LLM: pkgllm.RunnerFunc(func(context.Context, pkgllm.Request) (pkgllm.Result, error) { - return pkgllm.Result{Identity: pkgllm.Identity{Provider: "test", Model: "test"}}, nil - }), - }) - if err != nil { - t.Fatal(err) - } - application, err := sdd.NewApplication(sdd.ApplicationOptions{Access: &runtimeAccessResolver{runtime: runtime}, Sessions: sessions, StagedBlobs: blobs}) - if err != nil { - t.Fatal(err) - } - identity := sdd.RequestIdentity{Subject: "christopher"} - binding := openBinding(t, sessions, identity.Subject, "append-failure") - prepared := preparedEntry(t, graph, binding, "append-failure", "2026/07/13-053000-s-tac-fai.md") - sessions.fail(errors.New("injected intent append failure")) - - _, err = application.ApplyPrepared(t.Context(), identity, "example", binding, prepared) - if err == nil || !strings.Contains(err.Error(), "injected intent append failure") { - t.Fatalf("ApplyPrepared error = %v, want the intent append failure", err) - } -} - func TestSessionReplayFailsClosedForUnsupportedCodec(t *testing.T) { application, sessions, _ := newDurableApplication(t, time.Now, nil, nil) created, err := sessions.Create(t.Context(), sdd.SessionMetadata{ID: "future", Subject: "christopher", Project: "example"}) @@ -755,7 +164,7 @@ type graphFixture struct { dir string } -func newDurableApplication(t *testing.T, now func() time.Time, wrap func(sdd.GraphStore) sdd.GraphStore, finalizers []sdd.MutationFinalizer, authorizers ...sdd.RecoveryAuthorizer) (*sdd.Application, *localadapter.FilesystemSessionStore, graphFixture) { +func newDurableApplication(t *testing.T, now func() time.Time, wrap func(sdd.GraphStore) sdd.GraphStore, finalizers []sdd.MutationFinalizer) (*sdd.Application, *localadapter.FilesystemSessionStore, graphFixture) { t.Helper() dir := t.TempDir() baseGraph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: dir}) @@ -775,13 +184,8 @@ func newDurableApplication(t *testing.T, now func() time.Time, wrap func(sdd.Gra t.Fatal(err) } blobs := baseBlobs - authorizer := sdd.RecoveryAuthorizer(sdd.RecoveryAuthorizerFunc(func(context.Context, sdd.RecoveryAccessRequest) error { return nil })) - if len(authorizers) > 0 { - authorizer = authorizers[0] - } runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ Project: sdd.ProjectRef{ID: "example"}, DefaultBranch: "main", Graph: graph, Finalizers: finalizers, - Recovery: authorizer, LLM: pkgllm.RunnerFunc(func(context.Context, pkgllm.Request) (pkgllm.Result, error) { return pkgllm.Result{Identity: pkgllm.Identity{Provider: "test", Model: "test"}}, nil }), @@ -796,73 +200,3 @@ func newDurableApplication(t *testing.T, now func() time.Time, wrap func(sdd.Gra } return application, sessions, graphFixture{GraphStore: baseGraph, dir: dir} } - -func newDurableApplicationWithTargets(t *testing.T, graph sdd.GraphStore, targets sdd.TargetAcquirer) (*sdd.Application, *localadapter.FilesystemSessionStore) { - return newDurableApplicationWithHomeAndTargets(t, graph, targets) -} - -func newDurableApplicationWithHomeAndTargets(t *testing.T, home sdd.GraphStore, targets sdd.TargetAcquirer) (*sdd.Application, *localadapter.FilesystemSessionStore) { - t.Helper() - sessions, err := localadapter.NewFilesystemSessionStoreAt(t.TempDir()) - if err != nil { - t.Fatal(err) - } - baseBlobs, err := localadapter.NewFilesystemStagedBlobStoreAt(t.TempDir()) - if err != nil { - t.Fatal(err) - } - blobs := baseBlobs - runtime, err := sdd.NewProjectRuntime(sdd.ProjectRuntimeOptions{ - 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 - }), - }) - if err != nil { - t.Fatal(err) - } - application, err := sdd.NewApplication(sdd.ApplicationOptions{Access: &runtimeAccessResolver{runtime: runtime}, Sessions: sessions, StagedBlobs: blobs}) - if err != nil { - t.Fatal(err) - } - return application, sessions -} - -func sessionHasEvent(events []sdd.StoredEvent, code, payloadFragment string) bool { - for _, event := range events { - if event.Code == code && strings.Contains(string(event.Payload), payloadFragment) { - return true - } - } - return false -} - -func preparedEntry(t *testing.T, graph sdd.GraphStore, binding sdd.SessionBinding, id, path string) sdd.PreparedTransition { - t.Helper() - snapshot, err := graph.Current(t.Context()) - if err != nil { - t.Fatal(err) - } - canonical := []byte("---\ntype: signal\nkind: done\nlayer: tactical\nsummary: Durable transition fixture.\n---\n\nDurable transition fixture body.\n") - document := sdd.EntryDocument{LogicalPath: path, Frontmatter: map[string]any{ - "type": "signal", "kind": "done", "layer": "tactical", "summary": "Durable transition fixture.", - }, Body: "Durable transition fixture body."} - batch := sdd.MutationBatch{ID: id, Changes: []sdd.DocumentChange{{LogicalPath: path, Document: &document, CanonicalBytes: canonical}}} - digest, err := sdd.MutationBatchDigest(batch) - if err != nil { - t.Fatal(err) - } - batch.Digest = digest - return sdd.PreparedTransition{ - Version: sdd.PreparedTransitionVersion, Target: sdd.MutationTarget{Project: "example", Branch: "main"}, ExpectedGraphRevision: snapshot.Revision(), Batch: batch, - Staged: sdd.SessionRef{Subject: binding.Subject, Session: binding.SessionID}, - } -} - -func errorCode(err error) sdd.ErrorCode { - if applicationErr, ok := errors.AsType[*sdd.ApplicationError](err); ok { - return applicationErr.Code - } - return "" -} diff --git a/pkg/application/target.go b/pkg/application/target.go index 442e1458..1967dc6a 100644 --- a/pkg/application/target.go +++ b/pkg/application/target.go @@ -128,35 +128,3 @@ func (a FixedTargetAcquirer) Acquire(_ context.Context, target MutationTarget) ( Release: func() error { return nil }, }, nil } - -// RecoveryVerb is deliberately finer-grained than write access. Runtime -// compositions authorize each recovery action and the nonterminal reconcile -// refresh afresh. -type RecoveryVerb string - -const ( - RecoveryReconcile RecoveryVerb = "reconcile" - RecoveryApply RecoveryVerb = "apply" - RecoveryDiscard RecoveryVerb = "discard" - RecoveryFinalizeRetry RecoveryVerb = "finalize-retry" - RecoveryAbandonUnknown RecoveryVerb = "abandon-unknown" - RecoveryBindTarget RecoveryVerb = "bind-target" -) - -type RecoveryAccessRequest struct { - Actor Principal - Target MutationTarget - Verb RecoveryVerb - OriginalSubject string - OriginalSession SessionID -} - -type RecoveryAuthorizer interface { - AuthorizeRecovery(context.Context, RecoveryAccessRequest) error -} - -type RecoveryAuthorizerFunc func(context.Context, RecoveryAccessRequest) error - -func (f RecoveryAuthorizerFunc) AuthorizeRecovery(ctx context.Context, request RecoveryAccessRequest) error { - return f(ctx, request) -} diff --git a/pkg/application/transition.go b/pkg/application/transition.go deleted file mode 100644 index 5ad39821..00000000 --- a/pkg/application/transition.go +++ /dev/null @@ -1,270 +0,0 @@ -package application - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" -) - -const ( - LegacyPreparedTransitionVersion uint32 = 1 - PreparedTransitionVersion uint32 = 2 -) - -const ( - eventMutationIntent = "mutation_intent" - eventMutationOutcome = "mutation_outcome" - eventFinalizerOutcome = "finalizer_outcome" -) - -// maxApplyAttempts bounds the read-fresh → revalidate → apply loop: the graph -// adapter's lock is per-call, so a concurrent process can move the revision -// between our fresh read and the CAS apply. -const maxApplyAttempts = 3 - -// PreparedTransition is the storage-neutral write-gate output. It contains -// only pinned v1 facts; adapters never reconstruct application intent. -type PreparedTransition struct { - Version uint32 - Target MutationTarget - // ExpectedGraphRevision is prepare-time provenance only. The apply CAS - // operand is the freshly revalidated revision (see applyOnAcquired), so a - // concurrent unrelated append merges cleanly instead of failing the pin. - ExpectedGraphRevision string - Batch MutationBatch - // Staged keeps its persisted name so an in-flight intent stays replayable - // across an upgrade. - Staged SessionRef `json:"BlobOwner"` - BlobIDs []string -} - -type FinalizerOutcome struct { - Name string - Succeeded bool - Message string -} - -type TransitionResult struct { - Project ProjectRef - Binding SessionBinding - Apply ApplyResult - Finalizers []FinalizerOutcome -} - -type mutationIntentEvent struct { - Prepared PreparedTransition `json:"prepared"` -} - -type mutationOutcomeEvent struct { - MutationID string `json:"mutation_id"` - Digest string `json:"digest"` - Apply ApplyResult `json:"apply"` -} - -type finalizerOutcomeEvent struct { - MutationID string `json:"mutation_id"` - Outcome FinalizerOutcome `json:"outcome"` -} - -// ApplyPrepared durably records intent before canonical apply and outcome -// afterward. Staged bytes remain available until the session is collected. -func (a *Application) ApplyPrepared(ctx context.Context, identity RequestIdentity, project ProjectID, binding SessionBinding, prepared PreparedTransition) (TransitionResult, error) { - principal, runtime, err := a.resolve(ctx, identity, project, AccessWrite) - if err != nil { - return TransitionResult{}, err - } - if err := validatePreparedTransition(prepared, principal, binding, runtime.options.Project.ID); err != nil { - return TransitionResult{}, err - } - stored, err := a.sessions.Load(ctx, binding.SessionID) - if err != nil { - return TransitionResult{}, err - } - if err := verifyBinding(stored, binding); err != nil { - return TransitionResult{}, err - } - intent, err := storedEvent(eventMutationIntent, mutationIntentEvent{Prepared: prepared}) - if err != nil { - return TransitionResult{}, err - } - version, err := a.sessions.Append(ctx, binding.SessionID, stored.Version, SessionAppend{Events: []StoredEvent{intent}}) - if err != nil { - return TransitionResult{}, err - } - binding.Version = version - result := TransitionResult{Project: runtime.options.Project, Binding: binding, Apply: ApplyResult{State: MutationUnknown}} - acquired, err := runtime.acquire(ctx, prepared.Target) - if err != nil { - return result, &ApplicationError{Code: ErrorRecoveryRequired, Message: "mutation target could not be acquired after intent persistence", Cause: err} - } - return a.applyOnAcquired(ctx, runtime, acquired, binding, prepared, nil, principal.Subject, RecoveryApply) -} - -func (a *Application) applyOnAcquired(ctx context.Context, runtime *ProjectRuntime, acquired *AcquiredTarget, binding SessionBinding, prepared PreparedTransition, prior map[string]FinalizerOutcome, actor string, terminalVerb RecoveryVerb) (result TransitionResult, err error) { - defer func() { - if releaseErr := acquired.Release(); releaseErr != nil { - err = errors.Join(err, fmt.Errorf("releasing mutation target %s: %w", prepared.Target.Branch, releaseErr)) - } - }() - var apply ApplyResult - var applyErr error - for attempt := 1; ; attempt++ { - 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} - } - if revalidateErr := revalidatePreparedTransition(ctx, snapshot, prepared); revalidateErr != nil { - return TransitionResult{Project: runtime.options.Project, Binding: binding, Apply: ApplyResult{State: MutationNotApplied, Revision: snapshot.Revision()}}, revalidateErr - } - apply, applyErr = acquired.Graph.Apply(ctx, snapshot.Revision(), prepared.Batch, ownedBlobReader{store: a.blobs, ref: prepared.Staged}) - if !isGraphConflict(applyErr) || attempt >= maxApplyAttempts { - break - } - } - return a.finishTransition(ctx, runtime, acquired, binding, prepared, apply, applyErr, prior, actor, terminalVerb) -} - -func (a *Application) finishTransition(ctx context.Context, runtime *ProjectRuntime, acquired *AcquiredTarget, binding SessionBinding, prepared PreparedTransition, apply ApplyResult, applyErr error, prior map[string]FinalizerOutcome, actor string, terminalVerb RecoveryVerb) (TransitionResult, error) { - if prior == nil { - prior = map[string]FinalizerOutcome{} - } - result := TransitionResult{Project: runtime.options.Project, Binding: binding, Apply: apply} - if isGraphConflict(applyErr) { - return a.discardContendedTransition(ctx, result, prepared, actor) - } - if apply.State != MutationUnknown { - outcome, err := storedEvent(eventMutationOutcome, mutationOutcomeEvent{MutationID: prepared.Batch.ID, Digest: prepared.Batch.Digest, Apply: apply}) - if err != nil { - return result, err - } - next, err := a.sessions.Append(ctx, binding.SessionID, result.Binding.Version, SessionAppend{Events: []StoredEvent{outcome}}) - if err != nil { - return result, &ApplicationError{Code: ErrorRecoveryRequired, Message: "canonical mutation outcome was not persisted", ApplyState: apply.State, Revision: apply.Revision, Cause: err} - } - result.Binding.Version = next - } - if apply.State == MutationApplied { - for _, finalizer := range acquired.Finalizers { - if previous, ok := prior[finalizer.Name()]; ok && previous.Succeeded { - result.Finalizers = append(result.Finalizers, previous) - continue - } - outcome := FinalizerOutcome{Name: finalizer.Name(), Succeeded: true} - if err := finalizer.Finalize(ctx, AppliedMutation{Project: runtime.options.Project.ID, BatchID: prepared.Batch.ID, Revision: apply.Revision, Batch: prepared.Batch}); err != nil { - outcome.Succeeded = false - outcome.Message = err.Error() - } - event, err := storedEvent(eventFinalizerOutcome, finalizerOutcomeEvent{MutationID: prepared.Batch.ID, Outcome: outcome}) - if err != nil { - return result, err - } - next, err := a.sessions.Append(ctx, binding.SessionID, result.Binding.Version, SessionAppend{Events: []StoredEvent{event}}) - if err != nil { - return result, &ApplicationError{Code: ErrorRecoveryRequired, Message: "finalizer outcome was not persisted", Cause: err} - } - result.Binding.Version = next - result.Finalizers = append(result.Finalizers, outcome) - if !outcome.Succeeded { - return result, &ApplicationError{Code: ErrorRecoveryRequired, Message: "mutation finalizer failed", ApplyState: apply.State, Revision: apply.Revision} - } - } - } - if apply.State == MutationApplied { - next, err := appendRecoveryTerminal(ctx, a.sessions, result.Binding, recoveryTerminalEvent{ - MutationID: prepared.Batch.ID, Digest: prepared.Batch.Digest, Target: prepared.Target, - OriginalSubject: prepared.Staged.Subject, OriginalSession: prepared.Staged.Session, - Actor: actor, Verb: terminalVerb, - }) - if err != nil { - return result, &ApplicationError{Code: ErrorRecoveryRequired, Message: "recovered mutation outcome was not persisted", ApplyState: apply.State, Revision: apply.Revision, Cause: err} - } - result.Binding.Version = next - } - if apply.State == MutationUnknown { - return result, &ApplicationError{Code: ErrorRecoveryRequired, Message: "canonical mutation outcome is unknown", ApplyState: apply.State, Revision: apply.Revision, Cause: applyErr} - } - if apply.State == MutationNotApplied && applyErr == nil { - // A definitive not-applied with no error is a genuine awaiting-decision - // outcome (deduplicated or reconciled), never a revision conflict — - // conflicts short-circuit above and are re-tried, then closed. - return result, &ApplicationError{Code: ErrorRecoveryRequired, Message: "canonical mutation was not applied and awaits an explicit recovery decision", ApplyState: apply.State, Revision: apply.Revision} - } - if applyErr != nil { - return result, applyErr - } - return result, nil -} - -// isGraphConflict reports whether err is the adapter's typed revision-conflict -// signal — the graph moved under a CAS apply. -func isGraphConflict(err error) bool { - var appErr *ApplicationError - return errors.As(err, &appErr) && appErr.Code == ErrorGraphConflict -} - -// discardContendedTransition closes a durable intent whose bounded apply -// retries were all lost to concurrent writers. A revision conflict fails the -// CAS before any file write, so the intent carries no partial graph state: -// tear it down as a discard so it never surfaces as a pending recovery, and -// return the typed conflict inviting a plain re-try. -func (a *Application) discardContendedTransition(ctx context.Context, result TransitionResult, prepared PreparedTransition, actor string) (TransitionResult, error) { - next, err := a.recordRecoveryTerminal(ctx, result.Binding, recoveryTerminalEvent{ - MutationID: prepared.Batch.ID, Digest: prepared.Batch.Digest, Target: prepared.Target, - OriginalSubject: prepared.Staged.Subject, OriginalSession: prepared.Staged.Session, - Actor: actor, Verb: RecoveryDiscard, Cause: recoveryCauseGraphContention, - Reason: "graph contention: bounded apply retries exhausted", - }) - if err != nil { - return result, err - } - result.Binding.Version = next - return result, &ApplicationError{ - Code: ErrorGraphConflict, - Message: "the graph is being written concurrently and this change lost every apply retry; re-try the write", - Revision: result.Apply.Revision, - } -} - -func validatePreparedTransition(prepared PreparedTransition, principal Principal, binding SessionBinding, project ProjectID) error { - if prepared.Version != PreparedTransitionVersion { - return &ApplicationError{Code: ErrorMigrationRequired, Message: "unsupported prepared transition version", Version: prepared.Version} - } - // The binding's project is the session's home; the target project may be a - // dependency the instance works in (d-cpt-yjc), so only the principal and - // the staging provenance tie the transition to the session here. - if binding.Subject != principal.Subject || prepared.Staged.Subject != principal.Subject || prepared.Staged.Session != binding.SessionID { - return &ApplicationError{Code: ErrorSessionOwnership, Message: "prepared transition ownership mismatch"} - } - if err := prepared.Target.Validate(project); err != nil { - return err - } - digest, err := MutationBatchDigest(prepared.Batch) - if err != nil { - return err - } - if prepared.Batch.ID == "" || prepared.Batch.Digest == "" || prepared.Batch.Digest != digest { - return &ApplicationError{Code: ErrorRecoveryRequired, Message: "prepared mutation digest mismatch"} - } - return nil -} - -func storedEvent(code string, value any) (StoredEvent, error) { - payload, err := json.Marshal(value) - if err != nil { - return StoredEvent{}, err - } - return StoredEvent{CodecVersion: SessionCodecVersion, Code: code, Payload: payload}, nil -} - -type ownedBlobReader struct { - store StagedBlobStore - ref SessionRef -} - -func (r ownedBlobReader) Open(ctx context.Context, id string) (io.ReadCloser, error) { - return r.store.Open(ctx, r.ref, id) -} diff --git a/pkg/application/workflow.go b/pkg/application/workflow.go index 42bee708..611b0772 100644 --- a/pkg/application/workflow.go +++ b/pkg/application/workflow.go @@ -307,7 +307,7 @@ func (a *Application) LoadWorkflow(ctx context.Context, identity RequestIdentity if request.SessionID == "" { return nil, fmt.Errorf("sdd: session ID is required") } - principal, runtime, stored, err := a.resolveSession(ctx, identity, request.SessionID, AccessRead) + principal, runtime, stored, err := a.resolveSession(ctx, identity, request.SessionID) if err != nil { return nil, err } @@ -325,7 +325,7 @@ func (a *Application) LoadWorkflow(ctx context.Context, identity RequestIdentity // RefreshWorkflow replays an authorized session without changing its attachment stamp. func (a *Application) RefreshWorkflow(ctx context.Context, identity RequestIdentity, id SessionID) (*WorkflowSession, error) { - _, runtime, stored, err := a.resolveSession(ctx, identity, id, AccessRead) + _, runtime, stored, err := a.resolveSession(ctx, identity, id) if err != nil { return nil, err } @@ -831,7 +831,7 @@ func (w *WorkflowSession) BindBranch(ctx context.Context, identity RequestIdenti } func (w *WorkflowSession) bindBranchOnce(branch string, clear bool) error { - principal, runtime, stored, err := w.app.resolveSession(w.ctx, w.identity, w.ID(), AccessRead) + principal, runtime, stored, err := w.app.resolveSession(w.ctx, w.identity, w.ID()) if err != nil { return err } @@ -1086,7 +1086,7 @@ func (a *Application) AbandonWorkflowSession(ctx context.Context, identity Reque if request.SessionID == "" { return WorkflowAbandonResult{}, fmt.Errorf("sdd: session ID is required") } - _, runtime, stored, err := a.resolveSession(ctx, identity, request.SessionID, AccessRead) + _, runtime, stored, err := a.resolveSession(ctx, identity, request.SessionID) if err != nil { return WorkflowAbandonResult{}, err } @@ -1454,7 +1454,7 @@ func (w *WorkflowSession) appendStoredEvents(events []StoredEvent) error { // the store's so a retried append passes the version CAS; an ended session // surfaces typed instead of being written on. func (w *WorkflowSession) resyncBindingVersion() error { - _, _, stored, err := w.app.resolveSession(w.ctx, w.identity, w.ID(), AccessRead) + _, _, stored, err := w.app.resolveSession(w.ctx, w.identity, w.ID()) if err != nil { return err } @@ -1467,7 +1467,7 @@ func (w *WorkflowSession) resyncBindingVersion() error { } func (w *WorkflowSession) appendStoredEventsOnce(events []StoredEvent) error { - principal, _, stored, err := w.app.resolveSession(w.ctx, w.identity, w.ID(), AccessRead) + principal, _, stored, err := w.app.resolveSession(w.ctx, w.identity, w.ID()) if err != nil { return err } diff --git a/pkg/application/workflow_capture.go b/pkg/application/workflow_capture.go index 01a481f5..87d0719e 100644 --- a/pkg/application/workflow_capture.go +++ b/pkg/application/workflow_capture.go @@ -109,7 +109,7 @@ func (w *WorkflowSession) reportWorkflowNewEntryEffects(ctx *engine.Context) ([] } func (w *WorkflowSession) stagedAt(position uint64) (map[string]string, error) { - _, _, stored, err := w.app.resolveSession(w.ctx, w.identity, w.ID(), AccessRead) + _, _, stored, err := w.app.resolveSession(w.ctx, w.identity, w.ID()) if err != nil { return nil, err } @@ -150,7 +150,7 @@ func (w *WorkflowSession) verifyCapturePreflight(ctx *engine.Context) error { } func (w *WorkflowSession) capturePreflightState(ctx *engine.Context) (bool, bool, error) { - _, _, stored, err := w.app.resolveSession(w.ctx, w.identity, w.ID(), AccessRead) + _, _, stored, err := w.app.resolveSession(w.ctx, w.identity, w.ID()) if err != nil { return false, false, err } diff --git a/pkg/application/workflow_document.go b/pkg/application/workflow_document.go new file mode 100644 index 00000000..7dfcb26d --- /dev/null +++ b/pkg/application/workflow_document.go @@ -0,0 +1,191 @@ +package application + +import ( + "fmt" + "path/filepath" + + "github.com/networkteam/slogutils" + + "github.com/networkteam/sdd/internal/engine" + "github.com/networkteam/sdd/internal/model" +) + +// The entry-less graph writes of the base procedures — a summary replaced, a +// WIP marker created or removed — run like capture: identities and the +// precondition are allocated before the intent, the write publishes once under +// the intent's key, and the command reports what it left (d-tac-n47, +// d-tac-wgw, d-tac-7mh). + +// documentPublicationKey is a document write's storage identity: this +// session, the intent's position and the command with its resource. +func (w *WorkflowSession) documentPublicationKey(intent *engine.MutationIntent, resource string) PublicationKey { + return PublicationKey{Session: w.ID(), Sequence: intent.Ref, Discriminator: intent.Command + ":" + resource} +} + +func intentTarget(intent *engine.MutationIntent) MutationTarget { + return MutationTarget{Project: ProjectID(intent.Values["project"]), Branch: intent.Values["branch"]} +} + +// prepareWorkflowReplaceSummary records the entry, its target and the bytes +// the correction was read from, so the write conditions on that document. +func (w *WorkflowSession) prepareWorkflowReplaceSummary(ctx *engine.Context) (map[string]string, error) { + id, ok := workflowStoreString(ctx.Store, "entryId") + if !ok { + return nil, fmt.Errorf("replaceSummary: entryId is not set") + } + if _, ok := workflowStoreString(ctx.Store, "correctedSummary"); !ok { + return nil, fmt.Errorf("replaceSummary: correctedSummary is not set") + } + target, fromBinding := w.effectiveTargetFor(w.instanceProject(ctx.Instance), ctx.Store) + if err := w.authorizeTarget(target.Project, AccessWrite); err != nil { + return nil, err + } + logicalPath, err := model.IDToRelPath(id) + if err != nil { + return nil, err + } + current, err := w.app.readDocument(w.ctx, w.identity, target, filepath.ToSlash(logicalPath)) + if err != nil { + return nil, w.withSessionBindingTargetError(err, fromBinding) + } + if current.Absent { + return nil, fmt.Errorf("replaceSummary: entry %s is not on the target", id) + } + return map[string]string{"entryId": id, "project": string(target.Project), "branch": target.Branch, "expectedBlob": GitBlobID(current.Content)}, nil +} + +func (w *WorkflowSession) runWorkflowReplaceSummary(ctx *engine.Context) error { + text, ok := workflowStoreString(ctx.Store, "correctedSummary") + if !ok { + return fmt.Errorf("replaceSummary: correctedSummary is not set") + } + intent := ctx.Intent + _, err := w.app.ReplaceSummary(w.ctx, w.identity, w.instanceProject(ctx.Instance), w.binding, SummaryReplacement{ + Target: intentTarget(intent), Publication: w.documentPublicationKey(intent, intent.Values["entryId"]), + EntryID: intent.Values["entryId"], ExpectedBlob: intent.Values["expectedBlob"], Summary: text, + }) + return err +} + +// reportWorkflowReplaceSummaryEffects reports the entry as replaced when the +// intent's key published, unchanged when it did not, unknown when the store +// cannot answer. +func (w *WorkflowSession) reportWorkflowReplaceSummaryEffects(ctx *engine.Context) ([]engine.Effect, error) { + intent := ctx.Intent + if intent == nil { + return nil, fmt.Errorf("replaceSummary effects require a recorded invocation") + } + entryID := intent.Values["entryId"] + logicalPath, err := model.IDToRelPath(entryID) + if err != nil { + return nil, err + } + state := "unknown" + _, exists, err := w.app.lookupDocumentPublication(w.ctx, w.identity, intentTarget(intent), w.documentPublicationKey(intent, entryID), filepath.ToSlash(logicalPath)) + switch { + case err != nil: + slogutils.FromContext(w.ctx).Info("publication lookup for summary replacement failed", "entry", entryID, "error", err) + case exists: + state = "replaced" + default: + state = "unchanged" + } + return []engine.Effect{{Kind: "entry", ID: entryID, State: state}}, nil +} + +// prepareWorkflowWIPStart allocates the marker's identity before the intent. +func (w *WorkflowSession) prepareWorkflowWIPStart(ctx *engine.Context) (map[string]string, error) { + anchor, ok := workflowStoreString(ctx.Store, "anchor") + if !ok { + return nil, fmt.Errorf("wipStart: anchor is not set") + } + target, err := w.wipTarget(ctx) + if err != nil { + return nil, err + } + marker, err := w.app.WIPMarkerID(w.ctx, w.identity, target.Project) + if err != nil { + return nil, err + } + return map[string]string{"markerId": marker, "anchor": anchor, "project": string(target.Project), "branch": target.Branch}, nil +} + +func (w *WorkflowSession) runWorkflowWIPStart(ctx *engine.Context) error { + description, _ := workflowStoreString(ctx.Store, "wipDescription") + intent := ctx.Intent + marker := intent.Values["markerId"] + _, err := w.app.StartWIP(w.ctx, w.identity, w.instanceProject(ctx.Instance), w.binding, WIPMarkerWrite{ + Target: intentTarget(intent), Publication: w.documentPublicationKey(intent, marker), + MarkerID: marker, EntryID: intent.Values["anchor"], Description: description, + }) + if err != nil { + return err + } + return ctx.Store.WriteEngine("wipMarker", marker) +} + +// prepareWorkflowWIPDone records the marker the implementation run removes. +func (w *WorkflowSession) prepareWorkflowWIPDone(ctx *engine.Context) (map[string]string, error) { + marker, ok := workflowStoreString(ctx.Store, "wipMarker") + if !ok { + return nil, fmt.Errorf("wipDone: wipMarker is not set") + } + target, err := w.wipTarget(ctx) + if err != nil { + return nil, err + } + return map[string]string{"markerId": marker, "project": string(target.Project), "branch": target.Branch}, nil +} + +func (w *WorkflowSession) runWorkflowWIPDone(ctx *engine.Context) error { + if err := w.removeWIPMarker(ctx); err != nil { + return err + } + return ctx.Store.WriteEngine("wipMarker", nil) +} + +// prepareWorkflowWIPRemove records the stale marker groom removes; its target +// is the instance project's default branch. +func (w *WorkflowSession) prepareWorkflowWIPRemove(ctx *engine.Context) (map[string]string, error) { + marker, ok := workflowStoreString(ctx.Store, "staleMarker") + if !ok { + return nil, fmt.Errorf("wipRemove: staleMarker is not set") + } + project := w.instanceProject(ctx.Instance) + if err := w.authorizeTarget(project, AccessWrite); err != nil { + return nil, err + } + return map[string]string{"markerId": marker, "project": string(project), "branch": ""}, nil +} + +func (w *WorkflowSession) runWorkflowWIPRemove(ctx *engine.Context) error { + return w.removeWIPMarker(ctx) +} + +func (w *WorkflowSession) removeWIPMarker(ctx *engine.Context) error { + intent := ctx.Intent + marker := intent.Values["markerId"] + _, err := w.app.FinishWIP(w.ctx, w.identity, w.instanceProject(ctx.Instance), w.binding, intentTarget(intent), w.documentPublicationKey(intent, marker), marker) + return err +} + +// reportWorkflowWIPEffects reports the marker as present or absent on the +// target after the intent, reading it live; unknown when the store cannot answer. +func (w *WorkflowSession) reportWorkflowWIPEffects(ctx *engine.Context) ([]engine.Effect, error) { + intent := ctx.Intent + if intent == nil { + return nil, fmt.Errorf("WIP effects require a recorded invocation") + } + marker := intent.Values["markerId"] + state := "unknown" + current, err := w.app.readDocument(w.ctx, w.identity, intentTarget(intent), filepath.ToSlash(model.WIPMarkerPath(marker))) + switch { + case err != nil: + slogutils.FromContext(w.ctx).Info("WIP marker read for effects report failed", "marker", marker, "error", err) + case current.Absent: + state = "absent" + default: + state = "present" + } + return []engine.Effect{{Kind: "wip-marker", ID: marker, State: state}}, nil +} diff --git a/pkg/application/workflow_registry.go b/pkg/application/workflow_registry.go index bb04a6d2..164fc611 100644 --- a/pkg/application/workflow_registry.go +++ b/pkg/application/workflow_registry.go @@ -72,7 +72,7 @@ func (w *WorkflowSession) registerWorkflowQueries(registry *engine.Registry) err if err != nil { return nil, err } - return map[string]any{"participant": info.Participant, "language": info.Language, "search": info.Search, "recovery": info.Recovery}, nil + return map[string]any{"participant": info.Participant, "language": info.Language, "search": info.Search}, nil }, }); err != nil { return err @@ -107,15 +107,11 @@ func (w *WorkflowSession) registerWorkflowQueries(registry *engine.Registry) err if strings.TrimSpace(layout) == "" { return nil, fmt.Errorf("viewLayout needs arg layout") } - omitRecovery := false - if rec, ok := args["recovery"].(bool); ok && !rec { - omitRecovery = true - } view, err := w.graphs.viewFor(ctx.Store) if err != nil { return nil, err } - result, err := w.app.viewFromSnapshot(w.ctx, w.identity, view.runtime, view.snapshot, ViewRequest{Layout: layout, Budget: servedViewBudget, OmitRecovery: omitRecovery}) + result, err := w.app.viewFromSnapshot(w.ctx, w.identity, view.runtime, view.snapshot, ViewRequest{Layout: layout, Budget: servedViewBudget}) if err != nil { return nil, err } @@ -280,95 +276,43 @@ func (w *WorkflowSession) registerWorkflowWrites(registry *engine.Registry) erro return err } return registry.RegisterCommand(engine.Command{ - Doc: engine.FuncDoc{Name: "replaceSummary", Doc: "Writes the user-supplied corrected summary onto the entry named by entryId.", Reads: []string{"entryId", "correctedSummary"}}, - MutatesGraph: true, - Fn: func(ctx *engine.Context) error { - id, ok := workflowStoreString(ctx.Store, "entryId") - if !ok { - return fmt.Errorf("replaceSummary: entryId is not set") - } - text, ok := workflowStoreString(ctx.Store, "correctedSummary") - if !ok { - return fmt.Errorf("replaceSummary: correctedSummary is not set") - } - target, fromBinding := w.effectiveTargetFor(w.instanceProject(ctx.Instance), ctx.Store) - if err := w.authorizeTarget(target.Project, AccessWrite); err != nil { - return err - } - result, err := w.app.ReplaceSummary(w.ctx, w.identity, target.Project, w.binding, target, id, text) - err = w.withSessionBindingTargetError(err, fromBinding) - if err == nil { - w.binding = result.Binding - } - return err - }, + Doc: engine.FuncDoc{Name: "replaceSummary", Doc: "Writes the user-supplied corrected summary onto the entry named by entryId, conditioned on the document the correction was read from.", Reads: []string{"entryId", "correctedSummary"}}, + MutatesGraph: true, + GraphIndependent: true, + Prepare: w.prepareWorkflowReplaceSummary, + Fn: w.runWorkflowReplaceSummary, + Effects: w.reportWorkflowReplaceSummaryEffects, }) } func (w *WorkflowSession) registerWorkflowWIP(registry *engine.Registry) error { if err := registry.RegisterCommand(engine.Command{ - Doc: engine.FuncDoc{Name: "wipStart", Doc: "Creates an exclusive WIP marker for the store's anchor entry on baseBranch, described by wipDescription.", Reads: []string{"anchor", "baseBranch", "wipDescription", "participants"}, Writes: []string{"wipMarker"}}, - MutatesGraph: true, - Fn: func(ctx *engine.Context) error { - anchor, ok := workflowStoreString(ctx.Store, "anchor") - if !ok { - return fmt.Errorf("wipStart: anchor is not set") - } - description, _ := workflowStoreString(ctx.Store, "wipDescription") - target, err := w.wipTarget(ctx) - if err != nil { - return err - } - marker, result, err := w.app.StartWIP(w.ctx, w.identity, target.Project, w.binding, target, anchor, description) - if err != nil { - return err - } - w.binding = result.Binding - return ctx.Store.WriteEngine("wipMarker", marker) - }, + Doc: engine.FuncDoc{Name: "wipStart", Doc: "Creates an exclusive WIP marker for the store's anchor entry on baseBranch, described by wipDescription.", Reads: []string{"anchor", "baseBranch", "wipDescription", "participants"}, Writes: []string{"wipMarker"}}, + MutatesGraph: true, + GraphIndependent: true, + Prepare: w.prepareWorkflowWIPStart, + Fn: w.runWorkflowWIPStart, + Effects: w.reportWorkflowWIPEffects, }); err != nil { return err } if err := registry.RegisterCommand(engine.Command{ - Doc: engine.FuncDoc{Name: "wipDone", Doc: "Removes the WIP marker named by the store's wipMarker field from baseBranch.", Reads: []string{"wipMarker", "baseBranch"}, Writes: []string{"wipMarker"}}, - MutatesGraph: true, - Fn: func(ctx *engine.Context) error { - marker, ok := workflowStoreString(ctx.Store, "wipMarker") - if !ok { - return fmt.Errorf("wipDone: wipMarker is not set") - } - target, err := w.wipTarget(ctx) - if err != nil { - return err - } - result, err := w.app.FinishWIP(w.ctx, w.identity, target.Project, w.binding, target, marker) - if err != nil { - return err - } - w.binding = result.Binding - return ctx.Store.WriteEngine("wipMarker", nil) - }, + Doc: engine.FuncDoc{Name: "wipDone", Doc: "Removes the WIP marker named by the store's wipMarker field from baseBranch.", Reads: []string{"wipMarker", "baseBranch"}, Writes: []string{"wipMarker"}}, + MutatesGraph: true, + GraphIndependent: true, + Prepare: w.prepareWorkflowWIPDone, + Fn: w.runWorkflowWIPDone, + Effects: w.reportWorkflowWIPEffects, }); err != nil { return err } return registry.RegisterCommand(engine.Command{ - Doc: engine.FuncDoc{Name: "wipRemove", Doc: "Removes the WIP marker named by the store's staleMarker field (groom's orphaned-marker cleanup).", Reads: []string{"staleMarker"}}, - MutatesGraph: true, - Fn: func(ctx *engine.Context) error { - marker, ok := workflowStoreString(ctx.Store, "staleMarker") - if !ok { - return fmt.Errorf("wipRemove: staleMarker is not set") - } - project := w.instanceProject(ctx.Instance) - if err := w.authorizeTarget(project, AccessWrite); err != nil { - return err - } - result, err := w.app.FinishWIP(w.ctx, w.identity, project, w.binding, MutationTarget{}, marker) - if err == nil { - w.binding = result.Binding - } - return err - }, + Doc: engine.FuncDoc{Name: "wipRemove", Doc: "Removes the WIP marker named by the store's staleMarker field (groom's orphaned-marker cleanup).", Reads: []string{"staleMarker"}}, + MutatesGraph: true, + GraphIndependent: true, + Prepare: w.prepareWorkflowWIPRemove, + Fn: w.runWorkflowWIPRemove, + Effects: w.reportWorkflowWIPEffects, }) } diff --git a/pkg/application/workflow_target_graph_internal_test.go b/pkg/application/workflow_target_graph_internal_test.go index 0db7d0d8..522af4c6 100644 --- a/pkg/application/workflow_target_graph_internal_test.go +++ b/pkg/application/workflow_target_graph_internal_test.go @@ -36,12 +36,6 @@ func (s workflowTargetGraphStore) AcquireSnapshot(ctx context.Context, q Snapsho } func (s workflowTargetGraphStore) Current(context.Context) (*Snapshot, error) { return s.snapshot, nil } -func (workflowTargetGraphStore) Apply(context.Context, string, MutationBatch, StagedBlobReader) (ApplyResult, error) { - return ApplyResult{}, nil -} -func (workflowTargetGraphStore) Reconcile(context.Context, string, string) (ApplyResult, error) { - return ApplyResult{}, nil -} func (workflowTargetGraphStore) ReadAttachmentPage(context.Context, string, string, int64, int) (AttachmentPage, error) { return AttachmentPage{}, nil } @@ -443,7 +437,7 @@ func TestWorkflowWIPRequiresExplicitBaseBranchBeforeCallingApplication(t *testin if !ok { t.Fatalf("%s command is not registered", tt.command) } - err := command.Fn(&engine.Context{Store: workflowTargetStore(t, tt.values)}) + _, err := command.Prepare(&engine.Context{Store: workflowTargetStore(t, tt.values)}) if err == nil || err.Error() != "WIP write requires an explicit baseBranch" { t.Fatalf("%s error = %v", tt.command, err) } diff --git a/pkg/application/write_api.go b/pkg/application/write_api.go index 3ca895c6..3e16fd95 100644 --- a/pkg/application/write_api.go +++ b/pkg/application/write_api.go @@ -3,12 +3,8 @@ package application import ( "bytes" "context" - "crypto/rand" - "encoding/hex" - "errors" "fmt" "io" - "path/filepath" "strings" "time" @@ -112,11 +108,6 @@ type CreateEntryResult struct { Findings []Finding } -type MutationResult struct { - Project ProjectRef - Binding SessionBinding -} - // CurrentSnapshot resolves current read access and returns the opaque // canonical snapshot for protocol adapters that host SDD's engine. func (a *Application) CurrentSnapshot(ctx context.Context, identity RequestIdentity, project ProjectID) (*Snapshot, error) { @@ -155,96 +146,6 @@ func (a *Application) OpenStagedBlob(ctx context.Context, identity RequestIdenti return a.blobs.Open(ctx, ref, blobID) } -func (a *Application) ReplaceSummary(ctx context.Context, identity RequestIdentity, project ProjectID, binding SessionBinding, target MutationTarget, entryID, summary string) (MutationResult, error) { - _, runtime, err := a.resolve(ctx, identity, project, AccessWrite) - if err != nil { - return MutationResult{}, err - } - target, err = resolveMutationTarget(runtime, target) - if err != nil { - return MutationResult{}, err - } - snapshot, err := snapshotMutationTarget(ctx, runtime, target) - if err != nil { - return MutationResult{}, err - } - current, ok := snapshot.graph.ByID[entryID] - if !ok { - return MutationResult{}, fmt.Errorf("entry not found: %s", entryID) - } - entry := *current - entry.Summary = summary - path, err := model.IDToRelPath(entryID) - if err != nil { - return MutationResult{}, err - } - canonical := []byte(model.FormatFrontmatter(&entry) + "\n" + entry.Content + "\n") - mutationID, err := newMutationID("summary-" + entryID) - if err != nil { - return MutationResult{}, err - } - document, err := ParseEntryDocument(filepath.ToSlash(path), canonical) - if err != nil { - return MutationResult{}, err - } - return a.applyDocumentMutation(ctx, identity, runtime, binding, target, mutationID, "sdd: summarize "+entryID+" (manual)", DocumentChange{LogicalPath: filepath.ToSlash(path), Document: &document, CanonicalBytes: canonical}) -} - -func (a *Application) StartWIP(ctx context.Context, identity RequestIdentity, project ProjectID, binding SessionBinding, target MutationTarget, entryID, description string) (string, MutationResult, error) { - principal, runtime, err := a.resolve(ctx, identity, project, AccessWrite) - if err != nil { - return "", MutationResult{}, err - } - participant, err := a.participantFor(ctx, principal, runtime) - if err != nil { - return "", MutationResult{}, err - } - if participant == "" { - return "", MutationResult{}, fmt.Errorf("sdd: resolved participant is required to start WIP") - } - marker := &model.WIPMarker{ - ID: model.GenerateWIPMarkerID(participant), Entry: entryID, Participant: participant, - Exclusive: true, Content: description, Time: a.now(), - } - target, err = resolveMutationTarget(runtime, target) - if err != nil { - return "", MutationResult{}, err - } - result, err := a.applyDocumentMutation(ctx, identity, runtime, binding, target, "wip-start-"+marker.ID, fmt.Sprintf("sdd: wip start %s (%s)", entryID, participant), DocumentChange{ - LogicalPath: filepath.ToSlash(model.WIPMarkerPath(marker.ID)), CanonicalBytes: []byte(model.FormatWIPMarker(marker)), - }) - return marker.ID, result, err -} - -func (a *Application) FinishWIP(ctx context.Context, identity RequestIdentity, project ProjectID, binding SessionBinding, target MutationTarget, markerID string) (MutationResult, error) { - _, runtime, err := a.resolve(ctx, identity, project, AccessWrite) - if err != nil { - return MutationResult{}, err - } - target, err = resolveMutationTarget(runtime, target) - if err != nil { - return MutationResult{}, err - } - return a.applyDocumentMutation(ctx, identity, runtime, binding, target, "wip-done-"+markerID, "sdd: wip done "+markerID, DocumentChange{LogicalPath: filepath.ToSlash(model.WIPMarkerPath(markerID)), Delete: true}) -} - -func (a *Application) applyDocumentMutation(ctx context.Context, identity RequestIdentity, runtime *ProjectRuntime, binding SessionBinding, target MutationTarget, id, message string, change DocumentChange) (MutationResult, error) { - snapshot, err := snapshotMutationTarget(ctx, runtime, target) - if err != nil { - return MutationResult{}, err - } - batch := MutationBatch{ID: id, Message: message, Changes: []DocumentChange{change}} - batch.Digest, err = MutationBatchDigest(batch) - if err != nil { - return MutationResult{}, err - } - transition, err := a.ApplyPrepared(ctx, identity, runtime.options.Project.ID, binding, PreparedTransition{ - Version: PreparedTransitionVersion, Target: target, ExpectedGraphRevision: snapshot.Revision(), Batch: batch, - Staged: SessionRef{Subject: binding.Subject, Session: binding.SessionID}, - }) - return MutationResult{Project: runtime.options.Project, Binding: transition.Binding}, err -} - // resolveMutationTarget completes a target against the runtime it is written // through: an empty branch means the runtime's configured default, and a named // project must be the runtime's own. @@ -264,30 +165,6 @@ func resolveMutationTarget(runtime *ProjectRuntime, requested MutationTarget) (M return requested, nil } -func snapshotMutationTarget(ctx context.Context, runtime *ProjectRuntime, target MutationTarget) (snapshot *Snapshot, err error) { - acquired, err := runtime.acquire(ctx, target) - if err != nil { - return nil, err - } - defer func() { - if releaseErr := acquired.Release(); releaseErr != nil { - err = errors.Join(err, fmt.Errorf("releasing mutation target %s after snapshot: %w", target.Branch, releaseErr)) - } - }() - selectedRuntime := *runtime - selectedRuntime.options.Graph = acquired.Graph - snapshot, _, err = readMaterializedSnapshot(ctx, &selectedRuntime, "") - return snapshot, err -} - -func newMutationID(prefix string) (string, error) { - var random [8]byte - if _, err := rand.Read(random[:]); err != nil { - return "", fmt.Errorf("sdd: generating mutation ID: %w", err) - } - return prefix + "-" + hex.EncodeToString(random[:]), nil -} - // draftLayer expands an abbreviated layer to its canonical form. func draftLayer(layer string) model.Layer { if expanded, ok := model.LayerFromAbbrev[layer]; ok { diff --git a/pkg/application/write_fixture_test.go b/pkg/application/write_fixture_test.go index 2dc4135c..1a5360a9 100644 --- a/pkg/application/write_fixture_test.go +++ b/pkg/application/write_fixture_test.go @@ -70,7 +70,7 @@ func newWriteFixture(t *testing.T, options ...writeFixtureOptions) *writeFixture graph = struct { sdd.GraphStore sdd.SnapshotReader - sdd.EntryPublicationStore + sdd.PublicationStore }{baseGraph, reader, baseGraph} } if option.Dependency != nil { diff --git a/pkg/local/capture_publication.go b/pkg/local/capture_publication.go index 6ff0b656..cfeba43a 100644 --- a/pkg/local/capture_publication.go +++ b/pkg/local/capture_publication.go @@ -101,6 +101,10 @@ func (s *FilesystemGraphStore) PublishEntry(ctx context.Context, key app.Publica return app.EntryPublication{}, err } } + before, err := graphDirectoryRevision(s.dir) + if err != nil { + return app.EntryPublication{}, err + } if err := publishFile(ctx, root, change.LogicalPath, bytes.NewReader(change.CanonicalBytes)); err != nil { return app.EntryPublication{}, err } @@ -108,10 +112,22 @@ func (s *FilesystemGraphStore) PublishEntry(ctx context.Context, key app.Publica if err != nil { return app.EntryPublication{}, err } + s.recordLineage(before, publication.Revision) } return publication, nil } +// recordLineage remembers which revision a publication advanced from. +func (s *FilesystemGraphStore) recordLineage(before, after string) { + if before == "" || after == "" || before == after { + return + } + if s.lineage == nil { + s.lineage = map[string]string{} + } + s.lineage[after] = before +} + func (s *FilesystemGraphStore) readExistingEntry(ctx context.Context, logicalPath string) (_ app.EntryPublication, _ bool, err error) { if err := ctx.Err(); err != nil { return app.EntryPublication{}, false, err @@ -206,3 +222,157 @@ func publishFile(ctx context.Context, root *os.Root, filename string, reader io. } return syncDirectory(filepath.Join(root.Name(), filepath.FromSlash(path.Dir(filename)))) } + +// ReadDocument returns a graph document's current bytes, or Absent. +func (s *FilesystemGraphStore) ReadDocument(ctx context.Context, logicalPath string) (app.DocumentPublication, error) { + if err := ctx.Err(); err != nil { + return app.DocumentPublication{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + lock, err := s.lock() + if err != nil { + return app.DocumentPublication{}, err + } + defer unlock(lock) + return s.readDocumentLocked(logicalPath) +} + +func (s *FilesystemGraphStore) readDocumentLocked(logicalPath string) (_ app.DocumentPublication, err error) { + if err := validateGraphPath(s.dir, logicalPath); err != nil { + return app.DocumentPublication{}, err + } + root, err := os.OpenRoot(s.dir) + if err != nil { + return app.DocumentPublication{}, err + } + defer func() { err = errors.Join(err, root.Close()) }() + raw, err := root.ReadFile(logicalPath) + if errors.Is(err, fs.ErrNotExist) { + revision, err := graphDirectoryRevision(s.dir) + return app.DocumentPublication{Revision: revision, Absent: true}, err + } + if err != nil { + return app.DocumentPublication{}, err + } + revision, err := graphDirectoryRevision(s.dir) + if err != nil { + return app.DocumentPublication{}, err + } + return app.DocumentPublication{Revision: revision, Content: raw}, nil +} + +// LookupDocumentPublication finds what the key published for the path: on a +// Git-backed target the commit carrying the key's trailer, read at that +// revision (absent when the commit removed the document); without Git, +// nothing is ever found, so retries fall back to the document's current state. +func (s *FilesystemGraphStore) LookupDocumentPublication(ctx context.Context, key app.PublicationKey, logicalPath string) (app.DocumentPublication, bool, error) { + if err := key.Validate(); err != nil { + return app.DocumentPublication{}, false, err + } + s.mu.Lock() + defer s.mu.Unlock() + lock, err := s.lock() + if err != nil { + return app.DocumentPublication{}, false, err + } + defer unlock(lock) + return s.lookupDocumentPublicationLocked(ctx, key, logicalPath) +} + +func (s *FilesystemGraphStore) lookupDocumentPublicationLocked(ctx context.Context, key app.PublicationKey, logicalPath string) (app.DocumentPublication, bool, error) { + if err := validateGraphPath(s.dir, logicalPath); err != nil { + return app.DocumentPublication{}, false, err + } + if s.publicationGit == nil { + return app.DocumentPublication{}, false, nil + } + revision, err := s.publicationGit.lookupTrailer(ctx, mutationTrailer(key.String())) + if err != nil || revision == "" { + return app.DocumentPublication{}, false, err + } + content, exists, err := s.publicationGit.readCommittedFile(ctx, revision, logicalPath) + if err != nil { + return app.DocumentPublication{}, false, err + } + return app.DocumentPublication{Revision: revision, Content: content, Absent: !exists}, true, nil +} + +// PublishDocument writes one document change once under its key. A +// replacement checks the document it replaces by blob ID; a removal of an +// absent document succeeds with nothing to commit. On a Git-backed target the +// publication counts as existing only after its finalizer committed it. +func (s *FilesystemGraphStore) PublishDocument(ctx context.Context, key app.PublicationKey, mutation app.DocumentMutation) (_ app.DocumentPublication, err error) { + if err := key.Validate(); err != nil { + return app.DocumentPublication{}, err + } + if err := validateGraphPath(s.dir, mutation.LogicalPath); err != nil { + return app.DocumentPublication{}, err + } + if mutation.Content != nil && len(mutation.Content) == 0 { + return app.DocumentPublication{}, fmt.Errorf("sdd: empty canonical bytes for %s", mutation.LogicalPath) + } + s.mu.Lock() + defer s.mu.Unlock() + lock, err := s.lock() + if err != nil { + return app.DocumentPublication{}, err + } + defer unlock(lock) + if publication, exists, err := s.lookupDocumentPublicationLocked(ctx, key, mutation.LogicalPath); err != nil || exists { + return publication, err + } + current, err := s.readDocumentLocked(mutation.LogicalPath) + if err != nil { + return app.DocumentPublication{}, err + } + switch { + case mutation.Content == nil: + if current.Absent { + if s.publicationGit != nil { + // Removed before a lost commit: the branch still carries the file, + // so the finalizer has a removal to commit. + _, committed, err := s.publicationGit.readCommittedFile(ctx, s.publicationGit.Branch, mutation.LogicalPath) + if err != nil { + return app.DocumentPublication{}, err + } + if committed { + break + } + } + return app.DocumentPublication{Absent: true}, nil + } + root, openErr := os.OpenRoot(s.dir) + if openErr != nil { + return app.DocumentPublication{}, openErr + } + defer func() { err = errors.Join(err, root.Close()) }() + if err := root.Remove(mutation.LogicalPath); err != nil { + return app.DocumentPublication{}, err + } + if err := syncDirectory(filepath.Join(s.dir, filepath.FromSlash(path.Dir(mutation.LogicalPath)))); err != nil { + return app.DocumentPublication{}, err + } + case !current.Absent && bytes.Equal(current.Content, mutation.Content): + // Written before a lost commit; the finalizer completes it. + case mutation.ExpectedBlob != "" && (current.Absent || app.GitBlobID(current.Content) != mutation.ExpectedBlob): + return app.DocumentPublication{}, &app.ApplicationError{Code: app.ErrorGraphConflict, Message: "the document changed since it was read", Revision: current.Revision} + case mutation.ExpectedBlob == "" && !current.Absent: + return app.DocumentPublication{}, &app.ApplicationError{Code: app.ErrorGraphConflict, Message: "the document already exists with other content", Revision: current.Revision} + default: + root, openErr := os.OpenRoot(s.dir) + if openErr != nil { + return app.DocumentPublication{}, openErr + } + defer func() { err = errors.Join(err, root.Close()) }() + if err := publishFile(ctx, root, mutation.LogicalPath, bytes.NewReader(mutation.Content)); err != nil { + return app.DocumentPublication{}, err + } + } + revision, err := graphDirectoryRevision(s.dir) + if err != nil { + return app.DocumentPublication{}, err + } + s.recordLineage(current.Revision, revision) + return app.DocumentPublication{Revision: revision, Content: mutation.Content, Absent: mutation.Content == nil}, nil +} diff --git a/pkg/local/document_publication_test.go b/pkg/local/document_publication_test.go new file mode 100644 index 00000000..069b358f --- /dev/null +++ b/pkg/local/document_publication_test.go @@ -0,0 +1,132 @@ +package local_test + +import ( + "bytes" + "errors" + "strings" + "testing" + + sdd "github.com/networkteam/sdd/pkg/application" +) + +// A retried summary correction finds its own commit by key and leaves a later +// correction from another session in place: S1 corrects to X and loses its +// reply, S2 corrects to Y, S1 retries and gets X back without touching Y. +func TestDocumentPublicationRetryFindsItsOwnCommitAndKeepsLaterWrites(t *testing.T) { + repo := newGitRepository(t) + store := repo.graphStore() + const entryID = "20260923-010000-s-tac-sum" + capture := sdd.PublicationKey{Session: "s1", Sequence: 2, Discriminator: "newEntry"} + batch := captureBatch(t, capture, entryID, "Generated.") + if _, err := store.PublishEntry(t.Context(), capture, batch, nil); err != nil { + t.Fatal(err) + } + repo.finalize(batch) + logicalPath := batch.Changes[0].LogicalPath + + first := sdd.PublicationKey{Session: "s1", Sequence: 4, Discriminator: "replaceSummary:" + entryID} + current, err := store.ReadDocument(t.Context(), logicalPath) + if err != nil || current.Absent { + t.Fatalf("ReadDocument = %+v, %v", current, err) + } + x := bytes.Replace(current.Content, []byte("summary: Generated."), []byte("summary: X."), 1) + if _, err := store.PublishDocument(t.Context(), first, sdd.DocumentMutation{LogicalPath: logicalPath, Content: x, ExpectedBlob: sdd.GitBlobID(current.Content), Message: "s1 corrects"}); err != nil { + t.Fatal(err) + } + repo.finalizeDocument(first, logicalPath, x) + firstCommit := repo.git("rev-parse", "HEAD") + + second := sdd.PublicationKey{Session: "s2", Sequence: 3, Discriminator: "replaceSummary:" + entryID} + y := bytes.Replace(x, []byte("summary: X."), []byte("summary: Y."), 1) + if _, err := store.PublishDocument(t.Context(), second, sdd.DocumentMutation{LogicalPath: logicalPath, Content: y, ExpectedBlob: sdd.GitBlobID(x), Message: "s2 corrects"}); err != nil { + t.Fatal(err) + } + repo.finalizeDocument(second, logicalPath, y) + + retried, err := store.PublishDocument(t.Context(), first, sdd.DocumentMutation{LogicalPath: logicalPath, Content: []byte("regenerated bytes must not be written"), ExpectedBlob: sdd.GitBlobID(current.Content), Message: "s1 retries"}) + if err != nil || retried.Revision != firstCommit || !bytes.Equal(retried.Content, x) { + t.Fatalf("retry of the first correction = %+v, %v; want its own commit %s carrying X", retried, err, firstCommit) + } + now, err := store.ReadDocument(t.Context(), logicalPath) + if err != nil || !bytes.Equal(now.Content, y) { + t.Fatalf("the later correction was overwritten: %s", now.Content) + } + repo.finalizeDocument(first, logicalPath, x) + if count := repo.git("rev-list", "--count", "HEAD"); count != "4" { + t.Fatalf("commit count = %s; the retry added a commit", count) + } + + // A fresh correction from a stale read is a conflict, not a silent overwrite. + stale := sdd.PublicationKey{Session: "s3", Sequence: 2, Discriminator: "replaceSummary:" + entryID} + _, err = store.PublishDocument(t.Context(), stale, sdd.DocumentMutation{LogicalPath: logicalPath, Content: x, ExpectedBlob: sdd.GitBlobID(current.Content), Message: "stale"}) + var appErr *sdd.ApplicationError + if !errors.As(err, &appErr) || appErr.Code != sdd.ErrorGraphConflict { + t.Fatalf("stale correction = %v, want %s", err, sdd.ErrorGraphConflict) + } +} + +// A marker removal counts only after its commit: removed files with a failed +// commit are completed by the retry, and removing an absent marker publishes +// nothing while never touching another marker. +func TestDocumentRemovalCountsOnlyAfterItsCommit(t *testing.T) { + repo := newGitRepository(t) + store := repo.graphStore() + const marker = "wip/20260923-010000-christopher.md" + const other = "wip/20260923-010100-someone-else.md" + content := []byte("---\nentry: 20260923-010000-s-tac-sum\nparticipant: Christopher\nexclusive: true\n---\n\nWork.\n") + for path, key := range map[string]sdd.PublicationKey{marker: {Session: "s1", Sequence: 2, Discriminator: "wipStart:a"}, other: {Session: "s9", Sequence: 2, Discriminator: "wipStart:b"}} { + if _, err := store.PublishDocument(t.Context(), key, sdd.DocumentMutation{LogicalPath: path, Content: content, Message: "start"}); err != nil { + t.Fatal(err) + } + repo.finalizeDocument(key, path, content) + } + allowCommits := repo.failCommits() + remove := sdd.PublicationKey{Session: "s1", Sequence: 6, Discriminator: "wipDone:a"} + removed, err := store.PublishDocument(t.Context(), remove, sdd.DocumentMutation{LogicalPath: marker, Message: "done"}) + if err != nil || !removed.Absent || removed.Revision == "" { + t.Fatalf("removal = %+v, %v; want absent with a revision to commit", removed, err) + } + if err := repo.finalizer().Finalize(t.Context(), removalMutation(remove, marker)); err == nil { + t.Fatal("the commit was expected to fail") + } + if _, found, err := store.LookupDocumentPublication(t.Context(), remove, marker); err != nil || found { + t.Fatalf("an uncommitted removal counted as a publication: found=%v err=%v", found, err) + } + allowCommits() + again, err := store.PublishDocument(t.Context(), remove, sdd.DocumentMutation{LogicalPath: marker, Message: "done"}) + if err != nil || !again.Absent || again.Revision == "" { + t.Fatalf("retried removal = %+v, %v; the branch still carries the file, so there is a removal to commit", again, err) + } + repo.finalizeDocument(remove, marker, nil) + looked, found, err := store.LookupDocumentPublication(t.Context(), remove, marker) + if err != nil || !found || !looked.Absent { + t.Fatalf("committed removal lookup = %+v, %v, %v", looked, found, err) + } + if listed := repo.git("ls-tree", "--name-only", "HEAD", "--", ".sdd/graph/wip/"); !strings.Contains(listed, other) || strings.Contains(listed, marker) { + t.Fatalf("branch after removal lists %q", listed) + } + + absent := sdd.PublicationKey{Session: "s1", Sequence: 8, Discriminator: "wipRemove:a"} + none, err := store.PublishDocument(t.Context(), absent, sdd.DocumentMutation{LogicalPath: marker, Message: "remove again"}) + if err != nil || !none.Absent || none.Revision != "" { + t.Fatalf("removing an absent marker = %+v, %v; want absent with nothing to commit", none, err) + } + if current, err := store.ReadDocument(t.Context(), other); err != nil || current.Absent { + t.Fatalf("another marker was touched: %+v, %v", current, err) + } +} + +func removalMutation(key sdd.PublicationKey, logicalPath string) sdd.AppliedMutation { + batch := sdd.MutationBatch{ID: key.String(), Message: "sdd: wip done", Changes: []sdd.DocumentChange{{LogicalPath: logicalPath, Delete: true}}} + return sdd.AppliedMutation{Project: "example", BatchID: batch.ID, Batch: batch} +} + +// finalizeDocument commits one document publication the way the application +// does after PublishDocument; nil content is a removal. +func (r *gitRepository) finalizeDocument(key sdd.PublicationKey, logicalPath string, content []byte) { + r.t.Helper() + batch := sdd.MutationBatch{ID: key.String(), Message: "sdd: document " + logicalPath, Changes: []sdd.DocumentChange{{LogicalPath: logicalPath, CanonicalBytes: content, Delete: content == nil}}} + if err := r.finalizer().Finalize(r.t.Context(), sdd.AppliedMutation{Project: "example", BatchID: batch.ID, Batch: batch}); err != nil { + r.t.Fatal(err) + } +} diff --git a/pkg/local/git_finalizer.go b/pkg/local/git_finalizer.go index d22ed14f..fe0bd9b8 100644 --- a/pkg/local/git_finalizer.go +++ b/pkg/local/git_finalizer.go @@ -2,8 +2,10 @@ package local import ( "context" + "errors" "fmt" "os/exec" + "path" "path/filepath" "regexp" "strings" @@ -46,29 +48,45 @@ func (f GitFinalizer) Finalize(ctx context.Context, mutation app.AppliedMutation return nil } seen := map[string]bool{} - var paths []string - addPath := func(logical string) { + var paths, added, removed []string + addPath := func(logical string, remove bool) { if logical == "" { return } path := filepath.Join(f.GraphDir, filepath.FromSlash(logical)) - if !seen[path] { - seen[path] = true - paths = append(paths, path) + if seen[path] { + return + } + seen[path] = true + paths = append(paths, path) + if remove { + removed = append(removed, path) + } else { + added = append(added, path) } } for _, change := range mutation.Batch.Changes { - addPath(change.LogicalPath) + addPath(change.LogicalPath, change.Delete) } for _, attachment := range mutation.Batch.Attachments { - addPath(attachment.LogicalPath) + addPath(attachment.LogicalPath, false) } if len(paths) == 0 { return fmt.Errorf("git finalizer: mutation %s has no paths", mutation.BatchID) } - addArgs := append([]string{"-C", f.Checkout, "add", "--all", "--"}, paths...) - if out, err := exec.CommandContext(ctx, "git", addArgs...).CombinedOutput(); err != nil { - return fmt.Errorf("git finalizer add: %s (%w)", strings.TrimSpace(string(out)), err) + if len(added) > 0 { + addArgs := append([]string{"-C", f.Checkout, "add", "--all", "--"}, added...) + if out, err := exec.CommandContext(ctx, "git", addArgs...).CombinedOutput(); err != nil { + return fmt.Errorf("git finalizer add: %s (%w)", strings.TrimSpace(string(out)), err) + } + } + if len(removed) > 0 { + // A removal may already be staged by an attempt whose commit failed; + // --ignore-unmatch keeps the retry from failing on the missing path. + rmArgs := append([]string{"-C", f.Checkout, "rm", "--quiet", "--cached", "--ignore-unmatch", "--"}, removed...) + if out, err := exec.CommandContext(ctx, "git", rmArgs...).CombinedOutput(); err != nil { + return fmt.Errorf("git finalizer rm: %s (%w)", strings.TrimSpace(string(out)), err) + } } message := mutation.Batch.Message if message == "" { @@ -94,3 +112,34 @@ func (f GitFinalizer) lookupTrailer(ctx context.Context, trailer string) (string } return strings.TrimSpace(string(out)), nil } + +// readCommittedFile returns a graph file's bytes at a revision, and whether the +// revision carries it at all. +func (f GitFinalizer) readCommittedFile(ctx context.Context, revision, logicalPath string) ([]byte, bool, error) { + filename := path.Join(f.GraphDir, logicalPath) + listed, err := exec.CommandContext(ctx, "git", "-C", f.Checkout, "ls-tree", "--name-only", revision, "--", filename).Output() + if err != nil { + return nil, false, fmt.Errorf("listing %s at %s: %w", filename, revision, err) + } + if strings.TrimSpace(string(listed)) == "" { + return nil, false, nil + } + content, err := exec.CommandContext(ctx, "git", "-C", f.Checkout, "show", revision+":"+filename).Output() + if err != nil { + return nil, false, fmt.Errorf("reading %s at %s: %w", filename, revision, err) + } + return content, true, nil +} + +// isAncestor reports whether the revision is reachable from the branch head. +func (f GitFinalizer) isAncestor(ctx context.Context, revision string) (bool, error) { + err := exec.CommandContext(ctx, "git", "-C", f.Checkout, "merge-base", "--is-ancestor", revision, f.Branch).Run() + if err == nil { + return true, nil + } + var exit *exec.ExitError + if errors.As(err, &exit) && exit.ExitCode() == 1 { + return false, nil + } + return false, fmt.Errorf("git ancestry of %s on %s: %w", revision, f.Branch, err) +} diff --git a/pkg/local/local_adapters_test.go b/pkg/local/local_adapters_test.go index c3e42b2e..3f6b15c4 100644 --- a/pkg/local/local_adapters_test.go +++ b/pkg/local/local_adapters_test.go @@ -42,29 +42,26 @@ func TestFilesystemGraphStoreConformance(t *testing.T) { if err != nil { t.Fatal(err) } - batch := sdd.MutationBatch{ - ID: "mutation-1", - Changes: []sdd.DocumentChange{{ - LogicalPath: "2026/07/13-020000-s-tac-api.md", - CanonicalBytes: []byte(localEntry), - }}, - } - batch.Digest, err = sdd.MutationBatchDigest(batch) + const entryPath = "2026/07/13-020000-s-tac-api.md" + document, err := sdd.ParseEntryDocument(entryPath, []byte(localEntry)) if err != nil { t.Fatal(err) } - secondBatch := sdd.MutationBatch{ - ID: "mutation-2", - Changes: []sdd.DocumentChange{{ - LogicalPath: "2026/07/13-030000-s-tac-two.md", - CanonicalBytes: []byte(localEntry), - }}, + key := sdd.PublicationKey{Session: "s_conformance", Sequence: 3, Discriminator: "newEntry"} + batch := sdd.MutationBatch{ + ID: key.String(), + Changes: []sdd.DocumentChange{{LogicalPath: entryPath, Document: &document, CanonicalBytes: []byte(localEntry)}}, } - secondBatch.Digest, err = sdd.MutationBatchDigest(secondBatch) - if err != nil { - t.Fatal(err) + return sddtest.GraphStoreFixture{ + Store: store, InitialRevision: initial.Revision(), Entry: batch, EntryID: "20260713-020000-s-tac-api", EntryKey: key, + DocumentPath: "wip/20260713-030000-christopher.md", DocumentContent: []byte("---\nentry: 20260713-020000-s-tac-api\nparticipant: Christopher\n---\n\nfirst\n"), + DocumentReplacement: []byte("---\nentry: 20260713-020000-s-tac-api\nparticipant: Christopher\n---\n\nsecond\n"), + DocumentKeys: [3]sdd.PublicationKey{ + {Session: "s_conformance", Sequence: 5, Discriminator: "wipStart:m"}, + {Session: "s_conformance", Sequence: 7, Discriminator: "replace:m"}, + {Session: "s_conformance", Sequence: 9, Discriminator: "wipDone:m"}, + }, } - return sddtest.GraphStoreFixture{Store: store, InitialRevision: initial.Revision(), Batch: batch, SecondBatch: secondBatch} }) } diff --git a/pkg/local/local_graphstore.go b/pkg/local/local_graphstore.go index 3e2995a6..985ff9db 100644 --- a/pkg/local/local_graphstore.go +++ b/pkg/local/local_graphstore.go @@ -4,8 +4,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "encoding/json" - "errors" "fmt" "io" "io/fs" @@ -40,34 +38,9 @@ type FilesystemGraphStore struct { mu sync.Mutex snapshots map[string]*retainedSnapshot publicationGit *GitFinalizer - - beforeApplyOperation func(int) error - beforeRollbackOperation func(int) error -} - -type filesystemApplyRecord struct { - Digest string `json:"digest"` - ExpectedRevision string `json:"expected_revision"` - Result app.ApplyResult `json:"result"` - Transaction *filesystemTransaction `json:"transaction,omitempty"` -} - -type filesystemTransaction struct { - Operations []filesystemOperation `json:"operations"` -} - -type filesystemOperation struct { - LogicalPath string `json:"logical_path"` - Before filesystemFileState `json:"before"` - After filesystemFileState `json:"after"` - BackupPath string `json:"backup_path,omitempty"` - StagedPath string `json:"staged_path,omitempty"` -} - -type filesystemFileState struct { - Exists bool `json:"exists"` - Digest string `json:"digest,omitempty"` - Mode uint32 `json:"mode,omitempty"` + // lineage maps a revision this store published to the revision it replaced, + // so a read can be shown to include an earlier write of this process. + lineage map[string]string } func NewFilesystemGraphStore(options FilesystemGraphStoreOptions) (*FilesystemGraphStore, error) { @@ -80,12 +53,9 @@ func NewFilesystemGraphStore(options FilesystemGraphStoreOptions) (*FilesystemGr if err := os.MkdirAll(options.GraphDir, 0o755); err != nil { return nil, fmt.Errorf("sdd: creating filesystem graph directory: %w", err) } - if err := os.MkdirAll(filepath.Join(options.GraphDir, ".sdd-runtime", "applied"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(options.GraphDir, ".sdd-runtime"), 0o755); err != nil { return nil, fmt.Errorf("sdd: creating graph runtime directory: %w", err) } - if err := os.MkdirAll(filepath.Join(options.GraphDir, ".sdd-runtime", "transactions"), 0o755); err != nil { - return nil, fmt.Errorf("sdd: creating graph transaction directory: %w", err) - } return &FilesystemGraphStore{ project: options.Project, branch: options.Branch, @@ -102,9 +72,6 @@ func (s *FilesystemGraphStore) Current(ctx context.Context) (*app.Snapshot, erro return nil, err } defer unlock(lock) - if err := s.recoverPendingTransactionsLocked(); err != nil { - return nil, err - } return s.currentLocked(ctx) } @@ -116,307 +83,6 @@ func (s *FilesystemGraphStore) currentLocked(ctx context.Context) (*app.Snapshot return app.LoadSnapshotFS(ctx, s.project, revision, os.DirFS(s.dir), ".") } -func (s *FilesystemGraphStore) Apply(ctx context.Context, expectedRevision string, batch app.MutationBatch, blobs app.StagedBlobReader) (app.ApplyResult, error) { - s.mu.Lock() - defer s.mu.Unlock() - lock, err := s.lock() - if err != nil { - return app.ApplyResult{State: app.MutationNotApplied}, err - } - defer unlock(lock) - if err := s.recoverPendingTransactionsLocked(); err != nil { - return app.ApplyResult{State: app.MutationUnknown}, err - } - if prior, ok, err := s.loadApplyRecord(batch.ID); err != nil { - return app.ApplyResult{State: app.MutationUnknown}, err - } else if ok { - if prior.Digest != batch.Digest { - return app.ApplyResult{State: app.MutationNotApplied}, &app.ApplicationError{Code: app.ErrorRecoveryRequired, Message: "mutation ID reused with a different digest"} - } - if prior.Result.State == app.MutationUnknown { - return s.reconcileRecord(batch.ID, prior) - } - return prior.Result, nil - } - currentRevision, err := graphDirectoryRevision(s.dir) - if err != nil { - return app.ApplyResult{State: app.MutationNotApplied}, err - } - if currentRevision != expectedRevision { - return app.ApplyResult{State: app.MutationNotApplied, Revision: currentRevision}, &app.ApplicationError{Code: app.ErrorGraphConflict, Message: "graph revision changed", Revision: currentRevision} - } - wantDigest, err := app.MutationBatchDigest(batch) - if err != nil { - return app.ApplyResult{State: app.MutationNotApplied, Revision: currentRevision}, err - } - if batch.ID == "" || batch.Digest == "" || batch.Digest != wantDigest { - return app.ApplyResult{State: app.MutationNotApplied, Revision: currentRevision}, &app.ApplicationError{Code: app.ErrorRecoveryRequired, Message: "mutation batch digest mismatch"} - } - transaction, err := s.prepareTransaction(ctx, batch, blobs) - if err != nil { - return app.ApplyResult{State: app.MutationNotApplied, Revision: currentRevision}, err - } - record := filesystemApplyRecord{ - Digest: batch.Digest, ExpectedRevision: expectedRevision, - Result: app.ApplyResult{State: app.MutationUnknown, Revision: currentRevision}, Transaction: transaction, - } - if err := s.persistApplyRecord(batch.ID, record); err != nil { - cleanupErr := s.removeTransaction(batch.ID) - return app.ApplyResult{State: app.MutationNotApplied, Revision: currentRevision}, errors.Join(err, cleanupErr) - } - if err := s.applyTransaction(transaction); err != nil { - if rollbackErr := s.rollbackTransaction(transaction); rollbackErr != nil { - return app.ApplyResult{State: app.MutationUnknown, Revision: currentRevision}, errors.Join(err, fmt.Errorf("rolling back mutation %s: %w", batch.ID, rollbackErr)) - } - rolledBackRevision, revisionErr := graphDirectoryRevision(s.dir) - if revisionErr != nil { - return app.ApplyResult{State: app.MutationUnknown}, errors.Join(err, revisionErr) - } - result := app.ApplyResult{State: app.MutationNotApplied, Revision: rolledBackRevision} - record.Result = result - if persistErr := s.persistApplyRecord(batch.ID, record); persistErr != nil { - return app.ApplyResult{State: app.MutationUnknown, Revision: rolledBackRevision}, errors.Join(err, persistErr) - } - cleanupErr := s.removeTransaction(batch.ID) - return result, errors.Join(err, cleanupErr) - } - newRevision, err := graphDirectoryRevision(s.dir) - if err != nil { - return app.ApplyResult{State: app.MutationUnknown}, err - } - result := app.ApplyResult{State: app.MutationApplied, Revision: newRevision} - record.Result = result - if err := s.persistApplyRecord(batch.ID, record); err != nil { - return app.ApplyResult{State: app.MutationUnknown, Revision: newRevision}, err - } - return result, s.removeTransaction(batch.ID) -} - -func (s *FilesystemGraphStore) prepareTransaction(ctx context.Context, batch app.MutationBatch, blobs app.StagedBlobReader) (_ *filesystemTransaction, err error) { - type plannedOperation struct { - logicalPath string - after []byte - delete bool - } - var planned []plannedOperation - seen := map[string]bool{} - add := func(logicalPath string, data []byte, deleteFile bool) error { - if _, err := canonicalGraphPath(s.dir, logicalPath); err != nil { - return err - } - if seen[logicalPath] { - return fmt.Errorf("sdd: mutation contains duplicate graph path %q", logicalPath) - } - seen[logicalPath] = true - if !deleteFile && len(data) == 0 { - return fmt.Errorf("sdd: empty canonical bytes for %s", logicalPath) - } - planned = append(planned, plannedOperation{logicalPath: logicalPath, after: append([]byte(nil), data...), delete: deleteFile}) - return nil - } - for _, change := range batch.Changes { - if err := add(change.LogicalPath, change.CanonicalBytes, change.Delete); err != nil { - return nil, err - } - } - for _, attachment := range batch.Attachments { - if blobs == nil { - return nil, fmt.Errorf("sdd: staged blob reader is required") - } - reader, err := blobs.Open(ctx, attachment.BlobID) - if err != nil { - return nil, err - } - data, readErr := io.ReadAll(reader) - closeErr := reader.Close() - if readErr != nil { - return nil, readErr - } - if closeErr != nil { - return nil, closeErr - } - if int64(len(data)) != attachment.Size || !digestMatches(data, attachment.Digest) { - return nil, fmt.Errorf("sdd: staged blob %s does not match prepared facts", attachment.BlobID) - } - if err := add(attachment.LogicalPath, data, false); err != nil { - return nil, err - } - } - - if err := s.removeTransaction(batch.ID); err != nil { - return nil, err - } - transactionDir, err := s.transactionDir(batch.ID) - if err != nil { - return nil, err - } - if err := os.MkdirAll(transactionDir, 0o755); err != nil { - return nil, err - } - if err := syncDirectory(filepath.Dir(transactionDir)); err != nil { - return nil, err - } - defer func() { - if err != nil { - err = errors.Join(err, s.removeTransaction(batch.ID)) - } - }() - transaction := &filesystemTransaction{Operations: make([]filesystemOperation, 0, len(planned))} - for index, item := range planned { - target, err := canonicalGraphPath(s.dir, item.logicalPath) - if err != nil { - return nil, err - } - before, beforeBytes, err := readFilesystemState(target) - if err != nil { - return nil, fmt.Errorf("sdd: reading graph path %s before mutation: %w", item.logicalPath, err) - } - operation := filesystemOperation{LogicalPath: item.logicalPath, Before: before} - if before.Exists { - operation.BackupPath = transactionFilePath(batch.ID, "backup", index) - backup, err := journalPath(s.dir, operation.BackupPath) - if err != nil { - return nil, err - } - if err := writeFileDurable(backup, beforeBytes, fs.FileMode(before.Mode)); err != nil { - return nil, err - } - } - if !item.delete { - operation.After = filesystemFileState{Exists: true, Digest: filesystemDigest(item.after), Mode: uint32(0o644)} - operation.StagedPath = transactionFilePath(batch.ID, "staged", index) - staged, err := journalPath(s.dir, operation.StagedPath) - if err != nil { - return nil, err - } - if err := writeFileDurable(staged, item.after, 0o644); err != nil { - return nil, err - } - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return nil, err - } - transaction.Operations = append(transaction.Operations, operation) - } - if err := syncDirectory(transactionDir); err != nil { - return nil, err - } - return transaction, nil -} - -func (s *FilesystemGraphStore) applyTransaction(transaction *filesystemTransaction) error { - if err := s.validateTransactionStates(transaction); err != nil { - return err - } - for index, operation := range transaction.Operations { - if s.beforeApplyOperation != nil { - if err := s.beforeApplyOperation(index); err != nil { - return err - } - } - target, err := canonicalGraphPath(s.dir, operation.LogicalPath) - if err != nil { - return err - } - if operation.After.Exists { - staged, err := journalPath(s.dir, operation.StagedPath) - if err != nil { - return err - } - if err := os.Rename(staged, target); err != nil { - return err - } - } else if err := os.Remove(target); err != nil && !os.IsNotExist(err) { - return err - } - if err := syncDirectory(filepath.Dir(target)); err != nil { - return err - } - } - return nil -} - -func (s *FilesystemGraphStore) rollbackTransaction(transaction *filesystemTransaction) error { - if err := s.validateTransactionStates(transaction); err != nil { - return err - } - for index := len(transaction.Operations) - 1; index >= 0; index-- { - operation := transaction.Operations[index] - if s.beforeRollbackOperation != nil { - if err := s.beforeRollbackOperation(index); err != nil { - return err - } - } - target, err := canonicalGraphPath(s.dir, operation.LogicalPath) - if err != nil { - return err - } - current, _, err := readFilesystemState(target) - if err != nil { - return err - } - if sameFilesystemState(current, operation.Before) { - continue - } - if operation.Before.Exists { - backup, err := journalPath(s.dir, operation.BackupPath) - if err != nil { - return err - } - if err := restoreFile(backup, target, fs.FileMode(operation.Before.Mode)); err != nil { - return err - } - } else if err := os.Remove(target); err != nil && !os.IsNotExist(err) { - return err - } - if err := syncDirectory(filepath.Dir(target)); err != nil { - return err - } - } - return nil -} - -func (s *FilesystemGraphStore) validateTransactionStates(transaction *filesystemTransaction) error { - for _, operation := range transaction.Operations { - target, err := canonicalGraphPath(s.dir, operation.LogicalPath) - if err != nil { - return err - } - current, _, err := readFilesystemState(target) - if err != nil { - return err - } - if !sameFilesystemState(current, operation.Before) && !sameFilesystemState(current, operation.After) { - return fmt.Errorf("sdd: graph path %s changed outside pending mutation", operation.LogicalPath) - } - } - return nil -} - -func (s *FilesystemGraphStore) Reconcile(_ context.Context, mutationID, batchDigest string) (app.ApplyResult, error) { - s.mu.Lock() - defer s.mu.Unlock() - lock, err := s.lock() - if err != nil { - return app.ApplyResult{State: app.MutationUnknown}, err - } - defer unlock(lock) - record, ok, err := s.loadApplyRecord(mutationID) - if err != nil { - return app.ApplyResult{State: app.MutationUnknown}, err - } - if !ok { - // The applied-record directory is the filesystem adapter's canonical - // batch ledger. Absence while holding its lock is definitive evidence - // that this batch was not applied, not an unknown outcome. - return app.ApplyResult{State: app.MutationNotApplied}, nil - } - if record.Digest != batchDigest { - return app.ApplyResult{State: app.MutationUnknown}, &app.ApplicationError{Code: app.ErrorRecoveryRequired, Message: "mutation digest does not match recorded apply"} - } - return s.reconcileRecord(mutationID, record) -} - func (s *FilesystemGraphStore) ReadAttachmentPage(_ context.Context, entryID, filename string, offset int64, maxBytes int) (app.AttachmentPage, error) { s.mu.Lock() defer s.mu.Unlock() @@ -425,9 +91,6 @@ func (s *FilesystemGraphStore) ReadAttachmentPage(_ context.Context, entryID, fi return app.AttachmentPage{}, err } defer unlock(lock) - if err := s.recoverPendingTransactionsLocked(); err != nil { - return app.AttachmentPage{}, err - } page, err := app.PageAttachment(os.DirFS(s.dir), ".", entryID, filename, offset, maxBytes) if err != nil { return app.AttachmentPage{}, err @@ -451,94 +114,6 @@ func lockGraph(dir string) (*flock.Flock, error) { return lock, nil } -func (s *FilesystemGraphStore) applyRecordPath(id string) (string, error) { - if id == "" || filepath.Base(id) != id || strings.ContainsAny(id, `/\\`) { - return "", fmt.Errorf("sdd: invalid mutation ID %q", id) - } - return filepath.Join(s.dir, ".sdd-runtime", "applied", id+".json"), nil -} - -func (s *FilesystemGraphStore) loadApplyRecord(id string) (filesystemApplyRecord, bool, error) { - filename, err := s.applyRecordPath(id) - if err != nil { - return filesystemApplyRecord{}, false, err - } - raw, err := os.ReadFile(filename) - if os.IsNotExist(err) { - return filesystemApplyRecord{}, false, nil - } - if err != nil { - return filesystemApplyRecord{}, false, err - } - var record filesystemApplyRecord - if err := json.Unmarshal(raw, &record); err != nil { - return filesystemApplyRecord{}, false, err - } - return record, true, nil -} - -func (s *FilesystemGraphStore) persistApplyRecord(id string, record filesystemApplyRecord) error { - filename, err := s.applyRecordPath(id) - if err != nil { - return err - } - return writeJSONAtomic(filename, record) -} - -func (s *FilesystemGraphStore) reconcileRecord(id string, record filesystemApplyRecord) (app.ApplyResult, error) { - if record.Result.State != app.MutationUnknown { - return record.Result, nil - } - if record.Transaction != nil { - if err := s.rollbackTransaction(record.Transaction); err != nil { - return app.ApplyResult{State: app.MutationUnknown, Revision: record.Result.Revision}, err - } - revision, err := graphDirectoryRevision(s.dir) - if err != nil { - return app.ApplyResult{State: app.MutationUnknown}, err - } - result := app.ApplyResult{State: app.MutationNotApplied, Revision: revision} - record.Result = result - if err := s.persistApplyRecord(id, record); err != nil { - return app.ApplyResult{State: app.MutationUnknown, Revision: revision}, err - } - return result, s.removeTransaction(id) - } - revision, err := graphDirectoryRevision(s.dir) - if err != nil { - return app.ApplyResult{State: app.MutationUnknown}, err - } - if revision == record.ExpectedRevision { - return app.ApplyResult{State: app.MutationNotApplied, Revision: revision}, nil - } - return app.ApplyResult{State: app.MutationUnknown, Revision: revision}, nil -} - -func (s *FilesystemGraphStore) recoverPendingTransactionsLocked() error { - dir := filepath.Join(s.dir, ".sdd-runtime", "applied") - entries, err := os.ReadDir(dir) - if err != nil { - return err - } - for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { - continue - } - id := strings.TrimSuffix(entry.Name(), ".json") - record, ok, err := s.loadApplyRecord(id) - if err != nil { - return err - } - if !ok || record.Result.State != app.MutationUnknown || record.Transaction == nil { - continue - } - if _, err := s.reconcileRecord(id, record); err != nil { - return fmt.Errorf("sdd: recovering pending mutation %s: %w", id, err) - } - } - return nil -} - func graphDirectoryRevision(dir string) (string, error) { var files []string err := filepath.WalkDir(dir, func(filename string, entry fs.DirEntry, err error) error { @@ -578,125 +153,14 @@ func graphDirectoryRevision(dir string) (string, error) { return "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil } -func (s *FilesystemGraphStore) removeTransaction(id string) error { - dir, err := s.transactionDir(id) - if err != nil { - return err - } - if err := os.RemoveAll(dir); err != nil { - return err - } - return syncDirectory(filepath.Dir(dir)) -} - -func (s *FilesystemGraphStore) transactionDir(id string) (string, error) { - if _, err := s.applyRecordPath(id); err != nil { - return "", err - } - return filepath.Join(s.dir, ".sdd-runtime", "transactions", id), nil -} - -func transactionFilePath(id, class string, index int) string { - return filepath.ToSlash(filepath.Join(".sdd-runtime", "transactions", id, class, fmt.Sprintf("%06d", index))) -} - -func journalPath(root, logicalPath string) (string, error) { - if !strings.HasPrefix(logicalPath, ".sdd-runtime/transactions/") { - return "", fmt.Errorf("sdd: invalid transaction path %q", logicalPath) - } - return safeGraphPath(root, logicalPath) -} - -func canonicalGraphPath(root, logicalPath string) (string, error) { +// validateGraphPath refuses paths a publication may not target: outside the +// graph directory or inside its runtime directory. +func validateGraphPath(root, logicalPath string) error { if logicalPath == ".sdd-runtime" || strings.HasPrefix(logicalPath, ".sdd-runtime/") { - return "", fmt.Errorf("sdd: canonical mutation cannot target runtime path %q", logicalPath) + return fmt.Errorf("sdd: canonical mutation cannot target runtime path %q", logicalPath) } - return safeGraphPath(root, logicalPath) -} - -func readFilesystemState(filename string) (filesystemFileState, []byte, error) { - info, err := os.Stat(filename) - if os.IsNotExist(err) { - return filesystemFileState{}, nil, nil - } - if err != nil { - return filesystemFileState{}, nil, err - } - if !info.Mode().IsRegular() { - return filesystemFileState{}, nil, fmt.Errorf("not a regular file") - } - data, err := os.ReadFile(filename) - if err != nil { - return filesystemFileState{}, nil, err - } - return filesystemFileState{Exists: true, Digest: filesystemDigest(data), Mode: uint32(info.Mode().Perm())}, data, nil -} - -func sameFilesystemState(left, right filesystemFileState) bool { - if left.Exists != right.Exists { - return false - } - if !left.Exists { - return true - } - return left.Digest == right.Digest && left.Mode == right.Mode -} - -func filesystemDigest(data []byte) string { - sum := sha256.Sum256(data) - return "sha256:" + hex.EncodeToString(sum[:]) -} - -func writeFileDurable(filename string, data []byte, mode fs.FileMode) error { - if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil { - return err - } - file, err := os.OpenFile(filename, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) - if err != nil { - return err - } - if err := file.Chmod(mode); err != nil { - return errors.Join(err, file.Close()) - } - if _, err := file.Write(data); err != nil { - return errors.Join(err, file.Close()) - } - if err := file.Sync(); err != nil { - return errors.Join(err, file.Close()) - } - if err := file.Close(); err != nil { - return err - } - return syncDirectory(filepath.Dir(filename)) -} - -func restoreFile(backup, target string, mode fs.FileMode) error { - data, err := os.ReadFile(backup) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err - } - temp, err := os.CreateTemp(filepath.Dir(target), ".sdd-rollback-*") - if err != nil { - return err - } - name := temp.Name() - defer func() { _ = os.Remove(name) }() - if err := temp.Chmod(mode); err != nil { - return errors.Join(err, temp.Close()) - } - if _, err := temp.Write(data); err != nil { - return errors.Join(err, temp.Close()) - } - if err := temp.Sync(); err != nil { - return errors.Join(err, temp.Close()) - } - if err := temp.Close(); err != nil { - return err - } - return os.Rename(name, target) + _, err := safeGraphPath(root, logicalPath) + return err } func syncDirectory(dir string) error { @@ -722,12 +186,3 @@ func safeGraphPath(root, logicalPath string) (string, error) { } return target, nil } - -func sha256Digest(data []byte) app.BlobDigest { - sum := sha256.Sum256(data) - return app.BlobDigest{Algorithm: "sha256", Value: hex.EncodeToString(sum[:])} -} - -func digestMatches(data []byte, digest app.BlobDigest) bool { - return digest.Algorithm == "sha256" && sha256Digest(data).Value == digest.Value -} diff --git a/pkg/local/publish.go b/pkg/local/publish.go index d718dde1..1f768a96 100644 --- a/pkg/local/publish.go +++ b/pkg/local/publish.go @@ -3,7 +3,6 @@ package local import ( "crypto/rand" "encoding/hex" - "encoding/json" "errors" "fmt" "io/fs" @@ -38,31 +37,6 @@ func publishBytes(root *os.Root, name string, data []byte) error { return syncRootDir(root, directory) } -// writeJSONAtomic is the path-addressed form, for the graph store which sits -// outside this subsystem's containment root. -func writeJSONAtomic(filename string, value any) error { - encoded, err := json.Marshal(value) - if err != nil { - return err - } - temporary, err := os.CreateTemp(filepath.Dir(filename), ".sdd-publish-*") - if err != nil { - return err - } - name := temporary.Name() - defer func() { _ = os.Remove(name) }() - if _, err := temporary.Write(encoded); err != nil { - return errors.Join(err, temporary.Close()) - } - if err := errors.Join(temporary.Sync(), temporary.Close()); err != nil { - return err - } - if err := os.Rename(name, filename); err != nil { - return err - } - return syncDir(filepath.Dir(filename)) -} - func temporaryName(directory string) (string, error) { raw := make([]byte, 8) if _, err := rand.Read(raw); err != nil { diff --git a/pkg/local/read_snapshot.go b/pkg/local/read_snapshot.go index ae125b6a..94512fbd 100644 --- a/pkg/local/read_snapshot.go +++ b/pkg/local/read_snapshot.go @@ -60,9 +60,6 @@ func (s *FilesystemGraphStore) AcquireSnapshot(ctx context.Context, q app.Snapsh return nil, err } defer unlock(lock) - if err := s.recoverPendingTransactionsLocked(); err != nil { - return nil, err - } revision, err := graphDirectoryRevision(s.dir) if err != nil { return nil, err @@ -71,7 +68,7 @@ func (s *FilesystemGraphStore) AcquireSnapshot(ctx context.Context, q app.Snapsh return nil, fmt.Errorf("sdd: exact source revision is no longer retained") } if q.IncludesRevision != "" { - ok, err := s.includesRevision(revision, q.IncludesRevision) + ok, err := s.includesRevision(ctx, revision, q.IncludesRevision) if err != nil { return nil, err } @@ -121,43 +118,32 @@ func (s *FilesystemGraphStore) leaseSnapshot(retained *retainedSnapshot) *app.Ac }} } -func (s *FilesystemGraphStore) includesRevision(current, required string) (bool, error) { - if current == required { - return true, nil - } - names, err := os.ReadDir(filepath.Join(s.dir, ".sdd-runtime", "applied")) - if err != nil { - return false, err - } - parents := map[string][]string{} - for _, name := range names { - if name.IsDir() || filepath.Ext(name.Name()) != ".json" { - continue - } - id := name.Name()[:len(name.Name())-5] - record, found, err := s.loadApplyRecord(id) - if err != nil { - return false, err - } - if found && record.Result.State == app.MutationApplied { - parents[record.Result.Revision] = append(parents[record.Result.Revision], record.ExpectedRevision) - } - } - todo := []string{current} - seen := map[string]bool{} - for len(todo) > 0 { - node := todo[len(todo)-1] - todo = todo[:len(todo)-1] +// includesRevision reports whether the current revision carries the required +// one: equal revisions, or on a Git-backed target a commit that is an ancestor +// of the branch head. +func (s *FilesystemGraphStore) includesRevision(ctx context.Context, current, required string) (bool, error) { + for node, seen := current, map[string]bool{}; node != "" && !seen[node]; node = s.lineage[node] { if node == required { return true, nil } - if seen[node] { - continue - } seen[node] = true - todo = append(todo, parents[node]...) } - return false, nil + if s.publicationGit == nil || !isGitRevision(required) { + return false, nil + } + return s.publicationGit.isAncestor(ctx, required) +} + +func isGitRevision(revision string) bool { + if len(revision) < 7 || len(revision) > 64 { + return false + } + for _, r := range revision { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') { + return false + } + } + return true } func freezeGraphFS(ctx context.Context, dir string) (*zip.Reader, error) { diff --git a/pkg/local/store_root.go b/pkg/local/store_root.go index 3ef250cd..387415c7 100644 --- a/pkg/local/store_root.go +++ b/pkg/local/store_root.go @@ -1,7 +1,6 @@ package local import ( - "errors" "fmt" "os" ) @@ -27,15 +26,3 @@ func openStoreRoot(dir string, create bool) (*os.Root, error) { } return root, nil } - -// syncDir flushes a directory entry so a rename is durable. -func syncDir(dir string) error { - handle, err := os.Open(dir) - if err != nil { - return err - } - if err := handle.Sync(); err != nil { - return errors.Join(err, handle.Close()) - } - return handle.Close() -} diff --git a/pkg/mcpapp/.snapshots/TestToolContractSnapshot.json b/pkg/mcpapp/.snapshots/TestToolContractSnapshot.json index 62db980f..a2f8e1c9 100644 --- a/pkg/mcpapp/.snapshots/TestToolContractSnapshot.json +++ b/pkg/mcpapp/.snapshots/TestToolContractSnapshot.json @@ -235,10 +235,6 @@ "description": "the session's project ID", "type": "string" }, - "recovery": { - "description": "host-neutral actionable recovery notices; empty when no write awaits explicit recovery", - "type": "string" - }, "search": { "description": "available retrieval modes: text or vector,text", "type": "string" diff --git a/pkg/mcpapp/tools.go b/pkg/mcpapp/tools.go index 1fdcbfa6..efa5c718 100644 --- a/pkg/mcpapp/tools.go +++ b/pkg/mcpapp/tools.go @@ -338,7 +338,6 @@ type InfoResult struct { Participant string `json:"participant,omitempty" jsonschema:"configured local participant (canonical name)"` Language string `json:"language,omitempty" jsonschema:"configured graph language; empty = English"` Search string `json:"search" jsonschema:"available retrieval modes: text or vector,text"` - Recovery string `json:"recovery,omitempty" jsonschema:"host-neutral actionable recovery notices; empty when no write awaits explicit recovery"` Version string `json:"version,omitempty"` } @@ -1160,7 +1159,7 @@ func (s *Server) info(ctx context.Context, req *mcp.CallToolRequest, args InfoAr } return nil, InfoResult{ Project: string(info.Project.ID), Participant: info.Participant, Language: info.Language, Search: info.Search, - Recovery: info.Recovery, Version: s.version, + Version: s.version, }, nil } diff --git a/pkg/sddtest/conformance.go b/pkg/sddtest/conformance.go index f7465f96..d7d32483 100644 --- a/pkg/sddtest/conformance.go +++ b/pkg/sddtest/conformance.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "reflect" "slices" @@ -60,49 +61,70 @@ func RunAccessResolverTests(t *testing.T, factory func(*testing.T) AccessResolve } } +// GraphStoreFixture drives the publication conformance of a graph store: the +// store must implement sdd.PublicationStore. Entry is one complete entry +// publication under EntryKey; Document is an entry-less document the suite +// creates, replaces and removes under the three DocumentKeys. type GraphStoreFixture struct { Store sdd.GraphStore InitialRevision string - Batch sdd.MutationBatch + Entry sdd.MutationBatch + EntryID string + EntryKey sdd.PublicationKey Blobs sdd.StagedBlobReader AttachmentEntry string AttachmentName string - // SecondBatch, when set, exercises the merge-under-append guarantee: it - // must target paths unrelated to Batch so it can apply cleanly against the - // revision Batch advanced the store to. - SecondBatch sdd.MutationBatch + + DocumentPath string + DocumentContent []byte + DocumentReplacement []byte + DocumentKeys [3]sdd.PublicationKey } +// RunGraphStoreTests checks the guarantees every composition's store owes the +// engine's recorded-intent publication (d-tac-n47, d-tac-wgw): a key publishes +// once and repeats return the original, a replacement conditions on the +// document it replaces, and removing an absent document succeeds. func RunGraphStoreTests(t *testing.T, factory func(*testing.T) GraphStoreFixture) { t.Helper() fixture := factory(t) - snapshot, err := fixture.Store.Current(t.Context()) + ctx := t.Context() + snapshot, err := fixture.Store.Current(ctx) if err != nil { t.Fatalf("Current: %v", err) } if snapshot == nil || snapshot.Revision() != fixture.InitialRevision { t.Fatalf("Current revision = %q, want %q", snapshot.Revision(), fixture.InitialRevision) } - stale, staleErr := fixture.Store.Apply(t.Context(), fixture.InitialRevision+"-stale", fixture.Batch, fixture.Blobs) - if stale.State != sdd.MutationNotApplied { - t.Fatalf("stale Apply = %+v (error %v), want not_applied", stale, staleErr) + publisher, ok := fixture.Store.(sdd.PublicationStore) + if !ok { + t.Fatalf("store %T does not implement sdd.PublicationStore", fixture.Store) + } + + entryPath := fixture.Entry.Changes[0].LogicalPath + entryID := fixture.EntryID + if _, found, err := publisher.LookupEntryPublication(ctx, fixture.EntryKey, entryID); err != nil || found { + t.Fatalf("LookupEntryPublication before publish = found %v, %v; want absent", found, err) } - applied, err := fixture.Store.Apply(t.Context(), fixture.InitialRevision, fixture.Batch, fixture.Blobs) + published, err := publisher.PublishEntry(ctx, fixture.EntryKey, fixture.Entry, fixture.Blobs) if err != nil { - t.Fatalf("Apply: %v", err) + t.Fatalf("PublishEntry: %v", err) } - if applied.State != sdd.MutationApplied || applied.Revision == "" { - t.Fatalf("Apply = %+v, want applied with revision", applied) + if published.Document.LogicalPath != entryPath || published.Revision == "" { + t.Fatalf("PublishEntry = %+v, want document at %s with a revision", published, entryPath) } - reconciled, err := fixture.Store.Reconcile(t.Context(), fixture.Batch.ID, fixture.Batch.Digest) + repeated, err := publisher.PublishEntry(ctx, fixture.EntryKey, fixture.Entry, fixture.Blobs) if err != nil { - t.Fatalf("Reconcile: %v", err) + t.Fatalf("repeated PublishEntry: %v", err) + } + if repeated.Document.LogicalPath != published.Document.LogicalPath || repeated.Document.Body != published.Document.Body { + t.Fatalf("repeated PublishEntry = %+v, want the original %+v", repeated.Document, published.Document) } - if reconciled != applied { - t.Fatalf("Reconcile = %+v, want %+v", reconciled, applied) + if _, found, err := publisher.LookupEntryPublication(ctx, fixture.EntryKey, entryID); err != nil || !found { + t.Fatalf("LookupEntryPublication after publish = found %v, %v; want found", found, err) } if fixture.AttachmentEntry != "" { - page, err := fixture.Store.ReadAttachmentPage(t.Context(), fixture.AttachmentEntry, fixture.AttachmentName, 0, 1) + page, err := fixture.Store.ReadAttachmentPage(ctx, fixture.AttachmentEntry, fixture.AttachmentName, 0, 1) if err != nil { t.Fatalf("ReadAttachmentPage: %v", err) } @@ -110,24 +132,59 @@ func RunGraphStoreTests(t *testing.T, factory func(*testing.T) GraphStoreFixture t.Fatalf("ReadAttachmentPage = %+v", page) } } - if fixture.SecondBatch.ID != "" { - // Merge under append: SecondBatch was prepared against InitialRevision, - // but the first Apply advanced the store, so that pin is now stale and - // conflicts. Re-reading the fresh revision and applying there succeeds - // cleanly — the adapter guarantee the engine's bounded-retry merge - // depends on. A conflict must leave no ledger record blocking the retry. - stale, staleErr := fixture.Store.Apply(t.Context(), fixture.InitialRevision, fixture.SecondBatch, fixture.Blobs) - if stale.State != sdd.MutationNotApplied { - t.Fatalf("stale merge Apply = %+v (error %v), want not_applied conflict", stale, staleErr) - } - fresh, err := fixture.Store.Current(t.Context()) - if err != nil { - t.Fatalf("Current before merge apply: %v", err) - } - merged, err := fixture.Store.Apply(t.Context(), fresh.Revision(), fixture.SecondBatch, fixture.Blobs) - if err != nil || merged.State != sdd.MutationApplied || merged.Revision == "" { - t.Fatalf("merge-under-append Apply = %+v, %v", merged, err) - } + + if fixture.DocumentPath == "" { + return + } + create, replace, remove := fixture.DocumentKeys[0], fixture.DocumentKeys[1], fixture.DocumentKeys[2] + if current, err := publisher.ReadDocument(ctx, fixture.DocumentPath); err != nil || !current.Absent { + t.Fatalf("ReadDocument before create = %+v, %v; want absent", current, err) + } + created, err := publisher.PublishDocument(ctx, create, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, Content: fixture.DocumentContent, Message: "create"}) + if err != nil { + t.Fatalf("PublishDocument create: %v", err) + } + if created.Absent || !bytes.Equal(created.Content, fixture.DocumentContent) || created.Revision == "" { + t.Fatalf("PublishDocument create = %+v", created) + } + if current, err := publisher.ReadDocument(ctx, fixture.DocumentPath); err != nil || !bytes.Equal(current.Content, fixture.DocumentContent) { + t.Fatalf("ReadDocument after create = %+v, %v", current, err) + } + again, err := publisher.PublishDocument(ctx, create, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, Content: fixture.DocumentContent, Message: "create"}) + if err != nil || !bytes.Equal(again.Content, fixture.DocumentContent) { + t.Fatalf("repeated PublishDocument create = %+v, %v; want the original", again, err) + } + if looked, found, err := publisher.LookupDocumentPublication(ctx, create, fixture.DocumentPath); err != nil { + t.Fatalf("LookupDocumentPublication: %v", err) + } else if found && !bytes.Equal(looked.Content, fixture.DocumentContent) { + t.Fatalf("LookupDocumentPublication = %+v, want the created content", looked) + } + _, err = publisher.PublishDocument(ctx, replace, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, Content: fixture.DocumentReplacement, ExpectedBlob: sdd.GitBlobID([]byte("something else")), Message: "replace"}) + var appErr *sdd.ApplicationError + if !errors.As(err, &appErr) || appErr.Code != sdd.ErrorGraphConflict { + t.Fatalf("PublishDocument replace with a stale precondition = %v, want %s", err, sdd.ErrorGraphConflict) + } + if current, err := publisher.ReadDocument(ctx, fixture.DocumentPath); err != nil || !bytes.Equal(current.Content, fixture.DocumentContent) { + t.Fatalf("a refused replacement must leave the document unchanged: %+v, %v", current, err) + } + replaced, err := publisher.PublishDocument(ctx, replace, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, Content: fixture.DocumentReplacement, ExpectedBlob: sdd.GitBlobID(fixture.DocumentContent), Message: "replace"}) + if err != nil || !bytes.Equal(replaced.Content, fixture.DocumentReplacement) { + t.Fatalf("PublishDocument replace = %+v, %v", replaced, err) + } + if current, err := publisher.ReadDocument(ctx, fixture.DocumentPath); err != nil || !bytes.Equal(current.Content, fixture.DocumentReplacement) { + t.Fatalf("ReadDocument after replace = %+v, %v", current, err) + } + removed, err := publisher.PublishDocument(ctx, remove, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, Message: "remove"}) + if err != nil || !removed.Absent { + t.Fatalf("PublishDocument remove = %+v, %v; want absent", removed, err) + } + if current, err := publisher.ReadDocument(ctx, fixture.DocumentPath); err != nil || !current.Absent { + t.Fatalf("ReadDocument after remove = %+v, %v; want absent", current, err) + } + absentKey := remove + absentKey.Sequence++ + if again, err := publisher.PublishDocument(ctx, absentKey, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, Message: "remove again"}); err != nil || !again.Absent { + t.Fatalf("removing an absent document = %+v, %v; want absent without error", again, err) } } From 39bb410d8a24c3f12691a9e38f2c8a61b2d87cb7 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 23 Sep 2026 09:57:39 +0200 Subject: [PATCH 03/12] fix(application): condition WIP removals on the marker as read A removal carries the blob it read, like a replacement, so a marker recreated under the same path between read and commit is left standing. Review finding on the hosted half. Co-Authored-By: Claude Fable 5.1 --- pkg/application/document_publication.go | 28 +++++++++++++++++++------ pkg/application/graphstore.go | 7 ++++--- pkg/local/capture_publication.go | 3 +++ pkg/sddtest/conformance.go | 9 +++++++- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/pkg/application/document_publication.go b/pkg/application/document_publication.go index 98368bab..1204cccf 100644 --- a/pkg/application/document_publication.go +++ b/pkg/application/document_publication.go @@ -231,13 +231,29 @@ func (a *Application) StartWIP(ctx context.Context, identity RequestIdentity, pr }) } -// FinishWIP removes the named WIP marker from the target. Removing a marker -// that is already absent succeeds and never removes another one. +// FinishWIP removes the named WIP marker from the target, conditioned on the +// marker as it was read, so a marker recreated meanwhile under the same path +// stays. Removing a marker that is already absent succeeds and never removes +// another one. func (a *Application) FinishWIP(ctx context.Context, identity RequestIdentity, project ProjectID, binding SessionBinding, target MutationTarget, key PublicationKey, markerID string) (DocumentPublication, error) { - return a.PublishDocument(ctx, identity, project, binding, DocumentWrite{ - Target: target, Publication: key, - Mutation: DocumentMutation{LogicalPath: filepath.ToSlash(model.WIPMarkerPath(markerID)), Message: "sdd: wip done " + markerID}, - }) + logicalPath := filepath.ToSlash(model.WIPMarkerPath(markerID)) + if target.Project == "" { + target.Project = project + } + if published, exists, err := a.lookupDocumentPublication(ctx, identity, target, key, logicalPath); err != nil { + return DocumentPublication{}, err + } else if exists { + return published, nil + } + current, err := a.readDocument(ctx, identity, target, logicalPath) + if err != nil { + return DocumentPublication{}, err + } + mutation := DocumentMutation{LogicalPath: logicalPath, Message: "sdd: wip done " + markerID} + if !current.Absent { + mutation.ExpectedBlob = GitBlobID(current.Content) + } + return a.PublishDocument(ctx, identity, project, binding, DocumentWrite{Target: target, Publication: key, Mutation: mutation}) } // WIPMarkerID allocates the identity of a marker the resolved principal starts. diff --git a/pkg/application/graphstore.go b/pkg/application/graphstore.go index 97a53a58..325098fa 100644 --- a/pkg/application/graphstore.go +++ b/pkg/application/graphstore.go @@ -63,9 +63,10 @@ type DocumentMutation struct { LogicalPath string // Content is the complete document after the write; nil removes it. Content []byte - // ExpectedBlob is the Git blob ID of the document a replacement replaces - // (GitBlobID); a mismatch is an ErrorGraphConflict, never a retryable - // condition (d-tac-wgw). Empty for a creation or a removal. + // ExpectedBlob is the Git blob ID of the document a replacement replaces or + // a removal removes (GitBlobID); a mismatch is an ErrorGraphConflict, never a + // retryable condition (d-tac-wgw). Empty for a creation, or for a removal + // that only asks the path to be gone. ExpectedBlob string Message string } diff --git a/pkg/local/capture_publication.go b/pkg/local/capture_publication.go index cfeba43a..a376dac9 100644 --- a/pkg/local/capture_publication.go +++ b/pkg/local/capture_publication.go @@ -342,6 +342,9 @@ func (s *FilesystemGraphStore) PublishDocument(ctx context.Context, key app.Publ } return app.DocumentPublication{Absent: true}, nil } + if mutation.ExpectedBlob != "" && app.GitBlobID(current.Content) != mutation.ExpectedBlob { + return app.DocumentPublication{}, &app.ApplicationError{Code: app.ErrorGraphConflict, Message: "the document changed since it was read", Revision: current.Revision} + } root, openErr := os.OpenRoot(s.dir) if openErr != nil { return app.DocumentPublication{}, openErr diff --git a/pkg/sddtest/conformance.go b/pkg/sddtest/conformance.go index d7d32483..30188381 100644 --- a/pkg/sddtest/conformance.go +++ b/pkg/sddtest/conformance.go @@ -174,7 +174,14 @@ func RunGraphStoreTests(t *testing.T, factory func(*testing.T) GraphStoreFixture if current, err := publisher.ReadDocument(ctx, fixture.DocumentPath); err != nil || !bytes.Equal(current.Content, fixture.DocumentReplacement) { t.Fatalf("ReadDocument after replace = %+v, %v", current, err) } - removed, err := publisher.PublishDocument(ctx, remove, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, Message: "remove"}) + _, err = publisher.PublishDocument(ctx, remove, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, ExpectedBlob: sdd.GitBlobID(fixture.DocumentContent), Message: "remove"}) + if !errors.As(err, &appErr) || appErr.Code != sdd.ErrorGraphConflict { + t.Fatalf("PublishDocument remove against the replaced blob = %v, want %s", err, sdd.ErrorGraphConflict) + } + if current, err := publisher.ReadDocument(ctx, fixture.DocumentPath); err != nil || !bytes.Equal(current.Content, fixture.DocumentReplacement) { + t.Fatalf("a refused removal must leave the document: %+v, %v", current, err) + } + removed, err := publisher.PublishDocument(ctx, remove, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, ExpectedBlob: sdd.GitBlobID(fixture.DocumentReplacement), Message: "remove"}) if err != nil || !removed.Absent { t.Fatalf("PublishDocument remove = %+v, %v; want absent", removed, err) } From ff494ee0168cba47494c9fdfc7c766e9dfa44bd5 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 23 Sep 2026 12:19:47 +0200 Subject: [PATCH 04/12] sdd: capture 20260923-121943-d-tac-lqh SDD-Mutation: v1:95411d8fa3799cde7f106fc660cd96a31af44ba731457fc90cc87374206030d1 --- .sdd/graph/2026/09/23-121943-d-tac-lqh.md | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .sdd/graph/2026/09/23-121943-d-tac-lqh.md diff --git a/.sdd/graph/2026/09/23-121943-d-tac-lqh.md b/.sdd/graph/2026/09/23-121943-d-tac-lqh.md new file mode 100644 index 00000000..a8cae8e2 --- /dev/null +++ b/.sdd/graph/2026/09/23-121943-d-tac-lqh.md @@ -0,0 +1,34 @@ +--- +type: decision +layer: tactical +kind: directive +refs: + - id: 20260914-113911-d-tac-wgw + kind: refines + desc: 'adds the marker write semantics it left unstated: no key lookup and no precondition on marker writes' + - id: 20260914-001550-d-tac-n47 + kind: grounded-in + desc: the publication identity that stays recorded on marker commits without the marker path depending on it + - id: 20260906-113911-s-cpt-ikx + kind: related + desc: the project event stream meant to replace markers as the carrier of work movement; accounted for, not fulfilled +participants: + - Christopher +confidence: high +intent: pending +topics: + - implementation/engine + - collaboration/concurrent-work + - reliability/discipline +summary: 'Commits SDD to marker write semantics for WIP markers: writes are create-if-absent and remove-if-present with no key lookup or document precondition, making the file''s existence the marker''s whole state, while summary replacement keeps its document precondition and retry. It adds the unstated write semantics to the session-publication directive (20260914-113911-d-tac-wgw), withdraws the keyed-lookup machinery as undecisioned, and keeps the publication identity audit trail (20260914-001550-d-tac-n47) while decoupling the marker path from it. It remains an interim carrier pending the event stream replacement (20260906-113911-s-cpt-ikx), which it accounts for but does not fulfill.' +--- + +WIP marker writes are create-if-absent and remove-if-present: a start that finds the marker present publishes nothing, a finish or a groom removal that finds it absent publishes nothing, and neither looks its publication up by key nor conditions on the document it read; summary replacement keeps its document precondition, its own-bytes retry and its keyed lookup. + +A marker's path is unique to one run and allocated before the intent. It is created once and removed once, by the run at landing or by a groomer, and nobody replaces its content. Exclusivity is advisory: a lost marker lets someone start on the same anchor, a stale one blocks people until it is groomed, and neither loses data. So the file's existence is the marker's whole state, and every retry answers itself from a plain read. The publication key of 20260914-001550-d-tac-n47 stays recorded on the marker commit as audit, but the marker path no longer depends on it. The one sequence a keyed lookup would change, a groomer removing a marker seconds after its start commit lost its reply, is answered correctly by the retry recreating the marker, because the run is live. + +This refines 20260914-113911-d-tac-wgw, which asks for the summary precondition and for no marker removal before its closing evidence exists, and says nothing about marker write semantics. The delivery on branch `claude/d-tac-wgw-m4-slice4-writes` (PR #18, to be recorded by its closing done) had treated the marker like the shared entry document, with a keyed lookup before every write and a blob precondition on the removal guarding against a marker recreated under the same path between read and commit; both are withdrawn as machinery without a decision behind it, and the guarded case does not exist, because nothing ever recreates a marker under another run's path. The entry document keeps all of its protection because its path is shared: the precondition on the document a replacement replaces, the retry that recognizes its own bytes already written, and the lookup that returns a session's correction after another session published a later one instead of overwriting it. Rejected: making marker bytes deterministic so a start could compare content, because existence already answers the question. + +Markers remain the interim carrier of work movement between participants. The project event stream of 20260906-113911-s-cpt-ikx is meant to replace them with recorded occurrences and a derived in-flight projection, decided separately once it is modelled; this directive spends nothing on markers beyond removing machinery. + +Fulfilled when the local composition publishes markers this way, with the store conformance suite covering a creation over a present document, an unconditioned removal and the removal of an absent document, and the engine's tests covering a start retry after a committed start and the landing of an absent marker. From 898088a18aa4bb7d15a0a3696fe048c5eec941eb Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 23 Sep 2026 12:20:09 +0200 Subject: [PATCH 05/12] sdd: summarize 20260923-121943-d-tac-lqh (manual) SDD-Mutation: summary-20260923-121943-d-tac-lqh-70559a4e9fb5fef2 --- .sdd/graph/2026/09/23-121943-d-tac-lqh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.sdd/graph/2026/09/23-121943-d-tac-lqh.md b/.sdd/graph/2026/09/23-121943-d-tac-lqh.md index a8cae8e2..ee7c8de3 100644 --- a/.sdd/graph/2026/09/23-121943-d-tac-lqh.md +++ b/.sdd/graph/2026/09/23-121943-d-tac-lqh.md @@ -20,7 +20,7 @@ topics: - implementation/engine - collaboration/concurrent-work - reliability/discipline -summary: 'Commits SDD to marker write semantics for WIP markers: writes are create-if-absent and remove-if-present with no key lookup or document precondition, making the file''s existence the marker''s whole state, while summary replacement keeps its document precondition and retry. It adds the unstated write semantics to the session-publication directive (20260914-113911-d-tac-wgw), withdraws the keyed-lookup machinery as undecisioned, and keeps the publication identity audit trail (20260914-001550-d-tac-n47) while decoupling the marker path from it. It remains an interim carrier pending the event stream replacement (20260906-113911-s-cpt-ikx), which it accounts for but does not fulfill.' +summary: WIP marker writes become create-if-absent and remove-if-present, with no publication-key lookup and no document precondition, because a marker's path is unique to its run and the file's existence is its whole state; summary replacement keeps its document precondition, its own-bytes retry and its keyed lookup. It refines the session-publication directive (20260914-113911-d-tac-wgw) with the marker write semantics it left unstated, withdrawing the slice 4 delivery's keyed lookup and removal precondition as machinery without a decision behind it, while the publication identity (20260914-001550-d-tac-n47) stays recorded on marker commits as audit. Markers remain the interim carrier of work movement; the project event stream (20260906-113911-s-cpt-ikx) is meant to replace them and is accounted for, not fulfilled. --- WIP marker writes are create-if-absent and remove-if-present: a start that finds the marker present publishes nothing, a finish or a groom removal that finds it absent publishes nothing, and neither looks its publication up by key nor conditions on the document it read; summary replacement keeps its document precondition, its own-bytes retry and its keyed lookup. From 0cfd46240e5500b3571399ad70d1ea51180903dd Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 23 Sep 2026 12:25:13 +0200 Subject: [PATCH 06/12] refactor(application): publish WIP markers as create-if-absent and remove-if-present A marker's path is unique to its run, so its existence is its whole state: a start over a present marker and a removal of an absent one publish nothing, and neither looks its publication up by key nor conditions on the document it read. Summary replacement keeps its precondition, its own-bytes retry and its keyed lookup. Decided in 20260923-121943-d-tac-lqh. Co-Authored-By: Claude Fable 5.1 --- pkg/application/document_publication.go | 49 +++++++--------------- pkg/application/graphstore.go | 23 ++++++----- pkg/local/capture_publication.go | 54 ++++++++++++++++--------- pkg/sddtest/conformance.go | 24 ++++++----- 4 files changed, 76 insertions(+), 74 deletions(-) diff --git a/pkg/application/document_publication.go b/pkg/application/document_publication.go index 1204cccf..9cc57892 100644 --- a/pkg/application/document_publication.go +++ b/pkg/application/document_publication.go @@ -13,8 +13,7 @@ import ( // DocumentWrite is one entry-less graph write under the session's recorded // intent: a summary replacement, a WIP marker created or removed. The key is -// the intent's publication identity; the store publishes the mutation once -// under it, and a retry that finds the publication returns it (d-tac-n47). +// the intent's publication identity, recorded with the write (d-tac-n47). type DocumentWrite struct { Target MutationTarget Publication PublicationKey @@ -67,8 +66,8 @@ func (a *Application) lookupDocumentPublication(ctx context.Context, identity Re } // PublishDocument publishes one document write under its recorded key and runs -// the target's finalizers on what it changed. A key that already published -// returns that publication; a removal of an absent document publishes nothing. +// the target's finalizers on what it changed. A write that changed nothing, a +// marker already present or already absent, completes without them. func (a *Application) PublishDocument(ctx context.Context, identity RequestIdentity, project ProjectID, binding SessionBinding, write DocumentWrite) (_ DocumentPublication, err error) { principal, runtime, err := a.resolve(ctx, identity, project, AccessWrite) if err != nil { @@ -109,19 +108,12 @@ func (a *Application) PublishDocument(ctx context.Context, identity RequestIdent if err != nil { return DocumentPublication{}, err } - publication, exists, err := publisher.LookupDocumentPublication(ctx, write.Publication, write.Mutation.LogicalPath) + publication, err := publisher.PublishDocument(ctx, write.Publication, write.Mutation) if err != nil { return DocumentPublication{}, err } - if !exists { - publication, err = publisher.PublishDocument(ctx, write.Publication, write.Mutation) - if err != nil { - return DocumentPublication{}, err - } - if write.Mutation.Content == nil && publication.Absent && publication.Revision == "" { - // Removing what was already absent left nothing to commit. - return publication, nil - } + if publication.Revision == "" { + return publication, nil } batch := MutationBatch{ ID: write.Publication.String(), @@ -207,8 +199,8 @@ type WIPMarkerWrite struct { } // StartWIP publishes an exclusive WIP marker for the entry on the target. The -// marker's identity was allocated before the intent, so a retry publishes the -// same marker once. +// marker's identity was allocated before the intent and its path is unique to +// the run, so a retry that finds the marker present publishes nothing. func (a *Application) StartWIP(ctx context.Context, identity RequestIdentity, project ProjectID, binding SessionBinding, write WIPMarkerWrite) (DocumentPublication, error) { principal, runtime, err := a.resolve(ctx, identity, project, AccessWrite) if err != nil { @@ -231,29 +223,16 @@ func (a *Application) StartWIP(ctx context.Context, identity RequestIdentity, pr }) } -// FinishWIP removes the named WIP marker from the target, conditioned on the -// marker as it was read, so a marker recreated meanwhile under the same path -// stays. Removing a marker that is already absent succeeds and never removes -// another one. +// FinishWIP removes the named WIP marker from the target. Removing a marker +// that is already absent succeeds and never removes another one. func (a *Application) FinishWIP(ctx context.Context, identity RequestIdentity, project ProjectID, binding SessionBinding, target MutationTarget, key PublicationKey, markerID string) (DocumentPublication, error) { - logicalPath := filepath.ToSlash(model.WIPMarkerPath(markerID)) if target.Project == "" { target.Project = project } - if published, exists, err := a.lookupDocumentPublication(ctx, identity, target, key, logicalPath); err != nil { - return DocumentPublication{}, err - } else if exists { - return published, nil - } - current, err := a.readDocument(ctx, identity, target, logicalPath) - if err != nil { - return DocumentPublication{}, err - } - mutation := DocumentMutation{LogicalPath: logicalPath, Message: "sdd: wip done " + markerID} - if !current.Absent { - mutation.ExpectedBlob = GitBlobID(current.Content) - } - return a.PublishDocument(ctx, identity, project, binding, DocumentWrite{Target: target, Publication: key, Mutation: mutation}) + return a.PublishDocument(ctx, identity, project, binding, DocumentWrite{ + Target: target, Publication: key, + Mutation: DocumentMutation{LogicalPath: filepath.ToSlash(model.WIPMarkerPath(markerID)), Message: "sdd: wip done " + markerID}, + }) } // WIPMarkerID allocates the identity of a marker the resolved principal starts. diff --git a/pkg/application/graphstore.go b/pkg/application/graphstore.go index 325098fa..1f4f3f39 100644 --- a/pkg/application/graphstore.go +++ b/pkg/application/graphstore.go @@ -49,7 +49,9 @@ type EntryPublication struct { // DocumentPublication is what one keyed write left for a logical path: the // revision carrying it and the document's bytes there, or its absence when the -// write removed the document or found nothing to remove. +// write removed the document or found nothing to remove. An empty Revision +// says the write changed nothing that still needs completing: the document +// was already present as asked, or already absent. type DocumentPublication struct { Revision string Content []byte @@ -57,16 +59,19 @@ type DocumentPublication struct { } // DocumentMutation is one keyed write of a single graph document without -// attachments: a creation, a replacement conditioned on the document it -// replaces, or a removal. The store publishes it once under its key. +// attachments. Content with no ExpectedBlob creates the document if it is +// absent and otherwise leaves the existing document as it is; Content with an +// ExpectedBlob replaces the document that blob identifies; nil Content removes +// the document if it is present. Creation and removal are the WIP marker +// writes: a marker's path is unique to its run, so its existence is its whole +// state and no precondition applies (d-tac-lqh). type DocumentMutation struct { LogicalPath string // Content is the complete document after the write; nil removes it. Content []byte - // ExpectedBlob is the Git blob ID of the document a replacement replaces or - // a removal removes (GitBlobID); a mismatch is an ErrorGraphConflict, never a - // retryable condition (d-tac-wgw). Empty for a creation, or for a removal - // that only asks the path to be gone. + // ExpectedBlob is the Git blob ID of the document a replacement replaces + // (GitBlobID); a mismatch is an ErrorGraphConflict, never a retryable + // condition (d-tac-wgw). Empty for a creation or a removal. ExpectedBlob string Message string } @@ -76,8 +81,8 @@ type DocumentMutation struct { // publication; a lookup failure is never treated as absence. PublishEntry // accepts one entry and its staged attachments; PublishDocument one entry-less // document change: a WIP marker created or removed, a summary replaced. A -// removal of an absent document succeeds without a publication. Required -// storage commits precede success. +// creation over a present document and a removal of an absent one succeed +// with nothing published. Required storage commits precede success. type PublicationStore interface { LookupEntryPublication(context.Context, PublicationKey, string) (EntryPublication, bool, error) PublishEntry(context.Context, PublicationKey, MutationBatch, StagedBlobReader) (EntryPublication, error) diff --git a/pkg/local/capture_publication.go b/pkg/local/capture_publication.go index a376dac9..18d590a4 100644 --- a/pkg/local/capture_publication.go +++ b/pkg/local/capture_publication.go @@ -117,6 +117,17 @@ func (s *FilesystemGraphStore) PublishEntry(ctx context.Context, key app.Publica return publication, nil } +// committedOnBranch reports whether the publication branch carries the path. +// Without Git nothing is known to be complete, so a present document is +// handed to the finalizers again; they are idempotent. +func (s *FilesystemGraphStore) committedOnBranch(ctx context.Context, logicalPath string) (bool, error) { + if s.publicationGit == nil { + return false, nil + } + _, committed, err := s.publicationGit.readCommittedFile(ctx, s.publicationGit.Branch, logicalPath) + return committed, err +} + // recordLineage remembers which revision a publication advanced from. func (s *FilesystemGraphStore) recordLineage(before, after string) { if before == "" || after == "" || before == after { @@ -298,10 +309,12 @@ func (s *FilesystemGraphStore) lookupDocumentPublicationLocked(ctx context.Conte return app.DocumentPublication{Revision: revision, Content: content, Absent: !exists}, true, nil } -// PublishDocument writes one document change once under its key. A -// replacement checks the document it replaces by blob ID; a removal of an -// absent document succeeds with nothing to commit. On a Git-backed target the -// publication counts as existing only after its finalizer committed it. +// PublishDocument writes one document change once under its key. A creation +// over a present document and a removal of an absent one change nothing; a +// replacement checks the document it replaces by blob ID. On a Git-backed +// target the publication counts as existing only after its finalizer +// committed it, so a file written or removed by an attempt whose commit was +// lost is handed to the finalizer again. func (s *FilesystemGraphStore) PublishDocument(ctx context.Context, key app.PublicationKey, mutation app.DocumentMutation) (_ app.DocumentPublication, err error) { if err := key.Validate(); err != nil { return app.DocumentPublication{}, err @@ -329,22 +342,16 @@ func (s *FilesystemGraphStore) PublishDocument(ctx context.Context, key app.Publ switch { case mutation.Content == nil: if current.Absent { - if s.publicationGit != nil { - // Removed before a lost commit: the branch still carries the file, - // so the finalizer has a removal to commit. - _, committed, err := s.publicationGit.readCommittedFile(ctx, s.publicationGit.Branch, mutation.LogicalPath) - if err != nil { - return app.DocumentPublication{}, err - } - if committed { - break - } + committed, err := s.committedOnBranch(ctx, mutation.LogicalPath) + if err != nil { + return app.DocumentPublication{}, err + } + if committed { + // Removed before a lost commit: the finalizer has a removal to commit. + break } return app.DocumentPublication{Absent: true}, nil } - if mutation.ExpectedBlob != "" && app.GitBlobID(current.Content) != mutation.ExpectedBlob { - return app.DocumentPublication{}, &app.ApplicationError{Code: app.ErrorGraphConflict, Message: "the document changed since it was read", Revision: current.Revision} - } root, openErr := os.OpenRoot(s.dir) if openErr != nil { return app.DocumentPublication{}, openErr @@ -356,12 +363,21 @@ func (s *FilesystemGraphStore) PublishDocument(ctx context.Context, key app.Publ if err := syncDirectory(filepath.Join(s.dir, filepath.FromSlash(path.Dir(mutation.LogicalPath)))); err != nil { return app.DocumentPublication{}, err } + case mutation.ExpectedBlob == "" && !current.Absent: + // Create-if-absent: the document is present; a file whose commit was + // lost still goes to the finalizer, anything else changes nothing. + committed, err := s.committedOnBranch(ctx, mutation.LogicalPath) + if err != nil { + return app.DocumentPublication{}, err + } + if committed { + return app.DocumentPublication{Content: current.Content}, nil + } + return app.DocumentPublication{Revision: current.Revision, Content: current.Content}, nil case !current.Absent && bytes.Equal(current.Content, mutation.Content): // Written before a lost commit; the finalizer completes it. case mutation.ExpectedBlob != "" && (current.Absent || app.GitBlobID(current.Content) != mutation.ExpectedBlob): return app.DocumentPublication{}, &app.ApplicationError{Code: app.ErrorGraphConflict, Message: "the document changed since it was read", Revision: current.Revision} - case mutation.ExpectedBlob == "" && !current.Absent: - return app.DocumentPublication{}, &app.ApplicationError{Code: app.ErrorGraphConflict, Message: "the document already exists with other content", Revision: current.Revision} default: root, openErr := os.OpenRoot(s.dir) if openErr != nil { diff --git a/pkg/sddtest/conformance.go b/pkg/sddtest/conformance.go index 30188381..af471d80 100644 --- a/pkg/sddtest/conformance.go +++ b/pkg/sddtest/conformance.go @@ -159,6 +159,15 @@ func RunGraphStoreTests(t *testing.T, factory func(*testing.T) GraphStoreFixture } else if found && !bytes.Equal(looked.Content, fixture.DocumentContent) { t.Fatalf("LookupDocumentPublication = %+v, want the created content", looked) } + other := create + other.Sequence++ + existing, err := publisher.PublishDocument(ctx, other, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, Content: fixture.DocumentReplacement, Message: "create over"}) + if err != nil || !bytes.Equal(existing.Content, fixture.DocumentContent) || existing.Absent { + t.Fatalf("PublishDocument create over a present document = %+v, %v; want the present document", existing, err) + } + if current, err := publisher.ReadDocument(ctx, fixture.DocumentPath); err != nil || !bytes.Equal(current.Content, fixture.DocumentContent) { + t.Fatalf("a creation over a present document must leave it: %+v, %v", current, err) + } _, err = publisher.PublishDocument(ctx, replace, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, Content: fixture.DocumentReplacement, ExpectedBlob: sdd.GitBlobID([]byte("something else")), Message: "replace"}) var appErr *sdd.ApplicationError if !errors.As(err, &appErr) || appErr.Code != sdd.ErrorGraphConflict { @@ -174,15 +183,8 @@ func RunGraphStoreTests(t *testing.T, factory func(*testing.T) GraphStoreFixture if current, err := publisher.ReadDocument(ctx, fixture.DocumentPath); err != nil || !bytes.Equal(current.Content, fixture.DocumentReplacement) { t.Fatalf("ReadDocument after replace = %+v, %v", current, err) } - _, err = publisher.PublishDocument(ctx, remove, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, ExpectedBlob: sdd.GitBlobID(fixture.DocumentContent), Message: "remove"}) - if !errors.As(err, &appErr) || appErr.Code != sdd.ErrorGraphConflict { - t.Fatalf("PublishDocument remove against the replaced blob = %v, want %s", err, sdd.ErrorGraphConflict) - } - if current, err := publisher.ReadDocument(ctx, fixture.DocumentPath); err != nil || !bytes.Equal(current.Content, fixture.DocumentReplacement) { - t.Fatalf("a refused removal must leave the document: %+v, %v", current, err) - } - removed, err := publisher.PublishDocument(ctx, remove, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, ExpectedBlob: sdd.GitBlobID(fixture.DocumentReplacement), Message: "remove"}) - if err != nil || !removed.Absent { + removed, err := publisher.PublishDocument(ctx, remove, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, Message: "remove"}) + if err != nil || !removed.Absent || removed.Revision == "" { t.Fatalf("PublishDocument remove = %+v, %v; want absent", removed, err) } if current, err := publisher.ReadDocument(ctx, fixture.DocumentPath); err != nil || !current.Absent { @@ -190,8 +192,8 @@ func RunGraphStoreTests(t *testing.T, factory func(*testing.T) GraphStoreFixture } absentKey := remove absentKey.Sequence++ - if again, err := publisher.PublishDocument(ctx, absentKey, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, Message: "remove again"}); err != nil || !again.Absent { - t.Fatalf("removing an absent document = %+v, %v; want absent without error", again, err) + if again, err := publisher.PublishDocument(ctx, absentKey, sdd.DocumentMutation{LogicalPath: fixture.DocumentPath, Message: "remove again"}); err != nil || !again.Absent || again.Revision != "" { + t.Fatalf("removing an absent document = %+v, %v; want absent with nothing to complete", again, err) } } From e34d64cdce97e67ec3bfdc95e7d51e6fbf8e8639 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 23 Sep 2026 12:33:40 +0200 Subject: [PATCH 07/12] fix(local): keep revision lineage across store instances Reads and writes over one graph directory run in different store instances, and a process restart drops in-memory state, so a read that must include an earlier write could not be shown to after any later publication. Publications now append to a lineage file under the graph's runtime directory, and includes-revision reads consult it. Co-Authored-By: Claude Fable 5.1 --- pkg/local/capture_publication.go | 32 ++++++++++++++++++++- pkg/local/lineage_test.go | 48 ++++++++++++++++++++++++++++++++ pkg/local/local_graphstore.go | 11 ++++++-- pkg/local/read_snapshot.go | 29 ++++++++++++++----- 4 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 pkg/local/lineage_test.go diff --git a/pkg/local/capture_publication.go b/pkg/local/capture_publication.go index 18d590a4..9c33a5fe 100644 --- a/pkg/local/capture_publication.go +++ b/pkg/local/capture_publication.go @@ -128,7 +128,10 @@ func (s *FilesystemGraphStore) committedOnBranch(ctx context.Context, logicalPat return committed, err } -// recordLineage remembers which revision a publication advanced from. +// recordLineage remembers which revision a publication advanced from, in +// memory and in the lineage file; it runs under the graph lock. A failure to +// append is not a failed publication: the write is on disk, and only a later +// includes-revision read loses its proof. func (s *FilesystemGraphStore) recordLineage(before, after string) { if before == "" || after == "" || before == after { return @@ -137,6 +140,33 @@ func (s *FilesystemGraphStore) recordLineage(before, after string) { s.lineage = map[string]string{} } s.lineage[after] = before + file, err := os.OpenFile(filepath.Join(s.dir, filepath.FromSlash(lineageFile)), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644) + if err != nil { + return + } + _, _ = fmt.Fprintf(file, "%s %s\n", after, before) + _ = file.Close() +} + +// loadLineage merges the lineage file into the in-memory map. +func (s *FilesystemGraphStore) loadLineage() error { + raw, err := os.ReadFile(filepath.Join(s.dir, filepath.FromSlash(lineageFile))) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if s.lineage == nil { + s.lineage = map[string]string{} + } + for _, line := range strings.Split(string(raw), "\n") { + after, before, ok := strings.Cut(line, " ") + if ok && after != "" && before != "" { + s.lineage[after] = before + } + } + return nil } func (s *FilesystemGraphStore) readExistingEntry(ctx context.Context, logicalPath string) (_ app.EntryPublication, _ bool, err error) { diff --git a/pkg/local/lineage_test.go b/pkg/local/lineage_test.go new file mode 100644 index 00000000..38ff934e --- /dev/null +++ b/pkg/local/lineage_test.go @@ -0,0 +1,48 @@ +package local_test + +import ( + "testing" + + sdd "github.com/networkteam/sdd/pkg/application" + localadapter "github.com/networkteam/sdd/pkg/local" +) + +// A read that must include an earlier write is answered from the lineage every +// store over the graph directory shares: a fresh store, which never saw the +// writes, still shows the current revision descends from the first one, and +// still refuses a revision nobody published. +func TestIncludesRevisionIsAnsweredByAFreshStore(t *testing.T) { + dir := canonicalTempDir(t) + open := func() *localadapter.FilesystemGraphStore { + store, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: dir}) + if err != nil { + t.Fatal(err) + } + return store + } + writer := open() + content := []byte("---\nentry: 20260923-010000-s-tac-sum\nparticipant: Christopher\n---\n\nWork.\n") + first, err := writer.PublishDocument(t.Context(), sdd.PublicationKey{Session: "s1", Sequence: 2, Discriminator: "wipStart:a"}, sdd.DocumentMutation{LogicalPath: "wip/20260923-010000-a.md", Content: content, Message: "a"}) + if err != nil { + t.Fatal(err) + } + second, err := writer.PublishDocument(t.Context(), sdd.PublicationKey{Session: "s1", Sequence: 4, Discriminator: "wipStart:b"}, sdd.DocumentMutation{LogicalPath: "wip/20260923-010100-b.md", Content: content, Message: "b"}) + if err != nil || second.Revision == first.Revision { + t.Fatalf("second publication = %+v, %v; want a new revision", second, err) + } + + reader := open() + acquired, err := reader.AcquireSnapshot(t.Context(), sdd.SnapshotReadQuery{IncludesRevision: first.Revision}) + if err != nil { + t.Fatalf("a fresh store must show the current revision includes the first write: %v", err) + } + if acquired.Snapshot.Revision() != second.Revision { + t.Fatalf("snapshot revision = %s, want the current %s", acquired.Snapshot.Revision(), second.Revision) + } + if err := acquired.Release(); err != nil { + t.Fatal(err) + } + if _, err := reader.AcquireSnapshot(t.Context(), sdd.SnapshotReadQuery{IncludesRevision: "sha256:0000000000000000000000000000000000000000000000000000000000000000"}); err == nil { + t.Fatal("a revision nobody published must not be shown as included") + } +} diff --git a/pkg/local/local_graphstore.go b/pkg/local/local_graphstore.go index 985ff9db..eb8c3faa 100644 --- a/pkg/local/local_graphstore.go +++ b/pkg/local/local_graphstore.go @@ -38,11 +38,18 @@ type FilesystemGraphStore struct { mu sync.Mutex snapshots map[string]*retainedSnapshot publicationGit *GitFinalizer - // lineage maps a revision this store published to the revision it replaced, - // so a read can be shown to include an earlier write of this process. + // lineage maps a published revision to the revision it replaced, so a read + // can be shown to include an earlier write. It is cached from the lineage + // file under the runtime directory, which every store over this graph + // directory appends to and reads, so a reader is never limited to the + // writes of its own instance or process. lineage map[string]string } +// lineageFile is the append-only record of published revisions, one line per +// publication: the revision written, then the revision it replaced. +const lineageFile = ".sdd-runtime/lineage" + func NewFilesystemGraphStore(options FilesystemGraphStoreOptions) (*FilesystemGraphStore, error) { if options.Project == "" { return nil, fmt.Errorf("sdd: filesystem graph project is required") diff --git a/pkg/local/read_snapshot.go b/pkg/local/read_snapshot.go index 94512fbd..dae26387 100644 --- a/pkg/local/read_snapshot.go +++ b/pkg/local/read_snapshot.go @@ -119,14 +119,18 @@ func (s *FilesystemGraphStore) leaseSnapshot(retained *retainedSnapshot) *app.Ac } // includesRevision reports whether the current revision carries the required -// one: equal revisions, or on a Git-backed target a commit that is an ancestor -// of the branch head. +// one: equal revisions, a revision the lineage file shows the current one +// descends from, or on a Git-backed target a commit that is an ancestor of the +// branch head. It runs under the graph lock. func (s *FilesystemGraphStore) includesRevision(ctx context.Context, current, required string) (bool, error) { - for node, seen := current, map[string]bool{}; node != "" && !seen[node]; node = s.lineage[node] { - if node == required { - return true, nil - } - seen[node] = true + if s.descendsFrom(current, required) { + return true, nil + } + if err := s.loadLineage(); err != nil { + return false, err + } + if s.descendsFrom(current, required) { + return true, nil } if s.publicationGit == nil || !isGitRevision(required) { return false, nil @@ -134,6 +138,17 @@ func (s *FilesystemGraphStore) includesRevision(ctx context.Context, current, re return s.publicationGit.isAncestor(ctx, required) } +// descendsFrom walks the cached lineage from current back to required. +func (s *FilesystemGraphStore) descendsFrom(current, required string) bool { + for node, seen := current, map[string]bool{}; node != "" && !seen[node]; node = s.lineage[node] { + if node == required { + return true + } + seen[node] = true + } + return false +} + func isGitRevision(revision string) bool { if len(revision) < 7 || len(revision) > 64 { return false From 23decc0b84cdba7c544f77ba1fb57e45fa55ef6d Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 23 Sep 2026 15:02:37 +0200 Subject: [PATCH 08/12] fix(application): refuse a home-project write before its intent authorizeTarget skipped the home project, so a principal with read access only could prepare a write there and record an intent that the publication then refused. The gate now resolves the home project with the access the command needs, and a reader is refused before anything is remembered. Co-Authored-By: Claude Fable 5.1 --- .../application_composition_test.go | 21 +++++++++++++++++++ pkg/application/workflow_project.go | 10 +++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/pkg/application/application_composition_test.go b/pkg/application/application_composition_test.go index ef3f17ac..101c96f8 100644 --- a/pkg/application/application_composition_test.go +++ b/pkg/application/application_composition_test.go @@ -188,6 +188,27 @@ Project B is readable as an authorized dependency.`), 0o644); err != nil { if _, err := application.CreateEntry(t.Context(), reader, "project-a", workflow.Binding(), sdd.EntryDraft{}); applicationErrorCode(err) != sdd.ErrorWriteDenied { t.Fatalf("read-only mutation = %v", err) } + // A write the dialogue reaches at home is refused before its intent is + // recorded, not at the publication: the reader's groom removal leaves no + // pending operation behind. + groom, err := workflow.Start(t.Context(), reader, sdd.WorkflowStartRequest{Canonical: "groom"}) + if err != nil { + t.Fatalf("read-only groom start: %v", err) + } + if _, err := workflow.Advance(t.Context(), reader, sdd.WorkflowAdvanceRequest{Instance: groom.Instance, Report: map[string]any{"candidates": "1. stale marker 20260101-000000-someone"}}); err != nil { + t.Fatalf("read-only groom sweep: %v", err) + } + _, err = workflow.Advance(t.Context(), reader, sdd.WorkflowAdvanceRequest{Instance: groom.Instance, Report: map[string]any{ + "chooser": "walk", "choice": "removeMarker", "userWords": "remove it", "fields": map[string]any{"staleMarker": "20260101-000000-someone"}, + }}) + if applicationErrorCode(err) != sdd.ErrorWriteDenied { + t.Fatalf("read-only marker removal = %v, want %s before any intent", err, sdd.ErrorWriteDenied) + } + if _, position, err := application.ResumeWorkflow(t.Context(), reader, sdd.WorkflowResumeRequest{SessionID: workflow.ID(), ClientName: "reader-mcp"}); err != nil { + t.Fatal(err) + } else if position.PendingOperation != nil { + t.Fatalf("a refused write must record no intent: %+v", position.PendingOperation) + } aliceWorkflow, _, err := application.OpenWorkflow(t.Context(), alice, "project-a", sdd.WorkflowOpenRequest{ClientName: "alice-mcp"}) if err != nil { diff --git a/pkg/application/workflow_project.go b/pkg/application/workflow_project.go index f8646b8f..fbeb407e 100644 --- a/pkg/application/workflow_project.go +++ b/pkg/application/workflow_project.go @@ -110,11 +110,13 @@ func (w *WorkflowSession) targetRuntime(project ProjectID, required Access) (*Pr return runtime, err } -// authorizeTarget is targetRuntime without the runtime — the gate a write in -// another project passes before the application resolves it again for the -// write itself. At home it costs nothing. +// authorizeTarget is targetRuntime without the runtime — the gate a write +// passes before its intent is recorded, so a principal without write access is +// refused before anything is remembered rather than at the publication the +// application authorizes again. A read at home costs nothing: the session +// resolved the home project for reading when it opened. func (w *WorkflowSession) authorizeTarget(project ProjectID, required Access) error { - if project == "" || project == w.project { + if (project == "" || project == w.project) && required == AccessRead { return nil } _, err := w.targetRuntime(project, required) From da020b34158e728156db6366089c0f670d06dc69 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 23 Sep 2026 15:31:49 +0200 Subject: [PATCH 09/12] sdd: capture 20260923-153145-s-tac-m95 SDD-Mutation: v1:0abb9225099c6a2afb3e76d4bf83ec925645a39d873c90bd6fc1f281fd94216d --- .sdd/graph/2026/09/23-153145-s-tac-m95.md | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .sdd/graph/2026/09/23-153145-s-tac-m95.md diff --git a/.sdd/graph/2026/09/23-153145-s-tac-m95.md b/.sdd/graph/2026/09/23-153145-s-tac-m95.md new file mode 100644 index 00000000..e0ffaccc --- /dev/null +++ b/.sdd/graph/2026/09/23-153145-s-tac-m95.md @@ -0,0 +1,27 @@ +--- +type: signal +layer: tactical +kind: gap +refs: + - id: 20260707-170902-s-cpt-ev3 + kind: related + desc: the neighbouring diagnosability gap on the session log; this one is the process's operational log, a different axis +participants: + - Christopher +confidence: high +topics: + - engine/observability + - portability/mcp + - implementation/mcp +summary: 'Gap signal (tactical): the stdio-served local engine has no operational log — `sdd serve` records nothing about handled MCP methods (tool, session, duration, outcome) and provides no flag or environment variable to direct such a log to a file, so local server activity is invisible live and afterwards. It relates to the neighbouring diagnosability gap on the session log (20260707-170902-s-cpt-ev3) as a distinct axis: that log is per session and part of the record, whereas this concerns the running process''s operational log. A candidate fix proposes SDK receiving-middleware logging written by serve to a file named via flag or env var, off by default, with a pass-through option for external compositions.' +--- + +The local engine served over stdio has no operational log: `sdd serve` writes nothing about the MCP methods it handles, neither tool name, session, duration nor outcome, and offers no way to direct such a log to a file, so what the local server did during a dialogue can be seen neither live nor afterwards. + +**Observed.** On 2026-09-23, while requests against the hosted composition were found hanging for minutes without any line naming their method, the local composition was checked for the same view and has less: the serve command has no log flag or environment variable and no log destination, the shared MCP application logs nothing per call and exposes no hook for a composition to add it, and the stdio transport occupies standard output, so even an added log needs a destination of its own. + +**Expected.** Christopher's expectation, stated in the dialogue: a log that lets us understand what the server is doing, especially when something fails, reachable for local debugging by naming a log file through a CLI argument or an environment variable. The session log's diagnosability gap 20260707-170902-s-cpt-ev3 is a neighbour on a different axis: that log is per session and part of the record, this is the operational log of the running process. + +**Deviation.** There is nothing to read: a hanging or failing call in the local engine leaves no trace outside the agent's conversation. + +**Candidate, not observation.** A logging middleware on the SDK's receiving-middleware hook, logging method, tool name, session, duration and outcome with errors at error level, written by the serve command to a file named by a flag or environment variable and off by default; the same middleware offered to external compositions through a pass-through option of the shared application. From 875dbedc2e48880212c24ba4b98ea63363b28801 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 23 Sep 2026 15:32:13 +0200 Subject: [PATCH 10/12] sdd: summarize 20260923-153145-s-tac-m95 (manual) SDD-Mutation: summary-20260923-153145-s-tac-m95-6ea569d4234ab502 --- .sdd/graph/2026/09/23-153145-s-tac-m95.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.sdd/graph/2026/09/23-153145-s-tac-m95.md b/.sdd/graph/2026/09/23-153145-s-tac-m95.md index e0ffaccc..86920bc7 100644 --- a/.sdd/graph/2026/09/23-153145-s-tac-m95.md +++ b/.sdd/graph/2026/09/23-153145-s-tac-m95.md @@ -13,7 +13,7 @@ topics: - engine/observability - portability/mcp - implementation/mcp -summary: 'Gap signal (tactical): the stdio-served local engine has no operational log — `sdd serve` records nothing about handled MCP methods (tool, session, duration, outcome) and provides no flag or environment variable to direct such a log to a file, so local server activity is invisible live and afterwards. It relates to the neighbouring diagnosability gap on the session log (20260707-170902-s-cpt-ev3) as a distinct axis: that log is per session and part of the record, whereas this concerns the running process''s operational log. A candidate fix proposes SDK receiving-middleware logging written by serve to a file named via flag or env var, off by default, with a pass-through option for external compositions.' +summary: 'The local engine served over stdio has no operational log: `sdd serve` writes nothing about the MCP methods it handles, neither tool name, session, duration nor outcome, and offers no flag or environment variable to direct such a log to a file, so what the local server did during a dialogue can be seen neither live nor afterwards. It neighbours the session log''s diagnosability gap (20260707-170902-s-cpt-ev3) on a different axis, that log being per session and part of the record while this is the running process''s log. Candidate: a logging middleware on the SDK''s receiving-middleware hook, written by the serve command to a file named by flag or environment variable and off by default, offered to external compositions through a pass-through option.' --- The local engine served over stdio has no operational log: `sdd serve` writes nothing about the MCP methods it handles, neither tool name, session, duration nor outcome, and offers no way to direct such a log to a file, so what the local server did during a dialogue can be seen neither live nor afterwards. From 9c223a5d9a156051e0e35b447065c51ce0d49cbe Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 23 Sep 2026 16:07:01 +0200 Subject: [PATCH 11/12] sdd: capture 20260923-160657-s-tac-tii SDD-Mutation: v1:ac83793ab6ebb4170c9e9265a0e5508fd998db8f4a43ea5101695b44a0dfe554 --- .sdd/graph/2026/09/23-160657-s-tac-tii.md | 39 +++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .sdd/graph/2026/09/23-160657-s-tac-tii.md diff --git a/.sdd/graph/2026/09/23-160657-s-tac-tii.md b/.sdd/graph/2026/09/23-160657-s-tac-tii.md new file mode 100644 index 00000000..68fcdab5 --- /dev/null +++ b/.sdd/graph/2026/09/23-160657-s-tac-tii.md @@ -0,0 +1,39 @@ +--- +type: signal +layer: tactical +kind: done +refs: + - id: 20260915-085829-s-tac-awi + kind: builds-on + desc: the capture half of the publication path this delivery completes for the remaining writes + - id: 20260915-093114-s-tac-ixt + kind: addresses + desc: removes the digest and size checks it lists on the prepared-write path; its other findings stay open + - id: 20260727-224047-d-cpt-u8o + kind: grounded-in + desc: the standard under which the prepared-write path's pending records were dropped without a compatibility reader + - id: 20260923-153145-s-tac-m95 + kind: surfaces + desc: the missing operational log found while the hosted exercise's hangs could not be attributed +closes: + - 20260914-113911-d-tac-wgw + - 20260914-001550-d-tac-n47 + - 20260923-121943-d-tac-lqh + - 20260915-093058-s-tac-do6 + - 20260915-093106-s-tac-aen +participants: + - Christopher +confidence: high +topics: + - implementation/engine + - reliability/testing +summary: 'The remaining write paths — summary correction and WIP markers — now publish through the recorded-intent path capture uses, delivered as PR #18 (not merged), with a retried recorded intent landing on the answered chooser option''s target; the prepared-write path and its records, digests and checks are removed. This completes the session-log completion directive (20260914-113911-d-tac-wgw) and the publication directive it refines (20260914-001550-d-tac-n47), building on the capture half (20260915-085829-s-tac-awi); it also closes the marker-semantics directive (20260923-121943-d-tac-lqh), the retry-transition gap (20260915-093058-s-tac-do6) and the conformance gap (20260915-093106-s-tac-aen). Discarding the pending-intent records without a compatibility reader applies the sessions-as-scaffolding standard (20260727-224047-d-cpt-u8o), and the review findings on the prepared-write path (20260915-093114-s-tac-ixt) fall away with the removed path.' +--- + +Summary correction and the WIP marker writes now publish through the recorded-intent path capture uses, the prepared-write path they were the last users of is removed, and a retry of a recorded intent lands on the answered chooser option's target: delivered as PR #18 on branch `claude/d-tac-wgw-m4-slice4-writes`, commits c1479e7d, ae330df3, 0cfd4624, e34d64cd and 23decc0b, not merged at the time of this record. This closes 20260914-113911-d-tac-wgw and with it the publication directive 20260914-001550-d-tac-n47 it refines, the marker directive 20260923-121943-d-tac-lqh, the retry-transition gap 20260915-093058-s-tac-do6 and the conformance gap 20260915-093106-s-tac-aen; it builds on the capture half delivered in 20260915-085829-s-tac-awi. + +c1479e7d records the transition a chooser option owes with the mutation intent and completes it on retry, so a retried summary correction lands on the step the verifySummary answer owed instead of re-serving the chooser; a procedure test reproduces the gap and its closure. ae330df3 moves `replaceSummary`, `wipStart`, `wipDone` and `wipRemove` onto the publication path: identities are allocated before the intent, each command publishes under the intent's key and reports its effects, the `PublicationStore` port gains `ReadDocument`, `LookupDocumentPublication` and `PublishDocument`, a summary replacement conditions on the blob it read and answers a moved document with a conflict naming the current summary, a retry recognizes its own bytes and finds its own publication instead of overwriting a later correction, and the conformance suite is rewritten around keyed publication so an external store proves entry and document publication with the same tests. Removed with the path: `ApplyPrepared` and the prepared transition, revalidation, the recovery projections and notices, `sdd recover` and its document, the local store's transactions and apply records, `GraphStore.Apply` and `Reconcile`, the mutation digests, and the digest and size checks on that path that 20260915-093114-s-tac-ixt lists. 0cfd4624 makes marker writes create-if-absent and remove-if-present with no key lookup and no precondition, after 39bb410d had added a removal precondition on a review finding and the dialogue withdrew it; the local store hands a file whose commit was lost to the finalizer again, and the suite covers a creation over a present document, an unconditioned removal, the removal of an absent document, a start retry after a committed start and the landing of an absent marker. e34d64cd keeps revision lineage in a file under the graph's runtime directory, because reads and writes over one directory run in different store instances and an includes-revision read could not be shown after a later write. 23decc0b authorizes the home project at prepare with the access a command needs, so a reader is refused before an intent is recorded. + +Against 20260914-113911-d-tac-wgw: reconstruction from events, competing appends, replay without dispatch, publication succeeding before its outcome, bounded retry returning control, cancellation with remaining effects and collection were delivered before; this delivery adds the document-level summary precondition with the conflict carrying the current summary, automatic retry that never overwrites a competing replacement, the marker ordering kept by the procedure and tested for an absent marker, and the collection check that open sessions and their pending writes stay untouched, which `collect.go` already did. The older pending-intent formats it asked a decision for were the prepared-write path's records; they were removed with the path and no compatibility reader was written, applying 20260727-224047-d-cpt-u8o, under which incomplete session bookkeeping is discarded rather than recovered; the hosted composition watched its stale notices disappear without loss. Bounded transient retry inside the request stays with each composition's transport. Against 20260914-001550-d-tac-n47, every mutating command now records intent and outcome and converges on its publication, and capture with an attachment, interrupted publication and same-entry retry are proven through the local and the hosted composition. + +In the doing: the marker path was first built like the shared summary document, with a keyed lookup and a blob precondition on removal, and the dialogue simplified it to the marker's actual shape. Review found the lineage loss and the prepare-time authorization, both fixed, and the collision of two marker starts by one participant within one second, accepted since the marker is advisory and its layer is slated for replacement. The hosted exercise of the same slice raised four things for this repository, recorded in the hosted graph and to become entries here: the work step's conclude option seeds a capture rather than opening one, which the served prose does not make clear; a successful marker write serves no confirmation of what it did; groom does not sweep again after a removal; and generated summaries speak about the entry instead of its content. The missing operational log of the local engine is recorded as 20260923-153145-s-tac-m95. From 0bb21890888f732f4496359eff45018273434975 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 23 Sep 2026 16:07:32 +0200 Subject: [PATCH 12/12] sdd: summarize 20260923-160657-s-tac-tii (manual) SDD-Mutation: summary-20260923-160657-s-tac-tii-250d820003ec0682 --- .sdd/graph/2026/09/23-160657-s-tac-tii.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.sdd/graph/2026/09/23-160657-s-tac-tii.md b/.sdd/graph/2026/09/23-160657-s-tac-tii.md index 68fcdab5..d4b4f732 100644 --- a/.sdd/graph/2026/09/23-160657-s-tac-tii.md +++ b/.sdd/graph/2026/09/23-160657-s-tac-tii.md @@ -27,7 +27,7 @@ confidence: high topics: - implementation/engine - reliability/testing -summary: 'The remaining write paths — summary correction and WIP markers — now publish through the recorded-intent path capture uses, delivered as PR #18 (not merged), with a retried recorded intent landing on the answered chooser option''s target; the prepared-write path and its records, digests and checks are removed. This completes the session-log completion directive (20260914-113911-d-tac-wgw) and the publication directive it refines (20260914-001550-d-tac-n47), building on the capture half (20260915-085829-s-tac-awi); it also closes the marker-semantics directive (20260923-121943-d-tac-lqh), the retry-transition gap (20260915-093058-s-tac-do6) and the conformance gap (20260915-093106-s-tac-aen). Discarding the pending-intent records without a compatibility reader applies the sessions-as-scaffolding standard (20260727-224047-d-cpt-u8o), and the review findings on the prepared-write path (20260915-093114-s-tac-ixt) fall away with the removed path.' +summary: 'Summary correction and the WIP marker writes now publish through the recorded-intent path capture uses, the prepared-write path they were the last users of is removed, and a retry of a recorded intent lands on the answered chooser option''s target, delivered as PR #18 on branch claude/d-tac-wgw-m4-slice4-writes (commits c1479e7d, ae330df3, 0cfd4624, e34d64cd, 23decc0b; not merged at the time of the record). It closes the session-log completion directive (20260914-113911-d-tac-wgw) and the publication directive it refines (20260914-001550-d-tac-n47), the marker directive (20260923-121943-d-tac-lqh), the retry-transition gap (20260915-093058-s-tac-do6) and the conformance gap (20260915-093106-s-tac-aen), building on the capture half (20260915-085829-s-tac-awi). Dropping the prepared-write path''s pending records without a compatibility reader applies the sessions-as-scaffolding standard (20260727-224047-d-cpt-u8o); of the review findings in 20260915-093114-s-tac-ixt only the digest and size checks fall away with the path, and the missing operational log of the local engine surfaced as 20260923-153145-s-tac-m95.' --- Summary correction and the WIP marker writes now publish through the recorded-intent path capture uses, the prepared-write path they were the last users of is removed, and a retry of a recorded intent lands on the answered chooser option's target: delivered as PR #18 on branch `claude/d-tac-wgw-m4-slice4-writes`, commits c1479e7d, ae330df3, 0cfd4624, e34d64cd and 23decc0b, not merged at the time of this record. This closes 20260914-113911-d-tac-wgw and with it the publication directive 20260914-001550-d-tac-n47 it refines, the marker directive 20260923-121943-d-tac-lqh, the retry-transition gap 20260915-093058-s-tac-do6 and the conformance gap 20260915-093106-s-tac-aen; it builds on the capture half delivered in 20260915-085829-s-tac-awi.