diff --git a/specs/port/trios-railway/crates/trios-railway-experience/src/lib.t27 b/specs/port/trios-railway/crates/trios-railway-experience/src/lib.t27 new file mode 100644 index 0000000000..76b983a5f9 --- /dev/null +++ b/specs/port/trios-railway/crates/trios-railway-experience/src/lib.t27 @@ -0,0 +1,215 @@ +// The append-only experience writer for .trinity/experience/. +// Port of gHashTag/trios-railway:crates/trios-railway-experience/src/lib.rs +// (at 8b229e9489ee, 140+ lines) — Roadmap Stage 6, goal #4548, target t27c +// gen-rust. +// +// The original enforces L7 (the experience log) and L21 (context +// immutability): the writer only ever opens the daily file in append mode +// and never seeks or truncates — a caller that would truncate has no +// method to call. An ExperienceLine is built from a RailwayHash only when +// its phi_step is one of the eleven allowed PHI LOOP steps (mirroring +// NOW.json); any other step is rejected. The file layout is fixed: the +// experience directory under the root, the daily file named +// trios_railway_.trinity, one serialized JSON line per append, +// each with a trailing newline. +// +// The filesystem, the clock, the JSON serializer and the RailwayHash are +// plumbing; the decisions — the eleven-step catalogue and the membership +// gate, the line's built/invalid verdict with its carried fields and the +// absent evidence, and the file layout rules with the append-only +// contract — are ported here as pure functions and tested, including the +// original's own rejects_unknown_phi_step test. The clock and the hash +// triplet arrive as parameters (Utc::now and hash.triplet() are the +// caller's plumbing), and the append_line transport keeps its name with +// an `undefined;` body that no test calls (issue #9769, "Port the +// decision, not the plumbing"). + +module port::trios_railway_experience_lib; + +// --------------------------------------------------------------------------- +// Original lines 18-31: `pub const PHI_STEPS` — the eleven allowed PHI +// LOOP steps, mirroring NOW.json. +// --------------------------------------------------------------------------- + +pub fn phi_step_count() -> usize { + return 11; +} + +pub fn phi_step_in_order(index: usize) -> str { + if (index == 0) { return "CLAIM"; } + if (index == 1) { return "NAME"; } + if (index == 2) { return "SPEC"; } + if (index == 3) { return "SEAL"; } + if (index == 4) { return "GEN"; } + if (index == 5) { return "TEST"; } + if (index == 6) { return "VERDICT"; } + if (index == 7) { return "EXPERIENCE"; } + if (index == 8) { return "REPORT"; } + if (index == 9) { return "COMMIT"; } + if (index == 10) { return "PUSH"; } + return ""; +} + +// Original line 57: `if !PHI_STEPS.contains(&phi_step) { bail!(...) }` — +// the membership gate. +pub fn is_valid_phi_step(step: str) -> bool { + var i: usize = 0; + while (i < phi_step_count()) { + if (step == phi_step_in_order(i)) { + return true; + } + i += 1; + } + return false; +} + +// --------------------------------------------------------------------------- +// Original lines 33-72: `pub struct ExperienceLine` and +// `pub fn from_hash(...) -> Result`. The line carries the audit +// facts; the evidence is None (skip_serializing_if) — carried as the +// absent flag. The clock (Utc::now) and the hash triplet arrive as +// parameters; the built flag is the Ok/bail verdict. +// --------------------------------------------------------------------------- + +pub struct ExperienceLine { + built: bool, + ts: str, + agent: str, + soul_name: str, + issue: str, + task: str, + status: str, + phi_step: str, + triplet: str, + evidence_present: bool, +} + +pub fn ExperienceLine_from_hash( + agent: str, + soul_name: str, + issue: str, + task: str, + status: str, + phi_step: str, + triplet: str, + ts: str, +) -> ExperienceLine { + if (!is_valid_phi_step(phi_step)) { + return ExperienceLine { + built: false, + ts: "", + agent: "", + soul_name: "", + issue: "", + task: "", + status: "", + phi_step: phi_step, + triplet: "", + evidence_present: false, + }; + } + return ExperienceLine { + built: true, + ts: ts, + agent: agent, + soul_name: soul_name, + issue: issue, + task: task, + status: status, + phi_step: phi_step, + triplet: triplet, + evidence_present: false, + }; +} + +// --------------------------------------------------------------------------- +// Original lines 74-98: `pub async fn append_line(root, line) -> +// Result`. The append-only writer: creates the experience +// directory, opens the daily file with create(true).append(true) — never +// truncating, per L21 — writes the serialized line with its trailing +// newline, and returns the path. Pure transport; the layout rules it +// fixes are the decisions, ported below. +// --------------------------------------------------------------------------- + +pub fn append_line(root: str, day: str) -> ?str { + return undefined; // plumbing: append-only write to the daily experience file (lines 74-98) +} + +pub fn experience_dir_name() -> str { + return "experience"; +} + +pub fn daily_file_prefix() -> str { + return "trios_railway_"; +} + +pub fn daily_file_suffix() -> str { + return ".trinity"; +} + +pub fn day_format() -> str { + return "%Y%m%d"; +} + +// Original lines 88-90: `OpenOptions::new().create(true).append(true)` — +// append-only, never truncate (L21: no truncating method exists). +pub fn opens_append_only() -> bool { + return true; +} + +// Original line 86: `serialized.push('\n')` — every appended line ends +// with a newline, so the file is line-per-record. +pub fn line_ends_with_newline() -> bool { + return true; +} + +// --------------------------------------------------------------------------- +// Tests: every decision above asserted against values read from the +// original's own tests (lines 105-140). No test calls the append_line +// transport. +// --------------------------------------------------------------------------- + +test valid_step_builds_the_line { + const line = ExperienceLine_from_hash("GENERAL", "DustyDeployer", "#1", "bootstrap", "OK", "EXPERIENCE", "triplet-x", "2026-10-17T02:39:00Z"); + assert(line.built); + assert(line.agent == "GENERAL"); + assert(line.soul_name == "DustyDeployer"); + assert(line.issue == "#1"); + assert(line.task == "bootstrap"); + assert(line.status == "OK"); + assert(line.phi_step == "EXPERIENCE"); + assert(line.triplet == "triplet-x"); + assert(!line.evidence_present); +} + +test rejects_unknown_phi_step { + const line = ExperienceLine_from_hash("GENERAL", "DustyDeployer", "#1", "bootstrap", "OK", "RUN", "triplet-x", "2026-10-17T02:39:00Z"); + assert(!line.built); +} + +test phi_steps_catalog { + assert(phi_step_count() == 11); + assert(phi_step_in_order(0) == "CLAIM"); + assert(phi_step_in_order(6) == "VERDICT"); + assert(phi_step_in_order(7) == "EXPERIENCE"); + assert(phi_step_in_order(10) == "PUSH"); + assert(phi_step_in_order(11) == ""); +} + +test membership_gate { + assert(is_valid_phi_step("CLAIM")); + assert(is_valid_phi_step("EXPERIENCE")); + assert(is_valid_phi_step("COMMIT")); + assert(is_valid_phi_step("PUSH")); + assert(!is_valid_phi_step("RUN")); + assert(!is_valid_phi_step("")); +} + +test daily_file_layout_and_append_contract { + assert(experience_dir_name() == "experience"); + assert(daily_file_prefix() == "trios_railway_"); + assert(daily_file_suffix() == ".trinity"); + assert(day_format() == "%Y%m%d"); + assert(opens_append_only()); + assert(line_ends_with_newline()); +}