package ingest import ( "encoding/json" "fmt" ) // bank.go: the ALLOWLISTED reading of the engine's whole-bank sidecar (`.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 }