136 lines
6.4 KiB
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
|
|
}
|