textmachine/platform/internal/ingest/bankdecisions.go

136 lines
6.4 KiB
Go

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
}