From 51e0e8c5d08bf378710efe9aaad3c8ce95bc9759 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 29 Aug 2026 15:16:06 +0000 Subject: [PATCH] Pass reference bindings to Notarius --- docs/roadmap/implementation.md | 2 +- internal/adapters/notarius/fake.go | 4 +- internal/adapters/notarius/runner.go | 8 ++ internal/adapters/notarius/subprocess.go | 56 +++++++--- internal/adapters/notarius/subprocess_test.go | 101 +++++++++++++++++- 5 files changed, 152 insertions(+), 19 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 09b2467..15fe2dc 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -19,7 +19,7 @@ different contract. | 1 | Add the reference-selector and configuration vocabulary, including optional spell-catalog inputs. | Completed | | 2 | Materialize and inventory the optional spell catalog through the prepare and operator lifecycle. | Completed | | 3 | Centralize manifest-authoritative prepared-input resolution and migrate analyze to it. | Completed | -| 4 | Add deterministic Notarius v0.6 reference arguments at the subprocess adapter boundary. | Pending | +| 4 | Add deterministic Notarius v0.6 reference arguments at the subprocess adapter boundary. | Completed | | 5 | Resolve references in extract and bind fingerprints, resume, and metadata to their identities. | Pending | | 6 | Prove assembled extraction lifecycle and downstream invalidation behavior. | Pending | | 7 | Update canonical documentation and maintained examples for the completed feature. | Pending | diff --git a/internal/adapters/notarius/fake.go b/internal/adapters/notarius/fake.go index 0932dbf..756d5b0 100644 --- a/internal/adapters/notarius/fake.go +++ b/internal/adapters/notarius/fake.go @@ -14,7 +14,9 @@ func (f *FakeRunner) Run(ctx context.Context, req RunRequest) (RunResult, error) if err := ctx.Err(); err != nil { return RunResult{}, err } - f.Requests = append(f.Requests, req) + copyRequest := req + copyRequest.References = append([]ReferenceBinding(nil), req.References...) + f.Requests = append(f.Requests, copyRequest) if f.Err != nil { return RunResult{}, f.Err } diff --git a/internal/adapters/notarius/runner.go b/internal/adapters/notarius/runner.go index faa156d..97ec73f 100644 --- a/internal/adapters/notarius/runner.go +++ b/internal/adapters/notarius/runner.go @@ -13,6 +13,13 @@ type Runner interface { Run(ctx context.Context, req RunRequest) (RunResult, error) } +// ReferenceBinding maps one normalized Notarius selector to an absolute +// external reference path. +type ReferenceBinding struct { + Selector string + Path string +} + // RunRequest contains the resolved inputs and diagnostic destinations for one invocation. type RunRequest struct { Binary string @@ -24,6 +31,7 @@ type RunRequest struct { ReceiptPath string LogPath string Timeout time.Duration + References []ReferenceBinding } // Receipt is the transport-neutral successful run receipt. diff --git a/internal/adapters/notarius/subprocess.go b/internal/adapters/notarius/subprocess.go index 940f085..f9231bd 100644 --- a/internal/adapters/notarius/subprocess.go +++ b/internal/adapters/notarius/subprocess.go @@ -11,6 +11,7 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" "gitea.maximumdirect.net/eric/narratio/internal/fileops" + "gitea.maximumdirect.net/eric/narratio/internal/notariusref" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) @@ -47,7 +48,8 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult, if r == nil || r.run == nil { return RunResult{}, fmt.Errorf("notarius subprocess runner is nil") } - if err := validateRunRequest(req); err != nil { + references, err := validateRunRequest(req) + if err != nil { return RunResult{}, err } @@ -56,8 +58,11 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult, "--config", req.ConfigPath, "--input", req.InputPath, "--output-dir", req.OutputRoot, - "--json", } + for _, reference := range references { + args = append(args, "--reference", reference.Selector+"="+reference.Path) + } + args = append(args, "--json") processResult, err := r.run(ctx, subprocess.RunRequest{ Executable: req.Binary, Args: args, @@ -128,15 +133,15 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult, return baseResult, nil } -func validateRunRequest(req RunRequest) error { +func validateRunRequest(req RunRequest) ([]ReferenceBinding, error) { if strings.TrimSpace(req.Binary) == "" { - return fmt.Errorf("notarius binary is required") + return nil, fmt.Errorf("notarius binary is required") } if strings.TrimSpace(req.PipelineID) == "" { - return fmt.Errorf("notarius pipeline id is required") + return nil, fmt.Errorf("notarius pipeline id is required") } if req.Timeout <= 0 { - return fmt.Errorf("notarius timeout must be positive") + return nil, fmt.Errorf("notarius timeout must be positive") } for label, path := range map[string]string{ "config": req.ConfigPath, @@ -147,34 +152,53 @@ func validateRunRequest(req RunRequest) error { "log": req.LogPath, } { if strings.TrimSpace(path) == "" { - return fmt.Errorf("notarius %s path is required", label) + return nil, fmt.Errorf("notarius %s path is required", label) } if !filepath.IsAbs(path) { - return fmt.Errorf("notarius %s path must be absolute", label) + return nil, fmt.Errorf("notarius %s path must be absolute", label) } } if filepath.Clean(req.ReceiptPath) == filepath.Clean(req.LogPath) { - return fmt.Errorf("notarius receipt and log paths must be different") + return nil, fmt.Errorf("notarius receipt and log paths must be different") + } + references := make([]ReferenceBinding, 0, len(req.References)) + selectors := make(map[string]struct{}, len(req.References)) + for index, binding := range req.References { + selector, err := notariusref.NormalizeSelector(binding.Selector) + if err != nil { + return nil, fmt.Errorf("notarius reference %d selector: %w", index, err) + } + if _, duplicate := selectors[selector]; duplicate { + return nil, fmt.Errorf("notarius reference selector %q is duplicated", selector) + } + selectors[selector] = struct{}{} + if strings.TrimSpace(binding.Path) == "" { + return nil, fmt.Errorf("notarius reference %q path is required", selector) + } + if !filepath.IsAbs(binding.Path) { + return nil, fmt.Errorf("notarius reference %q path must be absolute", selector) + } + references = append(references, ReferenceBinding{Selector: selector, Path: binding.Path}) } if err := requireRegularFile(req.ConfigPath); err != nil { - return fmt.Errorf("validate notarius config path: %w", err) + return nil, fmt.Errorf("validate notarius config path: %w", err) } if err := requireRegularFile(req.InputPath); err != nil { - return fmt.Errorf("validate notarius input path: %w", err) + return nil, fmt.Errorf("validate notarius input path: %w", err) } if err := requireDirectory(req.OutputRoot); err != nil { - return fmt.Errorf("validate notarius output root: %w", err) + return nil, fmt.Errorf("validate notarius output root: %w", err) } if err := requireDirectory(req.WorkingDirectory); err != nil { - return fmt.Errorf("validate notarius working directory: %w", err) + return nil, fmt.Errorf("validate notarius working directory: %w", err) } if err := validateLogDestination(req.ReceiptPath); err != nil { - return fmt.Errorf("validate notarius receipt path: %w", err) + return nil, fmt.Errorf("validate notarius receipt path: %w", err) } if err := validateLogDestination(req.LogPath); err != nil { - return fmt.Errorf("validate notarius log path: %w", err) + return nil, fmt.Errorf("validate notarius log path: %w", err) } - return nil + return references, nil } type receiptDocument struct { diff --git a/internal/adapters/notarius/subprocess_test.go b/internal/adapters/notarius/subprocess_test.go index f0627b7..354c428 100644 --- a/internal/adapters/notarius/subprocess_test.go +++ b/internal/adapters/notarius/subprocess_test.go @@ -76,6 +76,101 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) { } } +func TestSubprocessRunnerBuildsOrderedReferenceArguments(t *testing.T) { + req := validRunRequest(t) + referenceRoot := t.TempDir() + req.References = []ReferenceBinding{ + {Selector: " party ", Path: filepath.Join(referenceRoot, "party context=primary.json")}, + {Selector: " npc-registry . extract . glossary ", Path: filepath.Join(referenceRoot, "glossary.json")}, + } + originalReferences := append([]ReferenceBinding(nil), req.References...) + var captured sharedsubprocess.RunRequest + runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) { + captured = processReq + writeValidBundleAndReceipt(t, req, false) + return sharedsubprocess.RunResult{ExitCode: 0}, nil + }} + + if _, err := runner.Run(context.Background(), req); err != nil { + t.Fatalf("Run() error = %v", err) + } + wantArgs := []string{ + "run", req.PipelineID, + "--config", req.ConfigPath, + "--input", req.InputPath, + "--output-dir", req.OutputRoot, + "--reference", "party=" + req.References[0].Path, + "--reference", "npc-registry.extract.glossary=" + req.References[1].Path, + "--json", + } + if !reflect.DeepEqual(captured.Args, wantArgs) { + t.Fatalf("subprocess args = %#v, want %#v", captured.Args, wantArgs) + } + if !reflect.DeepEqual(req.References, originalReferences) { + t.Fatalf("Run() mutated caller references = %#v, want %#v", req.References, originalReferences) + } +} + +func TestSubprocessRunnerRejectsInvalidReferencesBeforeLaunch(t *testing.T) { + tests := []struct { + name string + references func(string) []ReferenceBinding + wantErr string + }{ + { + name: "invalid selector", + references: func(root string) []ReferenceBinding { + return []ReferenceBinding{{Selector: "lane.prepare.party", Path: filepath.Join(root, "party.json")}} + }, + wantErr: "selector", + }, + { + name: "duplicate normalized selector", + references: func(root string) []ReferenceBinding { + return []ReferenceBinding{ + {Selector: "lane.party", Path: filepath.Join(root, "party.json")}, + {Selector: " lane . party ", Path: filepath.Join(root, "party-2.json")}, + } + }, + wantErr: "duplicated", + }, + { + name: "empty path", + references: func(string) []ReferenceBinding { + return []ReferenceBinding{{Selector: "party", Path: " "}} + }, + wantErr: "path is required", + }, + { + name: "relative path", + references: func(string) []ReferenceBinding { + return []ReferenceBinding{{Selector: "party", Path: "references/party.json"}} + }, + wantErr: "path must be absolute", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := validRunRequest(t) + req.References = tt.references(t.TempDir()) + started := false + runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) { + started = true + return sharedsubprocess.RunResult{}, nil + }} + + _, err := runner.Run(context.Background(), req) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Run() error = %v, want containing %q", err, tt.wantErr) + } + if started { + t.Fatal("subprocess started after request validation failure") + } + }) + } +} + func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) { req := validRunRequest(t) writeValidBundleAndReceipt(t, req, false) @@ -432,13 +527,17 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin } func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) { - req := RunRequest{PipelineID: "pipeline"} + req := RunRequest{PipelineID: "pipeline", References: []ReferenceBinding{{Selector: "party", Path: "/references/party.json"}}} want := RunResult{BundleRoot: "/bundle"} fake := &FakeRunner{Result: want} got, err := fake.Run(context.Background(), req) if err != nil || !reflect.DeepEqual(got, want) || !reflect.DeepEqual(fake.Requests, []RunRequest{req}) { t.Fatalf("Run() = %#v, %v; requests = %#v", got, err, fake.Requests) } + req.References[0].Path = "/references/changed.json" + if fake.Requests[0].References[0].Path != "/references/party.json" { + t.Fatalf("fake retained aliased request references: %#v", fake.Requests[0].References) + } wantErr := errors.New("configured failure") fake.Err = wantErr