package ingest import ( "bytes" "encoding/json" "fmt" ) // bankdecisions.go: the two documents of the engine's bank-correction door, `tmctl bank-apply` // (D39.158) — the request the platform RENDERS and the report it READS BACK. Both live here because // this package is where the seam's vocabulary lives; the HTTP shapes of the same facts are the // contract's and stay in httpapi. // DecisionsVersion and DecisionsReportVersion are the SHAPES of the two documents, as the engine // declares them (backend/internal/membank/decisions.go). Two constants because they are two // documents, free to move at different times. const ( DecisionsVersion = "tm-bank-decisions-v1" DecisionsReportVersion = "tm-bank-decisions-report-v2" ) // DepthEditWave is the engine's word for how far an accepted correction reaches: the edit wave — // the next run refines the produced text rather than re-forming the draft (membank.DecisionDepth). // A WAVE name, so it must never be forwarded to the wire as it stands. const DepthEditWave = "edit_wave" // BankDecision is one correction, in the engine's own request vocabulary (membank.Decision). The // wire's strictness — a tuple must carry all four members, `null` windows and an empty `sense` // meaning "none" — is the HANDLER's validation; by the time a value gets here the two forms mean // the same thing to the engine, which defaults an omitted member (`omitempty` is therefore safe). // Wire `null` windows are projected to 0 here, the seam's own null (canon §BankCorrection). type BankDecision struct { Action string `json:"action"` ID string `json:"id,omitempty"` Src string `json:"src,omitempty"` Sense string `json:"sense,omitempty"` SinceChapter int `json:"since_chapter,omitempty"` UntilChapter int `json:"until_chapter,omitempty"` Dst string `json:"dst,omitempty"` Kind string `json:"kind,omitempty"` Note string `json:"note,omitempty"` } // MaxDecisionsDocument mirrors the engine's own byte ceiling on the rendered document // (pipeline.maxDecisionsBytes) — the figure the platform measures BEFORE spawning the verb, so the // engine's copy of the cap answers as the canon's 413 instead of its refusal class. const MaxDecisionsDocument = 1 << 20 // EncodeDecisions renders the request document the verb reads (`--decisions`). // // HTML escaping is OFF: the document is read by the engine, never by a browser, and Go's default // escape turns one `&`/`<`/`>` byte into six — enough for a body inside the wire's 1 MiB cap to // render past the engine's identical cap and come back as «re-decide» instead of «too large» // (workflow finding, P9). Unescaped, the rendered form differs from the wire's by the envelope // alone, and the residual band is those few bytes, gated by the caller against MaxDecisionsDocument. func EncodeDecisions(bookID string, decisions []BankDecision) ([]byte, error) { doc := struct { Version string `json:"decisions_version"` BookID string `json:"book_id"` Decisions []BankDecision `json:"decisions"` }{Version: DecisionsVersion, BookID: bookID, Decisions: decisions} var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(doc); err != nil { return nil, fmt.Errorf("ingest: encode decisions: %w", err) } return bytes.TrimSpace(buf.Bytes()), nil } // BankReport is the ALLOWLISTED subset of the verb's report (pipeline.BankDecisionsReport). Absent // on purpose, like everywhere on this seam: the two file paths and the per-file write truth are // server topology, the canonical-rewrite warnings address an operator of files, and the TEXTS of // pre-existing faults are the engine's free vocabulary — the wire carries their COUNT. type BankReport struct { Version string `json:"report_version"` BookID string `json:"book_id"` // Mode names the outcome: `apply` · `projection` · `refused` · `stopped` · `write_incomplete`. Mode string `json:"mode"` Depth string `json:"depth"` Changed bool `json:"changed"` // PreexistingProblems is decoded for its LENGTH; the texts never cross the next boundary. PreexistingProblems []string `json:"preexisting_problems"` Accepted []AcceptedDecision `json:"accepted"` Rejected []RejectedDecision `json:"rejected"` Signature SignatureState `json:"signature"` } // AcceptedDecision is what one accepted correction did. type AcceptedDecision struct { Index int `json:"index"` Action string `json:"action"` ID string `json:"id"` Src string `json:"src"` Dst string `json:"dst"` // State is `applied` or `already_applied` — the whole of idempotency as a caller sees it. State string `json:"state"` // Replaced is decoded for its PRESENCE: the wire carries the boolean `displaced`, never the // engine's free-text itemization. Replaced []string `json:"replaced"` } // RejectedDecision is one refusal. Index is the decision's position in the request, or -1 for a // refusal about the result as a whole. type RejectedDecision struct { Index int `json:"index"` Reason string `json:"reason"` } // SignatureState counts the last signing stop's surfaces against the recorded decisions. // INFORMATIONAL, never a gate (D39.144). `Map` is a server-side path and is read here only for // "does a stop exist at all": empty means no run has reached one, which the wire says as `null`. type SignatureState struct { Map string `json:"map"` Surfaces int `json:"surfaces"` Undecided int `json:"undecided"` Unreadable bool `json:"unreadable"` } // DecodeBankReport parses the verb's report and refuses a shape this build does not speak. // // The version is matched EXACTLY, unlike the manifest's presence-only rule, and the asymmetry is // deliberate: the manifest feeds a read model that a stale field set degrades, while this report is // the receipt of a WRITE — a half-read receipt would tell a user their correction did something // other than what it did. An engine that moved the shape is a deployment skew to refuse loudly. func DecodeBankReport(b []byte) (BankReport, error) { var r BankReport if err := json.Unmarshal(b, &r); err != nil { return BankReport{}, fmt.Errorf("ingest: decode bank report: %w", err) } if r.Version != DecisionsReportVersion { return BankReport{}, fmt.Errorf("ingest: decode bank report: report_version is %q, this build speaks %q", r.Version, DecisionsReportVersion) } return r, nil }