package ingest import ( "encoding/json" "fmt" ) // build.go: the ALLOWLISTED reading of `tmctl build`'s report (`tm-build-v1`, D39.175). // // It is the LAST of the engine's seven channels to get a reader here, and it is read for one // ratified reason: the export door must check the REPORT and not only the file (D39.175 п.2, // D39.178 п.3). What the report carries beyond "a file exists" is addressed to the OPERATOR and to // the interface, never to the reader of the book — config drift and an unknowable staleness verdict // are the operator's vocabulary, and the honesty a reader is owed is inside the file itself, on the // title page and at every hole (D39.175 п.1). // // ⚠ `files` is deliberately NOT projected past this package. Absolute paths are server topology, // the same rule that keeps the book's `workdir` off the wire; the door reads the path it ASKED the // engine to write (`--out`), which is the one it can also serve and delete. // KnownBuildVersion is the shape of the engine's build report this build was written against // (backend/internal/pipeline/bookbuild.go, `buildVersion`). // // ⚠ Its value is NOT gated, and that is the manifest's rule read the other way round. The manifest // is a CUT whose fields renumber a book, so a shape this platform has not seen must stop the // intake; a build report is a receipt for a file that already exists on disk, and refusing to read // it would throw away a completed, paid-for artifact over a version string. The PRESENCE of the // field is required — that is the different question, "is this a build report at all" — and a value // other than this one is logged by the caller. const KnownBuildVersion = "tm-build-v1" // BuildReport is what one `tmctl build` produced. type BuildReport struct { // Version is `build_version`; recorded so a caller can say which shape answered it. Version string BookID string // Complete is true when the file has no hole of any kind — pending, withheld, incomplete, stale, // ghost. A file written under `--partial` with Complete false carries the notice on its first // page and a mark at every hole. Complete bool // The five counts behind Complete, so an operator sees WHY a copy is marked without opening it. PendingUnits int WithheldUnits int IncompleteUnits int StaleUnits int GhostRows int // StaleUnknown is the engine's honest third answer: for at least one shipped unit it could not // be decided whether the source moved underneath it. Never folded into "none" — that fold is // what the engine's own field comment forbids. StaleUnknown bool // ConfigDrift is NOT a hole of the file (owner's ratification, D39.175 п.2): the bytes written // are the bytes the run shipped. It is a warning to the operator that the current configuration // would render a different snapshot. ConfigDrift bool // RemovedFiles and StaleCopies are the engine's disclosure of what its housekeeping did beside // the project database. The door builds with `--out`, where the engine does no housekeeping at // all, so a non-empty list here means this platform asked for something it did not think it was // asking for — which is worth a line rather than a shrug. RemovedFiles []string StaleCopies []string } type wireBuild struct { Version string `json:"build_version"` BookID string `json:"book_id"` TotalUnits int `json:"total_units"` PendingUnits int `json:"pending_units"` WithheldUnits int `json:"withheld_units"` IncompleteUnits int `json:"incomplete_units"` StaleUnits int `json:"stale_units"` StaleUnknown bool `json:"stale_unknown"` GhostRows int `json:"ghost_rows"` ConfigDrift bool `json:"config_drift"` RemovedFiles []string `json:"removed_files"` StaleCopies []string `json:"stale_copies"` Complete bool `json:"complete"` } // DecodeBuild parses a build report, and refuses anything that does not identify itself as one. // // The guard is the same one the manifest and the bank carry, for the same reason: an empty JSON // object decodes into a report that says "complete, no holes, nothing removed", which is the most // reassuring thing this type can say and the one a document from a process that is not the engine // would say for free. func DecodeBuild(b []byte) (BuildReport, error) { var doc wireBuild if err := json.Unmarshal(b, &doc); err != nil { return BuildReport{}, fmt.Errorf("ingest: decode build report: %w", err) } if doc.Version == "" { return BuildReport{}, fmt.Errorf("ingest: decode build report: the document carries no build_version, so it is not a build report") } return BuildReport{ Version: doc.Version, BookID: doc.BookID, Complete: doc.Complete, PendingUnits: doc.PendingUnits, WithheldUnits: doc.WithheldUnits, IncompleteUnits: doc.IncompleteUnits, StaleUnits: doc.StaleUnits, GhostRows: doc.GhostRows, StaleUnknown: doc.StaleUnknown, ConfigDrift: doc.ConfigDrift, RemovedFiles: doc.RemovedFiles, StaleCopies: doc.StaleCopies, }, nil } // NeedsOperator reports whether this report carries something addressed to the operator rather than // to the reader of the book: the drift warning the door is REQUIRED to check (D39.175 п.2), the // unknowable staleness verdict, or housekeeping the door did not expect to trigger. // // It exists so the door has ONE place that decides what is worth a log line, instead of a condition // spelled out at every call site and drifting between them. func (r BuildReport) NeedsOperator() bool { return r.ConfigDrift || r.StaleUnknown || len(r.RemovedFiles) > 0 || len(r.StaleCopies) > 0 }