textmachine/platform/internal/ingest/bank.go

153 lines
5.3 KiB
Go

package ingest
import (
"encoding/json"
"fmt"
)
// bank.go: the ALLOWLISTED reading of the engine's whole-bank sidecar (`<project_db>.bank.json`,
// engine backlog row 125, landed D39.122).
//
// The bank lives in the engine's private SQLite, which this side must never open (D39.85), so the
// sidecar is the only channel there is. It is written atomically (temp + rename), which is what makes
// it readable while a run is going — the one engine artifact of the three that does not have to wait
// for a boundary.
//
// ⚠ Every vocabulary crossing here is TRANSLATED and none is passed through. `ruby` is Japanese
// furigana — pair-specific data in a shared layer — `mined` is the name of a pipeline stage and
// `draft` the name of a wave; the contract renamed all six values in 0.3.0 precisely so that none of
// them reaches a client (canon §TermStatus/§TermOrigin, companion §2.8). Doing the mapping at the
// seam rather than at projection time is deliberate: what is STORED is then already what a client
// reads, and no later path can leak a word by forgetting to translate it.
// BankTerm is one row of the bank, in the contract's own words.
type BankTerm struct {
// ID is the engine's derived key over the row's uniqueness key (src, sense, window). Stable
// across the bank being rebuilt — which it is on every run — and NOT across the book being cut
// differently, because the window is in chapter numbers.
ID string
Src string
Dst string
Kind string // "" when the engine did not decide: a legal state the row still needs signing in
Status string // proposed | in_progress | approved
Origin string // given | annotated | found
Sense string
// SinceChapter / UntilChapter are nil for "no boundary". The engine's own sentinel is 0, which is
// a value chapter numbering cannot produce (it starts at 1) and which meant two different things
// in two fields.
SinceChapter *int
UntilChapter *int
}
// Bank is the whole read-out.
type Bank struct {
Terms []BankTerm
}
// The contract's TermStatus and TermOrigin values.
const (
TermProposed = "proposed"
TermInProgress = "in_progress"
TermApproved = "approved"
OriginGiven = "given"
OriginAnnotated = "annotated"
OriginFound = "found"
)
type wireBank struct {
Version string `json:"bank_version"`
BookID string `json:"book_id"`
Terms []struct {
ID string `json:"id"`
Src string `json:"src"`
Dst string `json:"dst"`
Kind string `json:"kind"`
Status string `json:"status"`
Origin string `json:"origin"`
Sense string `json:"sense"`
SinceChapter int `json:"since_chapter"`
UntilChapter int `json:"until_chapter"`
} `json:"terms"`
}
// DecodeBank parses a bank read-out, and refuses anything that does not identify itself as one — the
// same guard the manifest carries, for the same reason: an empty bank and a document this build
// cannot read decode identically, and one of them would replace a book's whole bank with nothing.
func DecodeBank(b []byte) (Bank, error) {
var doc wireBank
if err := json.Unmarshal(b, &doc); err != nil {
return Bank{}, fmt.Errorf("ingest: decode bank: %w", err)
}
if doc.Version == "" {
return Bank{}, fmt.Errorf("ingest: decode bank: the document carries no bank_version, so it is not a bank")
}
out := Bank{Terms: make([]BankTerm, 0, len(doc.Terms))}
for _, t := range doc.Terms {
status, ok := termStatus(t.Status)
if !ok {
// A status this build has never heard of. Dropping the row is the wrong answer — it would
// silently shrink a bank somebody has to sign — and guessing `approved` would carry an
// unsigned term into the book as canon, so it is read as the state that asks for a
// decision.
status = TermProposed
}
origin, ok := termOrigin(t.Origin)
if !ok {
// Provenance is what the person signing judges trust by, and there is no safe guess: a row
// whose origin this build cannot name is reported as the one that claims the least about
// where it came from.
origin = OriginFound
}
out.Terms = append(out.Terms, BankTerm{
ID: t.ID, Src: t.Src, Dst: t.Dst, Kind: termKind(t.Kind),
Status: status, Origin: origin, Sense: t.Sense,
SinceChapter: chapterBound(t.SinceChapter), UntilChapter: chapterBound(t.UntilChapter),
})
}
return out, nil
}
func termStatus(engine string) (string, bool) {
switch engine {
case "auto":
return TermProposed, true
case "draft":
return TermInProgress, true
case "approved":
return TermApproved, true
}
return "", false
}
func termOrigin(engine string) (string, bool) {
switch engine {
case "seed":
return OriginGiven, true
case "ruby":
return OriginAnnotated, true
case "mined":
return OriginFound, true
}
return "", false
}
// termKind passes the engine's classification through the contract's closed vocabulary. A kind
// outside it — including the empty one the engine legitimately produces for a candidate it could not
// classify — becomes "kind not decided", which is a state the contract has and a client must render.
// Passing an unknown value through instead would hand a generated client a value its union does not
// contain.
func termKind(engine string) string {
switch engine {
case "name", "place", "title", "term", "nickname":
return engine
}
return ""
}
func chapterBound(n int) *int {
if n <= 0 {
return nil
}
return &n
}