root 2a6234ff82 ADR-004 + internal/pathway: Mem0 versioned trace substrate
Closes Sprint 2 design-bar work (audit reports/scrum/sprint-backlog.md):
  S2.1 — ADR-004 documents the pathway-memory data model
  S2.2 — pathway port lands with deterministic fixture corpus
         and full test coverage on day one
  S2.3 — retired traces are excluded from retrieval (test
         passes; would fail without the filter)

Mem0-style operations: Add / AddIdempotent / Update / Revise /
Retire / Get / History / Search. Each operation is a method on
Store; persistence is JSONL append-only with corruption recovery
on Replay.

internal/pathway/types.go     Trace + event + SearchFilter + sentinel errors
internal/pathway/store.go     in-memory state + RWMutex + ops
internal/pathway/persistor.go JSONL append-only log with replay
internal/pathway/store_test.go  20 test funcs covering all 7
                                Sprint 2 claim rows + concurrency
internal/pathway/persistor_test.go  6 test funcs covering missing-
                                file, corruption recovery, long-line
                                handling, parent-dir auto-create,
                                apply-error skip behavior

Sprint 2 claim coverage row-by-row:
  ADD          TestAdd_AssignsUIDAndTimestamps + TestAdd_RejectsInvalidJSON
  UPDATE       TestUpdate_ReplacesContentSameUID + Update_MissingUID_Errors
  REVISE       TestRevise_LinksToPredecessorViaHistory +
               TestRevise_PredecessorMissing_Errors +
               TestRevise_ChainOfThree_BackwardWalk
  RETIRE       TestRetire_ExcludedFromSearch +
               TestRetire_StillAccessibleViaGet +
               TestRetire_StillAccessibleViaHistory
  HISTORY/cycle TestHistory_CycleDetected (injected via internal map),
                TestHistory_PredecessorMissing_TruncatesChain,
                TestHistory_UnknownUID_ErrorsClean
  REPLAY/dup   TestAddIdempotent_IncrementsReplayCount (locks the
               "replay preserves original content" rule per ADR-004)
  CORRUPTION   TestPersistor_CorruptedLines_Skipped +
               TestPersistor_ApplyError_Skipped
  ROUND-TRIP   TestPersistor_RoundTrip locks the full Save → fresh
               Store → Load → Stats-match contract

Two real bugs caught during testing:
  - Add returned the same *Trace stored in the map, so callers
    holding a reference saw later mutations. Fixed: clone before
    return (matches Get's contract). Same fix in AddIdempotent
    + Revise.
  - Test typo: {"v":different} isn't valid JSON; AddIdempotent's
    json.Valid rejected it as ErrInvalidContent. Test fixed to
    use {"v":"different"}; the validation behavior is correct.

Skipped this commit (next):
  - cmd/pathwayd HTTP binary
  - gateway routing for /v1/pathway/*
  - end-to-end smoke
  These add the wire surface; the substrate ships first so the
  wire layer can be a pure proxy in the next commit.

Verified:
  go test -count=1 ./internal/pathway/ — 26 tests green
  just verify                          — vet + test + 9 smokes 34s

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 07:23:30 -05:00

90 lines
3.3 KiB
Go

// Package pathway implements Mem0-style versioned trace memory per
// ADR-004. Pathway memory is an append-only event log of opaque
// traces with Add / Update / Revise / Retire / History / Search
// operations. Persisted via JSONL (one event per line) with
// corruption recovery on load.
//
// Why this exists: agents need to remember what they tried and
// what worked. Mem0 is the lowest-common-denominator memory
// substrate; building on its surface means agent loops written
// against any Mem0-aware library work here. See feedback_meta_
// index_vision.md for the north-star learning-loop framing.
package pathway
import (
"encoding/json"
"errors"
)
// Trace is one entry in pathway memory. Content is opaque to the
// substrate — callers store whatever JSON shape they want; this
// layer just preserves and indexes it.
type Trace struct {
UID string `json:"uid"`
Content json.RawMessage `json:"content"`
PredecessorUID string `json:"predecessor_uid,omitempty"`
CreatedAtNs int64 `json:"created_at_ns"`
UpdatedAtNs int64 `json:"updated_at_ns"`
Retired bool `json:"retired"`
ReplayCount int `json:"replay_count"`
Tags []string `json:"tags,omitempty"`
}
// op is the wire-format kind tag for JSONL persistence. Internal
// to the package — operations exposed publicly are method calls
// on Store; the JSONL form is its own concern.
type op string
const (
opAdd op = "add"
opUpdate op = "update"
opRevise op = "revise"
opRetire op = "retire"
opReplay op = "replay"
)
// event is one line of the JSONL log. Trace is included for ops
// that introduce or replace a trace; UID alone suffices for retire
// and replay; Content alone suffices for update (reuses the
// existing trace's UID via the UID field).
type event struct {
Op op `json:"op"`
Trace *Trace `json:"trace,omitempty"`
UID string `json:"uid,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
}
// Errors surfaced to callers. Sentinel-based so HTTP handlers (when
// cmd/pathwayd lands) can map to status codes via errors.Is.
var (
ErrNotFound = errors.New("pathway: trace not found")
ErrAlreadyExists = errors.New("pathway: trace already exists")
ErrPredecessorMissing = errors.New("pathway: predecessor trace missing")
ErrCycle = errors.New("pathway: history cycle detected")
ErrEmptyUID = errors.New("pathway: empty uid")
ErrInvalidContent = errors.New("pathway: invalid content")
)
// SearchFilter narrows a Search to matching traces. Empty filter
// returns everything (excluding retired; flip IncludeRetired to
// override). All set fields are AND-combined.
type SearchFilter struct {
// Tag returns traces whose Tags slice contains this string.
Tag string
// ContentContains returns traces whose Content contains this
// substring (treats Content as raw bytes; caller's contract
// for whether that's meaningful).
ContentContains string
// CreatedAfterNs returns traces with CreatedAtNs >= this value.
CreatedAfterNs int64
// CreatedBeforeNs returns traces with CreatedAtNs <= this value.
// Zero = no upper bound.
CreatedBeforeNs int64
// IncludeRetired flips the default "exclude retired" behavior.
IncludeRetired bool
}