diff --git a/.sdd/graph/2026/07/20-113534-s-tac-ufs.md b/.sdd/graph/2026/07/20-113534-s-tac-ufs.md new file mode 100644 index 00000000..22a0c80b --- /dev/null +++ b/.sdd/graph/2026/07/20-113534-s-tac-ufs.md @@ -0,0 +1,20 @@ +--- +type: signal +layer: tactical +kind: done +refs: + - id: 20260719-190740-d-tac-ptn + kind: addresses + desc: delivers the plan's metadata, authoring, inspection, and derived-read slice +participants: + - Christopher +confidence: high +topics: + - engine/base-facts + - implementation/engine + - type-system/kinds + - reliability/testing +summary: Slice 1 of the derived base-fact index plan ships in commit 60adf47, delivering strict nested metadata, serialization and validation, propagation from engine capture through CLI and show, and a pure current-project read restricted to active indexed facts with deterministic ordering and no supersession inheritance. Three independent reviews drove fail-loud malformed enrollment, centralized validation, strict YAML shape, removable optional state, value isolation, and stronger ordering coverage; focused tests, vet, diff checks, and targeted lint are green. This intermediate done signal addresses the plan without closing it. +--- + +Slice 1 of the derived base-fact index plan ships strict metadata and the pure derived read in commit 60adf47. The slice adds exact nested fact-index serialization and validation, carries the value through engine capture, application, command, CLI, and show surfaces, and derives only active indexed facts from the current project's merged graph with deterministic ordering and no supersession inheritance. Three independent reviews covered architecture, acceptance-criteria fidelity, and adversarial risk; their findings drove fail-loud malformed enrollment, centralized validation, strict YAML shape, removable optional state, value isolation, and stronger ordering coverage. Focused tests, vet, diff checks, and targeted lint are green. This is an intermediate completion record and does not close the plan. diff --git a/.sdd/graph/wip/20260719-191635-christopher.md b/.sdd/graph/wip/20260719-191635-christopher.md new file mode 100644 index 00000000..66be02f6 --- /dev/null +++ b/.sdd/graph/wip/20260719-191635-christopher.md @@ -0,0 +1,7 @@ +--- +entry: 20260719-190740-d-tac-ptn +participant: Christopher +exclusive: true +--- + +Implement derived base-fact index plan slice by slice diff --git a/application/session_runtime_test.go b/application/session_runtime_test.go index fff8b401..5c513bcb 100644 --- a/application/session_runtime_test.go +++ b/application/session_runtime_test.go @@ -4,12 +4,15 @@ import ( "context" "encoding/json" "errors" + "os" + "path/filepath" "strings" "sync" "testing" "time" sdd "github.com/networkteam/sdd/application" + "github.com/networkteam/sdd/internal/model" localadapter "github.com/networkteam/sdd/local" ) @@ -390,7 +393,8 @@ func TestPreparedTransitionRejectsEmptyTargetAndStructuredDivergence(t *testing. } func TestCreateEntryResolvesConcreteDefaultWithoutCWDAndReleasesAroundLLM(t *testing.T) { - graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: t.TempDir()}) + graphDir := t.TempDir() + graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: graphDir}) if err != nil { t.Fatal(err) } @@ -432,7 +436,8 @@ func TestCreateEntryResolvesConcreteDefaultWithoutCWDAndReleasesAroundLLM(t *tes binding := openBinding(t, sessions, identity.Subject, "create-default") t.Chdir(t.TempDir()) created, err := application.CreateEntry(t.Context(), identity, "example", binding, sdd.EntryDraft{ - Kind: "gap", Layer: "tactical", Body: "Concrete target resolution must remain independent of the process working directory.", Confidence: "high", + Kind: "fact", Layer: "tactical", Body: "Concrete target resolution must remain independent of the process working directory.", Confidence: "high", + Topics: []string{"implementation/engine"}, Index: &sdd.FactIndex{Title: "Concrete target resolution", Topic: "implementation/engine"}, }) if err != nil || created.EntryID == "" { t.Fatalf("CreateEntry = %+v, %v", created, err) @@ -450,6 +455,17 @@ func TestCreateEntryResolvesConcreteDefaultWithoutCWDAndReleasesAroundLLM(t *tes if !sessionHasEvent(stored.Events, "mutation_intent", `"Target":{"project":"example","branch":"main"}`) { t.Fatalf("ordinary capture did not persist its concrete default target: %+v", stored.Events) } + rel, err := model.IDToRelPath(created.EntryID) + if err != nil { + t.Fatal(err) + } + written, err := os.ReadFile(filepath.Join(graphDir, rel)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(written), "index:\n title: Concrete target resolution\n topic: implementation/engine\n") { + t.Fatalf("written entry missing nested index:\n%s", written) + } } func TestPreparedRevalidationToleratesJSONRoundTripScalarTypes(t *testing.T) { diff --git a/application/workflow_registry.go b/application/workflow_registry.go index 5f67b6d7..93e253f9 100644 --- a/application/workflow_registry.go +++ b/application/workflow_registry.go @@ -53,6 +53,15 @@ func (w *WorkflowSession) registerWorkflowQueries(registry *engine.Registry) err }); err != nil { return err } + if err := registry.RegisterQuery(engine.Query{ + Doc: engine.FuncDoc{Name: "factIndex", Doc: "Active indexed facts from the current project graph, ordered for session discovery."}, + ServeSafe: true, + Fn: func(ctx *engine.Context, _ map[string]any) (any, error) { + return ctx.Graph.IndexedFacts() + }, + }); err != nil { + return err + } if err := registry.RegisterQuery(engine.Query{ Doc: engine.FuncDoc{Name: "viewLayout", Doc: "Rendered `sdd view` pipeline result. Arg layout: the full pipeline syntax; may be a Go template over the store. Optional arg maxBytes: cap the rendered result on a line boundary (0 = uncapped), for a framing lane that must stay bounded."}, ServeSafe: true, @@ -130,7 +139,7 @@ func (w *WorkflowSession) registerWorkflowWrites(registry *engine.Registry) erro if err := registry.RegisterCommand(engine.Command{ Doc: engine.FuncDoc{ Name: "newEntry", Doc: "Creates the entry from the capture state fields (pre-flight inside; staged attachments materialized from handles; a recorded override skips pre-flight, durably logged).", - Reads: []string{"body", "entryKind", "layer", "refs", "topics", "confidence", "intent", "attachments", "participants", "supersedes", "closes", "preflightOverride"}, Writes: []string{"entryId", "findings"}, + Reads: []string{"body", "entryKind", "layer", "refs", "topics", "index", "confidence", "intent", "attachments", "participants", "supersedes", "closes", "preflightOverride"}, Writes: []string{"entryId", "findings"}, }, MutatesGraph: true, Fn: w.runWorkflowNewEntry, @@ -241,6 +250,13 @@ func (w *WorkflowSession) runWorkflowNewEntry(ctx *engine.Context) error { } draft.Confidence, _ = workflowStoreString(ctx.Store, "confidence") draft.Intent, _ = workflowStoreString(ctx.Store, "intent") + if value, ok := ctx.Store.Get("index"); ok { + index, valid := value.(engine.FactIndex) + if !valid { + return fmt.Errorf("newEntry: index has invalid stored type") + } + draft.Index = &FactIndex{Title: index.Title, Topic: index.Topic} + } if override, ok := ctx.Store.Get("preflightOverride"); ok { draft.SkipPreflight, _ = override.(bool) } diff --git a/application/workflow_target_graph_test.go b/application/workflow_target_graph_test.go index c5e64934..a42e9062 100644 --- a/application/workflow_target_graph_test.go +++ b/application/workflow_target_graph_test.go @@ -2,6 +2,7 @@ package application import ( "context" + "strings" "testing" "github.com/networkteam/sdd/internal/engine" @@ -120,6 +121,56 @@ func TestWorkflowContextUsesBranchTargetForSummaryAndPredicates(t *testing.T) { } } +func TestFactIndexQueryReturnsModelRowsWithoutDependencies(t *testing.T) { + localIndex, err := model.NewFactIndex("Local cue", "cli/view") + if err != nil { + t.Fatal(err) + } + topic, err := model.ParseTopicPath("cli/view") + if err != nil { + t.Fatal(err) + } + local := model.NewGraph([]*model.Entry{{ + ID: "20260719-120000-s-tac-loc", Type: model.TypeSignal, Layer: model.LayerTactical, + Kind: model.KindFact, Topics: []model.TopicPath{topic}, Index: localIndex, + }}) + remoteIndex, err := model.NewFactIndex("Remote cue", "cli/view") + if err != nil { + t.Fatal(err) + } + remote := model.NewGraph([]*model.Entry{{ + ID: "20260719-120100-s-tac-rem", Type: model.TypeSignal, Layer: model.LayerTactical, + Kind: model.KindFact, Topics: []model.TopicPath{topic}, Index: remoteIndex, + }}) + model.NewMultiGraph(local, []string{"example/remote"}, func(string) (*model.Graph, error) { return remote, nil }) + + workflow := &WorkflowSession{} + registry := engine.NewRegistry() + if err := workflow.registerWorkflowQueries(registry); err != nil { + t.Fatal(err) + } + factIndex, ok := registry.Query("factIndex") + if !ok || !factIndex.ServeSafe { + t.Fatalf("factIndex registration = %+v", factIndex) + } + value, err := factIndex.Fn(&engine.Context{Graph: local}, nil) + if err != nil { + t.Fatal(err) + } + rows, ok := value.([]model.FactIndexRow) + if !ok || len(rows) != 1 || rows[0].ID != "20260719-120000-s-tac-loc" { + t.Fatalf("factIndex rows = %#v", value) + } + malicious := model.NewGraph([]*model.Entry{{ + ID: "20260719-120200-s-tac-bad", Type: model.TypeSignal, Layer: model.LayerTactical, + Kind: model.KindFact, Topics: []model.TopicPath{topic}, + Index: &model.FactIndex{Title: "Cue\n\n## Injected block", Topic: topic}, + }}) + if _, err := factIndex.Fn(&engine.Context{Graph: malicious}, nil); err == nil || !strings.Contains(err.Error(), "single-line") { + t.Fatalf("factIndex accepted a block-injecting title: %v", err) + } +} + func workflowTargetSnapshot(t *testing.T, revision string, entries []EntryDocument) *Snapshot { t.Helper() snapshot, err := BuildSnapshot(t.Context(), SnapshotData{ diff --git a/application/write_api.go b/application/write_api.go index ff34455a..a573abca 100644 --- a/application/write_api.go +++ b/application/write_api.go @@ -28,6 +28,11 @@ type EntryRef struct { Desc string } +type FactIndex struct { + Title string `json:"title"` + Topic string `json:"topic"` +} + type EntryDraft struct { Target MutationTarget Kind string @@ -40,6 +45,7 @@ type EntryDraft struct { Participants []string Confidence string Topics []string + Index *FactIndex AttachmentHandles []string SkipPreflight bool } @@ -122,10 +128,17 @@ func (a *Application) CreateEntry(ctx context.Context, identity RequestIdentity, } topics = append(topics, topic) } + var index *model.FactIndex + if draft.Index != nil { + index, err = model.NewFactIndex(draft.Index.Title, draft.Index.Topic) + if err != nil { + return CreateEntryResult{}, err + } + } entry := &model.Entry{ ID: id, Type: entryType, Kind: kind, Layer: layer, Intent: model.Intent(draft.Intent), Content: draft.Body, Participants: append([]string(nil), draft.Participants...), - Confidence: draft.Confidence, Topics: topics, Time: runtime.options.Now(), + Confidence: draft.Confidence, Topics: topics, Index: index, Time: runtime.options.Now(), } if len(entry.Participants) == 0 { if principal.Participant != "" { diff --git a/cmd/sdd/main.go b/cmd/sdd/main.go index 969a824c..adf8151b 100644 --- a/cmd/sdd/main.go +++ b/cmd/sdd/main.go @@ -554,6 +554,10 @@ func newCmd() *cli.Command { Name: "topics", Usage: "Comma-separated topic labels (any kind) — inline topic membership written as `topics:` strings", }, + &cli.StringFlag{ + Name: "index", + Usage: "Fact retrieval cue — JSON object {\"title\":\"\",\"topic\":\"\"}", + }, &cli.StringSliceFlag{ Name: "topic", Usage: "Topic cluster (kind: annotation only) — JSON object {\"label\":\"path\",\"members\":[\"id\",...]}; repeatable. A plain string in --topics works too for whole-refs membership.", @@ -700,6 +704,10 @@ func newCmd() *cli.Command { if err != nil { return err } + factIndex, err := parseFactIndexFlag(cmd.String("index")) + if err != nil { + return err + } refs, err := parseRefFlags(cmd.StringSlice("refs")) if err != nil { return err @@ -725,6 +733,7 @@ func newCmd() *cli.Command { Class: strings.TrimSpace(cmd.String("class")), Actor: strings.TrimSpace(cmd.String("actor")), TopicLabels: splitCSV(cmd.String("topics")), + Index: factIndex, AnnotationTopics: annotationTopics, FocusActors: focusActors, FocusWhen: focusWhen, @@ -2203,6 +2212,30 @@ func parseWhenFlag(s string) (*model.FocusWhen, error) { return &w, nil } +func parseFactIndexFlag(s string) (*model.FactIndex, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + decoder := json.NewDecoder(strings.NewReader(s)) + decoder.DisallowUnknownFields() + var raw struct { + Title string `json:"title"` + Topic string `json:"topic"` + } + if err := decoder.Decode(&raw); err != nil { + return nil, fmt.Errorf("--index: invalid JSON object: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("--index: expected one JSON object") + } + index, err := model.NewFactIndex(raw.Title, raw.Topic) + if err != nil { + return nil, fmt.Errorf("--index: %w", err) + } + return index, nil +} + // parseInvolvementFlags parses each --involvement JSON value into a // model.Involvement, preserving the actors-omitted vs explicit-empty // distinction (the latter declares pull-available involvement). diff --git a/cmd/sdd/main_test.go b/cmd/sdd/main_test.go index a0e3094b..74ae4e80 100644 --- a/cmd/sdd/main_test.go +++ b/cmd/sdd/main_test.go @@ -390,6 +390,28 @@ func TestParseRefFlags_UnknownKindRejectedAtCapture(t *testing.T) { } } +func TestParseFactIndexFlag(t *testing.T) { + index, err := parseFactIndexFlag(`{"title":"How to compose graph views","topic":"cli/view"}`) + if err != nil { + t.Fatal(err) + } + if index.Title != "How to compose graph views" || index.Topic.String() != "cli/view" { + t.Fatalf("index = %+v", index) + } + for _, input := range []string{ + `{"title":"Missing topic"}`, + `{"title":" ","topic":"cli/view"}`, + `{"title":"Title","topic":"cli view"}`, + `{"title":"Title\n## Injected","topic":"cli/view"}`, + `{"title":"Title","topic":"cli/view","extra":true}`, + `{"title":"Title","topic":"cli/view"} {}`, + } { + if _, err := parseFactIndexFlag(input); err == nil { + t.Errorf("parseFactIndexFlag(%q) accepted invalid object", input) + } + } +} + func TestParseRefFlags_EmptyInput(t *testing.T) { got, err := parseRefFlags(nil) if err != nil { diff --git a/internal/basefacts/basefacts.go b/internal/basefacts/basefacts.go index ea3ee22b..52c6a1eb 100644 --- a/internal/basefacts/basefacts.go +++ b/internal/basefacts/basefacts.go @@ -50,6 +50,9 @@ func build(id, frontmatter, body string) (*model.Entry, error) { if entry.Type != model.TypeSignal || entry.Kind != model.KindFact { return nil, fmt.Errorf("base fact %s is %s %s — base facts ship as kind: fact signals", id, entry.Type, entry.Kind) } + if err := entry.Index.ValidateForEntry(entry.Kind, entry.Topics); err != nil { + return nil, fmt.Errorf("base fact %s index: %w", id, err) + } entry.Embedded = true return entry, nil } @@ -70,6 +73,9 @@ confidence: high topics: - engine/base-facts - cli/view +index: + title: 'How to compose graph views (view tool): layout grammar, filters, ranking, quoting, and examples' + topic: cli/view summary: >- The view layout language composes graph views from a colon-chained pipeline — filters, then ranking, paging, and transforms, ending in a @@ -78,15 +84,6 @@ summary: >- quoting rule for multi-word, date, and duration arguments. ` -// viewGrammarBody renders the fact's body: a short neutral orientation line -// followed by the host-neutral grammar-and-vocabulary reference generated -// from the live executor vocabulary. Nothing here names a host command, so -// the fact stays host-neutral (d-cpt-476). func viewGrammarBody(vocab viewlayout.Vocabulary) string { - return "Reference for the view layout language — the grammar for composing graph " + - "views by pipeline. Pull it when building or debugging a layout: it lists " + - "every filter, rank algorithm, transform, render terminator, and macro the " + - "executor accepts, and the quoting rule that trips up unquoted multi-word, " + - "date, and duration arguments.\n\n" + - viewlayout.ReferenceBody(vocab) + return viewlayout.Markdown(vocab) } diff --git a/internal/basefacts/basefacts_test.go b/internal/basefacts/basefacts_test.go index 3b77fcff..bf47e819 100644 --- a/internal/basefacts/basefacts_test.go +++ b/internal/basefacts/basefacts_test.go @@ -10,7 +10,7 @@ import ( func testVocabulary() viewlayout.Vocabulary { return viewlayout.Vocabulary{ - Functions: []string{"active", "kind", "rank", "n", "as-list"}, + Functions: []string{"active", "indexed", "kind", "rank", "n", "as-list"}, Renders: []string{"as-list"}, Algorithms: []string{"heat"}, Decays: []string{"exp-14d"}, @@ -43,10 +43,12 @@ func TestEntriesShipViewGrammarFact(t *testing.T) { if len(fact.Refs) != 0 { t.Errorf("base fact carries refs %v, want none", fact.Refs) } + const title = "How to compose graph views (view tool): layout grammar, filters, ranking, quoting, and examples" + if fact.Index == nil || fact.Index.Title != title || fact.Index.Topic.String() != "cli/view" { + t.Errorf("fact index = %+v", fact.Index) + } } -// The body is generated from the supplied vocabulary and must stay host- -// neutral (d-cpt-476): no fact may name the sdd CLI or a command to run. func TestViewGrammarBodyIsGeneratedAndHostNeutral(t *testing.T) { entries, err := Entries(testVocabulary()) if err != nil { @@ -54,23 +56,29 @@ func TestViewGrammarBodyIsGeneratedAndHostNeutral(t *testing.T) { } body := entries[0].Content - for _, want := range []string{"active", "heat", "exp-14d", "top(N)", "Grammar:"} { + for _, want := range []string{"# How to compose graph views", "## Grammar", "```text", "| Category | Syntax | Meaning |", "active", "heat", "exp-14d", "top(N)", "active:indexed:as-list"} { if !strings.Contains(body, want) { t.Errorf("generated body missing live-vocabulary token %q", want) } } - for _, host := range []string{"sdd view", "Usage:", "--layout"} { + for _, host := range []string{"sdd view", "Usage:", "--layout", "MCP", "debugging"} { if strings.Contains(body, host) { t.Errorf("base fact body contains host-specific reference %q", host) } } } -// A base fact that is not a kind: fact signal is a build mistake and must -// fail construction rather than reach a graph. func TestBuildRejectsNonFact(t *testing.T) { _, err := build("20260101-000000-d-cpt-xxx", "type: decision\nlayer: conceptual\nkind: directive\nconfidence: low\nintent: guiding\n", "body") if err == nil { t.Fatal("build accepted a non-fact base entry, want error") } } + +func TestBuildRejectsInvalidFactIndexEnrollment(t *testing.T) { + frontmatter := "type: signal\nlayer: process\nkind: fact\ntopics: [cli/view]\nindex: {title: Reference, topic: agent/ux}\n" + _, err := build("20260101-000000-s-prc-idx", frontmatter, "body") + if err == nil || !strings.Contains(err.Error(), "must also appear in topics") { + t.Fatalf("build error = %v", err) + } +} diff --git a/internal/baseprocedures/baseprocedures_test.go b/internal/baseprocedures/baseprocedures_test.go index 85090300..eea82a66 100644 --- a/internal/baseprocedures/baseprocedures_test.go +++ b/internal/baseprocedures/baseprocedures_test.go @@ -110,3 +110,26 @@ func TestEntries_EmbeddedSetLoads(t *testing.T) { t.Errorf("embedded set must ship the capture procedure, got canonicals %v", canonicals) } } + +func TestCaptureCarriesFactIndexThroughPlaybackAndWrite(t *testing.T) { + entries, err := Entries() + if err != nil { + t.Fatal(err) + } + var capture string + for _, entry := range entries { + if entry.Canonical == "capture" { + capture = entry.Content + } + } + for _, want := range []string{ + "{{if .index}}- index:", + "title: {{.index.Title}}", + "topic: {{.index.Topic}}", + "optionally enrolls a `fact` in the retrieval index", + } { + if !strings.Contains(capture, want) { + t.Errorf("capture procedure missing %q", want) + } + } +} diff --git a/internal/baseprocedures/entries/20260703-094500-d-prc-cap.md b/internal/baseprocedures/entries/20260703-094500-d-prc-cap.md index 91d6a7c9..71f8daa5 100644 --- a/internal/baseprocedures/entries/20260703-094500-d-prc-cap.md +++ b/internal/baseprocedures/entries/20260703-094500-d-prc-cap.md @@ -23,6 +23,7 @@ state: layer: {type: layer, desc: strategic | conceptual | tactical | operational | process} refs: {type: list, desc: "each {id, kind, desc?}; kind chosen from what the body asserts"} topics: {type: list