diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad7c2c5c9..0c5faccc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,11 +185,17 @@ jobs: # gap. Verified via GOOS=windows cross-compile + vet (including test # files) before adding this, given tonight's pattern of Windows-only # surprises in newly-covered packages. + # + # adapters/nats joined this loop later: another Go module under + # go.work, same reasoning as the five above. Its tests start an + # in-process NATS server (nats-server/v2's server package) rather + # than requiring one listening externally, so they run the same way + # here as they do locally, no new service dependency for this job. - name: Build, vet, and test the other workspace modules shell: bash run: | set -euo pipefail - for m in search jsondb incfs adapters/cassandra adapters/redis; do + for m in search jsondb incfs adapters/cassandra adapters/redis adapters/nats; do echo "::group::$m" (cd "$m" && go build ./... && go vet ./... && go test -timeout 20m ./...) echo "::endgroup::" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 8c5ceefe7..5a889b9f3 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -181,7 +181,7 @@ jobs: limit-severities-for-sarif: 'true' - name: Upload SARIF (critical/high) - if: always() + if: always() && hashFiles('trivy-image-critical-high.sarif') != '' uses: github/codeql-action/upload-sarif@v4 with: sarif_file: trivy-image-critical-high.sarif diff --git a/Dockerfile b/Dockerfile index ecd09f5d5..e6ab4a14c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ WORKDIR /src COPY go.work go.work.sum* ./ COPY go.mod go.sum ./ COPY adapters/cassandra/go.mod adapters/cassandra/go.sum* ./adapters/cassandra/ +COPY adapters/nats/go.mod adapters/nats/go.sum* ./adapters/nats/ COPY adapters/redis/go.mod adapters/redis/go.sum* ./adapters/redis/ COPY ai/go.mod ai/go.sum* ./ai/ COPY incfs/go.mod incfs/go.sum* ./incfs/ diff --git a/README.md b/README.md index 9004308d6..2e3c154f6 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,7 @@ Every architecture involves tradeoffs. Here is an honest comparison of where Jol - **PostgreSQL**: Industry standard for general relational databases. Choose Postgres when you need complex relational schemas, advanced SQL aggregations, or standard ecosystem tooling. Joltrin is better suited when you want an embedded storage engine inside your application process without database server management. - **Redis**: Industry standard for ultra-low-latency in-memory key-value caching. Choose Redis when all data fits in RAM and you need simple cache operations. Joltrin provides durable B-Tree disk persistence, multi-account ACID transactions, and erasure coding. - **Kafka / RabbitMQ**: Industry standards for high-volume streaming and pub/sub. Choose Kafka when you need multi-datacenter event streams and log retention. Joltrin provides transactional task queues co-located with storage state for local swarms. +- **NATS (optional, `adapters/nats`)**: not a replacement for anything joltrin embeds, and not on the hot path. If a team already runs NATS as part of their own architecture, `adapters/nats.VerifyBridge` will publish `ai/verify` barrier decisions to it, fire-and-forget, after the decision is already made, so another service outside joltrin's process can observe it without polling. Nothing imports this by default and a publish failure can never change the barrier's own answer. See the addendum in `docs/MCP_A2A_AND_VERIFICATION_ENGINE.md` for the full reasoning on why this doesn't reverse the embedded design. - **Temporal**: Industry standard for long-running durable workflows spanning external microservices. Choose Temporal for multi-week human-in-the-loop workflows across disparate clouds. Joltrin is designed for local-to-cluster co-located data and task execution. - **SQLite**: Industry standard for embedded single-file relational databases. Choose SQLite for client desktop/mobile apps needing SQL. Joltrin is designed for high-concurrency multi-threaded workers, clustered coordination, partitioned vector stores, and erasure coding. diff --git a/adapters/nats/bridge.go b/adapters/nats/bridge.go new file mode 100644 index 000000000..7e6eff065 --- /dev/null +++ b/adapters/nats/bridge.go @@ -0,0 +1,170 @@ +package nats + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/nats-io/nats.go" + + "github.com/sharedcode/joltrin/ai/verify" +) + +// BarrierDecision is the structured event VerifyBridge publishes to NATS +// every time it runs a barrier check. It carries enough detail for an +// external subscriber to reconstruct what happened without re-deriving it +// from the workflow graph: which step, which workflow, whether it was +// allowed, and if not, which rule and which missing state blocked it. +type BarrierDecision struct { + Time time.Time `json:"time"` + Workflow string `json:"workflow,omitempty"` + Step verify.StepID `json:"step"` + // Allowed is true when the step passed the barrier (CheckSafety + // returned nil). It is false both for a blocked *verify.Violation and + // for a malformed request (an unknown step), Rule/MissingState + // distinguish the two: a Violation always sets Rule, a malformed + // request never does. + Allowed bool `json:"allowed"` + // Replayed is true when the decision came from + // CheckAndCommitIdempotent's idempotency cache rather than a fresh + // check, i.e. this event describes a retried call, not a new one. + Replayed bool `json:"replayed,omitempty"` + // Rule is the SafetyRule.Name that blocked this step, or the literal + // "precondition" when the step's own precondition was not met. Empty + // when Allowed is true or the request was malformed. + Rule string `json:"rule,omitempty"` + // MissingState is the State that still needs to be established before + // this step can pass, taken from verify.Violation.MissingState. Empty + // when Allowed is true or the request was malformed. + MissingState verify.State `json:"missing_state,omitempty"` + // Reason is the blocking error's message, verbatim, for a human + // reading the event directly. Empty when Allowed is true. + Reason string `json:"reason,omitempty"` +} + +// DefaultSubject is the NATS subject a VerifyBridge publishes to when +// constructed with an empty workflow name. +const DefaultSubject = "joltrin.verify.decision" + +// SubjectFor returns the NATS subject a VerifyBridge publishes +// BarrierDecision events to for the given workflow name. An empty name +// returns DefaultSubject. +func SubjectFor(workflow string) string { + if workflow == "" { + return DefaultSubject + } + return fmt.Sprintf("joltrin.verify.%s.decision", workflow) +} + +// VerifyBridge wraps a *verify.Workflow and publishes a BarrierDecision +// event to NATS after every barrier check it runs. It does not change +// ai/verify's behavior: CheckSafety, CheckAndCommit, and +// CheckAndCommitIdempotent each call straight through to the identically +// named *verify.Workflow method and return its exact result unchanged; the +// NATS publish is a side effect on the way out. A publish failure is never +// allowed to change or block the barrier's own decision, see publish +// below. +// +// A VerifyBridge is only ever a decorator a caller opts into at its own +// call sites. Nothing in ai/verify, tools/mcpserver, or tools/a2aagent +// constructs or requires one. +type VerifyBridge struct { + wf *verify.Workflow + nc *nats.Conn + workflow string + subject string + + // onPublishError, if set, is called with any error returned by the + // underlying NATS publish. Optional: a VerifyBridge with no handler set + // simply drops a publish failure, exactly like fire-and-forget metrics + // or logging, rather than letting it surface as a barrier error. + onPublishError func(error) +} + +// NewVerifyBridge returns a VerifyBridge that runs barrier checks against +// wf and publishes a BarrierDecision for each one to nc, on the subject +// SubjectFor(workflowName). workflowName is a label only, used to build the +// subject and to tag published events; it does not have to match any name +// wf is registered under in a tools/runbookstore.Store. +func NewVerifyBridge(wf *verify.Workflow, nc *nats.Conn, workflowName string) *VerifyBridge { + return &VerifyBridge{ + wf: wf, + nc: nc, + workflow: workflowName, + subject: SubjectFor(workflowName), + } +} + +// OnPublishError sets a handler called with any error the underlying NATS +// publish returns, and returns the bridge for chaining. Optional; without +// one, a publish failure is dropped silently. +func (b *VerifyBridge) OnPublishError(fn func(error)) *VerifyBridge { + b.onPublishError = fn + return b +} + +// Subject returns the NATS subject this bridge publishes to. +func (b *VerifyBridge) Subject() string { + return b.subject +} + +// CheckSafety runs wf.CheckSafety(trace, next), publishes the resulting +// BarrierDecision, and returns wf.CheckSafety's exact result. +func (b *VerifyBridge) CheckSafety(trace *verify.Trace, next verify.StepID) error { + err := b.wf.CheckSafety(trace, next) + b.publish(next, false, err) + return err +} + +// CheckAndCommit runs wf.CheckAndCommit(trace, next), publishes the +// resulting BarrierDecision, and returns wf.CheckAndCommit's exact result. +func (b *VerifyBridge) CheckAndCommit(trace *verify.Trace, next verify.StepID) error { + err := b.wf.CheckAndCommit(trace, next) + b.publish(next, false, err) + return err +} + +// CheckAndCommitIdempotent runs +// wf.CheckAndCommitIdempotent(trace, next, idempotencyKey), publishes the +// resulting BarrierDecision (with Replayed set from the same call), and +// returns wf.CheckAndCommitIdempotent's exact result. +func (b *VerifyBridge) CheckAndCommitIdempotent(trace *verify.Trace, next verify.StepID, idempotencyKey string) (replayed bool, err error) { + replayed, err = b.wf.CheckAndCommitIdempotent(trace, next, idempotencyKey) + b.publish(next, replayed, err) + return replayed, err +} + +func (b *VerifyBridge) publish(step verify.StepID, replayed bool, err error) { + ev := BarrierDecision{ + Time: time.Now().UTC(), + Workflow: b.workflow, + Step: step, + Allowed: err == nil, + Replayed: replayed, + } + if err != nil { + ev.Reason = err.Error() + var violation *verify.Violation + if errors.As(err, &violation) { + ev.Rule = violation.Rule + ev.MissingState = violation.MissingState + } + } + + data, err := json.Marshal(ev) + if err != nil { + // A BarrierDecision is a fixed, JSON-safe shape; this would only + // fail if that shape changes to include something unmarshalable. + // Report it the same way a publish failure is reported, rather than + // silently dropping a bug. + if b.onPublishError != nil { + b.onPublishError(fmt.Errorf("nats: marshal barrier decision: %w", err)) + } + return + } + + if err := b.nc.Publish(b.subject, data); err != nil && b.onPublishError != nil { + b.onPublishError(fmt.Errorf("nats: publish barrier decision: %w", err)) + } +} diff --git a/adapters/nats/bridge_test.go b/adapters/nats/bridge_test.go new file mode 100644 index 000000000..13aeee7ce --- /dev/null +++ b/adapters/nats/bridge_test.go @@ -0,0 +1,350 @@ +package nats + +import ( + "encoding/json" + "sync" + "testing" + "time" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + + "github.com/sharedcode/joltrin/ai/verify" +) + +// startTestServer starts an in-process NATS server on a random free port +// and returns a connected client, so these tests need no external NATS +// instance and nothing listening beyond the test process itself. Both are +// shut down via t.Cleanup. +func startTestServer(t *testing.T) *nats.Conn { + t.Helper() + + opts := &server.Options{ + Host: "127.0.0.1", + Port: -1, // random free port + NoLog: true, + NoSigs: true, + MaxControlLine: 4096, + } + srv, err := server.NewServer(opts) + if err != nil { + t.Fatalf("start embedded NATS server: %v", err) + } + go srv.Start() + if !srv.ReadyForConnections(5 * time.Second) { + t.Fatal("embedded NATS server did not become ready") + } + t.Cleanup(srv.Shutdown) + + nc, err := nats.Connect(srv.ClientURL()) + if err != nil { + t.Fatalf("connect to embedded NATS server: %v", err) + } + t.Cleanup(nc.Close) + + return nc +} + +func TestSubjectFor(t *testing.T) { + cases := []struct { + workflow string + want string + }{ + {"", DefaultSubject}, + {"prod-db-rollout", "joltrin.verify.prod-db-rollout.decision"}, + } + for _, c := range cases { + if got := SubjectFor(c.workflow); got != c.want { + t.Errorf("SubjectFor(%q) = %q, want %q", c.workflow, got, c.want) + } + } +} + +// TestBarrierDecisionJSON pins the wire shape a subscriber in any language +// depends on: it needs no NATS connection at all, just the struct. +func TestBarrierDecisionJSON(t *testing.T) { + ev := BarrierDecision{ + Time: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + Workflow: "prod-db-rollout", + Step: "drop_prod_db", + Allowed: false, + Rule: "no-drop-without-validated-backup", + MissingState: "backup_validated", + Reason: `step "drop_prod_db" would establish forbidden state "prod_db_dropped" without required state "backup_validated" first (rule: no-drop-without-validated-backup)`, + } + data, err := json.Marshal(ev) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got BarrierDecision + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if !got.Time.Equal(ev.Time) || got.Workflow != ev.Workflow || got.Step != ev.Step || + got.Allowed != ev.Allowed || got.Rule != ev.Rule || + got.MissingState != ev.MissingState || got.Reason != ev.Reason { + t.Fatalf("round trip mismatch: got %+v, want %+v", got, ev) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("Unmarshal to map: %v", err) + } + if _, ok := raw["replayed"]; ok { + t.Errorf("replayed should be omitted when false, got %v", raw["replayed"]) + } +} + +func dropProdWorkflow(t *testing.T) *verify.Workflow { + t.Helper() + wf, err := verify.NewWorkflow( + []verify.Step{ + {ID: "take_backup", Establishes: []verify.State{"backup_taken"}}, + {ID: "validate_backup", Requires: []verify.State{"backup_taken"}, Establishes: []verify.State{"backup_validated"}}, + {ID: "drop_prod_db", Requires: []verify.State{"backup_validated"}, Establishes: []verify.State{"prod_db_dropped"}}, + }, + []verify.SafetyRule{ + {Name: "no-drop-without-validated-backup", Forbidden: "prod_db_dropped", Requires: "backup_validated"}, + }, + nil, + ) + if err != nil { + t.Fatalf("build workflow: %v", err) + } + return wf +} + +// collectDecisions subscribes to subject and returns a function that waits +// for exactly n BarrierDecision events (or fails the test on timeout). +func collectDecisions(t *testing.T, nc *nats.Conn, subject string, n int) func() []BarrierDecision { + t.Helper() + + var ( + mu sync.Mutex + got []BarrierDecision + done = make(chan struct{}) + ) + sub, err := Subscribe(nc, subject, func(ev BarrierDecision) { + mu.Lock() + got = append(got, ev) + reached := len(got) >= n + mu.Unlock() + if reached { + select { + case <-done: + default: + close(done) + } + } + }) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + t.Cleanup(func() { _ = sub.Unsubscribe() }) + + return func() []BarrierDecision { + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %d decisions, got %d", n, len(got)) + } + mu.Lock() + defer mu.Unlock() + out := make([]BarrierDecision, len(got)) + copy(out, got) + return out + } +} + +// TestVerifyBridge_PublishesRealBarrierOutcomes runs the exact blocked-then- +// allowed sequence examples/verify_barrier demonstrates against ai/verify +// directly, through a VerifyBridge instead, and checks the published events +// match what the barrier actually decided. +func TestVerifyBridge_PublishesRealBarrierOutcomes(t *testing.T) { + nc := startTestServer(t) + wf := dropProdWorkflow(t) + bridge := NewVerifyBridge(wf, nc, "prod-db-rollout") + + if want := "joltrin.verify.prod-db-rollout.decision"; bridge.Subject() != want { + t.Fatalf("Subject() = %q, want %q", bridge.Subject(), want) + } + + wait := collectDecisions(t, nc, bridge.Subject(), 3) + trace := verify.NewTrace() + + // 1) Blocked: dropping prod before any backup exists. + if err := bridge.CheckAndCommit(trace, "drop_prod_db"); err == nil { + t.Fatal("expected drop_prod_db to be blocked, got nil error") + } else if !verify.IsViolation(err) { + t.Fatalf("expected a *verify.Violation, got %v", err) + } + + // 2) Allowed: taking the backup has no precondition. + if err := bridge.CheckAndCommit(trace, "take_backup"); err != nil { + t.Fatalf("take_backup: unexpected error: %v", err) + } + + // 3) Blocked: drop still forbidden, backup taken but not validated yet. + if err := bridge.CheckAndCommit(trace, "drop_prod_db"); err == nil { + t.Fatal("expected drop_prod_db to still be blocked, got nil error") + } + + events := wait() + if len(events) != 3 { + t.Fatalf("got %d events, want 3", len(events)) + } + + if events[0].Allowed { + t.Errorf("event 0: Allowed = true, want false (blocked drop)") + } + // drop_prod_db itself Requires backup_validated, so an empty trace + // blocks on that precondition before the safety rule is even reached; + // see ai/verify.Workflow.checkSafetyLocked's ordering. + if events[0].Rule != "precondition" { + t.Errorf("event 0: Rule = %q, want %q", events[0].Rule, "precondition") + } + if events[0].MissingState != "backup_validated" { + t.Errorf("event 0: MissingState = %q, want %q", events[0].MissingState, "backup_validated") + } + + if !events[1].Allowed { + t.Errorf("event 1: Allowed = false, want true (take_backup)") + } + if events[1].Step != "take_backup" { + t.Errorf("event 1: Step = %q, want %q", events[1].Step, "take_backup") + } + + if events[2].Allowed { + t.Errorf("event 2: Allowed = true, want false (still-blocked drop)") + } + for i, ev := range events { + if ev.Workflow != "prod-db-rollout" { + t.Errorf("event %d: Workflow = %q, want %q", i, ev.Workflow, "prod-db-rollout") + } + } + + // The barrier's own trace is unaffected by any of this: only + // take_backup actually committed. + if got := trace.ExecutedSteps(); len(got) != 1 || got[0] != "take_backup" { + t.Fatalf("trace.ExecutedSteps() = %v, want [take_backup]", got) + } +} + +// TestVerifyBridge_PublishesSafetyRuleViolation exercises the other branch +// of checkSafetyLocked: a step with no failing precondition of its own, +// blocked purely by a SafetyRule on the state it would establish. Confirms +// publish's errors.As extraction of Rule/MissingState from *verify.Violation +// covers the safety-rule case, not just the precondition case +// TestVerifyBridge_PublishesRealBarrierOutcomes already covers. +func TestVerifyBridge_PublishesSafetyRuleViolation(t *testing.T) { + nc := startTestServer(t) + wf, err := verify.NewWorkflow( + []verify.Step{ + {ID: "grant_admin", Establishes: []verify.State{"admin_granted"}}, + {ID: "log_grant", Establishes: []verify.State{"grant_logged"}}, + }, + []verify.SafetyRule{ + {Name: "no-admin-without-audit-log", Forbidden: "admin_granted", Requires: "grant_logged"}, + }, + nil, + ) + if err != nil { + t.Fatalf("build workflow: %v", err) + } + bridge := NewVerifyBridge(wf, nc, "admin-grant") + trace := verify.NewTrace() + + wait := collectDecisions(t, nc, bridge.Subject(), 1) + if err := bridge.CheckAndCommit(trace, "grant_admin"); err == nil { + t.Fatal("expected grant_admin to be blocked by the safety rule, got nil error") + } + + events := wait() + if len(events) != 1 { + t.Fatalf("got %d events, want 1", len(events)) + } + if events[0].Allowed { + t.Error("Allowed = true, want false") + } + if events[0].Rule != "no-admin-without-audit-log" { + t.Errorf("Rule = %q, want %q", events[0].Rule, "no-admin-without-audit-log") + } + if events[0].MissingState != "grant_logged" { + t.Errorf("MissingState = %q, want %q", events[0].MissingState, "grant_logged") + } +} + +// TestVerifyBridge_Idempotent confirms a replayed CheckAndCommitIdempotent +// call still publishes an event, with Replayed set, matching the real +// outcome from the original call rather than being recomputed. +func TestVerifyBridge_Idempotent(t *testing.T) { + nc := startTestServer(t) + wf := dropProdWorkflow(t) + bridge := NewVerifyBridge(wf, nc, "") + trace := verify.NewTrace() + + wait := collectDecisions(t, nc, bridge.Subject(), 2) + + replayed, err := bridge.CheckAndCommitIdempotent(trace, "take_backup", "retry-key-1") + if err != nil { + t.Fatalf("first call: unexpected error: %v", err) + } + if replayed { + t.Fatal("first call: replayed = true, want false") + } + + replayed, err = bridge.CheckAndCommitIdempotent(trace, "take_backup", "retry-key-1") + if err != nil { + t.Fatalf("second call: unexpected error: %v", err) + } + if !replayed { + t.Fatal("second call: replayed = false, want true") + } + + events := wait() + if len(events) != 2 { + t.Fatalf("got %d events, want 2", len(events)) + } + if events[0].Replayed { + t.Error("event 0: Replayed = true, want false") + } + if !events[1].Replayed { + t.Error("event 1: Replayed = false, want true") + } + if !events[0].Allowed || !events[1].Allowed { + t.Errorf("both events should be Allowed: %+v, %+v", events[0], events[1]) + } + + // take_backup committed exactly once despite two calls. + if got := trace.ExecutedSteps(); len(got) != 1 { + t.Fatalf("trace.ExecutedSteps() = %v, want exactly one commit", got) + } +} + +// TestVerifyBridge_PublishFailureDoesNotChangeDecision confirms that a +// broken connection to NATS never turns an allowed step into a blocked one +// or vice versa: the barrier's own return value is untouched by a publish +// failure, only OnPublishError observes it. +func TestVerifyBridge_PublishFailureDoesNotChangeDecision(t *testing.T) { + nc := startTestServer(t) + wf := dropProdWorkflow(t) + bridge := NewVerifyBridge(wf, nc, "prod-db-rollout") + + var publishErrs int + bridge.OnPublishError(func(error) { publishErrs++ }) + + nc.Close() // force every subsequent publish to fail + + trace := verify.NewTrace() + if err := bridge.CheckAndCommit(trace, "take_backup"); err != nil { + t.Fatalf("take_backup should still succeed with NATS down: %v", err) + } + if err := bridge.CheckAndCommit(trace, "drop_prod_db"); err == nil { + t.Fatal("drop_prod_db should still be blocked with NATS down") + } + + if publishErrs != 2 { + t.Fatalf("OnPublishError called %d times, want 2", publishErrs) + } +} diff --git a/adapters/nats/doc.go b/adapters/nats/doc.go new file mode 100644 index 000000000..da4723240 --- /dev/null +++ b/adapters/nats/doc.go @@ -0,0 +1,33 @@ +// Package nats is an optional, opt-in bridge that publishes ai/verify +// barrier decisions to a NATS subject so an external system (a dashboard, +// a SIEM, another service in a team's stack that already runs NATS) can +// observe them without joltrin's embedded core ever depending on NATS or +// requiring it to function. +// +// This package changes nothing about ai/verify. VerifyBridge wraps a +// *verify.Workflow and calls straight through to the same-named method on +// it; the barrier's decision (allow or block) is exactly what +// ai/verify.Workflow would have returned on its own. Publishing to NATS is +// a side effect on the way out, not a precondition for the barrier to +// work: a caller that never imports this package, or that imports it but +// never wires it in, sees no behavior change and no new dependency. +// +// Nothing here touches storage. VerifyBridge only ever calls ai/verify +// methods (CheckSafety, CheckAndCommit, CheckAndCommitIdempotent), which +// hold an in-memory *verify.Trace lock and do not read or write the +// B-Tree. There is no code path from this package into btree, inmemory, +// fs, or any transaction path. +// +// Typical use: a server that already owns a *verify.Workflow and a +// *nats.Conn (tools/mcpserver and tools/a2aagent are two examples in this +// repo, see their use of ai/verify directly) wraps the workflow once at +// startup: +// +// nc, _ := natsgo.Connect(natsgo.DefaultURL) +// bridge := nats.NewVerifyBridge(wf, nc, "prod-db-rollout") +// // use bridge.CheckAndCommit in place of wf.CheckAndCommit +// +// and every barrier decision made through bridge is also published as a +// BarrierDecision event to bridge's subject. See examples/verify_barrier_nats +// for a full runnable demonstration, including a subscriber. +package nats diff --git a/adapters/nats/go.mod b/adapters/nats/go.mod new file mode 100644 index 000000000..70b5a1dad --- /dev/null +++ b/adapters/nats/go.mod @@ -0,0 +1,26 @@ +module github.com/sharedcode/joltrin/adapters/nats + +go 1.26.8 + +replace github.com/sharedcode/joltrin => ../../ + +replace github.com/sharedcode/joltrin/ai => ../../ai + +require ( + github.com/nats-io/nats-server/v2 v2.15.0 + github.com/nats-io/nats.go v1.54.0 + github.com/sharedcode/joltrin/ai v0.0.0 +) + +require ( + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/klauspost/compress v1.20.0 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/time v0.16.0 // indirect +) diff --git a/adapters/nats/go.sum b/adapters/nats/go.sum new file mode 100644 index 000000000..7b9e441ba --- /dev/null +++ b/adapters/nats/go.sum @@ -0,0 +1,25 @@ +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= +github.com/nats-io/nats-server/v2 v2.15.0 h1:M99yf0y05rTr46/qc/Is6ZAowI58Ryp2SjufLCUeVJc= +github.com/nats-io/nats-server/v2 v2.15.0/go.mod h1:5qLF4CDGzZVFt//3fUrY1ePpwbi05r7QHPNroSUtolk= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/adapters/nats/subscribe.go b/adapters/nats/subscribe.go new file mode 100644 index 000000000..8dd5f27cd --- /dev/null +++ b/adapters/nats/subscribe.go @@ -0,0 +1,34 @@ +package nats + +import ( + "encoding/json" + "fmt" + + "github.com/nats-io/nats.go" +) + +// Subscribe subscribes to subject on nc and calls handler with each +// BarrierDecision received. It is a thin convenience wrapper around +// nc.Subscribe for the common case of consuming VerifyBridge's own output; +// nothing in this package requires a caller to use it, a NATS subscriber +// in any other language or client can consume SubjectFor's subject +// directly since BarrierDecision is plain JSON. +// +// A message that fails to unmarshal as a BarrierDecision is dropped rather +// than passed to handler or returned as an error, since one malformed +// message on a shared subject should not stop delivery of the rest; wrap +// nc.Subscribe directly instead if that behavior does not fit a caller's +// needs. +func Subscribe(nc *nats.Conn, subject string, handler func(BarrierDecision)) (*nats.Subscription, error) { + sub, err := nc.Subscribe(subject, func(msg *nats.Msg) { + var ev BarrierDecision + if err := json.Unmarshal(msg.Data, &ev); err != nil { + return + } + handler(ev) + }) + if err != nil { + return nil, fmt.Errorf("nats: subscribe to %q: %w", subject, err) + } + return sub, nil +} diff --git a/docs/MCP_A2A_AND_VERIFICATION_ENGINE.md b/docs/MCP_A2A_AND_VERIFICATION_ENGINE.md index ee2450338..dc5318055 100644 --- a/docs/MCP_A2A_AND_VERIFICATION_ENGINE.md +++ b/docs/MCP_A2A_AND_VERIFICATION_ENGINE.md @@ -115,3 +115,23 @@ reachability := []ReachabilityRule{ ### A real limitation, found by testing this, not by inspection While writing the test fixture above, `VerifyReachability` correctly rejected it: `prod_db_dropped` had no path back to `rollback_complete`, because no step's precondition was `prod_db_dropped` alone. The fix was a real modeling correction (add `restore_from_backup_post_drop`, requiring `prod_db_dropped` directly), documented in detail in `ai/verify/reachability.go`'s doc comment: this checker verifies reachability **per individual state**, not over the full powerset of states that might simultaneously hold in a real trace. A state that never appears as any step's precondition is a dead end under this check even if, in reality, it always co-occurs with some other state that does have a path forward. Every state that must stay "exitable" needs its own explicit step, relying on a concurrently-true state to carry the path forward for it won't be seen. Full conjunctive-state (powerset) reachability would close this gap, at the cost of real state-space explosion risk for larger workflows, a deliberate tradeoff for this package's target (finite runbooks), not an oversight. + +--- + +## Addendum: an optional NATS observability bridge (`adapters/nats`) + +Added later, in a separate change, not part of the original MCP/A2A work above. Recorded here because it observes the exact barrier decisions this document describes. + +`ai/verify.Workflow` has no event, hook, or listener mechanism of its own, by design: `CheckSafety`, `CheckAndCommit`, and `CheckAndCommitIdempotent` are synchronous calls that return the decision directly to whoever called them (`tools/mcpserver`, `tools/a2aagent`, or any other caller). That is enough for those two servers, which already get the answer they need in the return value. It is not enough for a team that wants some other service, one that already runs NATS, to observe those same decisions as they happen without polling or re-implementing the barrier logic itself. + +`adapters/nats.VerifyBridge` closes that specific gap as its own Go module under `go.work`, the same pattern `adapters/redis` and `adapters/cassandra` already use. It wraps a `*verify.Workflow` and, after each barrier check, publishes a `BarrierDecision` (workflow, step, allowed/blocked, which rule and which missing state if blocked, and whether the decision was replayed from an idempotency key) as JSON to a NATS subject. It changes nothing about `ai/verify` itself; a caller who does not import `adapters/nats` gets identical behavior to before it existed, and a publish failure inside the bridge can never change or block the barrier's own decision, see `adapters/nats/bridge.go`'s `publish`. It does not touch storage: nothing in this bridge reads or writes the B-Tree, it only ever calls the same in-memory `*verify.Trace` methods `tools/mcpserver` and `tools/a2aagent` already call directly. + +Entirely opt-in: joltrin's embedded core does not depend on NATS, require it to function, or load this package by default. A caller who never runs a NATS server, or never imports `adapters/nats`, is unaffected. See `examples/verify_barrier_nats` for a runnable end-to-end demonstration (the same blocked-then-allowed sequence as `examples/verify_barrier` above, with a second goroutine subscribed to the published events), and `adapters/nats/bridge_test.go` for tests against a real, in-process NATS server. + +### Why this doesn't contradict joltrin's embedded thesis + +The README's core argument against Redis, Kafka, and Postgres is specifically about the **hot path**: a single service paying a 15-50ms network round trip, on every operation, to talk to infrastructure sitting outside its own process. Joltrin's answer is to collapse that into one embedded, in-process call. `adapters/nats` does not put anything back on that path. `VerifyBridge` calls the same `ai/verify.Workflow` methods `tools/mcpserver` and `tools/a2aagent` already call directly, gets the decision back exactly as fast as before, and only then, as a side effect, fires a publish. That publish is asynchronous (no `Flush`), and a publish failure is reported through `OnPublishError`, never returned as, or allowed to change, the barrier's own decision. There is no code path where using this bridge makes a barrier check slower or less reliable than not using it. + +The actual problem this solves is a different one, and it isn't one an embedded library can solve by being more embedded: `ai/verify.Workflow` lives inside one process. The moment a second process, a dashboard, a SIEM, an on-call bot, anything not sharing that process's memory, needs to know what the barrier just decided, something has to cross a process boundary. That crossing is unavoidable once more than one service exists; no amount of in-process speed changes it, because the whole point is informing something outside the process. `adapters/nats` is that crossing, not a caching tier or a state store competing with the B-Tree. It is the same category of thing as `adapters/redis` and `adapters/cassandra` already sitting in this repo: an optional bridge to infrastructure a team may already be running, kept out of joltrin's default path, not a reversal of the embedded design. + +Put plainly: if a team has no NATS server and never imports this package, nothing about their joltrin deployment changes. If a team already runs NATS elsewhere in their architecture, and today has no way to watch this specific decision point without polling or re-implementing `ai/verify`'s logic in a second place, this gives them one, at zero cost to the barrier itself. diff --git a/examples/verify_barrier_nats/main.go b/examples/verify_barrier_nats/main.go new file mode 100644 index 000000000..7a48cae50 --- /dev/null +++ b/examples/verify_barrier_nats/main.go @@ -0,0 +1,105 @@ +// Package main is examples/verify_barrier plus one opt-in addition: +// adapters/nats.VerifyBridge, publishing every barrier decision to NATS so +// an external subscriber (this program's own second goroutine, standing in +// for a dashboard or another service) can observe them in real time. The +// barrier itself behaves identically to examples/verify_barrier; nothing +// about ai/verify changes, and joltrin's embedded core never depends on +// NATS, only this example (and any caller who opts in the same way) does. +// +// Needs a NATS server reachable at nats://127.0.0.1:4222 (the default of +// `nats-server` with no flags, or `docker run -p 4222:4222 nats`). Run with: +// +// go run ./examples/verify_barrier_nats +package main + +import ( + "fmt" + "time" + + natsgo "github.com/nats-io/nats.go" + + natsbridge "github.com/sharedcode/joltrin/adapters/nats" + "github.com/sharedcode/joltrin/ai/verify" +) + +func must(err error) { + if err != nil { + panic(err) + } +} + +func main() { + nc, err := natsgo.Connect(natsgo.DefaultURL) + if err != nil { + fmt.Printf("could not connect to NATS at %s: %v\n", natsgo.DefaultURL, err) + fmt.Println("start one with `nats-server` or `docker run -p 4222:4222 nats` and try again") + return + } + defer nc.Close() + + wf, err := verify.NewWorkflow( + []verify.Step{ + {ID: "take_backup", Establishes: []verify.State{"backup_taken"}}, + {ID: "validate_backup", Requires: []verify.State{"backup_taken"}, Establishes: []verify.State{"backup_validated"}}, + {ID: "drop_prod_db", Requires: []verify.State{"backup_validated"}, Establishes: []verify.State{"prod_db_dropped"}}, + {ID: "restore_from_backup", Requires: []verify.State{"backup_validated"}, Establishes: []verify.State{"rollback_complete"}}, + {ID: "restore_from_backup_post_drop", Requires: []verify.State{"prod_db_dropped"}, Establishes: []verify.State{"rollback_complete"}}, + }, + []verify.SafetyRule{ + {Name: "no-drop-without-validated-backup", Forbidden: "prod_db_dropped", Requires: "backup_validated"}, + }, + []verify.ReachabilityRule{ + {Name: "rollback-always-reachable", Target: "rollback_complete"}, + }, + ) + must(err) + must(wf.VerifyReachability()) + + bridge := natsbridge.NewVerifyBridge(wf, nc, "prod-db-rollout") + bridge.OnPublishError(func(err error) { fmt.Printf(" ! nats publish error: %v\n", err) }) + + // Stand-in for the external subscriber this bridge exists for: any + // service that already runs NATS, subscribing to the same subject a + // completely separate process could subscribe to. + sub, err := natsbridge.Subscribe(nc, bridge.Subject(), func(ev natsbridge.BarrierDecision) { + if ev.Allowed { + fmt.Printf(" [subscriber] %s: ALLOWED\n", ev.Step) + } else { + fmt.Printf(" [subscriber] %s: BLOCKED (%s, missing %q)\n", ev.Step, ev.Rule, ev.MissingState) + } + }) + must(err) + defer sub.Unsubscribe() + + trace := verify.NewTrace() + pause := 300 * time.Millisecond + + fmt.Printf("subscribed an observer to %q\n\n", bridge.Subject()) + + fmt.Println("agent: \"backup looks fine, dropping prod now\"") + fmt.Print("→ execute_step(drop_prod_db) ... ") + if err := bridge.CheckAndCommit(trace, "drop_prod_db"); err != nil { + fmt.Println("BLOCKED") + fmt.Printf(" barrier certificate: %v\n", err) + } + time.Sleep(pause) + + fmt.Println("\nagent: okay, taking a real backup first") + fmt.Print("→ execute_step(take_backup) ... ") + must(bridge.CheckAndCommit(trace, "take_backup")) + fmt.Println("committed") + time.Sleep(pause) + + fmt.Print("→ execute_step(validate_backup) ... ") + must(bridge.CheckAndCommit(trace, "validate_backup")) + fmt.Println("committed") + time.Sleep(pause) + + fmt.Println("\nagent: backup is now validated, retrying the drop") + fmt.Print("→ execute_step(drop_prod_db) ... ") + must(bridge.CheckAndCommit(trace, "drop_prod_db")) + fmt.Println("ALLOWED, committed") + time.Sleep(pause) + + fmt.Printf("\ntrace: %v\n", trace.ExecutedSteps()) +} diff --git a/go.work b/go.work index f6a7e7763..63634456c 100644 --- a/go.work +++ b/go.work @@ -3,6 +3,7 @@ go 1.26.8 use ( . ./adapters/cassandra + ./adapters/nats ./adapters/redis ./ai ./incfs diff --git a/go.work.sum b/go.work.sum index 3f8c9af82..2e783626c 100644 --- a/go.work.sum +++ b/go.work.sum @@ -146,6 +146,8 @@ golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= @@ -164,6 +166,8 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -177,6 +181,7 @@ golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJ golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= @@ -184,6 +189,7 @@ golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= @@ -192,6 +198,8 @@ golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= @@ -202,6 +210,7 @@ golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=