textmachine/platform/internal/ingest/build.go

110 lines
5.5 KiB
Go

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
}