package pipeline import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "textmachine/backend/internal/obs" ) // statusartifacts_test.go: backlog row 213 and 17-seam-inbound-law п.3 — the paths the engine REPORTS // instead of letting another zone derive, and the versioned envelope every JSON output carries. // // The defect row 213 records is not hypothetical: the platform parses book.yaml for two keys and, when // `project_db` is absent (which is what the operator template ships), computes `/.db` // itself — a byte-for-byte copy of config.LoadBook's convention — then appends its own copy of the bank // read-out's `.bank.json` suffix. Changing either default here would have quietly pointed another zone's // read at a file that does not exist. func TestStatusReportsTheEngineOwnedPaths(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := setupProject(t, srv.URL) dir := filepath.Dir(bookPath) r := newRunner(t, bookPath) defer r.Close() rep, err := r.Status(context.Background()) if err != nil { t.Fatal(err) } if rep.Version != statusVersion { t.Errorf("status_version = %q, want %q — the bank read-out and the manifest both carry one and this was the gap", rep.Version, statusVersion) } want := map[string]string{ "project_db": filepath.Join(dir, "test-book.db"), "bank_export": filepath.Join(dir, "test-book.db.bank.json"), "mined_delta": filepath.Join(dir, "test-book.mined-delta.yaml"), "mined_rejects": filepath.Join(dir, "test-book.mined-rejects.yaml"), } got := map[string]string{ "project_db": rep.Artifacts.ProjectDB, "bank_export": rep.Artifacts.BankExport, "mined_delta": rep.Artifacts.MinedDelta, "mined_rejects": rep.Artifacts.MinedRejects, } for k, w := range want { if got[k] != w { t.Errorf("artifacts.%s = %q, want %q", k, got[k], w) } if !filepath.IsAbs(got[k]) { t.Errorf("artifacts.%s must be absolute — the consumer reads it from another working directory: %q", k, got[k]) } } // The suffix stays what it is. Renaming the bank read-out to a fixed name beside events.jsonl is a // BREAKING change and its window is the chapter-structure re-cut (backlog row 161), not this one. if rep.Artifacts.BankExport != r.bankExportPath() { t.Errorf("the reported path must be the one the engine actually writes: %q vs %q", rep.Artifacts.BankExport, r.bankExportPath()) } } // TestStatusJSONCarriesTheEnvelopeAdditively: the platform allowlists the fields it materializes and // ignores the rest, so a new field is safe — but only if it is genuinely ADDED and nothing existing moved. func TestStatusJSONCarriesTheEnvelopeAdditively(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := setupProject(t, srv.URL) r := newRunner(t, bookPath) defer r.Close() rep, err := r.Status(context.Background()) if err != nil { t.Fatal(err) } body, err := json.Marshal(rep) if err != nil { t.Fatal(err) } var doc map[string]json.RawMessage if err := json.Unmarshal(body, &doc); err != nil { t.Fatal(err) } for _, k := range []string{"status_version", "artifacts", "book_id", "total_units", "committed_usd", "reserved_usd", "progress", "chapters"} { if _, ok := doc[k]; !ok { t.Errorf("status --json lost the field %q", k) } } } // TestExportJSONCarriesItsVersion: the law's п.3 is general ("every JSON output of the engine carries a // versioned envelope") and names `status --json` because that is where the gap was noticed. The export // was the second surface without one. func TestExportJSONCarriesItsVersion(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := setupProject(t, srv.URL) r := newRunner(t, bookPath) defer r.Close() exp, err := r.Export(false) if err != nil { t.Fatal(err) } if exp.Version != exportVersion { t.Errorf("export_version = %q, want %q", exp.Version, exportVersion) } } // --- the dofix pack --------------------------------------------------------------------------------- func TestTheReportedPathsAreAbsoluteEvenWhenTheConfigIsNOT(t *testing.T) { // The existing pin checks filepath.IsAbs, but its fixture hands the engine an ABSOLUTE --config, so // every path is already absolute before absPath sees it and removing absPath changed nothing. Measured: // with a relative --config the call is load-bearing, and without it a consumer running in its own // working directory — which is what the platform is — resolves the path against a different directory // and reads a different file, or none. rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := setupProject(t, srv.URL) dir := filepath.Dir(bookPath) t.Chdir(dir) r := newRunner(t, filepath.Base(bookPath)) // RELATIVE, the way an operator types it defer r.Close() rep, err := r.Status(context.Background()) if err != nil { t.Fatal(err) } for name, got := range map[string]string{ "project_db": rep.Artifacts.ProjectDB, "bank_export": rep.Artifacts.BankExport, "mined_delta": rep.Artifacts.MinedDelta, "mined_rejects": rep.Artifacts.MinedRejects, } { if !filepath.IsAbs(got) { t.Errorf("artifacts.%s = %q — a consumer in another working directory reads the wrong file", name, got) } } } func TestTheManifestCarriesTheArtifactEnvelope(t *testing.T) { // Where the envelope has to LAND. `status --json` publishes it, and the platform's read-model does not // call status at all — its engine interface is {Manifest, Export}, and the one place it needs an // engine-owned path it re-derives `/.db` itself, which is the duplicate unified // backlog row 213 records. An envelope on a surface nobody reads removes nothing. rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := setupProject(t, srv.URL) dir := filepath.Dir(bookPath) r := newRunner(t, bookPath) defer r.Close() m, err := r.BuildAndPersistManifest() if err != nil { t.Fatal(err) } want := map[string]string{ "project_db": filepath.Join(dir, "test-book.db"), "bank_export": filepath.Join(dir, "test-book.db.bank.json"), "mined_delta": filepath.Join(dir, "test-book.mined-delta.yaml"), "mined_rejects": filepath.Join(dir, "test-book.mined-rejects.yaml"), } got := map[string]string{ "project_db": m.Artifacts.ProjectDB, "bank_export": m.Artifacts.BankExport, "mined_delta": m.Artifacts.MinedDelta, "mined_rejects": m.Artifacts.MinedRejects, } for k, w := range want { if got[k] != w { t.Errorf("manifest artifacts.%s = %q, want %q", k, got[k], w) } } // It is in the DOCUMENT a consumer parses, not only in the Go struct. body, err := json.Marshal(m) if err != nil { t.Fatal(err) } var doc map[string]any if err := json.Unmarshal(body, &doc); err != nil { t.Fatal(err) } art, ok := doc["artifacts"].(map[string]any) if !ok || art["mined_delta"] != want["mined_delta"] { t.Fatalf("the emitted manifest must carry the envelope: %v", doc["artifacts"]) } // DERIVED on every read, never trusted from the file: a path stored in a sidecar that travels with // the book directory is wrong the first time the directory moves. A stored manifest with a stale // envelope must come back with the current one. raw, err := os.ReadFile(r.manifestPath()) if err != nil { t.Fatal(err) } stale := strings.Replace(string(raw), want["mined_delta"], "/gone/elsewhere.yaml", 1) if stale == string(raw) { t.Fatal("test premise broken: the stored manifest does not carry the path") } writeFile(t, r.manifestPath(), stale) if loaded := r.loadManifest(); loaded == nil { t.Fatal("the doctored manifest must still be structurally valid (only the path was changed)") } else if loaded.Artifacts.MinedDelta != want["mined_delta"] { t.Fatalf("a stored path must be OVERWRITTEN with the current truth, got %q", loaded.Artifacts.MinedDelta) } } // TestTheBankExportCarriesItsFreshnessAnchor pins fix3 §4.6: the export's write discipline is // loud-but-not-fatal, so the file on disk may be a previous boundary's projection — and «may be stale» // used to live only in a log line. The document itself now names the boundary that produced it and the // run identity a journal reader can compare against; a later boundary moves the anchor. func TestTheBankExportCarriesItsFreshnessAnchor(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() r := newRunner(t, setupProject(t, srv.URL)) defer r.Close() ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: "fx3-freshness-trace"}) if _, err := r.TranslateBook(ctx); err != nil { t.Fatal(err) } var exp BankExport mustParseJSON(t, r.bankExportPath(), &exp) if exp.AsOf != "run-finished" { t.Fatalf("the last boundary of a completed run is run-finished, got %q", exp.AsOf) } if exp.RunID != "fx3-freshness-trace" { t.Fatalf("the anchor must carry the run identity the journal streams, got %q", exp.RunID) } // A later boundary REPLACES the anchor — that is the whole point: two projections are tellable apart. ctx2 := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: "fx3-second-run"}) r.exportBank(ctx2, "redrive/re-seeded") mustParseJSON(t, r.bankExportPath(), &exp) if exp.AsOf != "redrive/re-seeded" || exp.RunID != "fx3-freshness-trace" { // The emitter is authoritative once the journal is open: the SAME runner re-exporting still // belongs to its own stream, whatever a caller stamped on the context. t.Fatalf("anchor after a later boundary: as_of=%q run_id=%q", exp.AsOf, exp.RunID) } // …and a runner with no journal and no traced context says «unknown», never «current». r2 := newRunner(t, setupProject(t, srv.URL)) defer r2.Close() r2.exportBank(context.Background(), "run-start/seeded") mustParseJSON(t, r2.bankExportPath(), &exp) if exp.RunID != "" || exp.AsOf != "run-start/seeded" { t.Fatalf("no run identity must export as empty, got as_of=%q run_id=%q", exp.AsOf, exp.RunID) } }