diff --git a/Cargo.lock b/Cargo.lock index b6e0c9d..27866d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -396,6 +396,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "fontdue" version = "0.9.4" @@ -481,7 +487,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -489,6 +495,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -668,6 +679,44 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "relative-path" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +dependencies = [ + "serde", +] + +[[package]] +name = "rquickjs" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e04e4eedfb060b503b5f0a2644abb890b0b3620d3fb674f9455f230014964e4" +dependencies = [ + "rquickjs-core", +] + +[[package]] +name = "rquickjs-core" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e4f499ac5b943d97ee6dbc44f23c2c10426f420f7d2f1793d6318911b6608c" +dependencies = [ + "hashbrown 0.17.1", + "relative-path", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-sys" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13ac243b86a74120814ef7e9e30ad5a2c1199b7b9963b1cf7c84e4cdc1cad99" +dependencies = [ + "cc", +] + [[package]] name = "rusqlite" version = "0.37.0" @@ -983,6 +1032,7 @@ dependencies = [ "thalyx-parser", "thalyx-permd", "thalyx-proc", + "thalyx-program", "thalyx-rust", "thalyx-sandbox", "thalyx-screen", @@ -1171,6 +1221,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "thalyx-program" +version = "0.1.0" +dependencies = [ + "rquickjs", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "thalyx-rust" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6747b45..0e69677 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/thalyx-graph", "crates/thalyx-know", "crates/thalyx-rust", + "crates/thalyx-program", "crates/thalyx-memory", "crates/thalyx-watch", "crates/thalyx-journal", @@ -61,6 +62,7 @@ thalyx-install = { path = "crates/thalyx-install" } thalyx-graph = { path = "crates/thalyx-graph" } thalyx-know = { path = "crates/thalyx-know" } thalyx-rust = { path = "crates/thalyx-rust" } +thalyx-program = { path = "crates/thalyx-program" } thalyx-memory = { path = "crates/thalyx-memory" } thalyx-watch = { path = "crates/thalyx-watch" } thalyx-journal = { path = "crates/thalyx-journal" } diff --git a/README.md b/README.md index 8c305bc..23b351f 100644 --- a/README.md +++ b/README.md @@ -255,11 +255,32 @@ first one found out and a stale answer says it is stale. The whole of that is one call: resolve the symbol, rewrite every real use, see what changed, work out what to compile, reuse what still holds, compile the rest, -commit or put the tree back — with no model inference in between. What is proven -in a container is stage 57 of `verify.sh`; the vertical over a real Btrfs -subvolume with the compiler running confined is stage 58, and whether any of it -makes a frontier agent do more correct work with less effort is **not measured**. -It is built to be measured next; no benchmark was run. +commit or put the tree back — with no model inference in between. + +**And the operations no longer have to be known in advance.** `hacer` takes a +short JavaScript program, run inside the same reversible boundary by a QuickJS +compiled into the binary: variables, loops, conditions, assertions, and calls to +the machine whose answers the next line reads. So a request can list a directory +nobody has described, loop over what came back, read each file, change only the +ones whose contents say to, watch what the tree really shows, validate, and +branch on the verdict — none of which is writable in advance, because writing it +would mean already having the answer. The program is untrusted code: QuickJS +ships no filesystem, no network and no process API, every call it makes goes +through the same door and the same workspace boundary a single request goes +through, and eight separate ceilings mean `while (true) {}` terminates and rolls +back. When it meets a decision a machine should not make — an ambiguous symbol +resolving to three crates — it stops with the workspace untouched and asks, +rather than committing a guess. + +The agent-facing surface is three tools: what a name is, do a stretch of work, +fetch what the work did not send back. The other eleven are still there behind +`--surface legacy`, as the control column. + +What is proven in a container is stage 57 of `verify.sh` and the program run +against a directory-backed boundary; the vertical over a real Btrfs subvolume +with the compiler running confined is stages 58 and 59. Whether any of it makes +a frontier agent do more correct work with less effort is **not measured**. It is +built to be measured next; no benchmark was run. **Not proven.** `thalyx_watch` — the filesystem watcher, ten BPF hooks against the LSM's two — has never been loaded by Thalyx's own loader; `bpftool` still diff --git a/crates/thalyx-cli/Cargo.toml b/crates/thalyx-cli/Cargo.toml index d3b8363..60349d0 100644 --- a/crates/thalyx-cli/Cargo.toml +++ b/crates/thalyx-cli/Cargo.toml @@ -27,6 +27,7 @@ thalyx-manifest.workspace = true thalyx-graph.workspace = true thalyx-know.workspace = true thalyx-rust.workspace = true +thalyx-program.workspace = true thalyx-parser.workspace = true thalyx-memory.workspace = true thalyx-watch.workspace = true @@ -67,6 +68,10 @@ thalyx-btrfs.workspace = true thalyx-install.workspace = true thalyx-manifest.workspace = true thalyx-sandbox.workspace = true +# So a test can ask whether this machine has a rust-analyzer the same way the +# binary does. Rule 3's skip has to be decided by the same search the thing +# under test uses, or the skip is about a different machine. +thalyx-rust.workspace = true [lints] workspace = true diff --git a/crates/thalyx-cli/src/catalogue.rs b/crates/thalyx-cli/src/catalogue.rs index a1bcb0d..13905c5 100644 --- a/crates/thalyx-cli/src/catalogue.rs +++ b/crates/thalyx-cli/src/catalogue.rs @@ -522,8 +522,10 @@ pub const VERBS: &[Verb] = &[ "the_whole_system", "already_open", ], - summary: "Run several requests inside one reversible boundary, check the result, \ - and keep it or undo all of it — in one call.", + summary: "Run a short program — JavaScript, with loops, conditions and \ + assertions — inside one reversible boundary, and keep what it did or \ + undo all of it. What it looks at decides what it does next, so a \ + whole stretch of work is one call.", }, Verb { id: "evidence", diff --git a/crates/thalyx-cli/src/exec.rs b/crates/thalyx-cli/src/exec.rs index 40582ca..f3fbdc5 100644 --- a/crates/thalyx-cli/src/exec.rs +++ b/crates/thalyx-cli/src/exec.rs @@ -177,10 +177,36 @@ pub enum OnFailure { Keep, } +/// The most bytes of program source one request may carry. +/// +/// Not a guess about complexity — a bound on what arrives from outside. Sixty +/// four kilobytes is far more than any program a model writes in one inference +/// and small enough that a caller sending a file by mistake is refused rather +/// than parsed. +pub const MOST_PROGRAM_BYTES: usize = 64 * 1024; + #[derive(Debug, Clone, Deserialize)] pub struct Program { #[serde(default = "agent")] pub label: String, + /// A program: JavaScript, run locally, with the machine's capabilities and + /// none of its authority. + /// + /// **This is the form that made the verb worth having.** `steps` below + /// requires every operation and every argument to be known before anything + /// runs, which cannot express the thing an agent actually spends its turns + /// on — ask, look at the answer, decide. A program can: the references a + /// query returned are a variable, the ones worth changing are an `if`, and + /// the validation that decides whether any of it is kept is a call whose + /// result the next line reads. + #[serde(default)] + pub run: Option, + /// The older form: a list of requests, in order. + /// + /// Kept, and not deprecated. It is the right shape when the work really is + /// known in advance — and it is the control column for every measurement + /// of what the programmable form buys, which is a use that does not expire. + #[serde(default)] pub steps: Vec, #[serde(default)] pub validate: Vec, @@ -207,13 +233,38 @@ impl Program { ) })?; - if program.steps.is_empty() { + let has_source = program + .run + .as_ref() + .is_some_and(|source| !source.trim().is_empty()); + // Exactly one, and refused rather than resolved by precedence. A + // caller that sent both has two ideas about what it wants done, and a + // rule that silently ran one of them would be Thalyx picking which — + // inside a transaction, over somebody's files. + if has_source && !program.steps.is_empty() { + return Err( + "a program is either `run` — code — or `steps` — a list of requests — \ + and this has both. Which one was meant is not something this machine \ + will decide inside a transaction" + .to_string(), + ); + } + if !has_source && program.steps.is_empty() { return Err( - "`steps` is empty; a program with no requests in it changes \ - nothing and there is nothing to be transactional about" + "there is nothing to run: give `run`, a short JavaScript program, or \ + `steps`, a list of requests. A program with neither changes nothing \ + and there is nothing to be transactional about" .to_string(), ); } + if let Some(source) = &program.run + && source.len() > MOST_PROGRAM_BYTES + { + return Err(format!( + "the program is {} bytes and this verb takes at most {MOST_PROGRAM_BYTES}", + source.len() + )); + } if program.steps.len() > MOST_STEPS { return Err(format!( "a program takes at most {MOST_STEPS} steps and this one has {}", @@ -270,6 +321,16 @@ pub struct StepRecord { /// One check, as the evidence records it. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CheckRecord { + /// What was asked for, exactly, so two runs of the same check can be + /// recognised as the same question. + /// + /// It exists because a **program** can validate more than once — the + /// ordinary shape being *check, see it fail, fix it, check again* — and a + /// transaction that rolled back because an earlier attempt had failed + /// would make that shape impossible to write. What gates the commit is the + /// last verdict per key; every attempt is still in the evidence. + #[serde(default)] + pub key: String, pub check: String, /// Three outcomes and not two. A check that could not run is not a check /// that failed and is not a check that passed — rule 10, and here it @@ -321,6 +382,27 @@ pub struct Evidence { pub changed: Vec, pub change_count: usize, pub metrics: Metrics, + + /// The source of the program, when there was one. + /// + /// Kept because a run is not auditable without it: the steps say what + /// happened and only this says what was *asked for*, and the two differ + /// exactly where the interesting behaviour is. + #[serde(default)] + pub program: Option, + /// How the program ended: `returned`, `needs_model`, `assertion`, + /// `threw`, `exhausted` or `refused`. + #[serde(default)] + pub finish: Option, + /// One sentence saying why it ended that way. + #[serde(default)] + pub finish_why: Option, + /// What it handed back, or what it asked the model about. + #[serde(default)] + pub returned: Value, + /// What the program said with `thalyx.log`, bounded. + #[serde(default)] + pub printed: Vec, } /// What the machine did, as numbers, so the hypothesis this verb exists to test @@ -363,6 +445,39 @@ pub struct Metrics { pub validation_cache_misses: usize, /// How many crates the change was found to reach. pub affected_packages: usize, + /// Whether the semantic provider that answered was under Thalyx's + /// confinement. + /// + /// `None` when nothing semantic was asked. Never assumed: rust-analyzer + /// runs Cargo, which compiles and runs build scripts, and a run that + /// reported nothing about where that happened would let a reader believe a + /// compiler tree had been confined when it had not. + #[serde(default)] + pub analyzer_confined: Option, + /// One phrase saying what started it. + #[serde(default)] + pub analyzer_how: Option, + + // ── what the programmable form produced ──────────────────────────────── + // + // Zeroes on a run that sent `steps`, and said anyway: a field that only + // appears on the interesting day is a field nobody handles on the + // interesting day. + /// Things the program asked the machine for: requests, validations and + /// observations of the tree. + /// + /// **The numerator of the whole claim**, against `external_requests`, which + /// is one. A static list of steps could produce a big number here too; what + /// it could not produce is a big number where the *later* operations were + /// chosen from the answers to the earlier ones. + pub program_operations: usize, + /// Premises the program checked, held or not. + pub program_assertions: usize, + /// Times the engine's interrupt handler fired. A rough measure of how much + /// the program actually did, and the units the `ticks` ceiling is in. + pub program_ticks: u64, + /// Whether the program was the code form. + pub programmable: bool, } // ── the evidence store ─────────────────────────────────────────────────────── @@ -413,6 +528,15 @@ fn keep(store: &Store, evidence: &Evidence) -> std::io::Result<()> { /// Everything one run needs that is not the program. pub struct Asked<'a> { pub store: &'a Store, + /// What a program of this request may spend. + /// + /// Carried rather than read from the environment where it is used, and + /// that is rule 11: a test that wanted a one-second ceiling would + /// otherwise have to set a variable of *this whole process*, which is a + /// global switch with no owner whose value is some other test's + /// precondition. The verb reads the environment once, here; everything + /// below takes what it is given. + pub limits: thalyx_program::Limits, /// The tree the boundary is about. Where the session stands, exactly, and /// never an ancestor — `crate::attempt::subvolume_to_attempt` is why. pub subvolume: PathBuf, @@ -458,6 +582,11 @@ pub fn carry_out( changed: Vec::new(), change_count: 0, metrics: metrics.clone(), + program: program.run.clone(), + finish: None, + finish_why: None, + returned: Value::Null, + printed: Vec::new(), }; // ── the boundary, before anything is written ──────────────────────────── @@ -481,50 +610,72 @@ pub fn carry_out( let start = thalyx_snapshot::witness(&asked.subvolume); evidence.start_state = start.is_complete().then(|| start.id.clone()); - // ── the steps ─────────────────────────────────────────────────────────── + // ── the work ──────────────────────────────────────────────────────────── let boundary = here.confined_to().map(Path::to_path_buf); let mut refused = None; - for (index, step) in program.steps.iter().enumerate() { - metrics.machine_operations += 1; - let answered = crate::external::one( - asked.store, + + if let Some(source) = &program.run { + let ran = drive( + asked, + &snapshots, + &opened.snapshot, here, boundary.as_deref(), - &step.verb, - &step.arguments, - ); - let (ok, answer) = match answered { - Ok(answer) => { - // A verb that answered is not a verb that succeeded: every - // refusal on this surface is a well-formed object with - // `ok: false` in it, and reading "it answered" as "it worked" - // is how a program carries on past the edit that did not - // happen and validates a tree nobody changed. - let ok = answer.get("ok").and_then(Value::as_bool).unwrap_or(false); - (ok, answer) - } - Err(refusal) => ( - false, - json!({ - "ok": false, - "word": refusal.word, - "remedy": refusal.remedy, - "message": refusal.message, - }), - ), - }; + source, + &mut metrics, + &mut evidence, + ); + // A program that did not run to the end is a program whose work is + // half done, whatever it managed before it stopped — and that includes + // `needs_model`, which is not a failure and is not a success either. + // The transaction settles it the same way it settles a failed check: + // put it back, unless the caller asked to keep it. + if !ran { + refused = Some(0); + } + } else { + for (index, step) in program.steps.iter().enumerate() { + metrics.machine_operations += 1; + let answered = crate::external::one( + asked.store, + here, + boundary.as_deref(), + &step.verb, + &step.arguments, + ); + let (ok, answer) = match answered { + Ok(answer) => { + // A verb that answered is not a verb that succeeded: every + // refusal on this surface is a well-formed object with + // `ok: false` in it, and reading "it answered" as "it worked" + // is how a program carries on past the edit that did not + // happen and validates a tree nobody changed. + let ok = answer.get("ok").and_then(Value::as_bool).unwrap_or(false); + (ok, answer) + } + Err(refusal) => ( + false, + json!({ + "ok": false, + "word": refusal.word, + "remedy": refusal.remedy, + "message": refusal.message, + }), + ), + }; - metrics.internal_bytes += answer.to_string().len(); - evidence.steps.push(StepRecord { - verb: step.verb.clone(), - arguments: step.arguments.clone(), - ok, - answer, - }); + metrics.internal_bytes += answer.to_string().len(); + evidence.steps.push(StepRecord { + verb: step.verb.clone(), + arguments: step.arguments.clone(), + ok, + answer, + }); - if !ok { - refused = Some(index); - break; + if !ok { + refused = Some(index); + break; + } } } @@ -559,9 +710,9 @@ pub fn carry_out( // against it would answer a question about a state the program never meant // to produce. The failure is already known. if refused.is_none() { - for check in &program.validate { + for (index, check) in program.validate.iter().enumerate() { metrics.validations += 1; - let record = run_check( + let mut record = run_check( asked, here, boundary.as_deref(), @@ -569,18 +720,38 @@ pub fn carry_out( &difference, &mut metrics, ); + // Position and not content: two identical checks in a declarative + // list are two things the caller asked for, and collapsing them + // under one key would let the second silently answer for the + // first. A program's repeated check is the opposite case and is + // keyed by what it asked — see `CheckRecord::key`. + record.key = format!("declared:{index}"); metrics.internal_bytes += record.output.to_string().len(); evidence.checks.push(record); } } - let everything_held = refused.is_none() - && evidence - .checks - .iter() - .all(|record| record.verdict == Verdict::Passed); - - evidence.reason = if let Some(index) = refused { + // The last verdict of each distinct check, and every one of them must have + // passed. `Passed` is never assumed for a check that did not run: a + // `not_proven` is a failure here, which is rule 9 — a commit that believed + // it had been checked would be a commit lying about itself. + let mut last: std::collections::BTreeMap<&str, Verdict> = std::collections::BTreeMap::new(); + for record in &evidence.checks { + last.insert(record.key.as_str(), record.verdict); + } + let everything_held = + refused.is_none() && last.values().all(|verdict| *verdict == Verdict::Passed); + + evidence.reason = if program.run.is_some() && refused.is_some() { + // The program's own word for how it stopped, which is the only thing + // that distinguishes "it asked for the model" from "it threw" from "it + // ran out of time" — three outcomes with the same effect on the tree + // and three different next moves for whoever reads this. + evidence + .finish_why + .clone() + .unwrap_or_else(|| "the program did not run to the end".to_string()) + } else if let Some(index) = refused { let step = &evidence.steps[index]; format!( "step {} (`{}`) was refused, so the rest of the program did not run", @@ -593,11 +764,10 @@ pub fn carry_out( n => format!("every step went through and all {n} check(s) held"), } } else { - let failed: Vec<&str> = evidence - .checks + let failed: Vec<&str> = last .iter() - .filter(|record| record.verdict != Verdict::Passed) - .map(|record| record.check.as_str()) + .filter(|(_, verdict)| **verdict != Verdict::Passed) + .map(|(key, _)| *key) .collect(); format!( "every step went through and {} did not hold", @@ -681,11 +851,268 @@ pub fn carry_out( metrics.analyzer_starts = semantics .analyzer_starts .saturating_sub(semantics_before.analyzer_starts); + metrics.analyzer_confined = semantics.analyzer_confined; + metrics.analyzer_how = semantics.analyzer_how.clone(); metrics.machine_time_ms = started.elapsed().as_millis(); evidence.metrics = metrics; evidence } +// ── the programmable form ──────────────────────────────────────────────────── + +/// The machine, as a program is allowed to see it. +/// +/// **Every field here is a borrow of something that already existed.** There is +/// no second store, no second session, no second boundary and no second +/// checker: a program's request goes through [`crate::external::one`], its +/// validation through [`run_check`], and its view of what changed through +/// `thalyx_snapshot::difference` — the same three things the static form uses, +/// called from a different place. If that were not true this would be the +/// parallel API `Agentes-Externos.md` forbids, and the workspace boundary would +/// hold for a list of steps and not for a loop. +struct Runner<'a, V: Volumes> { + asked: &'a Asked<'a>, + snapshots: &'a Snapshots, + snapshot: &'a str, + here: &'a mut Where, + boundary: Option<&'a Path>, + metrics: &'a mut Metrics, + /// Every validation the program asked for, in order, with what it found. + checks: Vec, +} + +impl Runner<'_, V> { + /// What the tree really shows changed since the boundary opened. + /// + /// Recomputed on every call and never remembered, which is the whole point + /// of it being available *inside* a program: after the third edit the + /// answer is different from what it was after the second, and a program + /// that could only see the difference at the end could not decide anything + /// from it. + fn difference(&self) -> thalyx_snapshot::Difference { + match self.snapshots.find(self.snapshot) { + Ok(found) => thalyx_snapshot::difference(&self.asked.subvolume, &found.path), + // Rule 10, and it matters more here than in the static form: a + // program that got an empty difference would read it as "nothing + // changed" and commit. So this is empty *and* the settling below + // reports the snapshot as gone, which is what stops the commit. + Err(_) => Default::default(), + } + } +} + +/// The two verbs a program may not reach, and why they are checked here. +/// +/// `Program::read` refuses a *step* named `exec` or `attempt` before a snapshot +/// is taken, which is the right place for a list: the list is a value something +/// can look at. **A program is not.** It reaches verbs by name at runtime, so +/// there is nothing to inspect in advance and the check has to be at the +/// moment of the call. +/// +/// Found by a test on 2026-08-30 that asked for `attempt abandonar` from inside +/// a program and got `ok: true` with a `confirm_with` line in it — carrying the +/// snapshot name and the exact state witness the machine had just computed. The +/// next two lines of that program would have been to send it back, and the +/// transaction would have been abandoned from inside itself, mid-run, with +/// `carry_out` still holding a boundary that no longer existed. Nothing was +/// destroyed in the test because a one-call abandon needs a state claim; what +/// the test found is that the machine hands the claim over on request. +/// +/// So it is the same rule as the static form's, applied where it can be +/// applied. It is not an argument check and does not belong in `EXPOSED`: those +/// verbs are legitimately reachable by a session, and what is illegitimate is +/// reaching them *from inside the transaction they would settle*. +const NOT_FROM_INSIDE: &[&str] = &[OP, "attempt"]; + +impl thalyx_program::Machine for Runner<'_, V> { + fn request(&mut self, verb: &str, arguments: &[String]) -> Value { + self.metrics.machine_operations += 1; + + if NOT_FROM_INSIDE.contains(&verb) { + return json!({ + "ok": false, + "word": "not_from_inside", + "error": "not_from_inside", + "remedy": "let_the_program_end", + "message": format!( + "`{verb}` is what this program is running *inside*. The boundary is opened around the whole program and settled by what the checks say; `on_failure` chooses which way. Return a value, or call `thalyx.needModel(…)`" + ), + }); + } + let answer = + crate::external::one(self.asked.store, self.here, self.boundary, verb, arguments); + match answer { + Ok(answer) => answer, + // A refusal is a value and not an end. The program branches on + // `ok`, exactly as the static form's runtime does, and a mistake it + // can recover from does not cost a round trip to whoever wrote it. + Err(refusal) => json!({ + "ok": false, + "word": refusal.word, + "error": refusal.word, + "remedy": refusal.remedy, + "message": refusal.message, + }), + } + } + + fn validate(&mut self, asked: &Value) -> Value { + self.metrics.validations += 1; + self.metrics.machine_operations += 1; + + // Read as the same object the declarative `validate` list takes, so + // there is one shape of check on this machine and not two. A program + // that sends something else is told what a check looks like rather + // than having one guessed for it. + let check: Check = match serde_json::from_value(asked.clone()) { + Ok(check) => check, + Err(error) => { + return json!({ + "verdict": "not_proven", + "summary": format!( + "that is not a check this machine knows: {error}. A check is \ + {{\"check\":\"text\"|\"parses\"|\"rust\"|\"program\", …}}" + ), + }); + } + }; + + let difference = self.difference(); + let mut record = run_check( + self.asked, + self.here, + self.boundary, + &check, + &difference, + self.metrics, + ); + record.key = asked.to_string(); + self.metrics.internal_bytes += record.output.to_string().len(); + let answer = json!({ + "check": record.check, + "verdict": record.verdict.word(), + "passed": record.verdict == Verdict::Passed, + "summary": record.summary, + // The whole of it, because this side of the boundary is the cheap + // side. What crosses back to a model is whatever the program + // decides to return, and that is a separate decision made later. + "output": record.output.clone(), + }); + self.checks.push(record); + answer + } + + fn changed(&mut self) -> Value { + self.metrics.machine_operations += 1; + let difference = self.difference(); + json!({ + "count": difference.added_total + difference.modified_total + difference.removed_total, + "added": difference.added, + "modified": difference.modified, + "removed": difference.removed, + }) + } + + fn process_launches(&self) -> usize { + self.metrics.process_launches + } + + fn verbs(&self) -> Vec { + crate::external::ExternalAgentSession::verbs() + } +} + +/// What a program may spend, on this machine. +/// +/// Read from the environment so a person can loosen or tighten it without a +/// rebuild, and defaulted to [`thalyx_program::Limits`]'s own numbers. Every +/// variable is separate, which is rule 3's shape applied to resources: a +/// machine that has time to spare and not memory must be able to say which. +fn limits() -> thalyx_program::Limits { + let mut limits = thalyx_program::Limits::default(); + let number = |name: &str| { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().ok()) + }; + if let Some(seconds) = number("THALYX_PROGRAM_SECONDS") { + limits.wall = std::time::Duration::from_secs(seconds.max(1)); + } + if let Some(megabytes) = number("THALYX_PROGRAM_MEGABYTES") { + limits.memory_bytes = (megabytes.max(1) as usize) * 1024 * 1024; + } + if let Some(calls) = number("THALYX_PROGRAM_CALLS") { + limits.calls = calls.max(1) as usize; + } + if let Some(launches) = number("THALYX_PROGRAM_LAUNCHES") { + limits.process_launches = launches as usize; + } + limits +} + +/// Run one program inside the boundary, and say whether it ran to the end. +/// +/// The transaction settles on the answer: `true` lets the checks decide, and +/// `false` puts the tree back unless the caller asked to keep it. Everything +/// the program did is in `evidence` either way — a run that stopped halfway is +/// the run whose record is most worth having. +#[allow(clippy::too_many_arguments)] +fn drive( + asked: &Asked<'_>, + snapshots: &Snapshots, + snapshot: &str, + here: &mut Where, + boundary: Option<&Path>, + source: &str, + metrics: &mut Metrics, + evidence: &mut Evidence, +) -> bool { + let mut runner = Runner { + asked, + snapshots, + snapshot, + here, + boundary, + metrics, + checks: Vec::new(), + }; + let outcome = thalyx_program::run(source, &mut runner, &asked.limits); + let checks = std::mem::take(&mut runner.checks); + + // Every call the program made becomes a step, in order, with its answer + // whole. Deliberately the same shape the static form produces, so + // `evidencia paso=N` fetches a program's ninth operation exactly as it + // fetches a list's ninth step — one shape for "what happened", and not a + // second one that only programs have. + for call in &outcome.calls { + if call.kind == "validate" { + // Already a check. Keeping it here too would put the compiler's + // whole output in the evidence twice. + continue; + } + evidence.steps.push(StepRecord { + verb: call.verb.clone(), + arguments: call.arguments.clone(), + ok: call.ok, + answer: call.answer.clone(), + }); + } + evidence.checks.extend(checks); + evidence.printed = outcome.printed.clone(); + evidence.finish = Some(outcome.finish.word().to_string()); + evidence.finish_why = Some(outcome.finish.why()); + evidence.returned = outcome.value(); + + metrics.programmable = true; + metrics.program_operations = + outcome.metrics.requests + outcome.metrics.validations + outcome.metrics.observations; + metrics.program_assertions = outcome.metrics.assertions; + metrics.program_ticks = outcome.metrics.ticks; + metrics.internal_bytes += outcome.metrics.answer_bytes; + + outcome.finish.went_through() +} + // ── validation ─────────────────────────────────────────────────────────────── /// Run one check and say what it found. @@ -718,6 +1145,7 @@ fn run_check( Ok(answer) => answer, Err(refusal) => { return CheckRecord { + key: String::new(), check: format!("text `{text}` {expect}"), verdict: Verdict::NotProven, summary: format!("the search could not be made: {}", refusal.message), @@ -747,6 +1175,7 @@ fn run_check( }; let hits = total.unwrap_or(0); CheckRecord { + key: String::new(), check: format!("text `{text}` {expect}"), verdict, summary: match verdict { @@ -792,6 +1221,7 @@ fn run_check( Verdict::Passed }; CheckRecord { + key: String::new(), check: "parses".to_string(), verdict, summary: match verdict { @@ -806,8 +1236,12 @@ fn run_check( } Check::Program { program, arguments } => { - let outcome = run_confined(asked, program, arguments, &[], &[], metrics); + // Nothing added to its environment. A check that names a program + // has named a program, and telling it where a Rust toolchain is + // would be this verb deciding what somebody else's binary is for. + let outcome = run_confined(asked, program, arguments, &[], &[], &[], metrics); CheckRecord { + key: String::new(), check: format!("program `{program}`"), verdict: outcome.verdict, summary: outcome.summary, @@ -847,6 +1281,7 @@ fn run_confined( arguments: &[String], also_readable: &[PathBuf], also_writable: &[PathBuf], + environment: &[(String, String)], metrics: &mut Metrics, ) -> Ran { use thalyx_manifest::{Permission, PermissionKind}; @@ -907,6 +1342,7 @@ fn run_confined( helper: std::env::current_exe().unwrap_or_else(|_| PathBuf::from("thalyx")), request_id: asked.request_id.clone(), profile: thalyx_sandbox::profile::MODULE_STANDARD, + environment: environment.to_vec(), }, ); @@ -986,12 +1422,13 @@ fn rust_check( crate::semantic::selection(asked.store.root(), &asked.subvolume, &changed, named) else { return CheckRecord { + key: String::new(), check: format!("cargo {subcommand}"), verdict: Verdict::NotProven, summary: "this workspace is not one Cargo can describe, so there is nothing \ this could have compiled" .to_string(), - output: json!({"word": "not_a_cargo_workspace"}), + output: json!({"word": "not_a_cargo_workspace", "cached": false}), }; }; let packages = selection.packages; @@ -999,12 +1436,14 @@ fn rust_check( if packages.is_empty() { return CheckRecord { + key: String::new(), check: format!("cargo {subcommand}"), verdict: Verdict::NotProven, summary: format!("nothing was compiled: {}", selection.why), output: json!({ "packages": packages, "why": selection.why, + "cached": false, // Named rather than dropped. A changed file nobody could place // is a reason this check might not cover the change, and a // caller that only saw "no packages" would read it as a clean @@ -1023,6 +1462,7 @@ fn rust_check( { metrics.validation_cache_hits += 1; return CheckRecord { + key: String::new(), check: format!("cargo {subcommand} over {}", packages.join(", ")), verdict: record.verdict, // Said out loud. A caller told a check passed, when what happened @@ -1042,15 +1482,37 @@ fn rust_check( } metrics.validation_cache_misses += 1; - let Some(cargo) = find_cargo() else { + let found = thalyx_rust::toolchain::cargo(); + let Some(cargo) = &found.path else { // Rule 10, in the place it matters most: a toolchain that is not here // is not a change that does not compile. + // + // It says *where it looked*, and that is the whole of the 2026-08-29 + // failure: `sudo` had made `$HOME` be `/root`, the search found + // nothing, and the sentence gave nobody a way to notice that the + // toolchain was one directory away under `$SUDO_USER`'s home. return CheckRecord { + key: String::new(), check: format!("cargo {subcommand}"), verdict: Verdict::NotProven, - summary: "there is no `cargo` on this machine, so the change was not compiled" - .to_string(), - output: json!({"packages": packages, "word": "no_cargo"}), + summary: found.why_not( + "`cargo`", + "So the change was not compiled. Name one with THALYX_CARGO, or run \ + with RUSTUP_HOME and CARGO_HOME set", + ), + output: json!({ + "packages": packages, + "word": "no_cargo", + // Present on every answer this check gives, whichever way it + // went. A field that disappears when a tool is missing is a + // field every caller has to write two readings of — and on + // 2026-08-29 it is what turned "there is no cargo here" into + // two assertions failing about a cache. + "cached": false, + "looked_at": found.looked_at.iter() + .map(|path| path.display().to_string()) + .collect::>(), + }), }; }; @@ -1078,21 +1540,32 @@ fn rust_check( // The toolchain, read-only. Without these `cargo` cannot find `rustc` or // the registry it already downloaded, and the run would fail for a reason // that has nothing to do with the change. - let mut readable = vec![cargo.parent().unwrap_or(Path::new("/")).to_path_buf()]; - if let Some(home) = std::env::var_os("HOME") { - readable.push(PathBuf::from(&home).join(".cargo")); - readable.push(PathBuf::from(&home).join(".rustup")); - } + // + // Asked of `toolchain::readable` rather than assembled here: a grant list + // written twice is two grant lists, and the second one is always missing + // the entry that only matters on somebody else's machine — which under + // `sudo` is every entry, because they are all under a home this process + // does not have. + let readable = thalyx_rust::toolchain::readable(); // Made here rather than left to Cargo: the grant names a path, and a // grant on a directory that does not exist yet is a grant on nothing. let _ = std::fs::create_dir_all(&build_into); + // Where the toolchain is, said rather than assumed. Under `sudo` the + // process running this has a `HOME` with no `.cargo` in it, and a Cargo + // that cannot find its registry fails `--offline` — which arrives as the + // change not compiling. + let environment: Vec<(String, String)> = thalyx_rust::toolchain::environment() + .into_iter() + .map(|(name, path)| (name.to_string(), path.display().to_string())) + .collect(); let outcome = run_confined( asked, &cargo.display().to_string(), &arguments, &readable, std::slice::from_ref(&build_into), + &environment, metrics, ); @@ -1116,6 +1589,7 @@ fn rust_check( } CheckRecord { + key: String::new(), check: format!("cargo {subcommand} over {}", packages.join(", ")), verdict: outcome.verdict, summary: outcome.summary, @@ -1144,41 +1618,6 @@ struct Remembered { summary: String, } -/// Where `cargo` is, if it is anywhere this machine can name. -/// -/// The places are listed rather than searched for on `PATH`, because inside -/// Thalyx there is no `PATH` and no shell to expand one — and because a -/// validation that ran whichever `cargo` came first on a caller's environment -/// would be a validation whose meaning depends on who started the session. -fn find_cargo() -> Option { - let mut candidates = Vec::new(); - // The **real** cargo of an installed toolchain first, before rustup's shim. - // - // `~/.cargo/bin/cargo` is a shim that re-executes `~/.cargo/bin/rustup`, - // and inside the confinement that is a second program the grants say - // nothing about — so the run is refused for naming `rustup`, which reads - // like a broken change and is a broken `PATH`. Rule 5, where the instrument - // is the installation layout. - if let Some(home) = std::env::var_os("HOME") { - let toolchains = PathBuf::from(&home).join(".rustup").join("toolchains"); - if let Ok(entries) = std::fs::read_dir(&toolchains) { - let mut found: Vec = entries - .flatten() - .map(|entry| entry.path().join("bin").join("cargo")) - .collect(); - // Sorted, so that a machine with several toolchains picks the same - // one every time. A validation whose meaning depends on directory - // order is a validation nobody can reproduce. - found.sort(); - candidates.extend(found); - } - candidates.push(PathBuf::from(&home).join(".cargo/bin/cargo")); - } - candidates.push(PathBuf::from("/usr/local/bin/cargo")); - candidates.push(PathBuf::from("/usr/bin/cargo")); - candidates.into_iter().find(|path| path.is_file()) -} - // ── the two faces ──────────────────────────────────────────────────────────── /// Cut a stream to something an answer can carry, and say that it was cut. @@ -1251,7 +1690,7 @@ pub fn answer_object(evidence: &Evidence) -> Vec<(&'static str, Value)> { }) }); - vec![ + let mut carried = vec![ ("status", json!(evidence.status)), ("transaction", json!(evidence.transaction)), ("attempt", json!(evidence.label)), @@ -1304,6 +1743,11 @@ pub fn answer_object(evidence: &Evidence) -> Vec<(&'static str, Value)> { json!(evidence.metrics.semantic_cache_hits), ), ("analyzer_starts", json!(evidence.metrics.analyzer_starts)), + ( + "analyzer_confined", + json!(evidence.metrics.analyzer_confined), + ), + ("analyzer_how", json!(evidence.metrics.analyzer_how)), ( "validation_cache_hits", json!(evidence.metrics.validation_cache_hits), @@ -1316,7 +1760,37 @@ pub fn answer_object(evidence: &Evidence) -> Vec<(&'static str, Value)> { "affected_packages", json!(evidence.metrics.affected_packages), ), - ] + ]; + + // The programmable form's own fields, and only on a run that was one. This + // is the one place in this file where a field is conditional, and the + // reason is that `steps_run` already means something for a list of steps: + // `returned` on a run that had no program would be a null every caller has + // to special-case, and `finish` would be a word about something that did + // not happen. + if evidence.metrics.programmable { + carried.extend([ + ("finish", json!(evidence.finish)), + ("finish_why", json!(evidence.finish_why)), + // **What the program handed back.** The whole of the compression is + // that this is small and everything it was computed from is not: + // the program read whole files, walked every reference and ran the + // compiler, and what crosses back is whatever it decided mattered. + ("returned", evidence.returned.clone()), + ( + "program_operations", + json!(evidence.metrics.program_operations), + ), + ( + "program_assertions", + json!(evidence.metrics.program_assertions), + ), + ("program_ticks", json!(evidence.metrics.program_ticks)), + ("printed", json!(evidence.printed)), + ]); + } + + carried } /// `hacer ` — the verb, in whichever face is asking. @@ -1379,6 +1853,7 @@ pub fn run(store: &Store, here: &mut Where, rest: &str, face: Face, request_id: let asked = Asked { store, + limits: limits(), subvolume, request_id: request_id.to_string(), }; @@ -1390,6 +1865,15 @@ pub fn run(store: &Store, here: &mut Where, rest: &str, face: Face, request_id: if face == Face::Machine { let mut carried = answer_object(&evidence); + // Measured on the object that actually leaves, not estimated. The + // whole claim of this verb is a ratio between this and + // `internal_bytes`, and a numerator nobody weighed is a ratio nobody + // can check. + let leaving: usize = carried + .iter() + .map(|(name, value)| name.len() + value.to_string().len() + 4) + .sum(); + carried.push(("returned_bytes", json!(leaving))); if let Err(error) = &kept { carried.push(("evidence_kept", json!(false))); carried.push(("evidence_why_not", json!(error.to_string()))); @@ -1720,9 +2204,27 @@ mod tests { } fn run(store: &Store, tree: &Path, here: &mut Where, program: &Program) -> Evidence { + run_within( + store, + tree, + here, + program, + thalyx_program::Limits::default(), + ) + } + + /// The same, with a ceiling of this test's own. + fn run_within( + store: &Store, + tree: &Path, + here: &mut Where, + program: &Program, + limits: thalyx_program::Limits, + ) -> Evidence { carry_out( &Asked { store, + limits, subvolume: tree.to_path_buf(), request_id: format!("t-{}", std::process::id()), }, @@ -2016,6 +2518,718 @@ mod tests { assert_eq!(evidence.metrics.validation_cache_misses, 1); } + #[test] + fn every_answer_the_rust_check_gives_says_whether_it_was_reused() { + // **The 2026-08-29 Fedora failure, as an assertion.** + // + // Two tests asserted `check.output["cached"] == false` and got `Null`, + // because the arm that answers "there is no cargo on this machine" + // never wrote the field. So the failure a person read was two + // assertions about a *cache*, and what had actually happened is that + // `sudo` put `HOME=/root` in front of a toolchain installed under + // `$SUDO_USER`'s home. + // + // A field that only appears on the days it is interesting is a field + // nobody handles on the interesting day. This walks every arm the + // check has and demands the shape be the same in all of them — + // including the two that cannot be reached on a machine that has + // everything, which is why it is a unit test over the arms and not a + // run that happens to take one of them. + let (_base, store, tree, mut here) = a_crate(); + crate::semantic::release(&tree); + + // Arm one: a tree Cargo cannot describe at all. + let (_plain_base, plain_store, plain_tree, mut plain_here) = + a_workspace(&[("notes.txt", "not a crate\n")]); + let plain = run( + &plain_store, + &plain_tree, + &mut plain_here, + &program(serde_json::json!({ + "steps": [{"verb": "edit", "arguments": ["notes.txt", "sustituir", "not", "still not"]}], + "validate": [{"check": "rust"}], + "on_failure": "keep" + })), + ); + let check = plain.checks.first().expect("the rust check ran"); + assert_eq!( + check.output["cached"], + serde_json::json!(false), + "a tree Cargo cannot describe answered without saying whether it \ + reused anything: {check:?}" + ); + + // Arm two: a real crate, whichever way the toolchain search goes on + // this machine. Both outcomes carry the field; only one of them can + // happen here, and the test is about the shape rather than the + // verdict. + let real = run( + &store, + &tree, + &mut here, + &program(serde_json::json!({ + "steps": [{"verb": "edit", "arguments": [ + "src/keystore.rs", "sustituir", "Keystore", "KeyVault" + ]}], + "validate": [{"check": "rust"}], + "on_failure": "keep" + })), + ); + let check = real.checks.first().expect("the rust check ran"); + assert!( + check.output["cached"].is_boolean(), + "the rust check answered `{}` for `cached`: {check:?}", + check.output["cached"] + ); + } + + #[test] + fn a_missing_toolchain_says_where_it_looked_and_never_says_the_change_failed() { + // Rule 10 with an address on it. "There is no cargo" is a sentence a + // person cannot act on when the cargo is right there under another + // home; the list of paths is what makes the `sudo` boundary visible at + // the moment somebody is looking at it. + let found = thalyx_rust::toolchain::cargo(); + let why = found.why_not("`cargo`", "So nothing was compiled"); + assert!(why.contains("place(s) this machine looks"), "{why}"); + assert!( + found.looked_at.iter().any(|path| path.ends_with("cargo")), + "the search names no candidate at all: {found:?}" + ); + } + + // ── the programmable form ──────────────────────────────────────────── + + /// The lockfile a real Rust workspace has committed. + /// + /// Byte for byte what `cargo generate-lockfile` writes for a one-package + /// workspace, because a hand-written approximation is rewritten by the + /// first tool that reads it — which is rule 6 in the small, and is exactly + /// what happened to the first version of this constant. + const LOCKFILE: &str = "# This file is automatically @generated by Cargo.\n# It is not intended for manual editing.\nversion = 4\n\n[[package]]\nname = \"vertical\"\nversion = \"0.1.0\"\n"; + + /// A tree whose files differ in a way nothing outside it can know in + /// advance. + /// + /// Five modules; three of them use `old_api` and two do not, and **which + /// three is not visible from the file names**. That is the property the + /// fixture exists for: a caller composing a static list of steps would + /// have to read all five first — five answers, five round trips — or edit + /// all five and be wrong about two. + fn a_tree_that_has_to_be_looked_at() -> (tempfile::TempDir, Store, PathBuf, Where) { + a_workspace(&[ + ( + "Cargo.toml", + "[workspace]\n\n[package]\nname = \"vertical\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ), + // **A lockfile, because a real workspace has one committed.** + // + // Found on 2026-08-30 by the program's own strict assertion — + // `the tree shows 4 change(s) and the program made 3` — where the + // fourth was `Cargo.lock`. rust-analyzer resolves the full + // dependency graph, which writes a lock, and a workspace with none + // therefore **gains a file because it was asked a question**: + // a read that mutates the tree, inside the transaction, attributed + // to nobody. + // + // Nothing is hidden by fixing the fixture — the lock still shows up + // in `changed` when it is created, and a rollback still puts the + // tree back without it. What the fixture is for is the *program's* + // claim, and a workspace with no lockfile is not the case that + // claim is about. The hazard itself is in + // `vault/03-Primitivas/Semantica-Compilada.md`. + ("Cargo.lock", LOCKFILE), + ( + "src/lib.rs", + "pub mod one;\npub mod two;\npub mod three;\npub mod four;\npub mod five;\n", + ), + ("src/one.rs", "pub fn one() -> u32 {\n old_api(1)\n}\n"), + ("src/two.rs", "pub fn two() -> u32 {\n 2\n}\n"), + ( + "src/three.rs", + "pub fn three() -> u32 {\n old_api(3)\n}\n", + ), + ("src/four.rs", "pub fn four() -> u32 {\n 4\n}\n"), + ("src/five.rs", "pub fn five() -> u32 {\n old_api(5)\n}\n"), + ]) + } + + /// The program the fixtures below run, in one place so the three columns + /// differ only in what they are run against. + /// + /// Read it as the claim: **nothing in it names a file.** The list comes + /// from the machine, the choice comes from what each file says, and the + /// last decision comes from what a check answered. + const LOOK_THEN_CHANGE: &str = r#" + const listing = thalyx.list("src"); + thalyx.assert(listing.ok, "src could not be listed", listing); + + const sources = (listing.entries || []) + .map((entry) => entry.name) + .filter((name) => name.endsWith(".rs") && name !== "lib.rs") + .sort(); + thalyx.assert(sources.length >= 4, "this tree should have several modules", sources); + + // The loop that could not have been written in advance: what is in each + // file decides whether it is touched. + const touched = []; + const skipped = []; + for (const name of sources) { + const path = "src/" + name; + const source = thalyx.read(path); + if (!source.ok) { skipped.push(name); continue; } + if (source.text.includes("old_api")) { + thalyx.mustWork( + thalyx.substitute(path, "old_api", "new_api"), + "the substitution in " + path + " did not happen" + ); + touched.push(name); + } else { + skipped.push(name); + } + } + + // What the tree says, not what the edits claimed. + const seen = thalyx.changed(); + thalyx.assert( + seen.count === touched.length, + "the tree shows " + seen.count + " change(s) and the program made " + touched.length, + seen + ); + + // And the branch on a validation, which is the last decision and the + // one a static list has to hand back to a model to make. + const parses = thalyx.validate({ check: "parses" }); + if (parses.verdict !== "passed") { + return { gave_up: true, why: parses.summary }; + } + + const left = thalyx.grep("old_api"); + thalyx.assert(left.total === 0, "old_api is still somewhere", left); + + return { + changed: touched, + left_alone: skipped.length, + still_there: left.total, + }; + "#; + + #[test] + fn one_request_looks_at_a_tree_and_changes_only_what_the_looking_says_to() { + // **The defining fixture of the programmable form.** + // + // One external request. Inside it: a listing whose contents were not + // known, a loop over that listing, a read per entry, a decision per + // read, a mutation for three of them and none for two, an observation + // of what really changed, a validation, a branch on the validation, and + // a compact answer. Not one of those decisions went back to a model. + // + // A `Vec` cannot express this. To produce the same result it + // would have to already contain the three file names — which is the + // answer, so producing it *is* the work this is doing. + let (_base, store, tree, mut here) = a_tree_that_has_to_be_looked_at(); + + let evidence = run( + &store, + &tree, + &mut here, + &program(serde_json::json!({ + "label": "only what needs it", + "run": LOOK_THEN_CHANGE, + })), + ); + + assert_eq!(evidence.status, "committed", "{}", evidence.reason); + assert_eq!(evidence.finish.as_deref(), Some("returned")); + assert_eq!( + evidence.returned["changed"], + serde_json::json!(["five.rs", "one.rs", "three.rs"]), + "the program changed the wrong set: {}", + evidence.returned + ); + assert_eq!(evidence.returned["left_alone"], serde_json::json!(2)); + assert_eq!(evidence.returned["still_there"], serde_json::json!(0)); + + // The bytes, which is the claim rather than the count. + for name in ["one", "three", "five"] { + let text = std::fs::read_to_string(tree.join(format!("src/{name}.rs"))).expect(name); + assert!(text.contains("new_api"), "src/{name}.rs: {text}"); + } + for name in ["two", "four"] { + let text = std::fs::read_to_string(tree.join(format!("src/{name}.rs"))).expect(name); + assert!( + !text.contains("new_api") && !text.contains("old_api"), + "src/{name}.rs was touched and had no reason to be: {text}" + ); + } + + // And the numbers the hypothesis is stated in. + assert_eq!(evidence.metrics.external_requests, 1); + assert!( + evidence.metrics.program_operations >= 10, + "one request did {} things inside the machine: {:?}", + evidence.metrics.program_operations, + evidence.metrics + ); + assert!( + evidence.metrics.program_assertions >= 3, + "{:?}", + evidence.metrics + ); + assert_eq!(evidence.change_count, 3); + } + + #[test] + fn the_program_verify_runs_is_the_one_that_is_tested_here() { + // **One program, two places that run it.** + // + // `dev/verify.sh`'s stage 59 needs a real Btrfs subvolume, a kernel + // that denies and a compiler, so it only runs on Cesar's machine. This + // runs the *same file* against the directory-backed fake, here, on + // every `cargo test` — so a typo in it is found by whoever wrote it + // rather than by the person who owns the hardware. + // + // What is **not** proven here is what stage 59 exists for: that the + // boundary is a real snapshot, that the confinement really denies, and + // that the compiler really ran. Rule 3, and it is why both exist. + let source = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../dev/programs/looking-decides.js"), + ) + .expect("the program dev/verify.sh runs"); + + let (_base, store, tree, mut here) = a_tree_that_has_to_be_looked_at(); + crate::semantic::release(&tree); + + let evidence = run( + &store, + &tree, + &mut here, + &program(serde_json::json!({ + "label": "only what needs it", + "run": source, + // The compiler cannot run under confinement here, so the `rust` + // check answers `not_proven` and the program takes its + // `gave_up` branch. That is the honest outcome and it is + // asserted below rather than being worked around — a fixture + // that pretended the check had passed would be testing a + // machine nobody has. + "on_failure": "keep", + })), + ); + + assert_eq!( + evidence.finish.as_deref(), + Some("returned"), + "the program did not run to the end: {}. changed: {:?}", + evidence.reason, + evidence.changed + ); + assert_eq!( + evidence.returned["changed"], + serde_json::json!(["five.rs", "one.rs", "three.rs"]), + "the program changed the wrong set: {}", + evidence.returned + ); + assert_eq!(evidence.change_count, 3); + // Two files that had no reason to be touched, and were not. + for name in ["two", "four"] { + let text = std::fs::read_to_string(tree.join(format!("src/{name}.rs"))).expect(name); + assert!(!text.contains("new_api"), "src/{name}.rs: {text}"); + } + + // And the branch the machine's own limits force it down, said out + // loud: either the compiler ran and it committed, or it could not and + // the program gave up. Never a third thing, and never a commit over a + // check that did not run. + assert!( + evidence.returned["compiled"].is_boolean(), + "{}", + evidence.returned + ); + if evidence.returned["compiled"] == serde_json::json!(false) { + assert!( + evidence.returned["gave_up"].is_string(), + "the program said it did not compile and did not say why: {}", + evidence.returned + ); + } + } + + #[test] + fn what_the_program_read_is_far_more_than_what_came_back() { + // The compression, as two numbers from the same run. Without it "the + // answer is small" is a sentence about a JSON blob rather than a + // measurement against what producing it cost. + let (_base, store, tree, mut here) = a_tree_that_has_to_be_looked_at(); + let evidence = run( + &store, + &tree, + &mut here, + &program(serde_json::json!({"label": "measured", "run": LOOK_THEN_CHANGE})), + ); + + let returned = serde_json::to_string(&evidence.returned) + .expect("the answer") + .len(); + assert!( + evidence.metrics.internal_bytes > returned * 8, + "the machine handled {} bytes and handed back {returned}; the whole point \ + of running the program here is that those two numbers are far apart", + evidence.metrics.internal_bytes + ); + } + + #[test] + fn a_program_whose_validation_fails_puts_every_one_of_its_changes_back() { + // **The control**, and the reason the fixture above is safe to use. The + // same program, over a tree with a file that will not parse after the + // substitution: the program mutates three files, the check says no, and + // a real boundary puts all three back byte for byte. + let (_base, store, tree, mut here) = a_workspace(&[ + ("src/one.rs", "pub fn one() -> u32 {\n old_api(1)\n}\n"), + // The trap: substituting here leaves an unbalanced brace, which is + // exactly what `parses` is for. + ("src/two.rs", "pub fn two() -> u32 {\n old_api(2) }}\n"), + ("src/three.rs", "pub fn three() -> u32 {\n 3\n}\n"), + ]); + let before: Vec = ["src/one.rs", "src/two.rs", "src/three.rs"] + .iter() + .map(|name| std::fs::read_to_string(tree.join(name)).expect(name)) + .collect(); + + let evidence = run( + &store, + &tree, + &mut here, + &program(serde_json::json!({ + "label": "it does not hold", + "run": r#" + const listing = thalyx.list("src"); + for (const entry of listing.entries || []) { + const path = "src/" + entry.name; + const source = thalyx.read(path); + if (source.ok && source.text.includes("old_api")) { + thalyx.substitute(path, "old_api", "new_api("); + } + } + const parses = thalyx.validate({ check: "parses" }); + thalyx.mustPass(parses, "a changed file no longer parses"); + return "should not get here"; + "#, + })), + ); + + assert_eq!(evidence.status, "rolled_back", "{}", evidence.reason); + assert!(evidence.rolled_back); + assert_eq!(evidence.finish.as_deref(), Some("assertion")); + for (name, was) in ["src/one.rs", "src/two.rs", "src/three.rs"] + .iter() + .zip(before.iter()) + { + assert_eq!( + &std::fs::read_to_string(tree.join(name)).expect(name), + was, + "{name} did not come back" + ); + } + // And the diagnosis survived the rollback, because it was never in the + // tree the rollback replaced. + assert!( + evidence + .checks + .iter() + .any(|record| record.verdict == Verdict::Failed), + "{:?}", + evidence.checks + ); + } + + #[test] + fn a_program_that_asks_for_the_model_has_changed_nothing() { + // The third column, and the one the ambiguity contract needs: a program + // that meets a decision it will not make stops **before** mutating and + // says what it needs decided. Not a failure, not a success, and — the + // part that matters — not a guess. + let (_base, store, tree, mut here) = a_tree_that_has_to_be_looked_at(); + let before = std::fs::read_to_string(tree.join("src/one.rs")).expect("one.rs"); + + let evidence = run( + &store, + &tree, + &mut here, + &program(serde_json::json!({ + "label": "not my decision", + "run": r#" + const listing = thalyx.list("src"); + const many = (listing.entries || []).filter( + (entry) => entry.name.endsWith(".rs") + ); + if (many.length > 2) { + return thalyx.needModel({ + question: "which of these should be migrated", + candidates: many.map((entry) => entry.name).sort(), + }); + } + thalyx.substitute("src/one.rs", "old_api", "new_api"); + return "changed it"; + "#, + })), + ); + + assert_eq!(evidence.finish.as_deref(), Some("needs_model")); + assert_eq!(evidence.status, "rolled_back", "{}", evidence.reason); + assert_eq!(evidence.change_count, 0, "{:?}", evidence.changed); + assert_eq!( + std::fs::read_to_string(tree.join("src/one.rs")).expect("one.rs"), + before + ); + assert!( + evidence.returned["candidates"].is_array(), + "the question came back without what it is about: {}", + evidence.returned + ); + } + + #[test] + fn the_answer_of_one_operation_decides_whether_a_third_runs() { + // **The composition test.** Written so that a hardcoded list of steps + // cannot satisfy it: operation A is a search whose answer is a number + // nothing outside the tree knows, B is an arithmetic decision on that + // number, and C happens only on one side of it. The assertion is on + // which requests reached the machine. + let (_base, store, tree, mut here) = + a_workspace(&[("a.txt", "needle\nneedle\n"), ("b.txt", "nothing here\n")]); + + let evidence = run( + &store, + &tree, + &mut here, + &program(serde_json::json!({ + "label": "a decides c", + "run": r#" + const found = thalyx.grep("needle"); // A + const many = found.total >= 2; // B + if (many) { // C + thalyx.substitute("a.txt", "needle", "pin"); + } else { + thalyx.substitute("b.txt", "nothing", "something"); + } + return { total: found.total, took: many ? "a" : "b" }; + "#, + })), + ); + + assert_eq!(evidence.status, "committed", "{}", evidence.reason); + assert_eq!(evidence.returned["took"], serde_json::json!("a")); + assert_eq!(evidence.returned["total"], serde_json::json!(2)); + assert_eq!( + std::fs::read_to_string(tree.join("a.txt")).expect("a.txt"), + "pin\npin\n" + ); + assert_eq!( + std::fs::read_to_string(tree.join("b.txt")).expect("b.txt"), + "nothing here\n", + "the branch that should not have run, ran" + ); + + // And the same program over a tree where A answers differently takes + // the other branch, with nothing in the program changed. Without this + // column, a program that always edited `a.txt` would pass everything + // above. + let (_base, store, tree, mut here) = + a_workspace(&[("a.txt", "needle\n"), ("b.txt", "nothing here\n")]); + let evidence = run( + &store, + &tree, + &mut here, + &program(serde_json::json!({ + "label": "a decides c, the other way", + "run": r#" + const found = thalyx.grep("needle"); + const many = found.total >= 2; + if (many) { + thalyx.substitute("a.txt", "needle", "pin"); + } else { + thalyx.substitute("b.txt", "nothing", "something"); + } + return { total: found.total, took: many ? "a" : "b" }; + "#, + })), + ); + assert_eq!(evidence.returned["took"], serde_json::json!("b")); + assert_eq!( + std::fs::read_to_string(tree.join("a.txt")).expect("a.txt"), + "needle\n", + "the branch that should not have run, ran" + ); + } + + #[test] + fn a_program_reaches_no_verb_and_no_path_a_single_request_could_not() { + // The boundary, put to the form that could most easily have escaped it. + // A loop can try a hundred paths where a list of steps tries one, so + // the check has to be in the door rather than in the composing — and it + // is: `Runner::request` is `external::one`. + let (_base, store, tree, mut here) = a_workspace(&[("inside.txt", "here\n")]); + std::fs::write(tree.parent().expect("a parent").join("outside.txt"), "no\n") + .expect("a file outside"); + + let evidence = run( + &store, + &tree, + &mut here, + &program(serde_json::json!({ + "label": "trying every door", + "run": r#" + const tried = []; + for (const path of ["../outside.txt", "/etc/passwd", + "inside/../../outside.txt"]) { + tried.push(thalyx.read(path).ok === true); + } + // And a verb that is not exposed at all. + tried.push(thalyx.call("install_at", ["/dev/sda"]).ok === true); + return { any: tried.some((got) => got), tried: tried.length }; + "#, + "on_failure": "keep", + })), + ); + + assert_eq!( + evidence.returned["any"], + serde_json::json!(false), + "{evidence:?}" + ); + assert_eq!(evidence.returned["tried"], serde_json::json!(4)); + } + + #[test] + fn a_program_may_not_open_a_boundary_or_settle_one() { + // The same rule the static form has, and it has to be checked here too + // because a program reaches verbs by name at runtime rather than in a + // list something looked at first. + let (_base, store, tree, mut here) = a_workspace(&[("a.txt", "x\n")]); + let evidence = run( + &store, + &tree, + &mut here, + &program(serde_json::json!({ + "label": "settling its own boundary", + "run": r#" + const opened = thalyx.call("attempt", ["abandonar", "agent"]); + return { ok: opened.ok === true, word: opened.word || opened.error }; + "#, + "on_failure": "keep", + })), + ); + assert_eq!( + evidence.returned["ok"], + serde_json::json!(false), + "{evidence:?}" + ); + assert_eq!( + evidence.returned["word"], + serde_json::json!("not_from_inside"), + "{evidence:?}" + ); + assert_eq!(evidence.change_count, 0); + + // And the fact this test was written for. Before 2026-08-30 the call + // above answered `ok: true` with a `confirm_with` line carrying the + // snapshot name and the exact state witness — everything a second call + // needs to abandon the transaction from inside itself. So the assertion + // is not only that it was refused: it is that the answer hands over + // nothing to try again with. + assert!( + evidence.returned.get("confirm_with").is_none(), + "the refusal handed the program what it needs to retry: {}", + evidence.returned + ); + + // The other half of the pair, and the one a list of steps is refused + // for by name. + let evidence = run( + &store, + &tree, + &mut here, + &program(serde_json::json!({ + "label": "a program inside a program", + "run": r#" + const inner = thalyx.call("exec", ['{"steps":[{"verb":"state"}]}']); + return { ok: inner.ok === true, word: inner.word || inner.error }; + "#, + "on_failure": "keep", + })), + ); + assert_eq!( + evidence.returned["word"], + serde_json::json!("not_from_inside"), + "{evidence:?}" + ); + } + + #[test] + fn a_program_and_a_list_of_steps_are_not_both_accepted() { + let error = Program::read( + &serde_json::json!({"run": "return 1;", "steps": [{"verb": "state"}]}).to_string(), + ) + .expect_err("two ideas about what to do"); + assert!(error.contains("both"), "{error}"); + } + + #[test] + fn a_request_with_neither_says_what_it_is_missing() { + let error = Program::read(&serde_json::json!({"label": "empty"}).to_string()) + .expect_err("nothing to run"); + assert!(error.contains("run"), "{error}"); + assert!(error.contains("steps"), "{error}"); + } + + #[test] + fn a_program_that_never_stops_stops_and_the_tree_is_untouched() { + // Rule: a limit failure produces evidence and rolls back. Nothing here + // is about the program being malicious — a loop with a bad condition is + // the commonest bug there is, and the machine's answer to it must not + // be to hold the session open forever. + let (_base, store, tree, mut here) = a_workspace(&[("a.txt", "x\n")]); + let started = std::time::Instant::now(); + let evidence = run_within( + &store, + &tree, + &mut here, + &program(serde_json::json!({ + "label": "forever", + "run": "thalyx.substitute('a.txt', 'x', 'y'); while (true) {}", + })), + // Its own ceiling, handed to this run and to nothing else. A + // variable would be a global switch with no owner — rule 11, and + // the thing it would break is every other test's patience. + thalyx_program::Limits { + wall: std::time::Duration::from_secs(1), + ..thalyx_program::Limits::default() + }, + ); + + assert!(started.elapsed() < std::time::Duration::from_secs(60)); + assert_eq!( + evidence.finish.as_deref(), + Some("exhausted"), + "{}", + evidence.reason + ); + assert_eq!(evidence.status, "rolled_back", "{}", evidence.reason); + assert_eq!( + std::fs::read_to_string(tree.join("a.txt")).expect("a.txt"), + "x\n", + "the edit it made before it hung was kept" + ); + assert!( + evidence.reason.contains("stopped") || evidence.reason.contains("all"), + "{}", + evidence.reason + ); + } + #[test] fn a_failing_check_puts_the_rename_back_and_keeps_the_evidence() { // The other half of the vertical, and the half that makes the first diff --git a/crates/thalyx-cli/src/foreign.rs b/crates/thalyx-cli/src/foreign.rs index 4479341..42d678c 100644 --- a/crates/thalyx-cli/src/foreign.rs +++ b/crates/thalyx-cli/src/foreign.rs @@ -244,6 +244,11 @@ pub fn execute(store: &Store, rest: &str, face: Face) -> Fallible { helper, request_id: crate::new_request_id(), profile: FOREIGN_PROFILE, + // Nothing added. What a person typed at a prompt runs with what + // Thalyx runs with, and a verb that quietly enriched the + // environment of a guest would be handing it something nobody + // confirmed. + environment: Vec::new(), }, ); diff --git a/crates/thalyx-cli/src/semantic.rs b/crates/thalyx-cli/src/semantic.rs index 90c025a..d06e6d2 100644 --- a/crates/thalyx-cli/src/semantic.rs +++ b/crates/thalyx-cli/src/semantic.rs @@ -37,6 +37,7 @@ use crate::files::{Face, Where}; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; +use thalyx_core::Store; use thalyx_know::{Knowledge, Standing}; use thalyx_rust::{At, Provider}; @@ -127,6 +128,194 @@ pub fn build_directory(store_root: &Path, tree: &Path) -> PathBuf { store_root.join("state").join("rust-target").join(key) } +// ── the provider's own process, under Thalyx's authority ───────────────────── + +/// Starts the semantic provider the way Thalyx starts a program nobody signed. +/// +/// `vault/03-Primitivas/Semantica-Compilada.md`, revised 2026-08-30. The +/// provider used to be an ordinary host process, justified by "it is a +/// reader" — which is true of the LSP protocol and false of the process tree. +/// rust-analyzer runs `cargo metadata`, and answering anything about a +/// workspace with a proc-macro or a build script in it means **compiling and +/// running them**: arbitrary code from a registry, executing at analysis time, +/// with whatever reach the process that started it had. +/// +/// So it goes through `thalyx_core::start_foreign` — the same establishment +/// `ejecutar` uses: an enforcement gate that refuses when nothing can deny, its +/// own user, its own cgroup with a policy in the kernel, its own root +/// filesystem holding the workspace and the toolchain and nothing else, its own +/// pid namespace so killing the one process Thalyx holds kills every compiler +/// under it, its own network namespace, and the seccomp filter. +/// +/// What it is granted, and nothing else: +/// +/// - the **workspace**, read and write. Write because rust-analyzer's first act +/// on a workspace with no `Cargo.lock` is to write one, and a provider that +/// could not would answer every question about a tree it had failed to +/// describe; +/// - the **toolchain and the registry**, read-only; +/// - a **build directory outside the workspace**, both ways — outside because +/// a `target/` inside the tree is inside the snapshot, and a rollback would +/// destroy the build cache that makes the next question cheap. +struct UnderThalyx { + store: Store, + request_id: String, +} + +impl thalyx_rust::analyzer::Spawn for UnderThalyx { + fn start( + &self, + asked: thalyx_rust::analyzer::Launching<'_>, + ) -> thalyx_rust::Result { + use thalyx_manifest::{Permission, PermissionKind}; + + let mut grants = Vec::new(); + let mut grant = |path: &Path, write: bool| { + grants.push(Permission { + resource: path.display().to_string(), + action: "read".to_string(), + kind: PermissionKind::Session, + }); + if write { + grants.push(Permission { + resource: path.display().to_string(), + action: "write".to_string(), + kind: PermissionKind::Session, + }); + } + }; + grant(asked.root, true); + if let Some(target) = asked.build_into { + // Made here rather than left to Cargo: a grant on a directory that + // does not exist yet is a grant on nothing, and `RootFs` refuses a + // granted path that is not there. + let _ = std::fs::create_dir_all(target); + grant(target, true); + } + for path in asked.readable { + if path.is_dir() { + grant(path, false); + } + } + + let mut environment: Vec<(String, String)> = asked + .environment + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect(); + if let Some(target) = asked.build_into { + environment.push(("CARGO_TARGET_DIR".to_string(), target.display().to_string())); + } + + let started = match thalyx_core::start_foreign( + &self.store, + &thalyx_permd::KernelStore::default_map(), + &thalyx_core::ForeignRequest { + program: asked.program, + args: Vec::new(), + grants, + helper: std::env::current_exe() + .unwrap_or_else(|_| std::path::PathBuf::from("thalyx")), + request_id: self.request_id.clone(), + profile: thalyx_sandbox::profile::SEMANTIC_PROVIDER, + environment, + }, + ) { + Ok(started) => started, + // **The fallback, and it is not a soft spot left open for + // convenience.** + // + // `start_foreign` refuses on a machine whose kernel is not denying. + // That is `Programas-Ajenos.md`'s decree and it is right — but a + // Thalyx that could therefore not resolve a symbol *at all* would + // be a machine where the programming face does not exist, and this + // container is such a machine, as is any Fedora that has not run + // `make -C lsm load`. + // + // So it falls back to what this crate could always do, and every + // answer that came through it says `analyzer_confined: false` with + // the reason attached. `THALYX_REQUIRE_CONFINED_ANALYZER=1` turns + // it into a refusal, which is rule 3's shape: one variable per + // requirement, so a machine that can enforce can demand that it did. + Err(why) => { + if std::env::var("THALYX_REQUIRE_CONFINED_ANALYZER").as_deref() == Ok("1") { + return Err(thalyx_rust::RustError::NoAnalyzer(format!( + "THALYX_REQUIRE_CONFINED_ANALYZER=1 and the semantic provider could \ + not be confined: {why}. Nothing was started" + ))); + } + let mut fell_back = + thalyx_rust::analyzer::Spawn::start(&thalyx_rust::analyzer::OnTheHost, asked)?; + fell_back.how = format!("host (not confined: {why})"); + fell_back.confined = false; + return Ok(fell_back); + } + }; + + let how = format!("confined: {}", started.isolation); + let confined = started.isolated; + let mut started = started; + // The child moves out — `Analyzer` owns the conversation over its + // pipes — and the confinement stays behind to be torn down. That split + // is why `ForeignProcess` holds an `Option` and not a `Child`: + // the first shape left a placeholder process behind, which made this + // depend on a `/bin/true` existing. The image holds the Linux kernel + // and one program. + let child = started.take_child().ok_or_else(|| { + thalyx_rust::RustError::NoAnalyzer( + "the confinement started nothing to talk to".to_string(), + ) + })?; + + Ok(thalyx_rust::analyzer::Started { + child, + release: Some(Box::new(move || { + started.shutdown(&thalyx_permd::KernelStore::default_map()); + })), + how, + confined, + }) + } +} + +/// The provider for a tree, confined where this machine can confine anything. +/// +/// **Falls back to a host process, says so, and can be made to refuse.** +/// +/// The fallback is not a soft spot left open for convenience: `start_foreign` +/// refuses on a machine whose kernel is not denying, which is +/// `Programas-Ajenos.md`'s decree and is right — and a Thalyx that therefore +/// could not resolve a symbol at all would be a machine where the programming +/// face does not exist. This container is such a machine, and so is any Fedora +/// that has not run `make -C lsm load`. +/// +/// So it is reported rather than hidden: every answer carries +/// `analyzer_confined`, and `THALYX_REQUIRE_CONFINED_ANALYZER=1` turns the +/// fallback into a refusal. Rule 3's shape — one variable per requirement — so +/// a machine that can enforce can demand that it did. +fn provider_for(store_root: &Path, tree: &Path) -> Provider { + let provider = Provider::open(tree, knowledge(store_root, tree)) + .building_into(&build_directory(store_root, tree)) + .reaching( + thalyx_rust::toolchain::readable(), + thalyx_rust::toolchain::environment() + .into_iter() + .map(|(name, path)| (name.to_string(), path.display().to_string())) + .collect(), + ); + match Store::open(store_root) { + Ok(store) => provider.spawning(std::sync::Arc::new(UnderThalyx { + store, + request_id: crate::new_request_id(), + })), + // No store is no journal and no uid registry, which are two of the + // things confining a program needs. Said as what it is rather than + // becoming a silent host process: the answer's `analyzer_confined` + // will be `false` and `analyzer_how` will be `host`. + Err(_) => provider, + } +} + /// Do something with the provider for a tree, starting or reusing one. /// /// Reused rather than rebuilt because the expensive half is the rust-analyzer @@ -147,11 +336,7 @@ pub fn with_provider( if live.len() >= MOST_LIVE { live.remove(0); } - live.push(( - tree.to_path_buf(), - Provider::open(tree, knowledge(store_root, tree)) - .building_into(&build_directory(store_root, tree)), - )); + live.push((tree.to_path_buf(), provider_for(store_root, tree))); } } let (_, provider) = live.last_mut().expect("just pushed"); @@ -194,6 +379,13 @@ struct Entry { package: Option, file: String, line: u32, + /// One-based, as every other coordinate on this surface is. + /// + /// Carried so the entry can name *itself* precisely: `file:line:column` is + /// what `renombrar` takes and what resolves an ambiguity, and an answer + /// that described three candidates without saying how to ask about one of + /// them would be an answer a caller cannot act on. + column: u32, through: u32, signature: Option, uses: usize, @@ -215,6 +407,10 @@ impl Entry { } fields.insert("file".into(), json!(self.file)); fields.insert("line".into(), json!(self.line)); + fields.insert( + "at".into(), + json!(format!("{}:{}:{}", self.file, self.line, self.column)), + ); fields.insert( "lines".into(), json!(self.through.saturating_sub(self.line) + 1), @@ -316,7 +512,20 @@ pub fn context(store_root: &Path, here: &Where, rest: &str, face: Face) -> Falli query: query.clone(), uses, }; - let (entries, source, fresh, why) = gather(&asked); + let Answered { + entries, + source, + fresh, + resolution, + why, + } = gather(&asked); + + let confinement = with_provider(store_root, &tree, |provider| { + ( + provider.analyzer_confined(), + provider.analyzer_how().map(str::to_string), + ) + }); let (returned, used, omitted) = fit(&entries, budget); @@ -351,11 +560,27 @@ pub fn context(store_root: &Path, here: &Where, rest: &str, face: Face) -> Falli ("held_bytes", json!(held)), ("source", json!(source)), ("fresh", json!(fresh)), + // The field a program branches on. Always present, on every + // answer, whichever provider gave it — a field that only turns + // up on the ambiguous day is a field nobody handles on the + // ambiguous day. + ("resolution", json!(resolution)), + // What stood behind the process that answered. rust-analyzer + // runs Cargo, which compiles and runs build scripts, so this + // is a fact about arbitrary code having executed — reported + // rather than assumed, on every answer, including the ones the + // index gave where it is `null`. + ("analyzer_confined", json!(confinement.0)), + ("analyzer_how", json!(confinement.1)), ("detail", json!(why)), ], )); } else { println!(); + if resolution == "ambiguous" { + println!(" {why}"); + println!(); + } if returned.is_empty() { println!(" nothing named `{query}` — {why}"); } @@ -415,24 +640,51 @@ struct Asked { uses: usize, } +/// What a query came back as. +/// +/// `resolution` is the field this whole file turns on: `one` means a compiler +/// frontend resolved the name to exactly one declaration, and **`ambiguous` +/// means it resolved to several and this machine refused to pick**. A surface +/// that answered a three-`Config` workspace with one `Config` and no word +/// about the other two would be handing a model a confident wrong answer, and +/// the thing that acts on it is a rename. +struct Answered { + entries: Vec, + source: &'static str, + fresh: &'static str, + resolution: &'static str, + why: String, +} + /// The entries for a query, from the compiler when there is one and from the /// index when there is not. -fn gather(asked: &Asked) -> (Vec, &'static str, &'static str, String) { +fn gather(asked: &Asked) -> Answered { match from_analyzer(asked) { - Ok(Some((entries, fresh))) => (entries, "rust-analyzer", fresh, String::new()), + Ok(Some(answered)) => answered, // Named rather than swallowed. A model told `source: index` knows the // answer was matched and not resolved; one told nothing would act on a // scan believing it had a compiler. Ok(None) | Err(_) => { let (entries, fresh, why) = from_index(asked); - (entries, "index", fresh, why) + Answered { + entries, + source: "index", + fresh, + // Never `one` and never `ambiguous`. The index matches text; it + // cannot say a name resolves to one thing, so it must not be + // able to say a name resolves to several either — an ambiguity + // is a claim, and only the thing that can resolve names is + // entitled to make it. + resolution: "matched", + why, + } } } } -/// Entries and the standing of the answer they came from, or `None` when this -/// provider has nothing to say about the query at all. -type Resolved = Option<(Vec, &'static str)>; +/// An answer, or `None` when this provider has nothing to say about the query +/// at all. +type Resolved = Option; fn from_analyzer(asked: &Asked) -> Result> { with_provider(&asked.store_root, &asked.tree, |provider| { @@ -458,6 +710,7 @@ fn from_analyzer(asked: &Asked) -> Result> package: package.clone(), file: item.at.path.clone(), line: item.at.line, + column: item.at.column, through: item.through, signature: None, uses: 0, @@ -466,7 +719,16 @@ fn from_analyzer(asked: &Asked) -> Result> }) .collect(); remember_spans(provider, &entries); - return Ok(Some((entries, "current"))); + return Ok(Some(Answered { + entries, + source: "rust-analyzer", + fresh: "current", + // A file's map is a list of everything in it and never a claim + // about which one a name means. There is no question here to + // be ambiguous about. + resolution: "file", + why: String::new(), + })); } // `Store::lock` — the last segment is the name and the first is the @@ -477,9 +739,54 @@ fn from_analyzer(asked: &Asked) -> Result> .next() .unwrap_or(&asked.query) .to_string(); - let (known, standing, _) = provider.known(&name)?; - let Some(known) = known else { - return Ok(Some((Vec::new(), standing_word(&standing)))); + let (resolution, standing, _) = provider.known(&name)?; + let fresh = standing_word(&standing); + + if resolution.is_ambiguous() { + // **The refusal, as an answer rather than an error.** Every + // candidate comes back described and handled, so the caller — + // model or program — can choose one and ask again with + // `file:line:column`, which names exactly one declaration. + // + // Nothing here is ranked. A "most likely" candidate at the top of + // this list would be the heuristic guess this whole shape exists + // to remove, wearing a disclaimer. + let entries: Vec = resolution + .candidates() + .iter() + .map(|candidate| Entry { + handle: handle_for(&candidate.at.path, candidate.at.line, candidate.at.line), + name: candidate.name.clone(), + kind: candidate.kind.clone(), + package: candidate.package.clone(), + file: candidate.at.path.clone(), + line: candidate.at.line, + column: candidate.at.column, + through: candidate.at.line, + signature: candidate.signature.clone(), + uses: 0, + used_at: Vec::new(), + source: "rust-analyzer", + }) + .collect(); + remember_spans(provider, &entries); + return Ok(Some(Answered { + why: resolution.ambiguity(&name), + entries, + source: "rust-analyzer", + fresh, + resolution: "ambiguous", + })); + } + + let Some(known) = resolution.only() else { + return Ok(Some(Answered { + entries: Vec::new(), + source: "rust-analyzer", + fresh, + resolution: "nothing", + why: format!("nothing in this workspace declares `{name}`"), + })); }; let at = known.defined.first().cloned().unwrap_or(At { path: String::new(), @@ -503,6 +810,7 @@ fn from_analyzer(asked: &Asked) -> Result> package: known.package.clone(), file: at.path.clone(), line: at.line, + column: at.column, through, signature: known.signature.clone(), uses: known.used.len(), @@ -515,7 +823,13 @@ fn from_analyzer(asked: &Asked) -> Result> source: "rust-analyzer", }]; remember_spans(provider, &entries); - Ok(Some((entries, standing_word(&standing)))) + Ok(Some(Answered { + entries, + source: "rust-analyzer", + fresh, + resolution: "one", + why: String::new(), + })) }) } @@ -579,6 +893,11 @@ fn from_index(asked: &Asked) -> (Vec, &'static str, String) { package: None, file: definition.path.clone(), line: definition.line as u32, + // The index knows which line and not which column: it matched a + // name in a file. Said as 1 rather than left out, because an + // absent column would make `at` two different shapes depending on + // which provider answered. + column: 1, through: definition.line as u32, signature: None, uses, @@ -749,20 +1068,41 @@ pub fn rename(store_root: &Path, here: &Where, rest: &str, face: Face) -> Fallib let tree = tree_of(here); let resolved = with_provider(store_root, &tree, |provider| { - let (file, line, column) = match place(provider, &tree, &anchor) { - Ok(place) => place, - Err(why) => return Err(why), - }; + let (file, line, column) = place(provider, &tree, &anchor)?; provider .rename_texts(&file, line, column, &to) .map(|texts| (texts, file, line, column)) - .map_err(|error| error.to_string()) + .map_err(|error| Unplaced::of("unresolved", error.to_string())) }); let (texts, file, line, column) = match resolved { Ok(all) => all, Err(why) => { - declined(face, RENAME_OP, "unresolved", &why); + // **Nothing has been written at this point, and that is the whole + // claim.** `place` runs before `rename_texts`, `rename_texts` + // writes nothing anywhere, and the loop that opens files is below + // both. A rename that met three candidates leaves the workspace + // byte for byte what it was, and says which three. + if face.is_machine() { + face.say(thalyx_files::machine::refused_with( + RENAME_OP, + why.word, + if why.word == "ambiguous" { + "name_one_candidate" + } else { + "ask_context" + }, + &why.message, + vec![ + ("from", json!(anchor)), + ("to", json!(to)), + ("candidates", json!(why.candidates)), + ("files_changed", json!(0)), + ], + )); + } else { + println!("\n {}\n", why.message); + } return Ok(()); } }; @@ -849,13 +1189,37 @@ pub fn rename(store_root: &Path, here: &Where, rest: &str, face: Face) -> Fallib Ok(()) } +/// Why a name could not be turned into a place. +/// +/// A word and not only a sentence, because the caller that matters most here +/// is a **program**: `renombrar` inside `hacer` is a step whose answer another +/// step branches on, and "ambiguous, here are three handles" and "there is no +/// such name" call for opposite next moves. A program handed one string for +/// both would have to match on prose. +pub struct Unplaced { + pub word: &'static str, + pub message: String, + /// The candidates, when the reason was that there were several. + pub candidates: Vec, +} + +impl Unplaced { + fn of(word: &'static str, message: String) -> Self { + Self { + word, + message, + candidates: Vec::new(), + } + } +} + /// Where the thing to rename is: a position if the caller gave one, and /// otherwise the declaration of the name. fn place( provider: &mut Provider, tree: &Path, anchor: &str, -) -> Result<(PathBuf, u32, u32), String> { +) -> Result<(PathBuf, u32, u32), Unplaced> { let parts: Vec<&str> = anchor.rsplitn(3, ':').collect(); if parts.len() == 3 && let (Ok(column), Ok(line)) = (parts[0].parse::(), parts[1].parse::()) @@ -864,21 +1228,73 @@ fn place( if file.is_file() { return Ok((file, line, column)); } - return Err(format!("{} is not a file of this workspace", parts[2])); + return Err(Unplaced::of( + "absent", + format!("{} is not a file of this workspace", parts[2]), + )); + } + + let (resolution, standing, _) = provider + .known(anchor) + .map_err(|error| Unplaced::of("unresolved", error.to_string()))?; + + // **Refused before anything is written, and refused by name.** + // + // The alternative was already in this file: `ask_about` took the first + // exact match rust-analyzer listed. A workspace with `crate_a::Config`, + // `crate_b::Config` and `crate_c::Config` in it would have had one of the + // three renamed across every file that uses it, chosen by index order, and + // the answer would have said `source: rust-analyzer` — which is true and + // is the reason it would have been believed. + // + // There is no heuristic here on purpose. A mutation is exactly the place + // where "probably this one" is worth less than nothing: a wrong guess + // costs a rollback and a lost round trip, and a *right* guess teaches the + // caller that the guessing is reliable. + if resolution.is_ambiguous() { + return Err(Unplaced { + word: "ambiguous", + message: resolution.ambiguity(anchor), + candidates: resolution + .candidates() + .iter() + .map(|candidate| { + json!({ + "name": candidate.name, + "kind": candidate.kind, + "crate": candidate.package, + "container": candidate.container, + "at": candidate.handle, + "file": candidate.at.path, + "line": candidate.at.line, + "signature": candidate.signature, + }) + }) + .collect(), + }); } - let (known, standing, _) = provider.known(anchor).map_err(|error| error.to_string())?; - let known = known.ok_or_else(|| format!("nothing in this workspace declares `{anchor}`"))?; + let known = resolution.only().ok_or_else(|| { + Unplaced::of( + "unresolved", + format!("nothing in this workspace declares `{anchor}`"), + ) + })?; if matches!(standing, Standing::Stale { .. }) { // Cannot happen through `known`, which never returns a stale answer; // written so that a future path that could is refused rather than // renaming against a tree that has moved. - return Err(format!("what is known about `{anchor}` is out of date")); + return Err(Unplaced::of( + "stale", + format!("what is known about `{anchor}` is out of date"), + )); } - let at = known - .defined - .first() - .ok_or_else(|| format!("`{anchor}` is known but has no declaration"))?; + let at = known.defined.first().ok_or_else(|| { + Unplaced::of( + "unresolved", + format!("`{anchor}` is known but has no declaration"), + ) + })?; Ok((tree.join(&at.path), at.line, at.column)) } @@ -995,6 +1411,7 @@ mod tests { package: Some("a-crate".to_string()), file: "src/lib.rs".to_string(), line: 1, + column: 8, through: lines, signature: Some(format!("pub fn {name}() -> Result<()>")), uses: 17, diff --git a/crates/thalyx-cli/tests/a_name_that_is_three_names_changes_nothing.rs b/crates/thalyx-cli/tests/a_name_that_is_three_names_changes_nothing.rs new file mode 100644 index 0000000..8593e98 --- /dev/null +++ b/crates/thalyx-cli/tests/a_name_that_is_three_names_changes_nothing.rs @@ -0,0 +1,377 @@ +//! The ambiguity contract, at the surface a model actually sees. +//! +//! `vault/03-Primitivas/Semantica-Compilada.md`. `thalyx-rust` has its own +//! tests for the resolution itself; this is the other half — that the two verbs +//! which *act* on a resolution behave the way the decree says, and above all +//! that the one which writes files does not write any. +//! +//! The tree is three crates declaring `Config`, plus one name declared once. +//! The second is not decoration: without it, a machine that answered +//! "ambiguous" to everything would pass every assertion below while having +//! broken the whole programming face. + +use std::io::Write; +use std::path::Path; +use std::process::{Command, Output, Stdio}; + +fn thalyx() -> &'static str { + env!("CARGO_BIN_EXE_thalyx") +} + +/// Type lines into a session down a plain pipe and keep the objects. +fn piped(store: &Path, lines: &[String]) -> Output { + let mut child = Command::new(thalyx()) + .arg("session") + .env("THALYX_ROOT", store) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the session"); + let mut typed = String::new(); + for line in lines { + typed.push_str(line); + typed.push('\n'); + } + child + .stdin + .take() + .expect("stdin") + .write_all(typed.as_bytes()) + .expect("feeding the session"); + child.wait_with_output().expect("waiting for the session") +} + +fn objects(output: &Output) -> Vec { + String::from_utf8_lossy(&output.stdout) + .replace('\r', "") + .lines() + .filter_map(|line| serde_json::from_str::(line.trim()).ok()) + .filter(|value| value.is_object()) + .collect() +} + +fn answer_to<'a>(objects: &'a [serde_json::Value], op: &str) -> &'a serde_json::Value { + objects + .iter() + .find(|value| value["op"] == serde_json::json!(op)) + .unwrap_or_else(|| panic!("nothing answered `{op}`; got {objects:#?}")) +} + +/// The fixture, copied somewhere writable, plus a store of its own. +fn three_configs() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("thalyx-rust") + .join("tests") + .join("trees") + .join("three-configs"); + let held = tempfile::tempdir().expect("a temporary directory"); + let tree = held.path().join("three-configs"); + copy(&source, &tree); + let store = held.path().join("store"); + std::fs::create_dir_all(&store).expect("a store"); + (held, tree, store) +} + +fn copy(from: &Path, to: &Path) { + std::fs::create_dir_all(to).expect("the destination"); + for entry in std::fs::read_dir(from).expect("the fixture") { + let entry = entry.expect("an entry"); + let target = to.join(entry.file_name()); + if entry.file_type().expect("a type").is_dir() { + copy(&entry.path(), &target); + } else { + std::fs::copy(entry.path(), &target).expect("a copy"); + } + } +} + +/// Rule 3: one variable per requirement, and a skip that says it skipped. +fn analyzer_or_skip(what: &str) -> bool { + if thalyx_rust::analyzer::find().is_some() { + return true; + } + let message = format!( + "NOT PROVEN: {what} — there is no rust-analyzer on this machine. \ + Set THALYX_REQUIRE_RUST_ANALYZER=1 to make this a failure." + ); + if std::env::var("THALYX_REQUIRE_RUST_ANALYZER").as_deref() == Ok("1") { + panic!("{message}"); + } + eprintln!("{message}"); + false +} + +/// Every source file of the tree and its bytes, for the byte-for-byte column. +fn everything(tree: &Path) -> Vec<(String, String)> { + let mut all = Vec::new(); + for package in ["alpha", "beta", "gamma"] { + let path = tree.join(package).join("src").join("lib.rs"); + all.push(( + format!("{package}/src/lib.rs"), + std::fs::read_to_string(&path).expect("a source file"), + )); + } + all +} + +#[test] +fn contexto_says_a_name_means_three_things_and_hands_over_three_handles() { + if !analyzer_or_skip("that an ambiguous name is answered as ambiguous at the surface") { + return; + } + let (_held, tree, store) = three_configs(); + + let output = piped( + &store, + &[ + "structured on".to_string(), + format!("cd {}", tree.display()), + "contexto Config".to_string(), + "salir".to_string(), + ], + ); + let said = objects(&output); + let answer = answer_to(&said, "context"); + + assert_eq!( + answer["resolution"], + serde_json::json!("ambiguous"), + "{answer:#}" + ); + assert_eq!(answer["source"], serde_json::json!("rust-analyzer")); + let entries = answer["entries"].as_array().expect("entries"); + assert_eq!(entries.len(), 3, "{answer:#}"); + + // Each entry says which crate it is in and carries the identity that + // resolves it. That is what makes the refusal actionable rather than + // merely correct: the next call is one the caller can write. + let mut crates: Vec<&str> = entries + .iter() + .filter_map(|entry| entry["crate"].as_str()) + .collect(); + crates.sort_unstable(); + assert_eq!(crates, ["alpha", "beta", "gamma"], "{answer:#}"); + for entry in entries { + let at = entry["at"].as_str().expect("an `at`"); + assert!(at.contains("src/lib.rs:"), "{at}"); + } +} + +#[test] +fn contexto_still_resolves_a_name_that_means_one_thing() { + // **The control.** A machine that called everything ambiguous would pass + // the test above, and this is the only thing that notices. + if !analyzer_or_skip("that a unique name still resolves at the surface") { + return; + } + let (_held, tree, store) = three_configs(); + + let output = piped( + &store, + &[ + "structured on".to_string(), + format!("cd {}", tree.display()), + "contexto Unmistakable".to_string(), + "salir".to_string(), + ], + ); + let answer = objects(&output); + let answer = answer_to(&answer, "context"); + + assert_eq!(answer["resolution"], serde_json::json!("one"), "{answer:#}"); + let entries = answer["entries"].as_array().expect("entries"); + assert_eq!(entries.len(), 1, "{answer:#}"); + assert_eq!(entries[0]["crate"], serde_json::json!("gamma")); + assert_eq!(entries[0]["kind"], serde_json::json!("struct")); +} + +#[test] +fn a_rename_against_three_candidates_writes_nothing_at_all() { + // **The assertion this contract exists for.** Before 2026-08-30 this call + // renamed one of the three — chosen by whatever order rust-analyzer listed + // its workspace symbols in — across every file that used it, and answered + // `source: rust-analyzer`, which is true. + // + // The bytes are the claim. `files_changed: 0` is what the machine says; + // the three files being identical is what happened. + if !analyzer_or_skip("that an ambiguous rename refuses before writing") { + return; + } + let (_held, tree, store) = three_configs(); + let before = everything(&tree); + + let output = piped( + &store, + &[ + "structured on".to_string(), + format!("cd {}", tree.display()), + "renombrar-simbolo Config Settings".to_string(), + "salir".to_string(), + ], + ); + let said = objects(&output); + let answer = answer_to(&said, "rename"); + + assert_eq!(answer["ok"], serde_json::json!(false), "{answer:#}"); + assert_eq!( + answer["error"], + serde_json::json!("ambiguous"), + "{answer:#}" + ); + assert_eq!(answer["files_changed"], serde_json::json!(0), "{answer:#}"); + let candidates = answer["candidates"].as_array().expect("candidates"); + assert_eq!(candidates.len(), 3, "{answer:#}"); + + for (name, was) in before { + let now = std::fs::read_to_string(tree.join(&name)).expect(&name); + assert_eq!(now, was, "{name} was changed by a rename that refused"); + assert!( + !now.contains("Settings"), + "{name} carries the new name after a refusal" + ); + } +} + +#[test] +fn a_rename_of_the_name_that_means_one_thing_goes_through() { + // The control for the refusal, and the one that would catch a + // `place` that had started refusing everything: the same verb, the same + // tree, the same call shape, on the name that is unique. + if !analyzer_or_skip("that an unambiguous rename still happens") { + return; + } + let (_held, tree, store) = three_configs(); + + let output = piped( + &store, + &[ + "structured on".to_string(), + format!("cd {}", tree.display()), + "renombrar-simbolo Unmistakable OnlyOne".to_string(), + "salir".to_string(), + ], + ); + let said = objects(&output); + let answer = answer_to(&said, "rename"); + + assert_eq!(answer["ok"], serde_json::json!(true), "{answer:#}"); + let gamma = std::fs::read_to_string(tree.join("gamma/src/lib.rs")).expect("gamma"); + assert!(gamma.contains("pub struct OnlyOne"), "{gamma}"); + assert!( + !gamma.contains("pub struct Unmistakable"), + "the old declaration is still there: {gamma}" + ); + // And the three `Config`s are untouched, because they were never the + // question. + assert!(gamma.contains("pub struct Config"), "{gamma}"); +} + +#[test] +fn every_semantic_answer_says_what_stood_behind_the_process_that_gave_it() { + // **The gap this closes, stated as an assertion.** + // + // rust-analyzer was started as an ordinary host process and described in + // this repository as "a reader" — true of the LSP protocol, false of the + // process tree. It runs `cargo metadata`, and answering anything about a + // workspace with a proc-macro or a build script in it means compiling and + // running them: arbitrary code from a registry, at analysis time. + // + // It now goes through `thalyx_core::start_foreign` — cgroup, kernel policy, + // private root filesystem, own user, pid namespace, network namespace, + // seccomp — and falls back to a host process only where nothing can + // enforce, which is this container and any Fedora that has not run + // `make -C lsm load`. + // + // What this test demands is the honesty, which holds on **every** machine: + // the answer says which of the two happened, and never nothing. Whether + // this machine confined it is `dev/verify.sh`'s question, not this one's. + if !analyzer_or_skip("that an answer says what confined the provider") { + return; + } + let (_held, tree, store) = three_configs(); + + let output = piped( + &store, + &[ + "structured on".to_string(), + format!("cd {}", tree.display()), + "contexto Unmistakable".to_string(), + "salir".to_string(), + ], + ); + let said = objects(&output); + let answer = answer_to(&said, "context"); + + assert_eq!(answer["source"], serde_json::json!("rust-analyzer")); + assert!( + answer["analyzer_confined"].is_boolean(), + "the answer does not say whether the provider was confined: {answer:#}" + ); + let how = answer["analyzer_how"] + .as_str() + .unwrap_or_else(|| panic!("the answer does not say what started it: {answer:#}")); + assert!( + how.starts_with("confined:") || how.starts_with("host"), + "`analyzer_how` is `{how}`, which is neither" + ); + // The two must agree. A `confined: true` beside a `host` phrase would be + // the worst possible shape: two fields, one of them wrong, and no way to + // tell which. + assert_eq!( + answer["analyzer_confined"], + serde_json::json!(how.starts_with("confined:")), + "{answer:#}" + ); +} + +#[test] +fn a_machine_that_can_enforce_can_demand_that_it_did() { + // Rule 3, for the confinement: one environment variable per requirement. + // With `THALYX_REQUIRE_CONFINED_ANALYZER=1` the fallback is not a fallback, + // it is a refusal — so a machine that *can* confine the provider can insist + // that it was, rather than silently getting a host process on the day the + // LSM failed to load. + // + // Asserted from the failing side, which is the side this container can + // show: nothing here can enforce, so the demand must produce a refusal and + // not an answer. + if !analyzer_or_skip("that the confinement can be demanded") { + return; + } + let (_held, tree, store) = three_configs(); + + let mut child = Command::new(thalyx()) + .arg("session") + .env("THALYX_ROOT", &store) + .env("THALYX_REQUIRE_CONFINED_ANALYZER", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the session"); + let typed = format!( + "structured on\ncd {}\ncontexto Unmistakable\nsalir\n", + tree.display() + ); + use std::io::Write as _; + child + .stdin + .take() + .expect("stdin") + .write_all(typed.as_bytes()) + .expect("feeding the session"); + let output = child.wait_with_output().expect("waiting"); + let said = objects(&output); + let answer = answer_to(&said, "context"); + + // It did not resolve anything with a compiler frontend, and it did not + // pretend to: the answer falls back to the index and says so, which is the + // existing contract for "there is no rust-analyzer here". + assert_ne!( + answer["source"], + serde_json::json!("rust-analyzer"), + "the demand was ignored and a host process answered anyway: {answer:#}" + ); +} diff --git a/crates/thalyx-cli/tests/a_program_nobody_signed_can_run.rs b/crates/thalyx-cli/tests/a_program_nobody_signed_can_run.rs index 97ff0ba..968a458 100644 --- a/crates/thalyx-cli/tests/a_program_nobody_signed_can_run.rs +++ b/crates/thalyx-cli/tests/a_program_nobody_signed_can_run.rs @@ -67,6 +67,7 @@ fn request<'a>(program: &'a Path, grants: Vec) -> ForeignRequest<'a> helper: PathBuf::from(env!("CARGO_BIN_EXE_thalyx")), request_id: "test-request".to_string(), profile: thalyx_sandbox::profile::MODULE_STANDARD, + environment: Vec::new(), } } diff --git a/crates/thalyx-core/src/foreign.rs b/crates/thalyx-core/src/foreign.rs index b0aae26..dfc9f3f 100644 --- a/crates/thalyx-core/src/foreign.rs +++ b/crates/thalyx-core/src/foreign.rs @@ -65,6 +65,16 @@ pub struct ForeignRequest<'a> { pub helper: PathBuf, pub request_id: String, pub profile: &'a str, + /// Environment variables the program is started with. + /// + /// **Not a widening.** A variable is a string, the root filesystem holds + /// only what was granted, and the LSM refuses every open the policy does + /// not name — so a path in here that nobody granted names something that + /// is not there. What it buys is the case Thalyx cannot avoid: a toolchain + /// installed by one user and run by another has to be *told* where its + /// registry is, and a `cargo` that cannot find one reports that as the + /// change not compiling. + pub environment: Vec<(String, String)>, } /// What happened, in the shape both faces read. @@ -183,11 +193,207 @@ pub fn run_foreign( } } -fn run_inner( +/// A confined program that is still running, and everything needed to end it. +/// +/// **Holding one owns the teardown.** [`ForeignProcess::shutdown`] kills the +/// process — which, under a profile with a pid namespace, kills every +/// descendant with it, because the kernel reaps a namespace when its init +/// dies — and then withdraws the policy and removes the cgroup. Dropping one +/// without that kills the process and leaves the cgroup and its kernel policy +/// behind, which the [`Drop`] below does as the half that can be done without +/// a policy store. +/// +/// It exists for the semantic provider: rust-analyzer costs about 25 seconds to +/// start and 20 milliseconds to answer, so it is started once and asked many +/// times — its confinement outlives the call that established it, and a +/// [`thalyx_sandbox::Confinement`] borrows the store and cannot be kept +/// anywhere but in that frame. That is exactly what `Held` is for, and this is +/// the second caller of it after the resident engine. +pub struct ForeignProcess { + /// The process Thalyx spawned, until somebody takes it. + /// + /// An `Option` because the one caller for this needs to *own* the child — + /// `Analyzer` holds a conversation over its pipes — while the confinement + /// stays here to be torn down. The first shape of this handed the child out + /// and left a placeholder process behind, which meant `thalyx` depended on + /// there being a `/bin/true` on the machine. The image holds the Linux + /// kernel and one program. There is no `/bin/true`. + child: Option, + held: Option, + pub program: PathBuf, + pub name: String, + pub cgroup_id: u64, + pub policy: thalyx_permd::Policy, + pub isolation: String, + pub isolated: bool, + pub uid: Option, +} + +impl ForeignProcess { + /// Take the process, leaving the confinement here to be torn down. + /// + /// For a caller that talks to what it started: it needs the pipes, and the + /// cgroup and the policy are not its to hold. Whoever takes the child still + /// has to call [`ForeignProcess::shutdown`] — and killing the child alone + /// is not enough, because the cgroup and its kernel policy would be left. + pub fn take_child(&mut self) -> Option { + self.child.take() + } + + /// Kill it and everything it started, then take the confinement down. + pub fn shutdown(mut self, policies: &dyn PolicyStore) { + self.end(policies); + } + + fn end(&mut self, policies: &dyn PolicyStore) { + // The cgroup first, and it is not belt and braces. A pid namespace's + // init dying takes the namespace with it — but the window between + // `spawn` and the re-exec that *becomes* that init is a window where + // the tree is ordinary processes, and a `cargo` started in it would + // outlive the kill. `cgroup.kill` covers every process in the cgroup + // whatever stage it is at, and it covers the case where the child was + // taken by somebody else and is not here to be killed. + if let Some(held) = &self.held { + let _ = held.cgroup().kill(); + } + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + + // `release` is a no-op while anything is still inside, and `SIGKILL` is + // delivered rather than completed: a compiler tree of forty processes + // does not vanish on the instruction after the write. Without this wait + // the release finds the cgroup occupied, declines, and leaves the + // directory and its map entry behind — which is not a leak of memory, + // it is a kernel policy outliving what it was written for. + // + // Bounded, and it gives up rather than blocking: a cgroup that will not + // empty is a fact to report, not a reason for Thalyx to stop. + if let Some(held) = &self.held { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while std::time::Instant::now() < deadline { + if held.cgroup().is_empty().unwrap_or(true) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + + if let Some(held) = self.held.take() { + let _ = held.release(policies); + } + } +} + +impl Drop for ForeignProcess { + /// Half a teardown, which is all that can be done from here. + /// + /// Withdrawing a policy needs a policy store and this holds no borrow of + /// one — that is the whole reason `Held` exists. So the process is killed, + /// and the cgroup and its map entry are left for the next start of the same + /// program to reuse. A handle dropped without + /// [`ForeignProcess::shutdown`] is a bug; this keeps it from being a live + /// compiler nobody is holding. + fn drop(&mut self) { + if let Some(held) = &self.held { + let _ = held.cgroup().kill(); + } + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +/// Start a foreign program under confinement and hand it back still running. +/// +/// The same establishment `run_foreign` does — the same enforcement gate, the +/// same user, the same cgroup, the same root filesystem, the same filter — up +/// to the moment the process exists. `stdin` is a pipe whose only writer is +/// Thalyx, because the one caller for this talks to what it starts. +pub fn start_foreign( store: &Store, policies: &dyn PolicyStore, request: &ForeignRequest<'_>, -) -> Result { +) -> Result { + let (program, home, name, profile, uid) = establish(store, policies, request)?; + + let parent = thalyx_sandbox::cgroup::parent()?; + let confinement = Confinement::establish( + policies, + &parent, + &name, + profile, + &request.grants, + thalyx_permd::boot_ns(), + thalyx_permd::DEFAULT_JIT_LIFETIME_NS, + )?; + + let cgroup_id = confinement.cgroup_id(); + let policy = confinement.policy(); + let isolation = confinement.profile().describe(); + let isolated = confinement.profile().isolates(); + + let child = confinement.spawn_talking( + &request.helper, + &home, + &program, + uid, + &request.args, + &request.environment, + )?; + + let journal = Journal::open(store.journal_path())?; + let _ = journal.append(&Entry { + timestamp: thalyx_journal::now(), + operation: "start_foreign".to_string(), + module_id: Some(program.display().to_string()), + version: None, + outcome: if isolated { + Outcome::Success + } else { + Outcome::Degraded { + reason: isolation.clone(), + } + }, + request_id: request.request_id.clone(), + origin: Origin::UserUtterance, + snapshot: None, + notes: vec![format!("started under {isolation}, held open")], + }); + + Ok(ForeignProcess { + child: Some(child), + held: Some(confinement.detach()), + program, + name, + cgroup_id, + policy, + isolation, + isolated, + uid, + }) +} + +/// Everything both starts do before a cgroup exists. +/// +/// Lifted out so there is one enforcement gate and one uid assignment rather +/// than two, which is the same argument `run::start` makes for the resident +/// module: a second launcher is a second place for the checks to drift. +type Established = ( + PathBuf, + PathBuf, + String, + thalyx_sandbox::Profile, + Option, +); + +fn establish( + store: &Store, + policies: &dyn PolicyStore, + request: &ForeignRequest<'_>, +) -> Result { let program = resolve_program(request.program)?; // The directory the binary lives in becomes its `/module` inside the pivot. @@ -204,7 +410,6 @@ fn run_inner( })? .to_path_buf(); - // The name, before anything is created with it. let name = cgroup_name(&program); // Resolved before the kernel is asked anything, for the reason `run.rs` @@ -259,6 +464,20 @@ fn run_inner( None }; + Ok((program, home, name, profile, uid)) +} + +fn run_inner( + store: &Store, + policies: &dyn PolicyStore, + request: &ForeignRequest<'_>, +) -> Result { + // The same establishment `start_foreign` does, through the same function. + // Two copies of the enforcement gate and the uid assignment would be two + // places for them to drift, and the one that drifts is always the one + // nobody runs on the machine that can enforce. + let (program, home, name, profile, uid) = establish(store, policies, request)?; + let parent = thalyx_sandbox::cgroup::parent()?; let confinement = Confinement::establish( policies, @@ -275,10 +494,16 @@ fn run_inner( let isolation = confinement.profile().describe(); let isolated = confinement.profile().isolates(); - // `None` for the channel, and that is the decree rather than an omission. - // See this module's header: a guest is not handed the API. - let mut child = - confinement.spawn(&request.helper, &home, &program, uid, &request.args, None)?; + // No channel, and that is the decree rather than an omission. See this + // module's header: a guest is not handed the API. + let mut child = confinement.spawn_with( + &request.helper, + &home, + &program, + uid, + &request.args, + &request.environment, + )?; // Before the wait, never after. The program holds the writing end of two // pipes, and nobody emptying them means it stops on a full buffer while diff --git a/crates/thalyx-core/src/lib.rs b/crates/thalyx-core/src/lib.rs index 0708675..c2b7c69 100644 --- a/crates/thalyx-core/src/lib.rs +++ b/crates/thalyx-core/src/lib.rs @@ -30,7 +30,7 @@ pub mod test_support; pub mod trusted_path; pub mod uids; -pub use foreign::{ForeignOutcome, ForeignRequest, run_foreign}; +pub use foreign::{ForeignOutcome, ForeignProcess, ForeignRequest, run_foreign, start_foreign}; pub use install::{InstallOutcome, InstallRequest, install, installed_manifest, remove}; pub use run::{RunForeseen, RunOutcome, RunRequest, foresee_run, run}; pub use store::Store; diff --git a/crates/thalyx-core/src/run.rs b/crates/thalyx-core/src/run.rs index 6be1b16..cd739aa 100644 --- a/crates/thalyx-core/src/run.rs +++ b/crates/thalyx-core/src/run.rs @@ -829,6 +829,9 @@ pub fn start( Wiring::Collected => thalyx_sandbox::Stdin::Closed, Wiring::Talks => thalyx_sandbox::Stdin::Piped, }, + // A module is described by a signed manifest and takes what it + // needs from that. Nothing here has anything to add. + environment: &[], }, )? }; diff --git a/crates/thalyx-mcp/src/main.rs b/crates/thalyx-mcp/src/main.rs index fb643d6..2adb3d9 100644 --- a/crates/thalyx-mcp/src/main.rs +++ b/crates/thalyx-mcp/src/main.rs @@ -77,6 +77,23 @@ struct Cli { /// only verbs it asks, and neither can change the workspace it is checking. #[arg(long)] preflight: bool, + + /// Which set of tools to offer the model + /// + /// `compact` — the default — offers three: what a name is, do a stretch of + /// work, and fetch what the work did not send back. Everything else is + /// reachable from inside a `thalyx_exec` program, where it costs no schema + /// and no attention until it is used. + /// + /// `legacy` offers the whole catalogue. It exists for compatibility, for + /// debugging, for the benchmarks already run against it, and — the reason + /// it will not be removed — as the control column. "The small surface is + /// better" is a comparison, and a comparison needs the other arm. + /// + /// `THALYX_MCP_SURFACE` sets the same thing, for a client that can pass an + /// environment and not an argument. The flag wins. + #[arg(long, default_value = "compact")] + surface: String, } fn main() { @@ -102,11 +119,24 @@ fn main() { greeting.workspace ); - let offered = usable(&greeting.verbs); + // The flag first, then the environment. A client that can pass one but not + // the other gets the same choice either way, and a person who passed both + // gets the one they typed. + let asked_for = if std::env::args().any(|argument| argument.starts_with("--surface")) { + cli.surface.clone() + } else { + std::env::var("THALYX_MCP_SURFACE").unwrap_or_else(|_| cli.surface.clone()) + }; + let whole_catalogue = asked_for.eq_ignore_ascii_case(tools::LEGACY_SURFACE) + || asked_for.eq_ignore_ascii_case("full") + || asked_for.eq_ignore_ascii_case("all"); + + let offered = usable(&greeting.verbs, whole_catalogue); eprintln!( - "thalyx-mcp: {} of {} tools offered", + "thalyx-mcp: {} of {} tools offered ({} surface)", offered.len(), - tools::TOOLS.len() + tools::TOOLS.len(), + if whole_catalogue { "legacy" } else { "compact" } ); if cli.preflight { @@ -149,9 +179,10 @@ fn main() { /// The anti-drift check, and it is a real one rather than a copy: the verbs come /// from the machine's own hello, so a tool built against a verb this Thalyx does /// not have is dropped here instead of failing on the model's first use of it. -fn usable(verbs: &[String]) -> Vec<&'static tools::Tool> { +fn usable(verbs: &[String], whole_catalogue: bool) -> Vec<&'static tools::Tool> { tools::TOOLS .iter() + .filter(|tool| whole_catalogue || tool.surface == tools::Surface::Hot) .filter(|tool| { let missing: Vec<&str> = tool .verbs @@ -457,6 +488,39 @@ fn tool_error(name: &str, arguments: &Value, why: &str, metrics: &mut Metrics) - mod tests { use super::*; + #[test] + fn the_default_surface_is_three_tools_and_the_legacy_one_is_all_of_them() { + // **The list is the prompt.** Fourteen schemas arrive with every + // inference of every session, and the model has to consider each of + // them before any work happens — which is the tool proliferation the + // research named as a hazard in its own right. + // + // What replaced them is not a deletion: every operation is one line + // inside a `thalyx_exec` program, where it costs nothing until it is + // used. So this asserts the shape rather than the number: three by + // default, all of them when asked, and nothing gone. + let every: Vec = tools::TOOLS + .iter() + .flat_map(|tool| tool.verbs.iter().map(|verb| verb.to_string())) + .collect(); + + let compact = usable(&every, false); + let names: Vec<&str> = compact.iter().map(|tool| tool.name).collect(); + assert_eq!( + names, + ["thalyx_context", "thalyx_exec", "thalyx_evidence"], + "the default surface changed" + ); + + let whole = usable(&every, true); + assert_eq!(whole.len(), tools::TOOLS.len()); + assert!( + whole.len() > compact.len(), + "the legacy surface is not larger than the compact one, which means the \ + control column for every measurement of the compact surface is gone" + ); + } + #[test] fn a_tool_whose_verb_the_machine_does_not_have_is_not_offered() { // The version skew this exists to make visible. A machine that lost @@ -467,7 +531,7 @@ mod tests { .iter() .map(|verb| verb.to_string()) .collect(); - let offered = usable(&without); + let offered = usable(&without, true); assert!(offered.iter().any(|tool| tool.name == "thalyx_read")); assert!(!offered.iter().any(|tool| tool.name == "thalyx_symbol")); } @@ -480,6 +544,6 @@ mod tests { .iter() .flat_map(|tool| tool.verbs.iter().map(|verb| verb.to_string())) .collect(); - assert_eq!(usable(&every).len(), tools::TOOLS.len()); + assert_eq!(usable(&every, true).len(), tools::TOOLS.len()); } } diff --git a/crates/thalyx-mcp/src/tools.rs b/crates/thalyx-mcp/src/tools.rs index 04db6ba..2950588 100644 --- a/crates/thalyx-mcp/src/tools.rs +++ b/crates/thalyx-mcp/src/tools.rs @@ -6,7 +6,29 @@ //! arguments, and the machine does the rest — which is why this file is a table //! and not a program. //! -//! ## Why there are eleven of these and not forty +//! ## Why the default surface is three +//! +//! **The list is the prompt.** Every tool an agent is shown arrives with every +//! inference of every session, and it is a branch the model has to consider +//! before any work happens. A surface of fourteen low-level operations spends +//! attention on choosing between them — which is the tool proliferation the +//! research named as a hazard in its own right rather than a matter of taste. +//! +//! Since 2026-08-30 `thalyx_exec` takes a **program**, so an operation no +//! longer needs a schema to be reachable: `thalyx.read`, `thalyx.grep`, +//! `thalyx.substitute` and thirty others are one line each inside it, and cost +//! nothing until one is used. That is the research's conclusion exactly — one +//! always-available programmable capability, with the specific operations +//! beneath it. +//! +//! So the default is `thalyx_context`, `thalyx_exec` and `thalyx_evidence`: +//! what a name is, do a stretch of work, and fetch what the work did not send +//! back. Everything else is [`Surface::Legacy`] — **still here, still tested, +//! still one flag away** — because those tools are the control column for +//! every measurement of what the small surface buys, and an ablation you have +//! deleted is one you cannot run. +//! +//! ## Why there were eleven of these and not forty //! //! Because the list is a prompt. Every tool an agent is shown is a branch it has //! to consider on every turn, and a surface that offers one tool per verb spends @@ -25,9 +47,48 @@ use serde_json::{Value, json}; +/// Which surface a tool is on. +/// +/// **The list is the prompt.** Every tool an agent is shown is a branch it has +/// to consider on every turn, and the schemas arrive with every inference of +/// every session — so a catalogue of fourteen low-level operations spends the +/// model's attention on choosing between them, forever, before any work +/// happens. +/// +/// The research that produced `hacer`'s programmable form concluded the same +/// thing from the other direction: **one always-available programmable +/// capability, with the specific operations beneath it.** A capability does +/// not need its own schema to be reachable — `thalyx.read`, `thalyx.grep`, +/// `thalyx.substitute` and thirty others are one line each inside a program, +/// and none of them costs a token until a program uses one. +/// +/// So the default surface is three tools. Nothing is deleted: every tool below +/// is still here, still tested, and still reachable — which matters because +/// they are the *control column* for every measurement of what the small +/// surface buys, and an ablation you have deleted is one you cannot run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Surface { + /// Offered always. Three, and each earns it: what a name is, do a stretch + /// of work, and fetch what the work did not send back. + Hot, + /// Offered when the caller asks for the whole catalogue. + Legacy, +} + +/// The whole catalogue, for a caller that wants the old surface. +/// +/// `THALYX_MCP_SURFACE=legacy`, or `--surface legacy`. It exists for +/// compatibility, for debugging, for the benchmarks already run against the +/// fourteen-tool surface, and — the reason it will not be removed — as the +/// control column: "the small surface is better" is a comparison, and a +/// comparison needs the other arm to still exist. +pub const LEGACY_SURFACE: &str = "legacy"; + /// One tool, and the verb it becomes. pub struct Tool { pub name: &'static str, + /// Which surface it is on. See [`Surface`]. + pub surface: Surface, /// The verb ids this tool needs the machine to have. Checked against what /// the machine said in its hello, so a tool whose verb is gone is never /// advertised — see `main::usable`. @@ -214,6 +275,7 @@ fn limit(arguments: &Value) -> Option { pub const TOOLS: &[Tool] = &[ Tool { name: "thalyx_state", + surface: Surface::Legacy, verbs: &["where", "state", "attempt"], description: "\ What this Thalyx machine is right now: where the workspace is, what the machine \ @@ -231,6 +293,7 @@ otherwise take several commands and several guesses.", }, Tool { name: "thalyx_list", + surface: Surface::Legacy, verbs: &["list"], description: "\ What is in a directory of the workspace. Sizes are exact and nothing is hidden. \ @@ -255,6 +318,7 @@ Use it to orient; use thalyx_symbol or thalyx_dependencies to find code.", }, Tool { name: "thalyx_read", + surface: Surface::Legacy, verbs: &["read"], description: "\ The text of one file, with its exact size and its sha256. A file that is not \ @@ -274,6 +338,7 @@ opening any of them.", }, Tool { name: "thalyx_context", + surface: Surface::Hot, verbs: &["context"], description: "\ What a name IS, resolved by a compiler frontend rather than matched as text: \ @@ -338,6 +403,7 @@ the machine last looked. Believe them.", }, Tool { name: "thalyx_symbol", + surface: Surface::Legacy, verbs: &["symbol", "index_build"], description: "\ Where a name is defined and every place it is used, from Thalyx's parsed \ @@ -368,6 +434,7 @@ is exact and case-sensitive, so `login` does not find `login_user`.", }, Tool { name: "thalyx_dependencies", + surface: Surface::Legacy, verbs: &["depends_on", "depended_on_by"], description: "\ Which files depend on one file, or which it depends on, from the index and \ @@ -417,6 +484,7 @@ thalyx_symbol is the finer question.", }, Tool { name: "thalyx_index", + surface: Surface::Legacy, verbs: &["index_build"], description: "\ Read the workspace and record what refers to what. thalyx_symbol and \ @@ -442,6 +510,7 @@ are skipped.", }, Tool { name: "thalyx_find", + surface: Surface::Legacy, verbs: &["find", "grep"], description: "\ Search the workspace by file name pattern, or for a literal string inside \ @@ -491,6 +560,7 @@ is exact where this is not.", }, Tool { name: "thalyx_attempt", + surface: Surface::Legacy, verbs: &["attempt"], description: "\ A reversible boundary around a task. `begin` takes a snapshot of the whole \ @@ -585,35 +655,73 @@ here between the steps — use thalyx_exec instead.", }, Tool { name: "thalyx_exec", + surface: Surface::Hot, verbs: &["exec"], description: "\ -Do a whole deterministic stretch of work in ONE call. Give Thalyx the list of \ -operations you already know you want, plus what must be true afterwards, and it \ -opens a reversible boundary, runs every operation in order, observes what really \ -changed, runs the checks, and then keeps the work or puts the workspace back \ -exactly as it was — without coming back to you in between. \ -Reach for this whenever the next several steps do not need you to think between \ -them: a rename across files then a search proving the old name is gone; an edit \ -then a compile; make files, change them, verify, and undo it all if the \ -verification fails. That is the normal case, not the exotic one. \ -`steps` are ordinary Thalyx requests — the same verbs and arguments the other \ -tools send, so anything you could do in five calls you can do here in one. Two \ -are worth knowing by name: `rename` takes a Rust symbol and a new name and \ -rewrites every place that really refers to it, resolved by a compiler frontend, \ -including the aliased imports a search would miss; and the `rust` check \ -compiles exactly the crates your change reaches — the ones it is in and the \ -ones that depend on them, worked out from Cargo's graph — and reuses the answer \ -when this machine has already compiled these exact bytes. They \ -run in order and stop at the first refusal. `validate` decides the outcome: if \ -every check passes the work is committed, and otherwise the whole thing is \ -rolled back and the workspace is byte-for-byte what it was. A check that could \ -not be run counts as failure, never as success. \ -The answer is deliberately small — status, what changed, how each check went, \ -and an `evidence` id. Every answer, every search hit and every line of compiler \ -output stays inside the machine; thalyx_evidence fetches any of it if you \ -actually need it. \ -Do not use it for exploring: if you need to read an answer before choosing the \ -next step, that is a real decision and belongs in its own call.", +Do a whole stretch of work in ONE call by sending Thalyx a short PROGRAM. \ +Thalyx opens a reversible boundary, runs your program, watches what really \ +changed, and then keeps the work or puts the workspace back exactly as it was \ +— without coming back to you in between. \ +\ +`run` is JavaScript. It has variables, if/else, loops, arrays, string methods \ +and JSON, and it calls Thalyx through a `thalyx` object. **The point is that \ +what one call returns decides what you do next, locally**: list a directory \ +and loop over what came back; read each file and edit only the ones whose \ +contents say to; validate, look at the verdict, and branch on it. None of that \ +costs you a turn. \ +\ +What `thalyx` gives you: \ +`state()`, `where()`, `list(path)`, `read(path)`, `window(path, line, around)`, \ +`grep(text)`, `find(pattern)`, `symbol(name)`, `dependsOn(path)`, \ +`dependedOnBy(path)`; `context(query)` — what a NAME is, resolved by a compiler \ +frontend, and `rename(from, to)` — rewrite every place that really refers to a \ +Rust symbol, aliased imports included; `substitute(path, old, new)`, \ +`edit(path, action, …)`, `write(path, line, text)`, `append(path, text)`, \ +`makeFile(...)`, `makeDirectory(...)`, `copy(from,to)`, `move(from,to)`, \ +`remove(...)`; `changed()` — what the tree really shows changed so far; \ +`validate(check)`; `assert(condition, message)`; `mustWork(answer, message)` \ +and `mustPass(record, message)`; `needModel(value)`; `log(text)`; and \ +`call(verb, args)` for anything else. \ +\ +Every call answers an object with `ok` on it. A refusal is a value, not an \ +error — read it and decide. `mustWork(...)` turns 'it answered' into 'it \ +worked', which is the mistake to guard against on every mutating call. \ +\ +`validate({check: …})` is the same set of checks the machine runs for itself: \ +`text` (a string is absent from, or still present in, the workspace), `parses` \ +(every changed source file still has balanced brackets — what a mechanical \ +edit breaks), `rust` (cargo over exactly the crates your change REACHES, \ +worked out from Cargo's graph, reusing the answer when this machine has \ +already compiled these exact bytes), and `program` (run an absolute path, \ +confined, require exit 0). Each answers `{verdict, summary, output}` where \ +verdict is `passed`, `failed` or `not_proven` — and `not_proven` is never a \ +pass. \ +\ +`assert(condition, message)` stops the run there and then; it cannot be caught. \ +Use it for premises: exactly N candidates, no occurrence left, the process \ +exited 0. Failing early is far cheaper than discovering it after twenty edits. \ +\ +Return a small value at the end — that is all that comes back to you. \ +Everything else — whole files, every reference, all the compiler output — \ +stays in the machine under an `evidence` id, and thalyx_evidence fetches any \ +of it if the summary was not enough. Keeping the reading local and the answer \ +small is the whole point. \ +\ +If you meet a decision a machine should not make — an ambiguous symbol, a \ +design choice — call `thalyx.needModel({...})`. The workspace is put back \ +untouched and you are asked, rather than a guess being committed. \ +\ +The transaction: if the program returns and every check that ran passed, the \ +work is committed. Otherwise the whole thing is rolled back and the workspace \ +is byte-for-byte what it was. `on_failure: \"keep\"` leaves the failed tree in \ +place to look at. \ +\ +There are ceilings — wall time, memory, how many things you may ask the \ +machine, how many processes may start. An infinite loop is stopped, and \ +stopping is a rollback. \ +\ +`steps` is the older form: a plain list of requests when the work really is \ +known in advance. Send `run` or `steps`, never both.", schema: || { json!({ "type": "object", @@ -622,11 +730,20 @@ next step, that is a real decision and belongs in its own call.", "type": "string", "description": "What this piece of work is about, for the journal." }, + "run": { + "type": "string", + "description": "The program: JavaScript, using the `thalyx` object. \ + Return the small value you want back. Example: \ + `const refs = thalyx.context('LauncherError'); \ + if (refs.resolution === 'ambiguous') { return \ + thalyx.needModel(refs.entries); } ...`" + }, "steps": { "type": "array", - "description": "The operations, in order. Each is a Thalyx verb and \ - its arguments — exactly what the single-purpose \ - tools send. They stop at the first refusal.", + "description": "The older form: operations in order, when nothing \ + needs to be decided from an intermediate answer. \ + Each is a Thalyx verb and its arguments. Do not \ + send this together with `run`.", "items": { "type": "object", "properties": { @@ -635,16 +752,14 @@ next step, that is a real decision and belongs in its own call.", "description": "One of: edit, make_file, make_directory, \ copy, move, remove, read, list, grep, \ find, symbol, depends_on, \ - depended_on_by, index_build, where, \ - state, describe, rehearse." + depended_on_by, context, rename, \ + index_build, where, state, describe, \ + rehearse." }, "arguments": { "type": "array", "items": {"type": "string"}, - "description": "The verb's arguments, in order. For \ - `edit`: [path, action, …] where action \ - is sustituir | sustituir-lote | poner | \ - cambiar | borrar." + "description": "The verb's arguments, in order." } }, "required": ["verb"] @@ -652,8 +767,10 @@ next step, that is a real decision and belongs in its own call.", }, "validate": { "type": "array", - "description": "What must be true for the work to be kept. An empty \ - list commits whatever the steps did.", + "description": "Checks that must hold for the work to be kept, run \ + after the program finishes. A program can also \ + call `thalyx.validate(...)` itself and branch on \ + the answer; both gate the commit.", "items": { "type": "object", "properties": { @@ -661,15 +778,12 @@ next step, that is a real decision and belongs in its own call.", "type": "string", "enum": ["text", "parses", "rust", "program"], "description": "`text`: a string must be absent from (or \ - present in) the workspace — the way to \ - prove a rename left nothing behind. \ - `parses`: every changed source file \ - still has balanced brackets, strings and \ - comments, which is what a mechanical \ - edit breaks. `rust`: cargo over the \ - packages the changed files belong to. \ - `program`: run an absolute path, \ - confined, and require exit 0." + present in) the workspace. `parses`: \ + every changed source file still has \ + balanced brackets, strings and \ + comments. `rust`: cargo over the crates \ + the change reaches. `program`: run an \ + absolute path, confined, require exit 0." }, "text": {"type": "string", "description": "For `text`."}, "expect": { @@ -680,8 +794,7 @@ next step, that is a real decision and belongs in its own call.", }, "in": { "type": "string", - "description": "For `text`: a folder to look in. \ - Defaults to the whole workspace." + "description": "For `text`: a folder to look in." }, "mode": { "type": "string", @@ -689,6 +802,12 @@ next step, that is a real decision and belongs in its own call.", "description": "For `rust`: `cargo check` (default) or \ `cargo test`." }, + "packages": { + "type": "array", + "items": {"type": "string"}, + "description": "For `rust`: check these crates instead \ + of the ones the change reaches." + }, "program": { "type": "string", "description": "For `program`: an absolute path." @@ -705,36 +824,50 @@ next step, that is a real decision and belongs in its own call.", "on_failure": { "type": "string", "enum": ["rollback", "keep"], - "description": "What to do when a step is refused or a check does \ - not hold. `rollback` is the default and is almost \ - always what you want; `keep` leaves the failed tree \ - in place for you to look at." + "description": "What to do when the program stops short or a check \ + does not hold. `rollback` is the default and is \ + almost always what you want; `keep` leaves the \ + failed tree in place for you to look at." } - }, - "required": ["steps"] + } }) }, // The whole program travels as **one** argument, and that is the shape // rather than an encoding trick: a request is a verb and a list of // strings, and a program is structured. Serialised here, read by the - // verb, and every step inside it then checked by the machine against + // verb, and every request inside it then checked by the machine against // the same table a single request is checked against. calls: |arguments| { let mut program = serde_json::Map::new(); - let Some(steps) = arguments.get("steps") else { + let has_run = arguments + .get("run") + .and_then(Value::as_str) + .is_some_and(|source| !source.trim().is_empty()); + let has_steps = arguments + .get("steps") + .and_then(Value::as_array) + .is_some_and(|steps| !steps.is_empty()); + if !has_run && !has_steps { return Err( - "`thalyx_exec` needs `steps`, the operations to run in order".to_string(), + "`thalyx_exec` needs `run` — a short JavaScript program — or `steps`, \ + a list of operations. With neither there is nothing to do and \ + nothing to be transactional about" + .to_string(), ); - }; - if steps.as_array().is_none_or(Vec::is_empty) { + } + // Refused here rather than passed on, for the reason every other + // refusal in this file is: a shape this adapter can see is wrong + // costs the caller one corrected call, and a shape it passes on + // costs a round trip into the machine first. + if has_run && has_steps { return Err( - "`steps` is empty; there is nothing to do and nothing to be \ - transactional about" + "send `run` or `steps`, not both: they are two different ideas about \ + what to do, and which one was meant is not something the machine \ + will decide inside a transaction" .to_string(), ); } - program.insert("steps".to_string(), steps.clone()); - for name in ["label", "validate", "on_failure"] { + for name in ["label", "run", "steps", "validate", "on_failure"] { if let Some(value) = arguments.get(name) { program.insert(name.to_string(), value.clone()); } @@ -744,6 +877,7 @@ next step, that is a real decision and belongs in its own call.", }, Tool { name: "thalyx_evidence", + surface: Surface::Hot, verbs: &["evidence"], description: "\ Everything a thalyx_exec run did and did not send back: each step's full answer, \ @@ -776,6 +910,7 @@ id from the run; add `step` to get one step's answer whole.", }, Tool { name: "thalyx_changed", + surface: Surface::Legacy, verbs: &["attempt"], description: "\ What has changed in the workspace since the open attempt began — files made, \ @@ -787,6 +922,7 @@ with none, it answers that none is open.", }, Tool { name: "thalyx_edit", + surface: Surface::Legacy, verbs: &["edit"], description: "\ Change a file. For anything repeated or mechanical — a rename, a changed \ @@ -935,6 +1071,7 @@ one call and not two.", }, Tool { name: "thalyx_file", + surface: Surface::Legacy, verbs: &["make_file", "make_directory", "remove", "move", "copy"], description: "\ Create, delete, move or copy a file or directory in the workspace. Every answer \ diff --git a/crates/thalyx-mcp/tests/the_adapter_speaks_mcp.rs b/crates/thalyx-mcp/tests/the_adapter_speaks_mcp.rs index 3cc2c70..78cd519 100644 --- a/crates/thalyx-mcp/tests/the_adapter_speaks_mcp.rs +++ b/crates/thalyx-mcp/tests/the_adapter_speaks_mcp.rs @@ -43,7 +43,17 @@ fn thalyx_binary() -> std::path::PathBuf { here.join("thalyx") } +/// The whole stack, with every tool offered. +/// +/// `legacy` and not the default, because most of what these tests exercise is +/// the *adapter* — framing, refusals, composition — and those are questions +/// about a tool call rather than about which tools are advertised. Which tools +/// are advertised has its own test below, and it uses the default. fn start() -> Stack { + started_with("legacy") +} + +fn started_with(surface: &str) -> Stack { let home = tempfile::tempdir().expect("tempdir"); let workspace = home.path().join("project"); let store = home.path().join("store"); @@ -82,6 +92,7 @@ fn start() -> Stack { let server = Command::new(env!("CARGO_BIN_EXE_thalyx-mcp")) .args(["--connect", &socket.to_string_lossy()]) .args(["--wait", "10"]) + .args(["--surface", surface]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) @@ -315,3 +326,187 @@ fn asking_the_machine_what_it_is_takes_one_call_and_answers_three_questions() { assert_eq!(answers[1]["op"], serde_json::json!("state")); assert_eq!(answers[2]["op"], serde_json::json!("attempt")); } + +#[test] +fn the_default_surface_hands_a_model_three_tools_and_the_legacy_one_hands_it_all() { + // **The list is the prompt.** Every schema here arrives with every + // inference of every session, and a model that is shown fourteen low-level + // operations spends attention choosing between them before any work + // happens — which the research named as a hazard in its own right, not a + // matter of taste. + // + // Nothing was deleted. Everything the fourteen did is one line inside a + // `thalyx_exec` program, where it costs no schema until it is used; and the + // whole catalogue is still one flag away, because "the small surface is + // better" is a comparison and a comparison needs its other arm. + let mut stack = started_with("compact"); + let listed = stack.call(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list" + })); + let names: Vec = listed["result"]["tools"] + .as_array() + .expect("a list of tools") + .iter() + .filter_map(|tool| tool["name"].as_str().map(str::to_string)) + .collect(); + assert_eq!( + names, + ["thalyx_context", "thalyx_exec", "thalyx_evidence"], + "the default surface changed" + ); + + // And a program reaches what the fourteen reached. One call, and the + // things it does are the things that used to be their own tools. + // + // Rule 3: `hacer` opens a real boundary, which needs a real subvolume, so + // on a machine with no Btrfs this half says it did not run rather than + // pretending. `THALYX_REQUIRE_BTRFS_TESTS=1` turns the skip into a failure. + if !a_boundary_can_be_opened(&mut stack) { + let message = "NOT PROVEN: that a program reaches what the fourteen tools \ + reached — there is no Btrfs here, so no boundary can be opened."; + assert!( + std::env::var("THALYX_REQUIRE_BTRFS_TESTS").is_err(), + "{message}" + ); + eprintln!("{message}"); + return; + } + let answered = stack.call(serde_json::json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { + "name": "thalyx_exec", + "arguments": { + "label": "reaching what the tools reached", + "run": "const seen = thalyx.list('.');\n\ + const state = thalyx.state();\n\ + const one = thalyx.read('src/lib.rs');\n\ + return { listed: seen.ok, stated: state.ok, \ + read: one.ok, bytes: (one.text || '').length };", + "on_failure": "keep" + } + } + })); + let text = answered["result"]["content"][0]["text"] + .as_str() + .expect("an answer"); + let object: serde_json::Value = serde_json::from_str(text).expect("an object"); + assert_eq!( + object["returned"]["listed"], + serde_json::json!(true), + "{object:#}" + ); + assert_eq!( + object["returned"]["stated"], + serde_json::json!(true), + "{object:#}" + ); + assert_eq!( + object["returned"]["read"], + serde_json::json!(true), + "{object:#}" + ); + assert!( + object["returned"]["bytes"].as_u64().unwrap_or(0) > 0, + "{object:#}" + ); + // And the numbers: one external request, several operations inside it. + assert_eq!( + object["external_requests"], + serde_json::json!(1), + "{object:#}" + ); + assert!( + object["program_operations"].as_u64().unwrap_or(0) >= 3, + "{object:#}" + ); + + let whole = started_with("legacy"); + drop(whole); +} + +/// Whether this machine can give `hacer` the boundary it needs. +/// +/// Asked by *running* the smallest possible program rather than by looking at +/// the filesystem: what decides is whether the verb can open a snapshot, and a +/// test that inferred its own precondition from something adjacent is rule 5's +/// eighth entry. +fn a_boundary_can_be_opened(stack: &mut Stack) -> bool { + let answered = stack.call(serde_json::json!({ + "jsonrpc": "2.0", "id": 9000, "method": "tools/call", + "params": { + "name": "thalyx_exec", + "arguments": {"label": "is there a boundary", "run": "return 1;"} + } + })); + let text = answered["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default(); + serde_json::from_str::(text) + .map(|object| object["error"] != serde_json::json!("not_a_subvolume")) + .unwrap_or(false) +} + +#[test] +fn a_program_arrives_at_the_machine_as_a_program() { + // The adapter's own job, on the tool that matters most: `run` is a string + // of JavaScript with quotes and newlines in it, it travels as one argument + // through a line-oriented protocol, and it has to come out the other end + // byte for byte. A quote eaten anywhere on that path is a syntax error the + // model gets blamed for. + let mut stack = start(); + if !a_boundary_can_be_opened(&mut stack) { + let message = "NOT PROVEN: that a program survives the adapter byte for byte — \ + there is no Btrfs here, so no boundary can be opened."; + assert!( + std::env::var("THALYX_REQUIRE_BTRFS_TESTS").is_err(), + "{message}" + ); + eprintln!("{message}"); + return; + } + let answered = stack.call(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { + "name": "thalyx_exec", + "arguments": { + "label": "quotes and newlines", + "run": "const said = \"a \\\"quoted\\\" thing\";\nreturn { said, lines: 2 };", + "on_failure": "keep" + } + } + })); + let text = answered["result"]["content"][0]["text"] + .as_str() + .expect("an answer"); + let object: serde_json::Value = serde_json::from_str(text).expect("an object"); + assert_eq!( + object["finish"], + serde_json::json!("returned"), + "{object:#}" + ); + assert_eq!( + object["returned"]["said"], + serde_json::json!("a \"quoted\" thing"), + "{object:#}" + ); +} + +#[test] +fn sending_a_program_and_a_list_of_steps_is_refused_before_the_machine_sees_it() { + let mut stack = start(); + let answered = stack.call(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { + "name": "thalyx_exec", + "arguments": { + "run": "return 1;", + "steps": [{"verb": "state"}] + } + } + })); + let text = answered["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default(); + assert!(text.contains("both"), "{answered:#}"); + assert_eq!(answered["result"]["isError"], serde_json::json!(true)); +} diff --git a/crates/thalyx-program/Cargo.toml b/crates/thalyx-program/Cargo.toml new file mode 100644 index 0000000..a69896f --- /dev/null +++ b/crates/thalyx-program/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "thalyx-program" +description = "The programmable transaction: a short program from a model, run locally with capabilities and no authority" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +# QuickJS, compiled in. +# +# The same argument the workspace makes for `rusqlite`'s `bundled`, for the +# same reason: the image holds the Linux kernel and one program, so there is no +# shared library on disk to link against and a static musl build cannot borrow +# the host's. Compiling it in is what makes the runtime live *inside* `thalyx` +# rather than beside it. +# +# Default features only — `bindgen` is deliberately off: `rquickjs-sys` ships +# generated bindings for `x86_64-unknown-linux-musl`, which is the image's +# target, and turning bindgen on would make the image build need libclang. +rquickjs = "0.12" + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/thalyx-program/src/bind.rs b/crates/thalyx-program/src/bind.rs new file mode 100644 index 0000000..87790d3 --- /dev/null +++ b/crates/thalyx-program/src/bind.rs @@ -0,0 +1,576 @@ +//! Where the engine meets the machine: five host functions and a prelude. +//! +//! Everything a program can do that is not arithmetic goes through one of the +//! functions bound here, and every one of them is a call into [`Machine`], +//! which is implemented by whoever owns the transaction. Nothing in this file +//! opens a file, resolves a path, starts a process or decides what an answer +//! means. +//! +//! ## Why the ergonomics are JavaScript and not Rust +//! +//! `thalyx.read(path)` is one line of [`PRELUDE`] over `thalyx.call`. Writing +//! it in Rust instead would mean a second host binding per convenience — a +//! second thing to keep in step with the verb table, a second place a boundary +//! check could be forgotten, and a second surface to describe. The prelude is +//! *inside* the sandbox, has no more authority than the program that follows +//! it, and is read by the same engine. +//! +//! Which is the same argument the MCP surface makes one level up: an operation +//! does not need its own schema to be reachable, and a capability that can be +//! composed cheaply should be composed rather than enumerated. + +use crate::{Ask, Finish, Halt, Limits, Served}; +use rquickjs::{Context, Ctx, Function, Object, Runtime, Value as JsValue}; +use serde_json::{Value, json}; +use std::sync::mpsc::{Receiver, SyncSender}; +use std::time::Instant; + +pub const PRELUDE: &str = r#" +(function () { + "use strict"; + + // Arguments to the machine are strings — a request is a verb and a list of + // them. Numbers and booleans are coerced because writing `line + 1` is + // natural; everything else is refused **by name**, because a silent + // `String(undefined)` sends the machine the word "undefined" as a path. + function word(value, where) { + const kind = typeof value; + if (kind === "string") { return value; } + if (kind === "number" || kind === "boolean") { return String(value); } + throw new TypeError( + "thalyx." + where + ": every argument must be a string, and one is " + + (value === null ? "null" : kind) + ); + } + + // Nothing is dropped here, and that is the fix rather than the tidy version. + // Skipping an `undefined` turned `thalyx.read(missing)` into `read` with no + // arguments, which the machine answered about the current directory — a + // well-formed request, a plausible answer, and an empty variable nobody was + // told about. + function words(given, where) { + const out = []; + for (const value of given) { + if (Array.isArray(value)) { + for (const inner of value) { out.push(word(inner, where)); } + } else { + out.push(word(value, where)); + } + } + return out; + } + + thalyx.call = function (verb, args) { + return thalyx.__call(word(verb, "call"), words(args === undefined ? [] : args, "call")); + }; + + // ── looking ────────────────────────────────────────────────────────────── + thalyx.state = () => thalyx.call("state", []); + thalyx.where = () => thalyx.call("where", []); + thalyx.list = (path, ...options) => thalyx.call("list", [path === undefined ? "." : path, ...options]); + thalyx.read = (path) => thalyx.call("read", [path]); + thalyx.grep = (text, ...options) => thalyx.call("grep", [...options, text]); + thalyx.find = (pattern, ...options) => thalyx.call("find", [pattern, ...options]); + thalyx.symbol = (name, ...options) => thalyx.call("symbol", [name, ...options]); + thalyx.dependsOn = (path, ...options) => thalyx.call("depends_on", [path, ...options]); + thalyx.dependedOnBy = (path, ...options) => thalyx.call("depended_on_by", [path, ...options]); + thalyx.describe = (verb) => thalyx.call("describe", [verb]); + + // ── what a name is ─────────────────────────────────────────────────────── + thalyx.context = (query, ...options) => thalyx.call("context", [query, ...options]); + thalyx.rename = (from, to) => thalyx.call("rename", [from, to]); + + // ── changing the workspace ─────────────────────────────────────────────── + thalyx.edit = (path, action, ...rest) => thalyx.call("edit", [path, action, ...rest]); + thalyx.substitute = (path, before, after, ...more) => + thalyx.call("edit", [path, "sustituir", before, after, ...more]); + thalyx.write = (path, line, text) => thalyx.call("edit", [path, "cambiar", line, text]); + thalyx.append = (path, text) => thalyx.call("edit", [path, "poner", text]); + thalyx.makeFile = (...paths) => thalyx.call("make_file", paths); + thalyx.makeDirectory = (...paths) => thalyx.call("make_directory", paths); + thalyx.copy = (from, to) => thalyx.call("copy", [from, to]); + thalyx.move = (from, to) => thalyx.call("move", [from, to]); + thalyx.remove = (...paths) => thalyx.call("remove", paths); + + // ── reading a window rather than a file ────────────────────────────────── + // + // Inside the program a whole file is cheap, so this is not about cost — it + // is about writing the loop that looks at what surrounds a reference without + // spelling the slicing out every time. + thalyx.window = function (path, line, around) { + const answer = thalyx.read(path); + if (!answer.ok || typeof answer.text !== "string") { return answer; } + const lines = answer.text.split("\n"); + const reach = around === undefined ? 4 : around; + const from = Math.max(1, line - reach); + const through = Math.min(lines.length, line + reach); + return { + ok: true, + path: path, + from: from, + through: through, + text: lines.slice(from - 1, through).join("\n"), + }; + }; + + // ── premises ───────────────────────────────────────────────────────────── + // + // `mustWork` is the one an agent needs on every mutating call and forgets on + // every one: a verb that answered is not a verb that succeeded. + thalyx.mustWork = function (answer, what) { + thalyx.assert(answer && answer.ok === true, what, answer); + return answer; + }; + + // The same for a validation, whose "did it work" is a verdict and not `ok`. + // Three outcomes and not two: `not_proven` is neither, and a program that + // treated it as a pass would commit over a check that never ran. + thalyx.mustPass = function (record, what) { + thalyx.assert( + record && record.verdict === "passed", + what === undefined + ? ("a check did not hold: " + (record && record.summary)) + : what, + record + ); + return record; + }; + + Object.freeze(thalyx); +})(); +"#; + +/// The program, wrapped so that every way it can end is a value. +/// +/// The wrapper is JavaScript rather than Rust error handling because the two +/// ordinary endings — returning and throwing — both happen *inside* the engine, +/// and a `catch` there can tell them apart while an `Err` outside cannot. What +/// the Rust side still handles is the two endings the language cannot see: a +/// ceiling, and a latch. +fn wrapped(program: &str) -> String { + format!( + r#"(function () {{ + "use strict"; + try {{ + const value = (function () {{ +{program} + }})(); + if (value && typeof value.then === "function") {{ + return {{ kind: "threw", message: + "this runtime is synchronous: every thalyx call returns its answer " + + "directly and there is nothing here that awaits. Return the value " + + "itself rather than a promise." }}; + }} + return {{ kind: "returned", value: value === undefined ? null : value }}; + }} catch (error) {{ + // The message **and** the stack, in that order. + // + // QuickJS's `error.stack` is only the frames — unlike V8's, it does not + // begin with the message — so a wrapper that preferred `.stack` reported + // every failure as a list of line numbers with the reason missing. Found + // by a test that asserted the message said which argument was empty and + // got seven frames instead. Rule 5: the instrument includes the harness, + // and here the harness is somebody's memory of another engine. + const said = (error instanceof Error) + ? (String(error.name || "Error") + ": " + String(error.message) + + (error.stack ? "\n" + String(error.stack) : "")) + : (error && typeof error === "object") ? JSON.stringify(error) + : String(error); + return {{ kind: "threw", message: said }}; + }} +}})()"# + ) +} + +/// Turn a JavaScript value into JSON, or say it could not be. +/// +/// `JSON.stringify` and not a hand-written walk: a value with a cycle in it, a +/// `BigInt`, a function or a `Symbol` are all things a program can produce, and +/// the engine's own serialiser already has a settled answer for every one of +/// them. Writing a second one would be rule 6 in the small — do not +/// re-implement a format the tool will tell you about. +fn to_json(value: &JsValue<'_>) -> Value { + if value.is_undefined() || value.is_null() { + return Value::Null; + } + let ctx = value.ctx(); + match ctx.json_stringify(value.clone()) { + Ok(Some(text)) => text + .to_string() + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or(Value::Null), + // A value JSON cannot carry — a function, a `Symbol`. Named rather than + // dropped: a program that returned one should be told that is what + // happened. + Ok(None) | Err(_) => json!({"unrepresentable": true}), + } +} + +fn from_json<'js>(ctx: &Ctx<'js>, value: &Value) -> rquickjs::Result> { + ctx.json_parse(value.to_string()) +} + +/// The engine's half of the conversation with the machine. +/// +/// Cloned into every bound function. Holding the channel ends rather than the +/// machine itself is what makes these closures `'static`, which is what +/// `rquickjs` requires and what lets a real transaction — which borrows a +/// store, a session and a workspace — be the thing on the other end. +struct Line { + asking: SyncSender, + answered: Receiver, + halt: Halt, +} + +impl Line { + /// Ask the machine one thing and wait for the answer. + /// + /// A [`Served::Stop`] halts the engine **and** throws. Both, because they + /// do different jobs: the throw ends the statement the program is in, and + /// the halt ends the run whatever the program does with the throw — + /// including catching it, which is the habit this exists to be safe + /// against. + fn ask<'js>(&self, ctx: &Ctx<'js>, ask: Ask) -> rquickjs::Result> { + if self.halt.is_set() { + return Err(ctx.throw(from_json( + ctx, + &json!({"stopped": "the run has already been stopped"}), + )?)); + } + if self.asking.send(ask).is_err() { + self.halt.set(); + return Err(ctx.throw(from_json( + ctx, + &json!({"stopped": "the machine stopped answering"}), + )?)); + } + match self.answered.recv() { + Ok(Served::Answer(answer)) => from_json(ctx, &answer), + Ok(Served::Nothing) => Ok(JsValue::new_undefined(ctx.clone())), + Ok(Served::Stop(why)) => { + self.halt.set(); + Err(ctx.throw(from_json(ctx, &json!({"stopped": why}))?)) + } + Err(_) => { + self.halt.set(); + Err(ctx.throw(from_json( + ctx, + &json!({"stopped": "the machine stopped answering"}), + )?)) + } + } + } +} + +/// Build the engine, bind the capabilities, run the program. +/// +/// Runs on the program's own thread; everything it needs from the machine +/// travels over `asking`/`answered`. +pub(crate) fn execute( + source: &str, + asking: SyncSender, + answered: Receiver, + verbs: Vec, + limits: &Limits, + started: Instant, +) -> Finish { + let runtime = match Runtime::new() { + Ok(runtime) => runtime, + Err(error) => { + return Finish::Refused { + message: format!("the runtime could not be built: {error}"), + }; + } + }; + runtime.set_memory_limit(limits.memory_bytes); + runtime.set_max_stack_size(limits.stack_bytes); + + // The interrupt, which is the only thing that can end a loop with nothing + // in it. It reads an atomic rather than any of the run's state, because it + // fires while a host function is blocked waiting for the machine — and a + // handler that waited for a lock would deadlock the first time a program + // called anything. + let halt = Halt::default(); + let deadline = started + limits.wall; + let ticks = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + // Whether the engine was ever told to stop. + // + // Kept separately from `halt` because of what QuickJS does with an + // interrupt: it raises an ordinary JavaScript exception, and an ordinary + // JavaScript exception is **catchable**. So a program inside a `try` — or + // for that matter the wrapper's own `catch` — turns "this run was + // terminated" into "the program threw", which is a sentence about the + // program rather than about the ceiling it hit. + // + // Found by the test for `while (true) {}`, which reported `Threw: + // interrupted` and would have let every ceiling in this file be reported + // as the program's own fault. + let interrupted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + { + let halt = halt.clone(); + let ticks = ticks.clone(); + let interrupted = interrupted.clone(); + let budget = limits.ticks; + runtime.set_interrupt_handler(Some(Box::new(move || { + let seen = ticks.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + let stop = halt.is_set() || Instant::now() >= deadline || seen > budget; + if stop { + interrupted.store(true, std::sync::atomic::Ordering::Relaxed); + } + stop + }))); + } + + // The language, and **no clock**. + // + // `Date` and `performance` are left out on purpose, and the reason is not + // security — a timer reaches nothing. It is that a program which can read + // the clock can behave differently depending on how busy this machine is, + // and a transaction whose outcome depends on that is one nobody can + // reproduce from its evidence. The runtime owns the wall time; the program + // is not asked to have an opinion about it. + // + // Everything else the language has stays: a model writing JavaScript + // expects `Map`, `RegExp`, `Proxy` and typed arrays to be there, and none + // of them can reach anything. + let context = match Context::builder() + .with::<( + rquickjs::context::intrinsic::Eval, + rquickjs::context::intrinsic::RegExpCompiler, + rquickjs::context::intrinsic::RegExp, + rquickjs::context::intrinsic::Json, + rquickjs::context::intrinsic::Proxy, + rquickjs::context::intrinsic::MapSet, + rquickjs::context::intrinsic::TypedArrays, + rquickjs::context::intrinsic::Promise, + rquickjs::context::intrinsic::WeakRef, + )>() + .build(&runtime) + { + Ok(context) => context, + Err(error) => { + return Finish::Refused { + message: format!("the context could not be built: {error}"), + }; + } + }; + + let line = std::rc::Rc::new(Line { + asking, + answered, + halt: halt.clone(), + }); + + context.with(|ctx| { + if let Err(error) = install(&ctx, &line, verbs) { + return Finish::Refused { + message: format!("the capabilities could not be bound: {error}"), + }; + } + if let Err(error) = ctx.eval::<(), _>(PRELUDE) { + return Finish::Refused { + message: format!( + "the prelude did not load, which is a defect in Thalyx and not in the \ + program: {}", + said(&ctx, &error) + ), + }; + } + + match ctx.eval::(wrapped(source)) { + // An interrupt beats whatever the wrapper managed to say. It is an + // ordinary catchable exception inside the engine, so a run that was + // terminated can arrive here looking like a program that threw — + // and reporting a ceiling as the program's mistake is the kind of + // wrong diagnosis rule 5 is about. + Ok(_) if interrupted.load(std::sync::atomic::Ordering::Relaxed) => Finish::Exhausted { + limit: limit_reached(started, limits, &ticks), + message: "the program was stopped before it finished".to_string(), + }, + Ok(value) => { + let value = to_json(&value); + match value.get("kind").and_then(Value::as_str) { + Some("returned") => Finish::Returned { + value: value.get("value").cloned().unwrap_or(Value::Null), + }, + Some("threw") => Finish::Threw { + message: value + .get("message") + .and_then(Value::as_str) + .unwrap_or("something with no message") + .to_string(), + }, + // Cannot happen through `wrapped`, which returns one of two + // shapes. Written so a future change to the wrapper is a + // refusal rather than a run reported as having returned + // nothing. + _ => Finish::Refused { + message: format!("the runtime produced a shape it does not know: {value}"), + }, + } + } + // Interrupted, out of memory, a stack overflow, or a program that + // is not JavaScript at all. Which one is decided here only when the + // serving side has nothing better to say: a latch — an assertion, a + // ceiling met inside a host call — also arrives as an interrupt, + // and `run` prefers the latch's reason because it is the useful one. + Err(error) => { + let stopped = + halt.is_set() || interrupted.load(std::sync::atomic::Ordering::Relaxed); + if matches!(error, rquickjs::Error::Exception) && !stopped { + // A syntax error in the program itself never reaches the + // `try` inside `wrapped`, because the whole wrapper failed + // to compile. It is the program's fault and is reported as + // such rather than as a ceiling nobody reached. + return Finish::Threw { + message: said(&ctx, &error), + }; + } + Finish::Exhausted { + limit: limit_reached(started, limits, &ticks), + message: format!("the program was stopped: {}", said(&ctx, &error)), + } + } + } + }) +} + +/// Which ceiling a stopped run reached, named in the units it is counted in. +/// +/// Asked of the clock and the counter rather than of the engine, because +/// QuickJS reports "interrupted" for every one of them and a report that said +/// only that would leave a person unable to tell a slow machine from a runaway +/// loop. +fn limit_reached( + started: Instant, + limits: &Limits, + ticks: &std::sync::atomic::AtomicU64, +) -> &'static str { + if started.elapsed() >= limits.wall { + return "wall"; + } + if ticks.load(std::sync::atomic::Ordering::Relaxed) > limits.ticks { + return "ticks"; + } + "memory" +} + +/// What the engine actually said, which for an exception is not the error type. +fn said(ctx: &Ctx<'_>, error: &rquickjs::Error) -> String { + if matches!(error, rquickjs::Error::Exception) { + let caught = ctx.catch(); + if let Some(exception) = caught.as_exception() { + return format!("{exception}"); + } + return to_json(&caught).to_string(); + } + error.to_string() +} + +/// Bind everything a program can do that is not arithmetic. +fn install<'js>( + ctx: &Ctx<'js>, + line: &std::rc::Rc, + verbs: Vec, +) -> rquickjs::Result<()> { + let thalyx = Object::new(ctx.clone())?; + + // ── the one door ──────────────────────────────────────────────────────── + { + let line = line.clone(); + let call = Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, + verb: String, + arguments: Vec| + -> rquickjs::Result> { + line.ask(&ctx, Ask::Request { verb, arguments }) + }, + )?; + thalyx.set("__call", call)?; + } + + // ── validation, which is the same checker the whole verb uses ─────────── + { + let line = line.clone(); + let validate = Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, check: JsValue<'js>| -> rquickjs::Result> { + let asked = to_json(&check); + line.ask(&ctx, Ask::Validate(asked)) + }, + )?; + thalyx.set("validate", validate)?; + } + + // ── what the tree really shows ────────────────────────────────────────── + { + let line = line.clone(); + let changed = Function::new( + ctx.clone(), + move |ctx: Ctx<'js>| -> rquickjs::Result> { line.ask(&ctx, Ask::Changed) }, + )?; + thalyx.set("changed", changed)?; + } + + // ── premises, which stop the run rather than raising an exception ─────── + { + let line = line.clone(); + let assert = Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, + holds: bool, + message: rquickjs::function::Opt, + detail: rquickjs::function::Opt>| + -> rquickjs::Result> { + let detail = detail.0.as_ref().map(to_json).unwrap_or(Value::Null); + let message = message + .0 + .unwrap_or_else(|| "an assertion did not hold".to_string()); + line.ask( + &ctx, + Ask::Assert { + holds, + message, + detail, + }, + ) + }, + )?; + thalyx.set("assert", assert)?; + } + + // ── asking for the model ──────────────────────────────────────────────── + { + let line = line.clone(); + let need = Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, + value: rquickjs::function::Opt>| + -> rquickjs::Result> { + let value = value.0.as_ref().map(to_json).unwrap_or(Value::Null); + line.ask(&ctx, Ask::NeedModel(value)) + }, + )?; + thalyx.set("needModel", need)?; + } + + // ── saying something, bounded ─────────────────────────────────────────── + { + let line = line.clone(); + let log = Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, text: String| -> rquickjs::Result> { + line.ask(&ctx, Ask::Log(text)) + }, + )?; + thalyx.set("log", log)?; + } + + // ── what this session can reach, so a program can look before it asks ── + thalyx.set("verbs", verbs)?; + + ctx.globals().set("thalyx", thalyx)?; + Ok(()) +} diff --git a/crates/thalyx-program/src/lib.rs b/crates/thalyx-program/src/lib.rs new file mode 100644 index 0000000..8be46a5 --- /dev/null +++ b/crates/thalyx-program/src/lib.rs @@ -0,0 +1,1161 @@ +//! The programmable transaction: a short program from a model, run here. +//! +//! `vault/03-Primitivas/Transaccion-Programable.md`, and the hypothesis it +//! serves is `vault/09-Notas-Tecnicas/Trabajo-Entre-Inferencias.md`. +//! +//! ## What was missing +//! +//! `hacer` already took several requests and ran them inside one reversible +//! boundary. But the requests were a `Vec`: **the model had to know every +//! operation and every argument before anything ran.** That is batching, and +//! batching cannot express the thing an agent actually spends its turns on — +//! *ask, look at the answer, decide what to do next*. A rename across the +//! references a query just returned, an edit applied only to the ones whose +//! surrounding lines say something, a validation whose result decides whether +//! the next thing happens at all: none of that is writable in advance, so every +//! one of those decisions was a round trip to a frontier model, dragging the +//! whole conversation with it. +//! +//! So the unit is now a **program**. One inference produces a short piece of +//! code; the machine runs it locally, with variables, loops, conditions and +//! assertions; the model is asked again when the program finishes, when it +//! explicitly asks for judgment, or when it genuinely cannot continue. +//! +//! ## Why JavaScript, and why QuickJS +//! +//! The language is not sacred and the properties are. What decided it: +//! +//! - **A frontier model already writes it.** The one thing a bespoke Thalyx +//! scripting language guarantees is that every model using it is writing a +//! language it has never seen, from a description in a tool schema. That is +//! the most expensive possible way to spend the very attention this whole +//! mechanism exists to save. JavaScript costs zero prompt. +//! - **QuickJS has no ambient authority to take away.** Its core is a language +//! and nothing else: no `fs`, no `net`, no `process`, no `require`, no +//! `fetch`, no clock that reaches the outside. A program starts able to do +//! arithmetic, and the only things it can reach are the functions bound here. +//! That is the opposite of embedding a shell, where the work would be +//! subtracting authority from something that starts with all of it — and +//! subtracting is the direction that fails quietly. +//! - **It is C99 with no dependencies**, so it compiles into a static musl +//! binary. Rule 12: the binary that gets verified has to be the binary that +//! ships, and a runtime that needed a shared library could not be in the +//! image at all. This is the same argument the workspace already makes for +//! compiling SQLite in. +//! - **It can be stopped.** An interrupt handler, a memory ceiling and a stack +//! ceiling are part of the engine, so `while (true) {}` terminates for a +//! reason rather than by luck. +//! +//! ## The program is not the authority +//! +//! It is untrusted code written by a language model. Everything it can do it +//! does by calling [`Machine`], which is implemented **above** this crate by +//! whoever owns the transaction — and every one of those calls goes through the +//! same door a single request goes through, against the same workspace +//! boundary, inside the same snapshot. This crate: +//! +//! - opens nothing, reads nothing, writes nothing, and starts no process; +//! - has no filesystem or network API to bind, because QuickJS ships none; +//! - never decides what an answer *means* — a refusal comes back as a value +//! with `ok: false` in it, and the program branches on it like any other. +//! +//! What a program can reach is exactly the union of what its calls could have +//! reached one at a time. If that were not true this crate would be the +//! parallel API `Agentes-Externos.md` forbids. +//! +//! ## Stopping is enforced twice +//! +//! An assertion that only threw a JavaScript exception could be caught by the +//! program that failed it — `try { thalyx.assert(false) } catch {}` — and the +//! run would carry on past the thing that was supposed to end it. So a failed +//! assertion **latches**: it is recorded on the Rust side, it throws, and from +//! that moment the interrupt handler stops the engine and every host call +//! refuses. A program cannot catch its way past a stop, because the thing that +//! stops it is not in the language. + +use serde_json::{Value, json}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +mod bind; + +pub use bind::PRELUDE; + +/// What a program is allowed to ask the machine for. +/// +/// Four questions and no more, and every one of them is answered by code that +/// already existed for the single-request path. A fifth would have to be +/// justified as a *capability*, not as a convenience: the way to give programs +/// more reach is to expose more verbs to [`Machine::request`], which is one +/// list in one place that a person can read. +pub trait Machine { + /// One Thalyx request — a verb and its arguments — and whatever it + /// answered. + /// + /// Returns the verb's own object, verbatim, refusals included. A refusal is + /// `{"ok": false, …}` and is a value the program branches on; turning it + /// into an error here would make every mistake a program makes end the + /// program, which is the opposite of being able to write `if`. + fn request(&mut self, verb: &str, arguments: &[String]) -> Value; + + /// One validation, through the same checker the whole verb uses. + fn validate(&mut self, check: &Value) -> Value; + + /// What the tree really shows changed since the boundary opened. + /// + /// Observed and not remembered: what a call *said* it changed is a claim by + /// the call, and this is the filesystem. + fn changed(&mut self) -> Value; + + /// How many processes have been started under confinement so far. + /// + /// Read by the runtime rather than counted here, because the launches + /// happen inside `request` and `validate` and only the machine knows about + /// them. It is what the process ceiling is checked against. + fn process_launches(&self) -> usize; + + /// The verb ids this session may reach, for the program to look at. + fn verbs(&self) -> Vec; +} + +/// What one program may spend. +/// +/// A static list of steps had one bound — how many steps — and that was enough +/// for something that could not loop. A program can, so every resource it can +/// consume needs a ceiling, and each is separate because a machine that has one +/// of them to spare and not another must be able to say so. +/// +/// Every default here is deliberately generous enough that no honest program +/// meets it and small enough that a runaway one dies in seconds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Limits { + /// How long the program may run in total. + /// + /// **The one that catches `while (true) {}`.** Checked by the engine's + /// interrupt handler, which QuickJS calls during bytecode execution, so a + /// loop that never allocates and never calls anything still stops. + pub wall: Duration, + /// How much the engine may allocate. Reaching it is an engine error, not a + /// kill: the program is unwound and the run is reported. + pub memory_bytes: usize, + /// How deep the JavaScript stack may go, so unbounded recursion is a + /// refusal rather than this process's own stack overflowing. + pub stack_bytes: usize, + /// How many times the program may ask the machine for anything. + /// + /// The successor to `MOST_STEPS`, and it counts *calls* rather than lines: + /// a loop over two hundred references is two hundred requests however + /// short the source is. + pub calls: usize, + /// How many processes the whole run may start under confinement. + /// + /// A fork bomb inside a program is not possible — there is nothing in the + /// language to fork with — but a loop calling a validation that compiles is + /// a process explosion by a slower route, and it is the same ceiling. + pub process_launches: usize, + /// How many bytes of answers the program may take in. + /// + /// Bounded because a program is welcome to read a hundred files and this + /// keeps a loop over a huge tree from becoming this process's memory. It is + /// *not* the model's budget: none of these bytes leave the machine. + pub answer_bytes: usize, + /// How much the program may write with `thalyx.log`. + pub log_bytes: usize, + /// How large the value the program returns may be. + /// + /// The one ceiling that is about the model's context rather than about this + /// machine, and the reason it is a refusal rather than a truncation: an + /// answer cut in half is an answer a model will act on believing it is + /// whole. The whole of it is in the evidence either way. + pub returned_bytes: usize, + /// How many times the interrupt handler may fire before the run is + /// stopped. + /// + /// A second ceiling beside the clock, and a different kind: the clock + /// measures how busy the machine is, and this measures how much the program + /// did. A run that fails this one fails it identically on a fast machine + /// and a slow one, which is what makes a limit reportable. + pub ticks: u64, +} + +impl Default for Limits { + fn default() -> Self { + Self { + wall: Duration::from_secs(120), + memory_bytes: 128 * 1024 * 1024, + stack_bytes: 1024 * 1024, + calls: 512, + process_launches: 32, + answer_bytes: 64 * 1024 * 1024, + log_bytes: 64 * 1024, + returned_bytes: 32 * 1024, + ticks: 200_000_000, + } + } +} + +/// How a run ended. +/// +/// Five arms and not two, because "it worked" and "it did not" is exactly the +/// distinction that loses the two interesting cases: a program that asked for +/// judgment did not fail, and a program that hit a ceiling did not produce a +/// wrong answer. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum Finish { + /// It ran to the end and returned this. + Returned { value: Value }, + /// It decided the next decision is not one a machine should make. + /// + /// The shape the ambiguity contract is answered in: three candidates come + /// back, the program sees `resolution === "ambiguous"`, and it stops + /// **without having mutated anything** rather than guessing. Not a failure + /// — the transaction commits or rolls back on the usual rules — and never + /// silently a success either. + NeedsModel { value: Value }, + /// An assertion said no. Everything after it did not run. + Assertion { message: String, detail: Value }, + /// The program threw something it did not catch. + Threw { message: String }, + /// A ceiling was reached. Names which one, in the units it is counted in. + Exhausted { + limit: &'static str, + message: String, + }, + /// The program could not be read or the engine could not be built. + Refused { message: String }, +} + +impl Finish { + /// Whether the transaction should treat this as the program having + /// succeeded. + /// + /// `needs_model` is **not** a success: the program stopped short of what it + /// was asked to do, and a commit here would keep half a change and report + /// it as finished work. It is not a failure either, and the difference is + /// visible in the word rather than in the outcome. + pub fn went_through(&self) -> bool { + matches!(self, Finish::Returned { .. }) + } + + pub fn word(&self) -> &'static str { + match self { + Finish::Returned { .. } => "returned", + Finish::NeedsModel { .. } => "needs_model", + Finish::Assertion { .. } => "assertion", + Finish::Threw { .. } => "threw", + Finish::Exhausted { .. } => "exhausted", + Finish::Refused { .. } => "refused", + } + } + + /// One sentence, for the answer that goes back. + pub fn why(&self) -> String { + match self { + Finish::Returned { .. } => "the program ran to the end".to_string(), + Finish::NeedsModel { .. } => { + "the program stopped and asked for a decision it would not make".to_string() + } + Finish::Assertion { message, .. } => format!("an assertion did not hold: {message}"), + Finish::Threw { message } => format!("the program threw: {message}"), + Finish::Exhausted { message, .. } => message.clone(), + Finish::Refused { message } => message.clone(), + } + } +} + +use serde::Serialize; + +/// One thing the program asked the machine for, as the evidence records it. +/// +/// The answer is kept whole. **This is the half that does not go back to the +/// model** — it is the compression's other side, and an evidence record that +/// only kept summaries would leave a caller unable to audit the run it is being +/// asked to trust. +#[derive(Debug, Clone, Serialize)] +pub struct CallRecord { + /// `request`, `validate` or `changed`. + pub kind: &'static str, + pub verb: String, + pub arguments: Vec, + pub ok: bool, + pub answer: Value, +} + +/// What one run cost, as numbers. +#[derive(Debug, Clone, Default, Serialize)] +pub struct ProgramMetrics { + /// Requests the program made through [`Machine::request`]. + pub requests: usize, + /// Validations it asked for. + pub validations: usize, + /// Times it observed the tree. + pub observations: usize, + /// Assertions it made, held or not. The count of *checked* premises, which + /// is the number the early-failure claim is about. + pub assertions: usize, + /// Bytes of answers the machine handed the program. + /// + /// The numerator of the compression: this against `returned_bytes` is how + /// much of what the program looked at never crossed back. + pub answer_bytes: usize, + /// Times the engine's interrupt handler fired. + pub ticks: u64, + pub wall_ms: u128, +} + +/// Everything one run produced. +#[derive(Debug, Clone, Serialize)] +pub struct Outcome { + pub finish: Finish, + pub calls: Vec, + pub printed: Vec, + pub metrics: ProgramMetrics, +} + +impl Outcome { + /// The value the program meant to hand back, whatever shape it ended in. + pub fn value(&self) -> Value { + match &self.finish { + Finish::Returned { value } | Finish::NeedsModel { value } => value.clone(), + _ => Value::Null, + } + } +} + +/// Why a run stopped, latched on the serving side where the program cannot +/// reach it. +/// +/// The point of latching: a `try`/`catch` around an assertion would otherwise +/// let a program carry on past the thing that was meant to end it, and a +/// program written by a language model is exactly the kind of program that +/// wraps everything in `try`/`catch`. Once this is set the interrupt handler +/// stops the engine and every host call refuses, neither of which is in the +/// language. +#[derive(Debug, Clone)] +enum Stopped { + NeedsModel(Value), + Assertion { + message: String, + detail: Value, + }, + Exhausted { + limit: &'static str, + message: String, + }, +} + +/// What the engine asks the machine for. +/// +/// A message and not a function call, because the engine runs on **its own +/// thread**. Two reasons, and the second is the one that decided it: +/// +/// 1. `rquickjs` binds `'static` closures, and the machine a real transaction +/// hands over borrows a store, a session and a workspace. A channel is the +/// ordinary way to lend a borrow to a thread; the alternative was `unsafe`, +/// which in this codebase lives in `thalyx-syscall` and nowhere else. +/// 2. Untrusted code gets its own stack. A program that recurses without end +/// unwinds a stack that is not the one Thalyx is standing on. +#[derive(Debug)] +enum Ask { + Request { + verb: String, + arguments: Vec, + }, + Validate(Value), + Changed, + Assert { + holds: bool, + message: String, + detail: Value, + }, + NeedModel(Value), + Log(String), +} + +/// What the machine answers. +#[derive(Debug)] +enum Served { + /// The answer, whole. Refusals included: `{"ok": false, …}` is a value. + Answer(Value), + /// The run is over. The host function throws this and halts the engine, so + /// a program cannot catch its way past it. + Stop(String), + /// Nothing to hand back — a log line, or an assertion that held. + Nothing, +} + +/// Everything the serving side keeps about a run. +struct Held<'a> { + machine: &'a mut dyn Machine, + limits: Limits, + calls: Vec, + printed: Vec, + printed_bytes: usize, + metrics: ProgramMetrics, + stopped: Option, +} + +impl Held<'_> { + /// Note a stop, unless one is already noted. + /// + /// First one wins: a program interrupted by a ceiling should be reported as + /// having hit the ceiling, and whatever the unwinding does after that is + /// not the reason. + fn stop(&mut self, why: Stopped) { + if self.stopped.is_none() { + self.stopped = Some(why); + } + } + + fn spend(&mut self, answer: &Value) { + self.metrics.answer_bytes += answer.to_string().len(); + if self.metrics.answer_bytes > self.limits.answer_bytes { + self.stop(Stopped::Exhausted { + limit: "answer_bytes", + message: format!( + "the program has taken in {} bytes of answers, past the {} it may; \ + nothing was cut, the run was stopped", + self.metrics.answer_bytes, self.limits.answer_bytes + ), + }); + } + } + + /// Whether the run may still do anything, and why not if it may not. + fn may_continue(&mut self) -> Option { + if let Some(stopped) = &self.stopped { + return Some(match stopped { + Stopped::NeedsModel(_) => "the program has asked for the model".to_string(), + Stopped::Assertion { message, .. } => { + format!("an assertion did not hold: {message}") + } + Stopped::Exhausted { message, .. } => message.clone(), + }); + } + let spent = self.metrics.requests + self.metrics.validations + self.metrics.observations; + if spent >= self.limits.calls { + let message = format!( + "the program has asked the machine {spent} things, which is all {} it may", + self.limits.calls + ); + self.stop(Stopped::Exhausted { + limit: "calls", + message: message.clone(), + }); + return Some(message); + } + let launched = self.machine.process_launches(); + if launched >= self.limits.process_launches { + let message = format!( + "the program has started {launched} process(es), which is all {} it may", + self.limits.process_launches + ); + self.stop(Stopped::Exhausted { + limit: "process_launches", + message: message.clone(), + }); + return Some(message); + } + None + } + + /// Serve one question from the engine. + /// + /// **The only place a program's request becomes a machine operation.** Every + /// ceiling is checked before the work rather than after: a call that has + /// already run cannot be un-run, and a limit that only notices afterwards + /// is a limit that is always exceeded by one. + fn serve(&mut self, ask: Ask) -> Served { + match ask { + Ask::Log(text) => { + let room = self.limits.log_bytes.saturating_sub(self.printed_bytes); + if room > 0 { + // Cut, and *said* to be cut. Nothing is lost in silence, + // and a log line is the one thing here whose whole purpose + // is to be read by a person. + let line = if text.len() > room { + let mut end = room; + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + format!( + "{}… (cut: the program has logged all {} bytes it may)", + &text[..end], + self.limits.log_bytes + ) + } else { + text + }; + self.printed_bytes += line.len(); + self.printed.push(line); + } + Served::Nothing + } + Ask::Assert { + holds, + message, + detail, + } => { + self.metrics.assertions += 1; + if holds { + return Served::Nothing; + } + self.stop(Stopped::Assertion { + message: message.clone(), + detail, + }); + Served::Stop(message) + } + Ask::NeedModel(value) => { + self.stop(Stopped::NeedsModel(value)); + Served::Stop("the program asked for the model".to_string()) + } + Ask::Request { verb, arguments } => { + if let Some(why) = self.may_continue() { + return Served::Stop(why); + } + self.metrics.requests += 1; + // Through `Machine::request`, which above this crate is + // `external::one` — the same function, the same argument check, + // the same workspace boundary a single request goes through. A + // program is not a way to reach a verb that is not exposed. + let answer = self.machine.request(&verb, &arguments); + self.spend(&answer); + self.calls.push(CallRecord { + kind: "request", + verb, + arguments, + ok: went_well(&answer), + answer: answer.clone(), + }); + Served::Answer(answer) + } + Ask::Validate(check) => { + if let Some(why) = self.may_continue() { + return Served::Stop(why); + } + self.metrics.validations += 1; + let answer = self.machine.validate(&check); + self.spend(&answer); + self.calls.push(CallRecord { + kind: "validate", + verb: check + .get("check") + .and_then(Value::as_str) + .unwrap_or("check") + .to_string(), + arguments: Vec::new(), + ok: answer.get("verdict") == Some(&json!("passed")), + answer: answer.clone(), + }); + Served::Answer(answer) + } + Ask::Changed => { + if let Some(why) = self.may_continue() { + return Served::Stop(why); + } + self.metrics.observations += 1; + let answer = self.machine.changed(); + self.spend(&answer); + self.calls.push(CallRecord { + kind: "changed", + verb: "changed".to_string(), + arguments: Vec::new(), + ok: true, + answer: answer.clone(), + }); + Served::Answer(answer) + } + } + } +} + +/// Whether the answer counts as the thing having worked. +/// +/// A verb that answered is not a verb that succeeded: every refusal on this +/// surface is a well-formed object with `ok: false` in it, and reading "it +/// answered" as "it worked" is how a program carries on past the edit that did +/// not happen. +fn went_well(answer: &Value) -> bool { + answer.get("ok").and_then(Value::as_bool).unwrap_or(false) +} + +/// The flag the interrupt handler reads. +/// +/// An atomic rather than the run's state, because the handler fires *while* a +/// host function is waiting for an answer — which is most of the time — and a +/// handler that took a lock would deadlock the first time a program called +/// anything. +#[derive(Clone, Default)] +struct Halt(Arc); + +impl Halt { + fn set(&self) { + self.0.store(true, Ordering::Relaxed); + } + fn is_set(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} + +/// How much stack the engine's own thread gets. +/// +/// Comfortably more than [`Limits::stack_bytes`]'s default, so that the +/// ceiling a runaway recursion meets is QuickJS's — which is a refusal — and +/// never this process's, which is a crash with no report in it. +const ENGINE_STACK: usize = 16 * 1024 * 1024; + +/// Run a program and say what it did. +/// +/// Never panics and never returns an error: every way this can go wrong is one +/// of [`Finish`]'s arms, because the caller is a transaction that has already +/// taken a snapshot and has to decide commit or rollback about *something*. +pub fn run(source: &str, machine: &mut dyn Machine, limits: &Limits) -> Outcome { + let started = Instant::now(); + let verbs = machine.verbs(); + let mut held = Held { + machine, + limits: limits.clone(), + calls: Vec::new(), + printed: Vec::new(), + printed_bytes: 0, + metrics: ProgramMetrics::default(), + stopped: None, + }; + + let (asking, asked) = std::sync::mpsc::sync_channel::(0); + let (answering, answered) = std::sync::mpsc::sync_channel::(0); + + let outcome = std::thread::scope(|scope| { + let engine = std::thread::Builder::new() + .name("thalyx-program".to_string()) + .stack_size(ENGINE_STACK) + .spawn_scoped(scope, { + let limits = limits.clone(); + let source = source.to_string(); + move || bind::execute(&source, asking, answered, verbs, &limits, started) + }); + let engine = match engine { + Ok(engine) => engine, + Err(error) => { + return Finish::Refused { + message: format!("the program's thread could not be started: {error}"), + }; + } + }; + + // The serving loop, and it ends by itself: the engine holds the only + // sender, so the iterator finishes exactly when the engine thread is + // gone. A loop with its own idea of when to stop would be a second + // opinion about whether the program had finished. + for ask in asked { + let served = held.serve(ask); + if answering.send(served).is_err() { + break; + } + } + + match engine.join() { + Ok(finish) => finish, + // A panic in the engine thread is a defect in Thalyx and not in the + // program, and it is reported as one rather than as the program + // having failed. + Err(_) => Finish::Refused { + message: "the program's runtime stopped unexpectedly; this is a defect in \ + Thalyx and not in the program" + .to_string(), + }, + } + }); + + held.metrics.wall_ms = started.elapsed().as_millis(); + + // The latch wins over whatever the engine said. A run stopped by an + // assertion comes back from QuickJS as "interrupted", which is true and is + // not the reason anybody needs. + let finish = match held.stopped.take() { + Some(Stopped::NeedsModel(value)) => Finish::NeedsModel { value }, + Some(Stopped::Assertion { message, detail }) => Finish::Assertion { message, detail }, + Some(Stopped::Exhausted { limit, message }) => Finish::Exhausted { limit, message }, + None => outcome, + }; + + // Checked last, because a value is only too big once it exists — and + // refused rather than cut, so a model is never handed half an answer to act + // on. All of it is in the evidence. + let finish = match &finish { + Finish::Returned { value } | Finish::NeedsModel { value } => { + let size = value.to_string().len(); + if size > limits.returned_bytes { + Finish::Exhausted { + limit: "returned_bytes", + message: format!( + "the program returned {size} bytes and may return {}. It is not cut \ + short here — a halved answer is one a model acts on believing it is \ + whole — and the whole of it is in the evidence", + limits.returned_bytes + ), + } + } else { + finish + } + } + _ => finish, + }; + + Outcome { + finish, + calls: held.calls, + printed: held.printed, + metrics: held.metrics, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A machine that answers from a script, so the runtime can be tested + /// without a filesystem, a workspace or a transaction. + /// + /// Rule 8: a fake must model the property under test. The property here is + /// **that an answer from one call reaches the next one**, so this fake + /// answers differently depending on what it is asked — a fake that answered + /// a constant would make every control-flow test pass by accident. + #[derive(Default)] + pub(crate) struct Recorder { + pub asked: Vec<(String, Vec)>, + pub launches: usize, + } + + impl Machine for Recorder { + fn request(&mut self, verb: &str, arguments: &[String]) -> Value { + self.asked.push((verb.to_string(), arguments.to_vec())); + match verb { + "read" => json!({ + "ok": true, "op": "read", + "path": arguments.first().cloned().unwrap_or_default(), + "text": format!("line one\\nold_api in {}\\nline three\\n", + arguments.first().cloned().unwrap_or_default()), + }), + "grep" => json!({"ok": true, "op": "grep", "total": 2}), + "nope" => json!({"ok": false, "op": "nope", "error": "not_exposed"}), + other => json!({"ok": true, "op": other, "arguments": arguments}), + } + } + fn validate(&mut self, check: &Value) -> Value { + self.launches += 1; + json!({"check": check, "verdict": "passed", "summary": "the fake checked"}) + } + fn changed(&mut self) -> Value { + json!({"count": 2, "paths": ["a.rs", "b.rs"]}) + } + fn process_launches(&self) -> usize { + self.launches + } + fn verbs(&self) -> Vec { + vec!["read".into(), "edit".into(), "grep".into()] + } + } + + fn ran(source: &str) -> Outcome { + let mut machine = Recorder::default(); + run(source, &mut machine, &Limits::default()) + } + + #[test] + fn a_value_returned_by_the_program_comes_back() { + let outcome = ran("return { hello: 1 + 1 };"); + assert_eq!( + outcome.finish, + Finish::Returned { + value: json!({"hello": 2}) + } + ); + } + + #[test] + fn a_program_that_returns_nothing_returns_null_rather_than_failing() { + // `undefined` is not JSON, and a runtime that turned "the program ended + // without a return" into an error would make the commonest shape of a + // program written for effect a failure. + let outcome = ran("thalyx.log('done');"); + assert_eq!(outcome.finish, Finish::Returned { value: Value::Null }); + assert_eq!(outcome.printed, vec!["done".to_string()]); + } + + #[test] + fn an_endless_loop_is_stopped_by_the_clock() { + // The claim a static list of steps never had to make. Nothing in this + // program allocates or calls anything, so only the engine's own + // interrupt can end it. + let mut machine = Recorder::default(); + let limits = Limits { + wall: Duration::from_millis(200), + ..Limits::default() + }; + let started = Instant::now(); + let outcome = run("while (true) {}", &mut machine, &limits); + assert!( + matches!(&outcome.finish, Finish::Exhausted { limit, .. } if *limit == "wall"), + "{:?}", + outcome.finish + ); + assert!( + started.elapsed() < Duration::from_secs(10), + "{:?}", + started.elapsed() + ); + } + + #[test] + fn a_loop_that_catches_its_own_interruption_is_still_stopped() { + // QuickJS raises an interrupt as an ordinary JavaScript exception, + // which means a program can catch it. So the adversarial shape is not + // `while (true) {}` — it is a program that swallows being terminated + // and keeps going, which is what a model that wraps everything in + // `try`/`catch` writes without meaning to. + let mut machine = Recorder::default(); + let limits = Limits { + wall: Duration::from_millis(200), + ..Limits::default() + }; + let started = Instant::now(); + let outcome = run( + "while (true) { try { for (let i = 0; i < 1e6; i++) {} } catch (e) { } }", + &mut machine, + &limits, + ); + assert!( + matches!(outcome.finish, Finish::Exhausted { .. }), + "{:?}", + outcome.finish + ); + assert!( + started.elapsed() < Duration::from_secs(20), + "it took {:?} to stop a loop that catches its own interruption", + started.elapsed() + ); + } + + #[test] + fn allocating_without_end_is_a_refusal_rather_than_this_machine_running_out() { + let mut machine = Recorder::default(); + let limits = Limits { + memory_bytes: 4 * 1024 * 1024, + wall: Duration::from_secs(20), + ..Limits::default() + }; + let outcome = run( + "const held = []; for (;;) { held.push(new Array(50000).fill('x')); } ", + &mut machine, + &limits, + ); + assert!( + matches!( + outcome.finish, + Finish::Exhausted { .. } | Finish::Threw { .. } + ), + "{:?}", + outcome.finish + ); + } + + #[test] + fn a_program_cannot_reach_the_machine_by_any_route_but_the_bound_one() { + // Not a proof of the sandbox — the proof is that QuickJS has no I/O to + // bind. What this catches is a future change: a convenience added to + // the prelude, or a global left behind by a binding, that hands a + // program something the verb table never approved. + let outcome = ran( + "const names = Object.getOwnPropertyNames(globalThis).sort(); + return names.filter((n) => !['thalyx'].includes(n) && + typeof globalThis[n] === 'object' && + globalThis[n] !== null);", + ); + let value = outcome.value(); + let reachable = value.as_array().cloned().unwrap_or_default(); + // Only the language's own namespace objects, and the ones QuickJS + // defines are `Math`, `JSON`, `Reflect` and `globalThis` itself. A new + // name here is a new thing a program can touch and wants reading. + let names: Vec<&str> = reachable.iter().filter_map(Value::as_str).collect(); + for name in &names { + assert!( + ["Math", "JSON", "Reflect", "Atomics", "globalThis"].contains(name), + "`{name}` is reachable from a program and is not one of the language's own \ + namespaces: {names:?}" + ); + } + // And the two that were reachable until 2026-08-30 and are not now. + // Neither is authority; both are a clock, and a program that can read + // one can behave differently depending on how busy this machine is — + // which makes a transaction nobody can reproduce from its evidence. + for clock in ["Date", "performance"] { + let outcome = ran(&format!("return typeof {clock};")); + assert_eq!( + outcome.value(), + json!("undefined"), + "a program can read the clock through `{clock}`" + ); + } + } + + #[test] + fn arguments_that_are_not_words_are_refused_by_name() { + // A silent `String(undefined)` would send the machine the *word* + // "undefined" as a path — a request that is well formed, is refused for + // the wrong reason, and costs a caller a round trip to work out that + // its variable was empty. + let mut machine = Recorder::default(); + let outcome = run( + "let missing; return thalyx.read(missing);", + &mut machine, + &Limits::default(), + ); + assert!( + matches!(&outcome.finish, Finish::Threw { message } if message.contains("undefined")), + "{:?}", + outcome.finish + ); + assert!(machine.asked.is_empty(), "{:?}", machine.asked); + } + + #[test] + fn a_loop_that_calls_forever_runs_out_of_calls() { + // The other runaway, and the one that costs the *machine* rather than + // the clock: a program looping over requests. `MOST_STEPS` used to + // bound this by construction; a language cannot be bounded by + // construction, so it is bounded by counting. + let mut machine = Recorder::default(); + let limits = Limits { + calls: 12, + ..Limits::default() + }; + let outcome = run( + "for (let i = 0; i < 1000; i++) { thalyx.read('f' + i); } return 'never';", + &mut machine, + &limits, + ); + assert!( + matches!(&outcome.finish, Finish::Exhausted { limit, .. } if *limit == "calls"), + "{:?}", + outcome.finish + ); + assert_eq!( + machine.asked.len(), + 12, + "the ceiling let extra calls through" + ); + } + + #[test] + fn an_assertion_that_fails_cannot_be_caught_by_the_program() { + // **The latch.** A program written by a language model wraps things in + // `try`/`catch`, and an assertion that were only an exception would be + // swallowed by exactly that habit — leaving a run that carried on past + // the premise it had just disproved, and committed. + let mut machine = Recorder::default(); + let outcome = run( + "try { thalyx.assert(1 === 2, 'one is not two'); } catch (e) { } + thalyx.read('after.rs'); + return 'the program continued';", + &mut machine, + &Limits::default(), + ); + assert!( + matches!(&outcome.finish, Finish::Assertion { message, .. } if message.contains("one is not two")), + "{:?}", + outcome.finish + ); + assert!( + machine.asked.is_empty(), + "the machine was asked for something after the assertion failed: {:?}", + machine.asked + ); + } + + #[test] + fn an_assertion_that_holds_costs_nothing_and_is_counted() { + let outcome = + ran("thalyx.assert(true, 'fine'); thalyx.assert(1 < 2, 'also fine'); return 'ok';"); + assert_eq!(outcome.finish, Finish::Returned { value: json!("ok") }); + assert_eq!(outcome.metrics.assertions, 2); + } + + #[test] + fn a_program_can_ask_for_the_model_without_having_failed() { + let outcome = ran("return thalyx.needModel({ candidates: [1, 2, 3] });"); + assert_eq!( + outcome.finish, + Finish::NeedsModel { + value: json!({"candidates": [1, 2, 3]}) + } + ); + assert!(!outcome.finish.went_through()); + assert_eq!(outcome.finish.word(), "needs_model"); + } + + #[test] + fn nothing_of_the_host_is_reachable_from_a_program() { + // The claim that makes "untrusted code" true rather than hopeful. None + // of these exist in QuickJS's core; the test is here so that a future + // change which adds a convenience module is caught by something rather + // than by nobody. + for name in [ + "require", + "process", + "fetch", + "XMLHttpRequest", + "WebAssembly", + "std", + "os", + "Deno", + "globalThis.process", + "import", + ] { + let outcome = ran(&format!("return typeof {name};")); + let value = outcome.value(); + assert!( + value == json!("undefined") || matches!(outcome.finish, Finish::Threw { .. }), + "`{name}` is reachable from a program: {outcome:?}" + ); + } + } + + #[test] + fn a_refusal_is_a_value_the_program_can_branch_on() { + // Not an error. A verb that refuses is information, and a program that + // could not read it would have to end at the first mistake it made — + // which is the thing that costs a round trip. + let outcome = ran("const answer = thalyx.call('nope', []); + if (answer.ok) { return 'wrong'; } + return { caught: answer.error };"); + assert_eq!( + outcome.finish, + Finish::Returned { + value: json!({"caught": "not_exposed"}) + } + ); + } + + #[test] + fn an_answer_from_one_call_decides_whether_a_later_one_happens() { + // **The defining property of the whole sprint**, at the smallest scale + // it can be stated: the machine answered something, the program read + // it, and what it read is what determined the next request. A + // hardcoded list of steps cannot express this, which is why the fake + // above answers differently per verb rather than answering a constant. + let mut machine = Recorder::default(); + let outcome = run( + "const window = thalyx.read('one.rs'); + if (window.text.includes('old_api')) { + thalyx.edit('one.rs', 'sustituir', 'old_api', 'new_api'); + } + const other = thalyx.read('two.rs'); + if (other.text.includes('nothing like this')) { + thalyx.edit('two.rs', 'sustituir', 'a', 'b'); + } + return thalyx.call('grep', ['new_api']).total;", + &mut machine, + &Limits::default(), + ); + assert_eq!(outcome.finish, Finish::Returned { value: json!(2) }); + let verbs: Vec<&str> = machine + .asked + .iter() + .map(|(verb, _)| verb.as_str()) + .collect(); + assert_eq!( + verbs, + ["read", "edit", "read", "grep"], + "the edit that should have been skipped ran, or the one that should \ + have happened did not: {:?}", + machine.asked + ); + } + + #[test] + fn what_a_program_reads_is_far_more_than_what_it_returns() { + // The compression, as a number rather than as a claim. The program + // reads three whole files and hands back one integer. + let outcome = ran("let hits = 0; + for (const name of ['a.rs', 'b.rs', 'c.rs']) { + if (thalyx.read(name).text.includes('old_api')) { hits++; } + } + return hits;"); + assert_eq!(outcome.finish, Finish::Returned { value: json!(3) }); + assert!(outcome.metrics.answer_bytes > 100, "{:?}", outcome.metrics); + assert!(outcome.metrics.requests == 3); + } + + #[test] + fn an_answer_too_big_to_hand_back_is_refused_and_not_halved() { + let mut machine = Recorder::default(); + let limits = Limits { + returned_bytes: 64, + ..Limits::default() + }; + let outcome = run("return 'x'.repeat(500);", &mut machine, &limits); + assert!( + matches!(&outcome.finish, Finish::Exhausted { limit, .. } if *limit == "returned_bytes"), + "{:?}", + outcome.finish + ); + assert!( + outcome.finish.why().contains("evidence"), + "{}", + outcome.finish.why() + ); + } + + #[test] + fn a_program_that_is_not_javascript_is_refused_rather_than_run() { + let outcome = ran("this is not a program {{{"); + assert!( + matches!( + outcome.finish, + Finish::Threw { .. } | Finish::Refused { .. } + ), + "{:?}", + outcome.finish + ); + } + + #[test] + fn every_call_is_recorded_whole_for_the_evidence() { + let outcome = ran( + "thalyx.read('a.rs'); thalyx.validate({check: 'parses'}); thalyx.changed(); return 1;", + ); + let kinds: Vec<&str> = outcome.calls.iter().map(|call| call.kind).collect(); + assert_eq!(kinds, ["request", "validate", "changed"]); + assert!( + outcome.calls[0].answer["text"].is_string(), + "the answer was summarised instead of kept: {:?}", + outcome.calls[0] + ); + } + + #[test] + fn recursion_without_end_is_a_refusal_and_not_this_process_falling_over() { + let mut machine = Recorder::default(); + let limits = Limits { + stack_bytes: 64 * 1024, + ..Limits::default() + }; + let outcome = run( + "function down(n) { return down(n + 1); } return down(0);", + &mut machine, + &limits, + ); + assert!( + matches!( + outcome.finish, + Finish::Threw { .. } | Finish::Exhausted { .. } + ), + "{:?}", + outcome.finish + ); + } +} diff --git a/crates/thalyx-rust/src/analyzer.rs b/crates/thalyx-rust/src/analyzer.rs index 4b9da68..b0f7a81 100644 --- a/crates/thalyx-rust/src/analyzer.rs +++ b/crates/thalyx-rust/src/analyzer.rs @@ -46,6 +46,125 @@ use std::time::{Duration, Instant}; use crate::{Result, RustError}; +// ── whose process it is ────────────────────────────────────────────────────── + +/// What starting a semantic provider needs. +#[derive(Debug, Clone, Copy)] +pub struct Launching<'a> { + /// The server binary. + pub program: &'a Path, + /// The workspace it is rooted at, and its working directory. + pub root: &'a Path, + /// Where its Cargo is told to build. Outside the workspace, always: see + /// [`Analyzer::start`]. + pub build_into: Option<&'a Path>, + /// Everything it must be able to read that is not the workspace — the + /// toolchain and the registry, read-only. + pub readable: &'a [PathBuf], + /// Where the toolchain is, for a process that is not the user who + /// installed it. + pub environment: &'a [(String, String)], +} + +/// A started server, and how to end it. +pub struct Started { + /// Its stdin and stdout must be pipes: LSP is a conversation. + pub child: Child, + /// Called when the analyzer is let go: kill the process tree and take the + /// confinement down. + /// + /// A boxed closure rather than a type, because what has to be torn down is + /// a cgroup and a kernel policy and **this crate must not know that**. It + /// resolves names and describes edits; the authority that confines things + /// is two layers up, and a `thalyx-rust` that depended on the sandbox + /// would be the semantic provider deciding what a process may reach. + pub release: Option>, + /// One phrase for the answer: `confined: `, or `host`. + pub how: String, + /// **Whether Thalyx's confinement is what stands behind this process.** + /// + /// Reported on every answer that came from it, and never assumed. See + /// [`Spawn`]. + pub confined: bool, +} + +/// How the semantic provider's process gets started. +/// +/// ## Why this is a trait and not a `Command` +/// +/// Until 2026-08-30 this file started rust-analyzer with `Command::new`, as an +/// ordinary host process, with everything Thalyx itself can reach — the whole +/// filesystem and the network. The justification written in this very module +/// was that it is *a reader*: it never applies an edit, a rename comes back as +/// a description, and Thalyx does the writing. +/// +/// **That reasoning is about the LSP protocol and not about the process tree.** +/// rust-analyzer runs `cargo metadata`, and to answer anything about a +/// workspace with a proc-macro or a build script in it, it *compiles and runs +/// them* — which is arbitrary code from a registry, executing at analysis time, +/// with Thalyx's own reach. "It does not apply edits, therefore it is +/// read-only" was the wrong conclusion from a true premise. +/// +/// So the process belongs under the same authority every other program nobody +/// signed runs under. The trait is what lets that authority live where it +/// already is: `thalyx-core` confines things, this crate asks names of a +/// compiler, and neither has to know how the other works. +pub trait Spawn: Send + Sync { + fn start(&self, asked: Launching<'_>) -> Result; +} + +/// The provider as a plain host process. +/// +/// **Not the default anywhere Thalyx can enforce**, and it says `confined: +/// false` on every answer that comes through it. It exists for exactly one +/// case, which is real and is this container: a machine with no BPF LSM cannot +/// confine anything, `start_foreign` refuses rather than degrading — that is +/// `Programas-Ajenos.md`'s decree, and it is right — and a Thalyx that +/// therefore could not resolve a symbol at all would be a machine where the +/// programming face does not exist. +/// +/// `THALYX_REQUIRE_CONFINED_ANALYZER=1` turns this into a refusal, which is +/// rule 3's shape: one variable per requirement, so a machine that can enforce +/// can demand that it did. +pub struct OnTheHost; + +impl Spawn for OnTheHost { + fn start(&self, asked: Launching<'_>) -> Result { + if std::env::var("THALYX_REQUIRE_CONFINED_ANALYZER").as_deref() == Ok("1") { + return Err(RustError::NoAnalyzer( + "THALYX_REQUIRE_CONFINED_ANALYZER=1 and this provider would have run as \ + an ordinary host process. Nothing started." + .to_string(), + )); + } + let mut command = Command::new(asked.program); + if let Some(target) = asked.build_into { + command.env("CARGO_TARGET_DIR", target); + } + for (name, value) in asked.environment { + command.env(name, value); + } + let child = command + .current_dir(asked.root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + // Its log is noise on the way to an answer, and a pipe nobody + // drains is a server that blocks on a full buffer halfway through + // indexing — which would look exactly like a server that hung. + .stderr(Stdio::null()) + .spawn() + .map_err(|error| { + RustError::NoAnalyzer(format!("{}: {error}", asked.program.display())) + })?; + Ok(Started { + child, + release: None, + how: "host".to_string(), + confined: false, + }) + } +} + /// How long to wait for the server to finish its first indexing pass. /// /// Measured rather than guessed: this workspace's twenty-eight crates take @@ -114,6 +233,12 @@ pub enum Ready { /// A running rust-analyzer, and the conversation with it. pub struct Analyzer { child: Child, + /// How the confinement around it is taken down. See [`Started::release`]. + release: Option>, + /// One phrase saying what started it. + how: String, + /// Whether Thalyx's confinement stands behind it. + confined: bool, stdin: ChildStdin, incoming: Receiver, next_id: i64, @@ -137,21 +262,26 @@ impl Analyzer { /// contains a build tree, a rollback destroys the build cache, and a run /// that changed two files reports twenty-nine. It was found by a test /// asserting the count. - pub fn start(root: &Path, binary: &Path, build_into: Option<&Path>) -> Result { - let mut command = Command::new(binary); - if let Some(target) = build_into { - command.env("CARGO_TARGET_DIR", target); - } - let mut child = command - .current_dir(root) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - // Its log is noise on the way to an answer, and a pipe nobody - // drains is a server that blocks on a full buffer halfway through - // indexing — which would look exactly like a server that hung. - .stderr(Stdio::null()) - .spawn() - .map_err(|error| RustError::NoAnalyzer(format!("{}: {error}", binary.display())))?; + pub fn start( + root: &Path, + binary: &Path, + build_into: Option<&Path>, + readable: &[PathBuf], + environment: &[(String, String)], + spawner: &dyn Spawn, + ) -> Result { + let Started { + mut child, + release, + how, + confined, + } = spawner.start(Launching { + program: binary, + root, + build_into, + readable, + environment, + })?; let stdin = child.stdin.take().ok_or_else(|| { RustError::NoAnalyzer("rust-analyzer was started without a stdin".to_string()) @@ -175,6 +305,9 @@ impl Analyzer { let mut analyzer = Self { child, + release, + how, + confined, stdin, incoming, next_id: 1, @@ -494,9 +627,37 @@ impl Drop for Analyzer { /// this matters most is the case where the server is not replying. It holds /// no state anybody needs: everything it learned is either in the answer /// already given or being recomputed next time. + /// Kill the server, then take down whatever was confining it. + /// + /// In that order and both, and the second half is the one that was not + /// here before there was anything to take down. A `release` skipped leaves + /// a cgroup and a kernel policy behind — and an entry left in the map after + /// its directory is gone becomes the policy of whatever cgroup the kernel + /// gives that inode to next. + /// + /// Killing the one process Thalyx holds is enough for the *tree* under a + /// profile with a pid namespace: the kernel reaps a namespace when its init + /// dies, and every `cargo`, `rustc` and build script the server started is + /// inside it. `release` kills the cgroup as well, which covers the window + /// before the re-exec that becomes that init. fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); + if let Some(release) = self.release.take() { + release(); + } + } +} + +impl Analyzer { + /// One phrase saying what started this server. + pub fn how(&self) -> &str { + &self.how + } + + /// Whether Thalyx's confinement stands behind it. + pub fn confined(&self) -> bool { + self.confined } } @@ -664,36 +825,18 @@ pub fn path_of(uri: &str) -> Option { /// The rust-analyzer this machine has, or nothing. /// -/// Every candidate is **run** rather than tested for existence, and the reason -/// is this container: `~/.cargo/bin/rust-analyzer` is there, is executable, and -/// is a rustup shim that answers `error: Unknown binary`. A search that stopped -/// at the first file it found would pick it every time — rule 5, where the -/// harness is the `PATH`. +/// One line, because the search itself belongs to [`crate::toolchain`] and +/// there used to be three of them that disagreed. Kept as a function here +/// because this is where a reader of the LSP client looks for it. pub fn find() -> Option { - let mut candidates: Vec = Vec::new(); - if let Some(named) = std::env::var_os("THALYX_RUST_ANALYZER") { - candidates.push(PathBuf::from(named)); - } - if let Some(home) = std::env::var_os("HOME") { - let toolchains = PathBuf::from(&home).join(".rustup").join("toolchains"); - if let Ok(entries) = std::fs::read_dir(&toolchains) { - for entry in entries.flatten() { - candidates.push(entry.path().join("bin").join("rust-analyzer")); - } - } - } - if let Some(path) = std::env::var_os("PATH") { - for directory in std::env::split_paths(&path) { - candidates.push(directory.join("rust-analyzer")); - } - } - candidates.into_iter().find(|candidate| { - candidate.is_file() - && Command::new(candidate) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok_and(|status| status.success()) - }) + crate::toolchain::rust_analyzer().path.clone() +} + +/// Why there is no rust-analyzer, naming every place that was looked at. +pub fn why_no_analyzer() -> String { + crate::toolchain::rust_analyzer().why_not( + "rust-analyzer", + "Add it with: rustup component add rust-analyzer, or name one with \ + THALYX_RUST_ANALYZER", + ) } diff --git a/crates/thalyx-rust/src/lib.rs b/crates/thalyx-rust/src/lib.rs index 5278df2..b7923dd 100644 --- a/crates/thalyx-rust/src/lib.rs +++ b/crates/thalyx-rust/src/lib.rs @@ -30,9 +30,10 @@ pub mod affected; pub mod analyzer; pub mod edits; pub mod metadata; +pub mod toolchain; pub use affected::{Affected, affected}; -pub use analyzer::{Analyzer, FileEdit, Ready, Spot}; +pub use analyzer::{Analyzer, FileEdit, Ready, Spot, Symbol}; pub use metadata::{Package, Workspace}; use serde::{Deserialize, Serialize}; @@ -72,7 +73,15 @@ pub type Result = std::result::Result; /// the key: a kind spelled two ways is two caches, and the second one is always /// the empty one somebody is confused by. pub const KIND_WORKSPACE: &str = "rust.workspace"; -pub const KIND_SYMBOL: &str = "rust.symbol"; +/// What is remembered about one name. +/// +/// **`.2` and not `.1`.** What is stored under this key changed shape on +/// 2026-08-30 — from "the one declaration, or nothing" to "nothing, one, or +/// several" — and a store written by the older shape would deserialise as a +/// miss on every question, forever, in silence. A new key throws the old +/// entries away once instead of tripping over them every time. Rule 9: the +/// cautious answer. +pub const KIND_SYMBOL: &str = "rust.symbol.2"; pub const KIND_IDENTITY: &str = "rust.identity"; pub const KIND_OUTLINE: &str = "rust.outline"; pub const KIND_VALIDATION: &str = "rust.validation"; @@ -130,6 +139,101 @@ pub struct Known { pub used: Vec, } +/// One of the declarations a name could mean, when there is more than one. +/// +/// Enough to choose between them without opening a file — which package, which +/// module, what kind of thing, what it looks like — and a handle that names +/// exactly this one. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Candidate { + pub name: String, + pub kind: String, + /// The crate it is declared in, when it is inside one. + pub package: Option, + /// The enclosing name the server gave, when it gave one. + pub container: Option, + pub at: At, + pub signature: Option, + /// The identity that resolves this one and no other: `path:line:column`. + /// + /// **Deliberately the shape `renombrar` and `contexto` already take.** A + /// new kind of handle would be a second identity for a place, and the + /// caller resolving an ambiguity would have to learn one thing to ask the + /// question and another to answer it. + pub handle: String, +} + +/// What a name turns out to be. +/// +/// Three answers and not two. Until 2026-08-30 this was `Option`, and +/// `ask_about` produced it with `candidates.into_iter().find(|s| s.name == +/// name)` — so a workspace with `crate_a::Config`, `crate_b::Config` and +/// `crate_c::Config` in it got whichever one rust-analyzer happened to list +/// first, described as *the* `Config`, with nothing anywhere saying a choice +/// had been made. +/// +/// That is the worst shape a wrong answer can have: it is confident, it is +/// well formed, and the caller that acts on it is a rename. So the several +/// case is a value the caller has to handle, and a mutation against it refuses +/// before it writes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "resolution", rename_all = "snake_case")] +pub enum Resolution { + /// Nothing in this workspace declares it. + Nothing, + /// Exactly one declaration, and everything known about it. + One { known: Box }, + /// Several, and choosing between them is not this machine's to do. + Several { candidates: Vec }, +} + +impl Resolution { + /// The one declaration, when there is exactly one. + /// + /// Named `only` rather than `known` so that a caller writing it is + /// visibly asserting the thing that might not be true. + pub fn only(&self) -> Option<&Known> { + match self { + Resolution::One { known } => Some(known), + _ => None, + } + } + + /// The candidates, when there is more than one. + pub fn candidates(&self) -> &[Candidate] { + match self { + Resolution::Several { candidates } => candidates, + _ => &[], + } + } + + pub fn is_ambiguous(&self) -> bool { + matches!(self, Resolution::Several { .. }) + } + + /// One sentence naming what could not be chosen between. + pub fn ambiguity(&self, name: &str) -> String { + let listed: Vec = self + .candidates() + .iter() + .map(|candidate| { + let package = candidate.package.as_deref().unwrap_or("this workspace"); + format!( + "{} {} in {package} ({})", + candidate.kind, candidate.name, candidate.handle + ) + }) + .collect(); + format!( + "`{name}` names {} declarations here and this machine will not choose \ + between them: {}. Ask again with one of the handles — \ + `path:line:column` — which names exactly one", + listed.len(), + listed.join("; ") + ) + } +} + /// What a query cost and where the answer came from. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Tally { @@ -142,6 +246,15 @@ pub struct Tally { /// Times a rust-analyzer was started. The expensive number: about 25 /// seconds on this workspace, and the reason the cache exists. pub analyzer_starts: usize, + /// Whether the running analyzer is under Thalyx's confinement. + /// + /// `None` when none is running. **Never assumed**: a machine whose kernel + /// cannot deny runs the provider as a host process, and an answer that did + /// not say so would let a reader believe a whole compiler tree had been + /// confined when it had not. See `analyzer::Spawn`. + pub analyzer_confined: Option, + /// One phrase saying what started it: `confined: `, or `host`. + pub analyzer_how: Option, /// Times Cargo was asked to describe the workspace. pub cargo_calls: usize, } @@ -164,6 +277,18 @@ pub struct Provider { /// second question does not pay the 25 seconds again to be told the same /// thing — and so the answer can say *which* failure it was. analyzer_refused: Option, + /// Who starts the analyzer's process, and under whose authority. + /// + /// Defaults to [`analyzer::OnTheHost`], which is what a crate that knows + /// nothing about cgroups can do by itself and which says `confined: false` + /// on every answer. The authority that can do better hands one in with + /// [`Provider::spawning`]. + spawner: std::sync::Arc, + /// Where the analyzer must be able to read outside the workspace. + readable: Vec, + /// Where its toolchain is, for a process that is not the user who + /// installed it. + environment: Vec<(String, String)>, pub tally: Tally, } @@ -176,10 +301,53 @@ impl Provider { workspace: None, analyzer: None, analyzer_refused: None, + spawner: std::sync::Arc::new(analyzer::OnTheHost), + readable: Vec::new(), + environment: Vec::new(), tally: Tally::default(), } } + /// The same, with somebody else starting the analyzer's process. + /// + /// The authority above this crate hands in a spawner that puts the server + /// — and every `cargo`, `rustc` and build script under it — in a cgroup + /// with a policy, a private root filesystem holding only the workspace and + /// the toolchain, no network, its own user and the seccomp filter. See + /// [`analyzer::Spawn`]. + pub fn spawning(mut self, spawner: std::sync::Arc) -> Self { + self.spawner = spawner; + self + } + + /// What the analyzer must be able to read outside the workspace, and where + /// its toolchain is. + /// + /// Both, together, because they are the same fact twice: a grant on + /// `~/.cargo` and a `CARGO_HOME` that names it are the permission and the + /// address, and one without the other is a process that may read a + /// directory it will never look in. + pub fn reaching(mut self, readable: Vec, environment: Vec<(String, String)>) -> Self { + self.readable = readable; + self.environment = environment; + self + } + + /// Whether the running analyzer is under Thalyx's confinement. + /// + /// `None` when none is running. Reported rather than assumed: a machine + /// that cannot enforce runs the provider on the host, and an answer that + /// did not say so would let a reader believe a compiler tree had been + /// confined when it had not. + pub fn analyzer_confined(&self) -> Option { + self.analyzer.as_ref().map(Analyzer::confined) + } + + /// One phrase saying what started the running analyzer. + pub fn analyzer_how(&self) -> Option<&str> { + self.analyzer.as_ref().map(Analyzer::how) + } + /// The same, told where to put anything it builds. pub fn building_into(mut self, target: &Path) -> Self { self.build_into = Some(target.to_path_buf()); @@ -296,33 +464,34 @@ impl Provider { /// /// Returns the answer **and its standing**, so a caller can never end up /// relaying a location from before four files moved without saying so. - pub fn known(&mut self, name: &str) -> Result<(Option, Standing, String)> { + pub fn known(&mut self, name: &str) -> Result<(Resolution, Standing, String)> { self.tally.queries += 1; let witness = self.source_witness()?; if let Some(held) = self.knowledge.recall_current(KIND_SYMBOL, name, &witness)? - && let Ok(known) = serde_json::from_str::>(&held.value) + && let Ok(resolution) = serde_json::from_str::(&held.value) { self.tally.hits += 1; - return Ok((known, Standing::Current, held.source)); + return Ok((resolution, Standing::Current, held.source)); } self.tally.misses += 1; let name = name.to_string(); - let (known, identity) = self.steady( + let (resolution, identity) = self.steady( |provider| provider.source_witness(), |provider| provider.ask_about(&name), )?; - let value = serde_json::to_string(&known).unwrap_or_else(|_| "null".into()); + let value = serde_json::to_string(&resolution) + .unwrap_or_else(|_| r#"{"resolution":"nothing"}"#.into()); match identity { Some(identity) => { self.knowledge .remember(KIND_SYMBOL, &name, &identity, "rust-analyzer", &value)?; - Ok((known, Standing::Current, "rust-analyzer".to_string())) + Ok((resolution, Standing::Current, "rust-analyzer".to_string())) } // Nothing is remembered, and the caller is told the answer has no // standing rather than being handed one it can rely on. - None => Ok((known, Standing::Unknown, "rust-analyzer".to_string())), + None => Ok((resolution, Standing::Unknown, "rust-analyzer".to_string())), } } @@ -331,28 +500,94 @@ impl Provider { /// One place, so that the definition, the signature and the uses are always /// about the same declaration. Three call sites picking their own would be /// three chances to describe one symbol and cite another. - fn ask_about(&mut self, name: &str) -> Result> { + fn ask_about(&mut self, name: &str) -> Result { let root = self.root.clone(); let analyzer = self.analyzer()?; - let candidates = analyzer.symbols_named(name)?; - let Some(declaration) = candidates.into_iter().find(|symbol| symbol.name == name) else { - return Ok(None); - }; - let at = declaration.at.clone(); - let signature = analyzer.signature(&at.path, at.line, at.character)?; - let used = analyzer.references(&at.path, at.line, at.character)?; - let package = self - .workspace()? - .package_of(&at.path) - .map(|package| package.name.clone()); - Ok(Some(Known { - name: name.to_string(), - kind: declaration.kind.to_string(), - package, - defined: vec![At::of(&at, &root)], - signature, - used: used.iter().map(|spot| At::of(spot, &root)).collect(), - })) + let mut exact: Vec = analyzer + .symbols_named(name)? + .into_iter() + .filter(|symbol| symbol.name == name) + .collect(); + + // Two entries at one place are one declaration. rust-analyzer answers a + // workspace-symbol query out of several indexes and the same item can + // come back twice; counting that as an ambiguity would refuse a rename + // over a workspace that has exactly one `Config` in it, which is a + // false alarm nobody can act on. Sorted first so the order a caller + // sees is the same on every run. + exact.sort_by(|left, right| { + (&left.at.path, left.at.line, left.at.character).cmp(&( + &right.at.path, + right.at.line, + right.at.character, + )) + }); + exact.dedup_by(|left, right| { + left.at.path == right.at.path + && left.at.line == right.at.line + && left.at.character == right.at.character + }); + + match exact.len() { + 0 => Ok(Resolution::Nothing), + 1 => { + let declaration = exact.remove(0); + let at = declaration.at.clone(); + let signature = analyzer.signature(&at.path, at.line, at.character)?; + let used = analyzer.references(&at.path, at.line, at.character)?; + let package = self + .workspace()? + .package_of(&at.path) + .map(|package| package.name.clone()); + Ok(Resolution::One { + known: Box::new(Known { + name: name.to_string(), + kind: declaration.kind.to_string(), + package, + defined: vec![At::of(&at, &root)], + signature, + used: used.iter().map(|spot| At::of(spot, &root)).collect(), + }), + }) + } + // Several. The signature of each is asked for and the *references* + // of none are: a caller looking at this has not decided which + // symbol it means yet, and asking rust-analyzer for the use sites + // of three declarations to answer a question about which one is + // meant is paying three times for an answer that is thrown away. + _ => { + // Everything the server has to be asked, asked while the + // server is borrowed; the workspace lookup happens after, + // because it borrows `self` too and a loop that alternated + // between them would not compile without releasing one of them + // every time round. + let mut described = Vec::with_capacity(exact.len()); + for declaration in exact { + let at = declaration.at.clone(); + let signature = analyzer.signature(&at.path, at.line, at.character)?; + described.push((declaration, signature)); + } + + let mut candidates = Vec::with_capacity(described.len()); + for (declaration, signature) in described { + let package = self + .workspace()? + .package_of(&declaration.at.path) + .map(|package| package.name.clone()); + let placed = At::of(&declaration.at, &root); + candidates.push(Candidate { + name: declaration.name.clone(), + kind: declaration.kind.to_string(), + package, + container: declaration.container.clone(), + handle: format!("{}:{}:{}", placed.path, placed.line, placed.column), + at: placed, + signature, + }); + } + Ok(Resolution::Several { candidates }) + } + } } /// What the name written at this exact place *is*. @@ -524,15 +759,32 @@ impl Provider { } if self.analyzer.is_none() { let Some(binary) = analyzer::find() else { - let why = "no rust-analyzer was found on PATH, in ~/.rustup/toolchains, \ - or named by THALYX_RUST_ANALYZER" - .to_string(); + // Naming every place that was looked at, and not just the + // absence. On 2026-08-29 this said "no rust-analyzer" on a + // machine where `rustup component add rust-analyzer` had just + // succeeded — because `sudo` had made `$HOME` be `/root` and + // the sentence gave the person nothing to notice that with. + let why = analyzer::why_no_analyzer(); self.analyzer_refused = Some(why.clone()); return Err(RustError::NoAnalyzer(why)); }; self.tally.analyzer_starts += 1; - match Analyzer::start(&self.root, &binary, self.build_into.as_deref()) { - Ok(analyzer) => self.analyzer = Some(analyzer), + match Analyzer::start( + &self.root, + &binary, + self.build_into.as_deref(), + &self.readable, + &self.environment, + self.spawner.as_ref(), + ) { + Ok(analyzer) => { + // Recorded at the moment of the start, so a caller that + // subtracts two tallies around a request still learns what + // stood behind the process that answered it. + self.tally.analyzer_confined = Some(analyzer.confined()); + self.tally.analyzer_how = Some(analyzer.how().to_string()); + self.analyzer = Some(analyzer); + } Err(error) => { self.analyzer_refused = Some(error.to_string()); return Err(error); diff --git a/crates/thalyx-rust/src/metadata.rs b/crates/thalyx-rust/src/metadata.rs index d490fa0..c00048a 100644 --- a/crates/thalyx-rust/src/metadata.rs +++ b/crates/thalyx-rust/src/metadata.rs @@ -198,10 +198,16 @@ impl Workspace { } } -/// The `cargo` to run: whatever `CARGO` names, which is what Cargo sets for -/// anything it launches, and the plain name otherwise. +/// The `cargo` to run. +/// +/// `CARGO` when this process was started by one, and otherwise the single +/// discovery in [`crate::toolchain`] — which is the only place that knows how +/// to find a toolchain installed by the person who typed `sudo`. pub fn cargo() -> PathBuf { + // `CARGO` first, because when this process *is* a `cargo` subcommand or a + // build script Cargo has already said which one it is, and disagreeing + // with it would run a second toolchain inside the first. std::env::var_os("CARGO") .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("cargo")) + .unwrap_or_else(crate::toolchain::cargo_command) } diff --git a/crates/thalyx-rust/src/toolchain.rs b/crates/thalyx-rust/src/toolchain.rs new file mode 100644 index 0000000..35d7b25 --- /dev/null +++ b/crates/thalyx-rust/src/toolchain.rs @@ -0,0 +1,459 @@ +//! Where this machine's Rust toolchain is — asked once, in one place. +//! +//! ## The failure this file exists to stop +//! +//! `dev/verify.sh` needs root: cgroups, namespaces, mounts, and an LSM that +//! actually denies. `rustup` installs into the *invoking* user's home. `sudo` +//! sets `HOME=/root`. So on the overwhelmingly common Fedora setup the whole +//! toolchain is right there and every `$HOME`-shaped search finds nothing — +//! and what Thalyx then reports is not "I am running as the wrong user", it is +//! `no_cargo`, `NoAnalyzer`, and two stages saying `NOT PROVEN` about a +//! machine that has everything they asked for. +//! +//! It happened on 2026-08-29 with `rustup component add rust-analyzer` typed +//! into the shell *immediately before* the run that said there was none. Rule +//! 5: the instrument includes the harness, and here the harness is the +//! environment `sudo` hands over. +//! +//! There were three separate searches before this file — one in +//! `metadata::cargo`, one in `analyzer::find`, one in `thalyx-cli`'s `hacer` — +//! and they disagreed about every question: which variables to read, whether +//! to trust `PATH`, whether to run a candidate before believing it. Three +//! answers to "where is cargo" is three machines. +//! +//! ## The order, and why it is this order +//! +//! 1. **What somebody said explicitly.** `THALYX_CARGO` and +//! `THALYX_RUST_ANALYZER` name a file. A run whose meaning has to be +//! reproducible is a run whose tools were named, not found. +//! 2. **What rustup itself was told.** `CARGO_HOME` and `RUSTUP_HOME` are +//! rustup's own variables, they survive `sudo -E`, and `verify.sh` sets +//! them from `$SUDO_USER`'s home precisely so that root can use the +//! person's toolchain on purpose. Reading them is not a workaround: it is +//! reading the configuration. +//! 3. **The invoking user's home, when `sudo` says who that was.** `SUDO_USER` +//! plus the passwd entry, so a root shell finds the toolchain of the person +//! who asked for it. +//! 4. **`HOME`.** The ordinary case, where nobody is pretending to be anybody. +//! 5. **Named system locations.** `/usr/local/bin`, `/usr/bin`. Two paths, in +//! a fixed order. +//! +//! ## And never a walk of `PATH` +//! +//! A validation that ran whichever `cargo` came first on a caller's `PATH` is +//! a validation whose meaning depends on who started the session — and inside +//! Thalyx there is no `PATH` and no shell to expand one. The list above is +//! *named places*, every one of which a person can print. The one thing +//! `PATH` was buying was "it works on my machine", and the price was that +//! nobody could say which compiler had produced a verdict. +//! +//! ## Every candidate is run, not stat'ed +//! +//! `~/.cargo/bin/rust-analyzer` exists on every rustup install and is a shim +//! that answers `error: Unknown binary`. `~/.cargo/bin/cargo` is a shim that +//! re-executes `rustup`, which inside a confinement is a second program the +//! grants say nothing about. A search that stopped at the first *file* would +//! pick both of them, every time. So a candidate becomes the answer only after +//! it has answered `--version`. +//! +//! The result is remembered for the life of the process. It cannot change +//! under a running one, and paying a subprocess per lookup would put a +//! `--version` in front of every question a program asks. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::OnceLock; + +/// A tool that was looked for, and what was found. +/// +/// Two fields and not an `Option`, because "there is no cargo here" and "here +/// is the cargo" have different remedies and a caller that only got a path or +/// nothing cannot tell a missing toolchain from a missing *component*. Rule +/// 10, at the smallest scale there is. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Found { + /// The executable, when one of the named places held one that ran. + pub path: Option, + /// Every place that was looked at, in the order they were looked at. + /// + /// Carried so a refusal can name them. "There is no rust-analyzer" is a + /// sentence a person cannot act on; "there is none at these five paths" + /// is one they can. + pub looked_at: Vec, + /// The variable that named it, when one did. + pub named_by: Option<&'static str>, +} + +impl Found { + /// One sentence saying what was not found and where it was looked for. + pub fn why_not(&self, what: &str, remedy: &str) -> String { + let places: Vec = self + .looked_at + .iter() + .map(|path| path.display().to_string()) + .collect(); + format!( + "there is no {what} at any of the {} place(s) this machine looks: {}. {remedy}", + places.len(), + places.join(", ") + ) + } +} + +/// The homes a toolchain could be under, most explicit first. +/// +/// Returned rather than searched in place so that both tools ask the same +/// question of the same list, and so a test can assert the *order* — which is +/// the part that decides whether root borrows the person's toolchain or looks +/// at its own empty one. +fn homes() -> Vec { + let mut homes = Vec::new(); + let mut push = |path: PathBuf| { + if !homes.contains(&path) { + homes.push(path); + } + }; + + // `sudo` hands root a `HOME` of `/root` and leaves `SUDO_USER` saying who + // actually typed the command. Their home is where rustup put everything. + if let Some(user) = std::env::var_os("SUDO_USER") + && let Some(home) = home_of(&user.to_string_lossy()) + { + push(home); + } + if let Some(home) = std::env::var_os("HOME") { + push(PathBuf::from(home)); + } + homes +} + +/// A user's home directory, read from the passwd database. +/// +/// `getent` rather than parsing `/etc/passwd`: a machine using LDAP, SSSD or +/// systemd-homed has users that are not in that file, and a lookup that only +/// worked for local accounts would be a lookup that works on a laptop and not +/// on a workstation joined to a domain. If `getent` is not there, this +/// answers nothing rather than guessing `/home/` — a guessed home is a +/// path that exists on most machines and belongs to the wrong person on some. +fn home_of(user: &str) -> Option { + let output = Command::new("getent") + .arg("passwd") + .arg(user) + .stderr(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let line = String::from_utf8_lossy(&output.stdout); + let home = line.trim_end().split(':').nth(5)?; + (!home.is_empty()).then(|| PathBuf::from(home)) +} + +/// Every `bin` directory of every installed toolchain under a rustup home. +/// +/// Sorted, so a machine with several toolchains picks the same one every time. +/// A verdict whose meaning depends on directory order is a verdict nobody can +/// reproduce. +fn toolchain_bins(rustup_home: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(rustup_home.join("toolchains")) else { + return Vec::new(); + }; + let mut bins: Vec = entries + .flatten() + .map(|entry| entry.path().join("bin")) + .collect(); + bins.sort(); + bins +} + +/// Where an installed toolchain's binaries could be, most explicit first. +fn toolchain_places() -> Vec { + let mut places = Vec::new(); + if let Some(named) = std::env::var_os("RUSTUP_HOME") { + places.extend(toolchain_bins(Path::new(&named))); + } + for home in homes() { + places.extend(toolchain_bins(&home.join(".rustup"))); + } + places +} + +/// The `bin` directories of a cargo installation — rustup's shims and the +/// distribution's. +/// +/// Last resort for both tools, and for cargo it is behind the real toolchains +/// on purpose: `~/.cargo/bin/cargo` is a shim that re-executes `rustup`, and +/// inside a confinement that is a second program the grants say nothing about. +/// The run is then refused for naming `rustup`, which reads like a broken +/// change and is a broken installation layout. +fn shim_places() -> Vec { + let mut places = Vec::new(); + if let Some(named) = std::env::var_os("CARGO_HOME") { + places.push(PathBuf::from(named).join("bin")); + } + for home in homes() { + places.push(home.join(".cargo").join("bin")); + } + places.push(PathBuf::from("/usr/local/bin")); + places.push(PathBuf::from("/usr/bin")); + places +} + +/// Whether a candidate is the tool, established by asking it. +fn answers(candidate: &Path) -> bool { + candidate.is_file() + && Command::new(candidate) + .arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +/// Look for one binary in the named places, running each candidate. +fn look_for(binary: &str, named_by: &'static str, places: Vec) -> Found { + let mut looked_at = Vec::new(); + let mut named = None; + + if let Some(explicit) = std::env::var_os(named_by) { + let path = PathBuf::from(explicit); + if !path.as_os_str().is_empty() { + looked_at.push(path.clone()); + if answers(&path) { + return Found { + path: Some(path), + looked_at, + named_by: Some(named_by), + }; + } + // Named and wrong is worth saying. A variable pointing at a file + // that will not run is a mistake somebody made on purpose, and + // falling through silently to a different binary would answer a + // question nobody asked. + named = Some(named_by); + } + } + + for place in places { + let candidate = place.join(binary); + if looked_at.contains(&candidate) { + continue; + } + looked_at.push(candidate.clone()); + if answers(&candidate) { + return Found { + path: Some(candidate), + looked_at, + named_by: None, + }; + } + } + + Found { + path: None, + looked_at, + named_by: named, + } +} + +/// The environment variable that names a cargo outright. +pub const CARGO_VARIABLE: &str = "THALYX_CARGO"; + +/// The environment variable that names a rust-analyzer outright. +pub const ANALYZER_VARIABLE: &str = "THALYX_RUST_ANALYZER"; + +/// Where this machine's `cargo` is, and where it was looked for. +pub fn cargo() -> &'static Found { + static ASKED: OnceLock = OnceLock::new(); + ASKED.get_or_init(|| { + let mut places = toolchain_places(); + places.extend(shim_places()); + look_for("cargo", CARGO_VARIABLE, places) + }) +} + +/// Where this machine's `rust-analyzer` is, and where it was looked for. +pub fn rust_analyzer() -> &'static Found { + static ASKED: OnceLock = OnceLock::new(); + ASKED.get_or_init(|| { + let mut places = toolchain_places(); + places.extend(shim_places()); + look_for("rust-analyzer", ANALYZER_VARIABLE, places) + }) +} + +/// The `cargo` to run, falling back to the bare name. +/// +/// The bare name is a `PATH` lookup, which everything above exists to avoid — +/// so it is reached only when the named places held nothing that ran, and it +/// exists so that a machine laying its toolchain out in some way nobody +/// anticipated still gets an attempt rather than a refusal. Every caller that +/// needs to *say* whether a real cargo was found asks [`cargo`] instead. +pub fn cargo_command() -> PathBuf { + cargo() + .path + .clone() + .unwrap_or_else(|| PathBuf::from("cargo")) +} + +/// The environment a Cargo — or anything that runs one — needs to find its +/// toolchain when the process asking is not the user who installed it. +/// +/// Returned as pairs rather than set on this process: setting `HOME` here +/// would change what every other part of Thalyx thinks the machine is, which +/// is rule 11 — a global switch with no owner, whose value is some other +/// check's precondition. +pub fn environment() -> Vec<(&'static str, PathBuf)> { + let mut environment = Vec::new(); + let rustup = std::env::var_os("RUSTUP_HOME") + .map(PathBuf::from) + .or_else(|| { + homes() + .into_iter() + .map(|home| home.join(".rustup")) + .find(|path| path.is_dir()) + }); + if let Some(rustup) = rustup { + environment.push(("RUSTUP_HOME", rustup)); + } + let cargo_home = std::env::var_os("CARGO_HOME") + .map(PathBuf::from) + .or_else(|| { + homes() + .into_iter() + .map(|home| home.join(".cargo")) + .find(|path| path.is_dir()) + }); + if let Some(cargo_home) = cargo_home { + environment.push(("CARGO_HOME", cargo_home)); + } + environment +} + +/// Everything a confined toolchain run must be able to read. +/// +/// The registry and the toolchain, and nothing else. Named here rather than at +/// each call site, because a grant list assembled twice is two grant lists and +/// the second one is always missing the thing that only breaks on somebody +/// else's machine. +pub fn readable() -> Vec { + let mut readable = Vec::new(); + let mut push = |path: PathBuf| { + if path.is_dir() && !readable.contains(&path) { + readable.push(path); + } + }; + for (_, path) in environment() { + push(path); + } + for home in homes() { + push(home.join(".cargo")); + push(home.join(".rustup")); + } + // The directory the binary itself is in, whichever of the above it turned + // out to be under. A grant on `~/.cargo` does not cover `/usr/bin/cargo`. + for found in [cargo(), rust_analyzer()] { + if let Some(path) = &found.path + && let Some(parent) = path.parent() + { + push(parent.to_path_buf()); + } + } + readable +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_variable_that_names_nothing_does_not_become_the_answer() { + // The shape of the mistake: a caller sets `THALYX_CARGO` to a typo and + // the machine quietly runs some other cargo, so the run is reproducible + // for everyone except the person who configured it. + let found = look_for( + "cargo", + "THALYX_TEST_NOTHING_NAMES_THIS", + vec![PathBuf::from("/nonexistent/place")], + ); + assert_eq!(found.path, None); + assert!( + found + .looked_at + .contains(&PathBuf::from("/nonexistent/place/cargo")), + "{found:?}" + ); + } + + #[test] + fn a_file_that_is_not_the_tool_is_not_the_tool() { + // `~/.cargo/bin/rust-analyzer` is exactly this: present, executable, + // and answers `error: Unknown binary`. A search that stopped at the + // first file would pick it on every rustup install there is. + let directory = tempfile::tempdir().expect("a temp dir"); + let impostor = directory.path().join("rust-analyzer"); + std::fs::write(&impostor, "#!/bin/sh\nexit 1\n").expect("the file"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&impostor, std::fs::Permissions::from_mode(0o755)) + .expect("executable"); + } + + let found = look_for( + "rust-analyzer", + "THALYX_TEST_NOTHING_NAMES_THIS", + vec![directory.path().to_path_buf()], + ); + assert_eq!(found.path, None, "a shim that fails was taken for the tool"); + assert_eq!(found.looked_at, vec![impostor]); + } + + #[test] + fn what_was_not_found_says_where_it_looked() { + // A refusal a person can act on. "There is no rust-analyzer" is not + // one; "there is none at these paths" tells them which home the search + // was in, which is the whole of the sudo problem. + let found = look_for( + "rust-analyzer", + "THALYX_TEST_NOTHING_NAMES_THIS", + vec![ + PathBuf::from("/nonexistent/one"), + PathBuf::from("/none/two"), + ], + ); + let why = found.why_not( + "rust-analyzer", + "Add it with: rustup component add rust-analyzer", + ); + assert!(why.contains("/nonexistent/one/rust-analyzer"), "{why}"); + assert!(why.contains("/none/two/rust-analyzer"), "{why}"); + assert!(why.contains("rustup component add"), "{why}"); + } + + #[test] + fn a_home_is_never_guessed_from_a_user_name() { + // `/home/` exists on most machines and belongs to the wrong + // person on some. Rule 9: the cautious answer, never the fast one. + assert_eq!(home_of("a-user-that-does-not-exist-here"), None); + } + + #[test] + fn several_toolchains_are_looked_at_in_the_same_order_every_time() { + // A verdict whose meaning depends on `read_dir` order is a verdict + // nobody can reproduce — and `read_dir` order is not sorted on any + // filesystem this runs on. + let directory = tempfile::tempdir().expect("a temp dir"); + for name in ["stable-x86_64", "beta-x86_64", "nightly-x86_64"] { + std::fs::create_dir_all(directory.path().join("toolchains").join(name).join("bin")) + .expect("a toolchain"); + } + let once = toolchain_bins(directory.path()); + let twice = toolchain_bins(directory.path()); + assert_eq!(once, twice); + assert_eq!(once.len(), 3); + assert!(once[0].ends_with("beta-x86_64/bin"), "{once:?}"); + } +} diff --git a/crates/thalyx-rust/tests/a_name_that_means_three_things_names_three_things.rs b/crates/thalyx-rust/tests/a_name_that_means_three_things_names_three_things.rs new file mode 100644 index 0000000..60553fc --- /dev/null +++ b/crates/thalyx-rust/tests/a_name_that_means_three_things_names_three_things.rs @@ -0,0 +1,199 @@ +//! What happens when a name is not one name. +//! +//! `vault/03-Primitivas/Semantica-Compilada.md`. Until 2026-08-30 the provider +//! answered a workspace with three `Config` declarations in it by taking the +//! first exact match rust-analyzer happened to list, and describing it as *the* +//! `Config` — with `source: "rust-analyzer"` on the answer, which is true and +//! is exactly why it would have been believed. +//! +//! What acts on that answer is `renombrar-simbolo`. So the failure mode was: +//! one of three crates gets rewritten everywhere it is used, chosen by index +//! order, with nothing anywhere saying a choice was made. +//! +//! Every test here has the control rule 4 asks for: the same question about a +//! name that really is unique, in the same tree, so that "it refused" is a +//! discrimination and not a machine that refuses everything. + +mod support; + +use std::path::Path; +use thalyx_know::Knowledge; +use thalyx_rust::{Provider, Resolution}; + +fn provider(root: &Path) -> Provider { + Provider::open(root, Knowledge::in_memory().expect("a knowledge store")) +} + +#[test] +fn three_declarations_of_one_name_come_back_as_three() { + if !support::analyzer_or_skip("that an ambiguous name is answered as ambiguous") { + return; + } + let (_held, root) = support::tree("three-configs"); + let mut provider = provider(&root); + + let (resolution, _, source) = provider.known("Config").expect("an answer"); + + assert!( + resolution.is_ambiguous(), + "`Config` is declared in three crates of this workspace and the provider \ + answered {resolution:?}" + ); + assert_eq!(source, "rust-analyzer"); + + let candidates = resolution.candidates(); + assert_eq!(candidates.len(), 3, "{candidates:?}"); + + // Each names its own crate, which is the fact that makes the list usable: + // a caller choosing between three identical names chooses by where they + // are, and an answer that did not say would be three of the same thing. + let mut packages: Vec<&str> = candidates + .iter() + .filter_map(|candidate| candidate.package.as_deref()) + .collect(); + packages.sort_unstable(); + assert_eq!(packages, ["alpha", "beta", "gamma"], "{candidates:?}"); + + // And each carries a handle that resolves exactly one of them — the same + // `file:line:column` shape `renombrar` and `contexto` already take, so + // resolving an ambiguity needs nothing new to be learned. + for candidate in candidates { + let parts: Vec<&str> = candidate.handle.rsplitn(3, ':').collect(); + assert_eq!(parts.len(), 3, "{}", candidate.handle); + assert!(parts[0].parse::().is_ok(), "{}", candidate.handle); + assert!(parts[1].parse::().is_ok(), "{}", candidate.handle); + assert!( + root.join(parts[2]).is_file(), + "the handle names {} which is not a file of this tree", + parts[2] + ); + } +} + +#[test] +fn a_name_that_really_is_unique_is_not_called_ambiguous() { + // **The control.** Without it a provider that answered `Several` to every + // question would pass the test above, and the whole programming face would + // stop working while looking careful. + if !support::analyzer_or_skip("that a unique name still resolves") { + return; + } + let (_held, root) = support::tree("three-configs"); + let mut provider = provider(&root); + + let (resolution, _, _) = provider.known("Unmistakable").expect("an answer"); + + let known = match &resolution { + Resolution::One { known } => known, + other => panic!("`Unmistakable` is declared once and came back as {other:?}"), + }; + assert_eq!(known.kind, "struct"); + assert_eq!(known.package.as_deref(), Some("gamma")); + assert!( + !known.used.is_empty(), + "a single resolution still carries its use sites: {known:?}" + ); +} + +#[test] +fn a_name_nothing_declares_is_nothing_and_not_an_ambiguity() { + // The third of the three answers, and the one that is easiest to lose: + // "nothing" and "several" are both "not one", and a caller that could only + // tell them apart by counting an empty list would read a typo as a choice + // it has to make. + if !support::analyzer_or_skip("that an absent name is absent") { + return; + } + let (_held, root) = support::tree("three-configs"); + let mut provider = provider(&root); + + let (resolution, _, _) = provider.known("NothingIsCalledThis").expect("an answer"); + + assert_eq!(resolution, Resolution::Nothing, "{resolution:?}"); + assert!(!resolution.is_ambiguous()); + assert!(resolution.only().is_none()); +} + +#[test] +fn what_is_remembered_about_an_ambiguous_name_is_still_ambiguous() { + // The cache is the place this contract could be lost quietly. If the second + // question came back from `thalyx-know` as a single `Config`, the refusal + // would hold for the first call of a session and not for the second — which + // is the worst possible schedule for it, because the first call is the one + // somebody is watching. + if !support::analyzer_or_skip("that an ambiguity survives being remembered") { + return; + } + let (_held, root) = support::tree("three-configs"); + let store = tempfile::tempdir().expect("a temp dir"); + let database = store.path().join("known.db"); + + let first = { + let mut provider = Provider::open(&root, Knowledge::open(&database).expect("a store")); + let (resolution, _, _) = provider.known("Config").expect("an answer"); + assert!(resolution.is_ambiguous(), "{resolution:?}"); + resolution + }; + + // A second provider over the same store, and no rust-analyzer question: + // `hits` proves it came from memory rather than from a second 25-second + // start, which is the difference between testing the cache and testing the + // analyzer twice. + let mut second = Provider::open(&root, Knowledge::open(&database).expect("a store")); + let (again, _, _) = second.known("Config").expect("an answer"); + + assert_eq!(again, first, "the remembered answer is a different answer"); + assert!(again.is_ambiguous(), "{again:?}"); + assert_eq!( + second.tally.hits, 1, + "the second question started an analyzer instead of reading what was kept" + ); + assert!(!second.analyzer_running()); +} + +#[test] +fn the_sentence_an_ambiguity_produces_names_every_candidate() { + // A refusal a model can act on. It has to say how many, which ones, and — + // the part that decides whether the next call succeeds — the shape of the + // handle that resolves it. + let resolution = Resolution::Several { + candidates: vec![ + thalyx_rust::Candidate { + name: "Config".into(), + kind: "struct".into(), + package: Some("alpha".into()), + container: None, + at: thalyx_rust::At { + path: "alpha/src/lib.rs".into(), + line: 2, + column: 12, + }, + signature: None, + handle: "alpha/src/lib.rs:2:12".into(), + }, + thalyx_rust::Candidate { + name: "Config".into(), + kind: "struct".into(), + package: Some("beta".into()), + container: None, + at: thalyx_rust::At { + path: "beta/src/lib.rs".into(), + line: 4, + column: 12, + }, + signature: None, + handle: "beta/src/lib.rs:4:12".into(), + }, + ], + }; + + let said = resolution.ambiguity("Config"); + assert!(said.contains("alpha/src/lib.rs:2:12"), "{said}"); + assert!(said.contains("beta/src/lib.rs:4:12"), "{said}"); + assert!(said.contains("alpha"), "{said}"); + assert!(said.contains("path:line:column"), "{said}"); + assert!( + !said.contains("probably") && !said.contains("likely"), + "the refusal is hedging towards a candidate: {said}" + ); +} diff --git a/crates/thalyx-rust/tests/the_compiler_answers_what_the_scan_cannot.rs b/crates/thalyx-rust/tests/the_compiler_answers_what_the_scan_cannot.rs index 044aaa5..0f857ff 100644 --- a/crates/thalyx-rust/tests/the_compiler_answers_what_the_scan_cannot.rs +++ b/crates/thalyx-rust/tests/the_compiler_answers_what_the_scan_cannot.rs @@ -78,8 +78,10 @@ fn a_reference_list_includes_the_import_that_renames_it() { let (_held, root) = support::tree("alias"); let mut provider = provider(&root); - let (known, standing, source) = provider.known("Keystore").expect("an answer"); - let known = known.expect("Keystore is declared in this tree"); + let (resolution, standing, source) = provider.known("Keystore").expect("an answer"); + let known = resolution + .only() + .expect("Keystore is declared exactly once in this tree"); assert_eq!(source, "rust-analyzer"); assert_eq!(standing, Standing::Current); @@ -153,9 +155,9 @@ fn a_second_session_answers_from_what_the_first_learned() { let first = { let mut provider = Provider::open(&root, Knowledge::open(&store).expect("a store")); - let (known, _, _) = provider.known("Keystore").expect("an answer"); + let (resolution, _, _) = provider.known("Keystore").expect("an answer"); assert_eq!(provider.tally.analyzer_starts, 1, "the first pays for it"); - known.expect("Keystore") + resolution }; // A whole new Provider over a whole new connection: the only thing shared @@ -168,7 +170,7 @@ fn a_second_session_answers_from_what_the_first_learned() { source, "rust-analyzer", "the answer keeps saying who made it" ); - assert_eq!(again.expect("Keystore"), first); + assert_eq!(again, first); assert_eq!( second.tally.analyzer_starts, 0, "the second session started a rust-analyzer, which is the 25 seconds \ diff --git a/crates/thalyx-rust/tests/trees/three-configs/Cargo.toml b/crates/thalyx-rust/tests/trees/three-configs/Cargo.toml new file mode 100644 index 0000000..e6b189c --- /dev/null +++ b/crates/thalyx-rust/tests/trees/three-configs/Cargo.toml @@ -0,0 +1,10 @@ +# Three crates, one name. +# +# The fixture for the ambiguity contract, and it is a real Cargo workspace for +# the same reason `chain` and `alias` are: rule 8 says a fake must model the +# property under test, and a directory of `.rs` files that Cargo cannot +# describe is not a small workspace, it is a different system. rust-analyzer +# will not resolve names in a tree it cannot get metadata for. +[workspace] +resolver = "2" +members = ["alpha", "beta", "gamma"] diff --git a/crates/thalyx-rust/tests/trees/three-configs/alpha/Cargo.toml b/crates/thalyx-rust/tests/trees/three-configs/alpha/Cargo.toml new file mode 100644 index 0000000..f0c18cc --- /dev/null +++ b/crates/thalyx-rust/tests/trees/three-configs/alpha/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "alpha" +version = "0.1.0" +edition = "2021" diff --git a/crates/thalyx-rust/tests/trees/three-configs/alpha/src/lib.rs b/crates/thalyx-rust/tests/trees/three-configs/alpha/src/lib.rs new file mode 100644 index 0000000..55cd5b2 --- /dev/null +++ b/crates/thalyx-rust/tests/trees/three-configs/alpha/src/lib.rs @@ -0,0 +1,8 @@ +/// One of three declarations of `Config` in this workspace. +pub struct Config { + pub alpha: u32, +} + +pub fn make() -> Config { + Config { alpha: 1 } +} diff --git a/crates/thalyx-rust/tests/trees/three-configs/beta/Cargo.toml b/crates/thalyx-rust/tests/trees/three-configs/beta/Cargo.toml new file mode 100644 index 0000000..3d1c5f1 --- /dev/null +++ b/crates/thalyx-rust/tests/trees/three-configs/beta/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "beta" +version = "0.1.0" +edition = "2021" diff --git a/crates/thalyx-rust/tests/trees/three-configs/beta/src/lib.rs b/crates/thalyx-rust/tests/trees/three-configs/beta/src/lib.rs new file mode 100644 index 0000000..5fecaab --- /dev/null +++ b/crates/thalyx-rust/tests/trees/three-configs/beta/src/lib.rs @@ -0,0 +1,10 @@ +/// The second `Config`, and nothing in the text distinguishes it from the +/// first — which is the point: a scan cannot tell these apart and neither can +/// a search, so the machine must not pretend it has. +pub struct Config { + pub beta: u32, +} + +pub fn make() -> Config { + Config { beta: 2 } +} diff --git a/crates/thalyx-rust/tests/trees/three-configs/gamma/Cargo.toml b/crates/thalyx-rust/tests/trees/three-configs/gamma/Cargo.toml new file mode 100644 index 0000000..de54e2a --- /dev/null +++ b/crates/thalyx-rust/tests/trees/three-configs/gamma/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "gamma" +version = "0.1.0" +edition = "2021" diff --git a/crates/thalyx-rust/tests/trees/three-configs/gamma/src/lib.rs b/crates/thalyx-rust/tests/trees/three-configs/gamma/src/lib.rs new file mode 100644 index 0000000..0c70fd0 --- /dev/null +++ b/crates/thalyx-rust/tests/trees/three-configs/gamma/src/lib.rs @@ -0,0 +1,18 @@ +/// The third. There is also exactly one `Unmistakable` here, which is the +/// positive control: without it, a machine that called every name ambiguous +/// would pass the ambiguity test. +pub struct Config { + pub gamma: u32, +} + +pub struct Unmistakable { + pub only: u32, +} + +pub fn make() -> Config { + Config { gamma: 3 } +} + +pub fn sole() -> Unmistakable { + Unmistakable { only: 0 } +} diff --git a/crates/thalyx-sandbox/src/cgroup.rs b/crates/thalyx-sandbox/src/cgroup.rs index e5bed1b..358dc88 100644 --- a/crates/thalyx-sandbox/src/cgroup.rs +++ b/crates/thalyx-sandbox/src/cgroup.rs @@ -189,6 +189,29 @@ impl Cgroup { Ok(self.members()?.is_empty()) } + /// Kill every process in this cgroup, whatever it is. + /// + /// `cgroup.kill` and not a walk of `cgroup.procs` sending signals: the walk + /// races with the tree it is walking, and the tree here is a compiler that + /// forks. A build script started between the read and the signal survives + /// it. The kernel's own file has no such window — one write and every + /// member of the cgroup and its descendants gets `SIGKILL`, atomically. + /// + /// It is a plain file write, which is why this is here rather than in + /// `thalyx-syscall`: nothing about it is `unsafe`. + /// + /// `cgroup.kill` arrived in Linux 5.14. On an older kernel the file is not + /// there, and this says so rather than pretending — a caller that read + /// "killed" and got a live process tree would leave a compiler running + /// under a policy that had just been withdrawn. + pub fn kill(&self) -> Result<()> { + let path = self.path.join("cgroup.kill"); + if !path.exists() { + return Err(SandboxError::NoSuchCgroup(path)); + } + std::fs::write(&path, "1").map_err(|source| SandboxError::io(&path, source)) + } + /// Delete the cgroup. /// /// Only ever correct once the policy keyed on its id has been withdrawn: diff --git a/crates/thalyx-sandbox/src/launch.rs b/crates/thalyx-sandbox/src/launch.rs index da75a11..db979c5 100644 --- a/crates/thalyx-sandbox/src/launch.rs +++ b/crates/thalyx-sandbox/src/launch.rs @@ -542,10 +542,25 @@ pub fn spawn( spec: &LaunchSpec, args: &[OsString], stdin: crate::Stdin, + environment: &[(String, String)], ) -> Result { use std::process::Stdio; - std::process::Command::new(helper) + let mut command = std::process::Command::new(helper); + // Set on the outermost process and carried the rest of the way by + // inheritance: both re-executions are `execve`, which preserves the + // environment, so naming these once here is naming them for the program. + // + // What they are for is a toolchain the confined program has to find. A + // `cargo` launched by a root that `sudo` gave `HOME=/root` looks for its + // registry under a home with nothing in it, fails `--offline`, and reports + // that as the change not compiling. The paths are granted separately — + // this only says where to look, and a name here reaches nothing that was + // not granted. + for (name, value) in environment { + command.env(name, value); + } + command .args(argv(ENTER_MARKER, spec, args)?) .stdin(match stdin { crate::Stdin::Closed => Stdio::null(), diff --git a/crates/thalyx-sandbox/src/lib.rs b/crates/thalyx-sandbox/src/lib.rs index f854c2f..6334468 100644 --- a/crates/thalyx-sandbox/src/lib.rs +++ b/crates/thalyx-sandbox/src/lib.rs @@ -432,6 +432,15 @@ pub struct Launch<'a> { /// The module's end of its socket to Thalyx. See the note on `spawn`. pub channel: Option>, pub stdin: Stdin, + /// Environment variables the program is started with, on top of Thalyx's + /// own. + /// + /// Not authority: a variable naming a directory reaches nothing that was + /// not granted, and the root filesystem contains nothing else to open. It + /// exists because a toolchain has to be *told* where its registry is when + /// the process running it is not the user who installed it — see + /// `launch::spawn`. + pub environment: &'a [(String, String)], } /// What a module gets on descriptor 0. @@ -588,6 +597,63 @@ impl<'a> Confinement<'a> { args, channel, stdin: Stdin::Closed, + environment: &[], + }, + ) + } + + /// The same, for a program that is talked to rather than only watched. + /// + /// One method and not four arguments added to the one above, because the + /// callers are different in kind: `spawn` starts something and reads what + /// it printed, and this starts something Thalyx then holds a conversation + /// with — a language server, today, and nothing else. Everything about the + /// confinement is identical; what differs is that descriptor 0 is a pipe + /// whose only writer is Thalyx, which is what the channel on descriptor 3 + /// already is for a module. + pub fn spawn_talking( + &self, + helper: &Path, + module_dir: &Path, + program: &Path, + uid: Option, + args: &[std::ffi::OsString], + environment: &[(String, String)], + ) -> Result { + self.held.spawn( + helper, + Launch { + module_dir, + program, + uid, + args, + channel: None, + stdin: Stdin::Piped, + environment, + }, + ) + } + + /// The same as [`Confinement::spawn`], with an environment. + pub fn spawn_with( + &self, + helper: &Path, + module_dir: &Path, + program: &Path, + uid: Option, + args: &[std::ffi::OsString], + environment: &[(String, String)], + ) -> Result { + self.held.spawn( + helper, + Launch { + module_dir, + program, + uid, + args, + channel: None, + stdin: Stdin::Closed, + environment, }, ) } @@ -638,6 +704,7 @@ impl Held { args, channel, stdin, + environment, } = start; let uid = if self.profile.own_user { uid } else { None }; let rootfs = if self.profile.pivot_root { @@ -679,7 +746,7 @@ impl Held { channel_fd, }; - launch::spawn(helper, &spec, args, stdin) + launch::spawn(helper, &spec, args, stdin, environment) } /// Withdraw the policy and remove the cgroup, in that order. diff --git a/crates/thalyx-sandbox/src/profile.rs b/crates/thalyx-sandbox/src/profile.rs index 49531bc..c8c93e6 100644 --- a/crates/thalyx-sandbox/src/profile.rs +++ b/crates/thalyx-sandbox/src/profile.rs @@ -120,6 +120,10 @@ pub const MODULE_STANDARD: &str = "module_standard"; /// degraded, exactly like `--unconfined`. pub const DIAGNOSTIC: &str = "diagnostic"; +/// What a semantic provider — rust-analyzer, and its whole compiler tree — is +/// confined by. See [`semantic_provider`]. +pub const SEMANTIC_PROVIDER: &str = "semantic_provider"; + /// Look up a profile by the name a contract declared. /// /// An unknown name is an error rather than a fallback to something safe. A @@ -128,11 +132,55 @@ pub const DIAGNOSTIC: &str = "diagnostic"; pub fn resolve(name: &str) -> Result { match name { MODULE_STANDARD => Ok(module_standard()), + SEMANTIC_PROVIDER => Ok(semantic_provider()), DIAGNOSTIC => Ok(diagnostic()), other => Err(SandboxError::UnknownProfile(other.to_string())), } } +/// The profile a semantic provider runs under. +/// +/// `vault/03-Primitivas/Semantica-Compilada.md`. rust-analyzer was described +/// in this repository as "a reader" and started as an ordinary host process, +/// with Thalyx's own reach over the whole filesystem and the network. The +/// description was wrong in a way that matters: **rust-analyzer runs Cargo**, +/// and Cargo compiles build scripts and proc-macro crates, which are arbitrary +/// code from a registry executing at analysis time. "It does not apply edits, +/// therefore it is read-only" is a sentence about the LSP protocol and not +/// about the process tree. +/// +/// So it is `module_standard` with two numbers changed, and nothing else. It +/// is not a service framework and there is exactly one of these: a plugin +/// architecture written before its second plugin is a guess about the second +/// plugin. +/// +/// - **Memory.** A module gets a gigabyte. rust-analyzer on a twenty-eight +/// crate workspace does not fit in one, and neither does the `rustc` it +/// starts — so a provider under the module ceiling would be killed partway +/// through indexing and report as "the analyzer timed out", which is a true +/// sentence about the wrong thing. +/// - **Processes.** A module gets 512. This one is a process *tree*: the +/// server, a `cargo metadata`, a `cargo check` per crate, a `rustc` per unit +/// and a build script per dependency that has one. +/// +/// Everything else is the module's: its own user, its own root filesystem +/// holding only what was granted, its own pid namespace — so killing the one +/// process Thalyx holds kills every compiler underneath it — its own network +/// namespace, which is what "network denied by default" means here, and the +/// same seccomp filter. +pub fn semantic_provider() -> Profile { + Profile { + name: SEMANTIC_PROVIDER, + limits: Limits { + memory_max: Some(6 << 30), // 6 GiB + pids_max: Some(2048), + cpu_max: None, + }, + hostname: "thalyx-semantics", + ..module_standard() + } +} + /// The `module_standard` profile as decreed. pub fn module_standard() -> Profile { Profile { diff --git a/dev/programs/looking-decides.js b/dev/programs/looking-decides.js new file mode 100644 index 0000000..b42e1d2 --- /dev/null +++ b/dev/programs/looking-decides.js @@ -0,0 +1,72 @@ +// The program stage 59 runs, and the one `exec::tests` runs against the +// directory-backed fake. +// +// **One file, read by both**, because a program copied into a shell script and +// into a Rust test is two programs, and the second one is the one that has the +// typo nobody finds until Fedora. `dev/verify.sh` reads it and so does +// `the_program_verify_runs_is_the_one_that_is_tested_here`. +// +// Read it as the claim of the whole sprint: **it names no file.** The list +// comes from the machine, the choice comes from what each file says, and the +// last decision comes from what a check answered. A `Vec` that produced +// the same result would have to already contain the three names — which is the +// answer, so producing it is the work this is doing. + +// The semantic provider, asked first — so a run says something about what +// confined it whichever way the tree goes. Not asserted on: one of the trees +// this runs against has no `old_api` in it at all, and an assertion here would +// be about which tree rather than about the program. +const what = thalyx.context("old_api"); + +const listing = thalyx.list("src"); +thalyx.assert(listing.ok, "src could not be listed", listing); + +const sources = (listing.entries || []) + .map((entry) => entry.name) + .filter((name) => name.endsWith(".rs") && name !== "lib.rs") + .sort(); +thalyx.assert(sources.length >= 4, "this tree should have several modules", sources); + +// The loop that could not have been written in advance: what is *in* each file +// decides whether it is touched. +const touched = []; +for (const name of sources) { + const path = "src/" + name; + const source = thalyx.read(path); + if (source.ok && source.text.includes("old_api")) { + thalyx.mustWork( + thalyx.substitute(path, "old_api", "new_api"), + "the substitution in " + path + " did not happen" + ); + touched.push(name); + } +} + +// What the tree says, not what the edits claimed. +const seen = thalyx.changed(); +thalyx.assert( + seen.count === touched.length, + "the tree shows " + seen.count + " change(s) and the program made " + touched.length, + seen +); + +if (touched.length === 0) { + return { changed: [], compiled: false, resolution: what.resolution }; +} + +// And the branch on a validation — the last decision, and the one a static list +// has to hand back to a model to make. +const built = thalyx.validate({ check: "rust", mode: "check" }); +if (built.verdict !== "passed") { + return { changed: touched, compiled: false, gave_up: built.summary }; +} + +const left = thalyx.grep("old_api"); +thalyx.assert(left.total === 0, "old_api is still somewhere", left); + +return { + changed: touched, + compiled: true, + still_there: left.total, + resolution: what.resolution, +}; diff --git a/dev/verify.sh b/dev/verify.sh index 129a06e..e675759 100755 --- a/dev/verify.sh +++ b/dev/verify.sh @@ -437,9 +437,24 @@ fi # clippy three releases newer than the one the code was written against. This # guard is kept because the hazard is real and cost nothing to remove, not # because it explained anything. +# +# 2026-08-30, and this is the third time this block has been widened: it used to +# be conditional on `$OWNER_HOME/.cargo/bin/cargo` being executable, which is a +# question about rustup's *shims* rather than about the toolchain. The run that +# found it had `rustup component add rust-analyzer` typed into the shell +# immediately before it, and stages 57 and 58 both said there was no +# rust-analyzer on the machine — because `HAVE_ANALYZER` below looked under +# `$HOME`, which `sudo` had made `/root`. +# +# So the condition is now "is there a rustup installation there at all", and +# `RUSTUP_HOME`/`CARGO_HOME` are exported whenever there is one. Those two are +# rustup's own variables, which means exporting them is configuration and not a +# workaround — and `thalyx_rust::toolchain` reads exactly them, so the binary +# under test and this script look in the same place by construction rather than +# by two searches that agree until they do not. if [ -n "${SUDO_USER:-}" ]; then OWNER_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6)" - if [ -x "$OWNER_HOME/.cargo/bin/cargo" ]; then + if [ -d "$OWNER_HOME/.rustup" ] || [ -d "$OWNER_HOME/.cargo" ]; then case ":$PATH:" in *":$OWNER_HOME/.cargo/bin:"*) ;; *) export PATH="$OWNER_HOME/.cargo/bin:$PATH" ;; @@ -720,12 +735,48 @@ SUITE_ENV+=(THALYX_REQUIRE_DEVICE_NODE_TESTS=1) # because `~/.cargo/bin/rust-analyzer` exists on every rustup install and is a # shim that answers `error: Unknown binary`. A search that stopped at the first # file it found would set this on a machine that cannot start one. +# +# **Looked for under `$RUSTUP_HOME` and not under `$HOME`.** This line said +# `$HOME` until 2026-08-30, and under `sudo` that is `/root` — so on the machine +# that had just installed the component, this said 0, stages 57 and 58 said +# `NOT PROVEN`, and the message told the person to install what they had +# installed. Rule 5: the instrument includes the harness, and the harness here +# is the environment `sudo` hands over. +# +# The one that is found is then **named**, for the whole suite and for every +# stage below. A search repeated in two places is two searches, and the second +# one is the one that disagrees on somebody's machine; naming it makes the +# binary under test and this script use the same file by construction. HAVE_ANALYZER=0 -for candidate in "${THALYX_RUST_ANALYZER:-}" "$HOME"/.rustup/toolchains/*/bin/rust-analyzer; do +ANALYZER_HOME="${RUSTUP_HOME:-$HOME/.rustup}" +for candidate in "${THALYX_RUST_ANALYZER:-}" "$ANALYZER_HOME"/toolchains/*/bin/rust-analyzer; do [ -n "$candidate" ] && [ -x "$candidate" ] || continue - if "$candidate" --version > /dev/null 2>&1; then HAVE_ANALYZER=1; break; fi + if "$candidate" --version > /dev/null 2>&1; then + HAVE_ANALYZER=1 + export THALYX_RUST_ANALYZER="$candidate" + break + fi done -[ "$HAVE_ANALYZER" = 1 ] && SUITE_ENV+=(THALYX_REQUIRE_RUST_ANALYZER=1) +if [ "$HAVE_ANALYZER" = 1 ]; then + SUITE_ENV+=(THALYX_REQUIRE_RUST_ANALYZER=1) + SUITE_ENV+=("THALYX_RUST_ANALYZER=$THALYX_RUST_ANALYZER") + proven "rust-analyzer present ($THALYX_RUST_ANALYZER)" +else + unproven "there is no rust-analyzer under $ANALYZER_HOME/toolchains; the semantic stages will say so. Add it with: rustup component add rust-analyzer" +fi + +# And the cargo the confined checks will run, named the same way and for the +# same reason. `thalyx_rust::toolchain` would find it — it reads `RUSTUP_HOME` +# too — but a report that says which binary produced a verdict is worth the one +# line it costs. +if [ -n "${RUSTUP_HOME:-}" ]; then + for candidate in "$RUSTUP_HOME"/toolchains/*/bin/cargo; do + [ -x "$candidate" ] || continue + export THALYX_CARGO="$candidate" + SUITE_ENV+=("THALYX_CARGO=$candidate") + break + done +fi # The seccomp filter, run over a real program rather than evaluated. Both halves # are read rather than assumed: a kernel built without CONFIG_SECCOMP_FILTER has @@ -7284,13 +7335,18 @@ step "53. a reversible change is two round trips where it was four" # Whether any of it moves an agent's cost or clock is answered by running the # benchmark, not here. if cargo test -p thalyx-mcp > "$WORK/round-trips.log" 2>&1 \ + && cargo test -p thalyx-program >> "$WORK/round-trips.log" 2>&1 \ && cargo test -p thalyx-cli --bin thalyx attempt:: >> "$WORK/round-trips.log" 2>&1 \ && cargo test -p thalyx-cli --bin thalyx exec:: >> "$WORK/round-trips.log" 2>&1 \ && cargo test -p thalyx-snapshot --test state_identity >> "$WORK/round-trips.log" 2>&1 \ && cargo test -p thalyx-core attempt >> "$WORK/round-trips.log" 2>&1 \ && cargo test -p thalyx-cli --test an_attempt_can_be_taken_back \ + >> "$WORK/round-trips.log" 2>&1 \ + && cargo test -p thalyx-cli --test a_name_that_is_three_names_changes_nothing \ + >> "$WORK/round-trips.log" 2>&1 \ + && cargo test -p thalyx-rust --test a_name_that_means_three_things_names_three_things \ >> "$WORK/round-trips.log" 2>&1; then - proven "opening an attempt and changing something is one round trip and two requests, abandoning is one call where it was two, a whole program is one call whatever it holds, and the one-call form refuses a state claim that stopped matching the tree — which is work somebody else did" + proven "opening an attempt and changing something is one round trip and two requests, abandoning is one call where it was two, a whole program is one call whatever it holds and its control flow continues here, the one-call abandon refuses a state claim that stopped matching the tree, and a name that means three things is not renamed by picking one" else failed "the reversible round trips do not hold their own claims; see $WORK/round-trips.log" excerpt "$WORK/round-trips.log" 25 @@ -7944,7 +8000,269 @@ fi } -parallel_stages stage_49 stage_50 stage_51 stage_52 stage_53 stage_54 stage_55 stage_56 stage_57 stage_58 +stage_59() { +step "59. one call programs the machine: it looks, it decides, it changes only what the looking said to" + +# **The sprint's own claim, on the only machine that can hold it.** +# +# Stage 58 proves the vertical with the operations known in advance: resolve a +# symbol, rewrite its uses, compile what the change reaches. That is a `Vec` +# with a rename in it, and everything about it could be written before anything ran. +# +# This one cannot be. The tree has five modules; three of them use `old_api` and +# **which three is not visible from the file names**. A caller composing a static +# list would have to read all five first — five answers, five round trips — or +# edit all five and be wrong about two. The program lists, loops, reads, decides +# per file, mutates three, observes what the tree really shows, validates with a +# real compiler under a kernel that really denies, branches on the verdict, and +# returns three names. +# +# Four columns, and the last three are what make the first safe to believe: +# +# - **it works**: three changed, two untouched, committed; +# - **the branch is real**: the same program over a tree where nothing matches +# changes nothing and says so — without that, a program that always edited +# three files would pass the first column; +# - **it comes back**: a program whose validation cannot pass leaves a real +# Btrfs subvolume byte for byte, with the diagnosis in the store; +# - **it stops**: `while (true) {}` after a mutation terminates and rolls back. + +PROG_STORE="$WORK/programmable-store" +PROG_TREE="$BTRFS_SCRATCH/.thalyx-verify-programmable" +mkdir -p "$PROG_STORE" +rm -rf "$PROG_TREE" 2>/dev/null || btrfs subvolume delete "$PROG_TREE" > /dev/null 2>&1 || true + +PROG_GAP="" +if [ ! -x "$THALYX" ]; then + PROG_GAP="there is no thalyx binary, so no program could be run" +elif [ -z "$BTRFS_SCRATCH" ]; then + PROG_GAP="there is nowhere on Btrfs here, so the boundary would not be a real snapshot" +elif ! btrfs subvolume create "$PROG_TREE" > "$WORK/programmable-subvol.log" 2>&1; then + PROG_GAP="a subvolume could not be made under $BTRFS_SCRATCH; see $WORK/programmable-subvol.log" +fi + +if [ -n "$PROG_GAP" ]; then + if [ "${THALYX_REQUIRE_BTRFS_TESTS:-0}" = 1 ]; then failed "$PROG_GAP"; else unproven "$PROG_GAP"; fi +else + programmable_tree() { + mkdir -p "$PROG_TREE/src" + cat > "$PROG_TREE/Cargo.toml" <<'PROGEOF' +[workspace] + +[package] +name = "verify-programmable" +version = "0.1.0" +edition = "2021" +PROGEOF + # A lockfile, because a real Rust workspace has one committed — and + # because without one the semantic provider *creates* it, which is a + # read that mutates the tree inside the transaction and shows up as a + # fourth changed file the program did not write. Found by the program's + # own assertion on 2026-08-30. + cat > "$PROG_TREE/Cargo.lock" <<'PROGLOCK' +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "verify-programmable" +version = "0.1.0" +PROGLOCK + printf 'pub mod one;\npub mod two;\npub mod three;\npub mod four;\npub mod five;\n' \ + > "$PROG_TREE/src/lib.rs" + # Three of the five, and nothing in the names says which. + printf 'pub fn old_api() -> u32 { 0 }\npub fn one() -> u32 {\n old_api()\n}\n' > "$PROG_TREE/src/one.rs" + printf 'pub fn two() -> u32 {\n 2\n}\n' > "$PROG_TREE/src/two.rs" + printf 'pub fn three() -> u32 {\n crate::one::old_api()\n}\n' > "$PROG_TREE/src/three.rs" + printf 'pub fn four() -> u32 {\n 4\n}\n' > "$PROG_TREE/src/four.rs" + printf 'pub fn five() -> u32 {\n crate::one::old_api()\n}\n' > "$PROG_TREE/src/five.rs" + } + programmable_run() { + printf '%s\n' "structured on" "cd $PROG_TREE" "hacer $1" salir | \ + THALYX_ROOT="$PROG_STORE" "$THALYX" session 2>&1 | tr -d '\r' + } + programmable_field() { + python3 -c ' +import json, sys +for line in open(sys.argv[1]): + line = line.strip() + if not line.startswith("{"): + continue + try: + value = json.loads(line) + except Exception: + continue + if value.get("op") == "exec": + here = value + for key in sys.argv[2].split("."): + if isinstance(here, list): + here = here[int(key)] if key.isdigit() and int(key) < len(here) else "absent" + elif isinstance(here, dict): + here = here.get(key, "absent") + else: + here = "absent" + print(json.dumps(here) if not isinstance(here, str) else here) + break +else: + print("none") +' "$1" "$2" + } + + # The program, read from the file the Rust tests read. + # + # One source and not two: a program copied into this script and into a test + # is two programs, and the second is the one with the typo nobody finds + # until this stage runs on Fedora. + PROG_FILE="$ROOT/dev/programs/looking-decides.js" + if [ ! -r "$PROG_FILE" ]; then + failed "$PROG_FILE is not there, so this stage has no program to run" + return + fi + + programmable_program() { + python3 -c ' +import json, sys +print(json.dumps({"label": sys.argv[1], "run": open(sys.argv[2]).read()})) +' "$1" "$PROG_FILE" + } + + # ── column one: it looks, and changes only what the looking says to ────── + programmable_tree + GOOD_P=$(programmable_program "only what needs it") + programmable_run "'$GOOD_P'" > "$WORK/programmable-good.log" + P_STATUS=$(programmable_field "$WORK/programmable-good.log" status) + P_FINISH=$(programmable_field "$WORK/programmable-good.log" finish) + P_CHANGED=$(programmable_field "$WORK/programmable-good.log" returned.changed) + P_EXTERNAL=$(programmable_field "$WORK/programmable-good.log" external_requests) + P_OPS=$(programmable_field "$WORK/programmable-good.log" program_operations) + P_ASSERTS=$(programmable_field "$WORK/programmable-good.log" program_assertions) + P_INTERNAL=$(programmable_field "$WORK/programmable-good.log" internal_bytes) + P_RETURNED=$(programmable_field "$WORK/programmable-good.log" returned_bytes) + P_COUNT=$(programmable_field "$WORK/programmable-good.log" change_count) + P_CONFINED=$(programmable_field "$WORK/programmable-good.log" analyzer_confined) + P_TWO=$(cat "$PROG_TREE/src/two.rs") + + # ── column two: the same program, a tree with nothing to change ────────── + rm -rf "$PROG_TREE" 2>/dev/null || btrfs subvolume delete "$PROG_TREE" > /dev/null 2>&1 || true + btrfs subvolume create "$PROG_TREE" > /dev/null 2>&1 + mkdir -p "$PROG_TREE/src" + printf '[workspace]\n\n[package]\nname = "verify-programmable"\nversion = "0.1.0"\nedition = "2021"\n' \ + > "$PROG_TREE/Cargo.toml" + cat > "$PROG_TREE/Cargo.lock" <<'PROGLOCK2' +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "verify-programmable" +version = "0.1.0" +PROGLOCK2 + printf 'pub mod one;\npub mod two;\npub mod three;\npub mod four;\npub mod five;\n' > "$PROG_TREE/src/lib.rs" + for n in one two three four five; do + printf 'pub fn %s() -> u32 {\n 1\n}\n' "$n" > "$PROG_TREE/src/$n.rs" + done + NONE_P=$(programmable_program "nothing to do") + programmable_run "'$NONE_P'" > "$WORK/programmable-none.log" + N_STATUS=$(programmable_field "$WORK/programmable-none.log" status) + N_CHANGED=$(programmable_field "$WORK/programmable-none.log" returned.changed) + N_COUNT=$(programmable_field "$WORK/programmable-none.log" change_count) + + # ── column three: a validation that cannot pass ───────────────────────── + rm -rf "$PROG_TREE" 2>/dev/null || btrfs subvolume delete "$PROG_TREE" > /dev/null 2>&1 || true + btrfs subvolume create "$PROG_TREE" > /dev/null 2>&1 + programmable_tree + BEFORE_ONE=$(cat "$PROG_TREE/src/one.rs") + BEFORE_THREE=$(cat "$PROG_TREE/src/three.rs") + BAD_P=$(python3 -c ' +import json +print(json.dumps({"label": "it does not hold", "run": """ +const listing = thalyx.list("src"); +for (const entry of listing.entries || []) { + const path = "src/" + entry.name; + const source = thalyx.read(path); + if (source.ok && source.text.includes("old_api")) { + thalyx.substitute(path, "old_api", "new_api"); + } +} +const check = thalyx.validate({ check: "text", text: "old_api", expect: "some" }); +thalyx.mustPass(check, "old_api should still be there and is not"); +return "should not get here"; +"""}))') + programmable_run "'$BAD_P'" > "$WORK/programmable-bad.log" + B_STATUS=$(programmable_field "$WORK/programmable-bad.log" status) + B_FINISH=$(programmable_field "$WORK/programmable-bad.log" finish) + B_EVIDENCE=$(programmable_field "$WORK/programmable-bad.log" evidence) + AFTER_ONE=$(cat "$PROG_TREE/src/one.rs" 2>/dev/null || echo unreadable) + AFTER_THREE=$(cat "$PROG_TREE/src/three.rs" 2>/dev/null || echo unreadable) + + # ── column four: a program that never stops ───────────────────────────── + LOOP_P='{"label":"forever","run":"thalyx.substitute(\"src/two.rs\", \"1\", \"2\"); while (true) {}"}' + LOOP_STARTED=$(date +%s) + printf '%s\n' "structured on" "cd $PROG_TREE" "hacer '$LOOP_P'" salir | \ + THALYX_ROOT="$PROG_STORE" THALYX_PROGRAM_SECONDS=5 "$THALYX" session 2>&1 | tr -d '\r' \ + > "$WORK/programmable-loop.log" + LOOP_TOOK=$(( $(date +%s) - LOOP_STARTED )) + L_STATUS=$(programmable_field "$WORK/programmable-loop.log" status) + L_FINISH=$(programmable_field "$WORK/programmable-loop.log" finish) + L_TWO=$(cat "$PROG_TREE/src/two.rs" 2>/dev/null || echo unreadable) + + if [ "$P_STATUS" = "committed" ] \ + && [ "$P_FINISH" = "returned" ] \ + && [ "$P_CHANGED" = '["five.rs", "one.rs", "three.rs"]' ] \ + && [ "$P_EXTERNAL" = "1" ] && [ "$P_OPS" -ge 10 ] && [ "$P_ASSERTS" -ge 3 ] \ + && [ "$P_COUNT" = "3" ] \ + && [ "$P_TWO" = "pub fn two() -> u32 { + 2 +}" ] \ + && [ "$P_INTERNAL" -gt "$P_RETURNED" ] \ + && [ "$N_STATUS" = "committed" ] && [ "$N_CHANGED" = "[]" ] && [ "$N_COUNT" = "0" ] \ + && [ "$B_STATUS" = "rolled_back" ] && [ "$B_FINISH" = "assertion" ] \ + && [ "$AFTER_ONE" = "$BEFORE_ONE" ] && [ "$AFTER_THREE" = "$BEFORE_THREE" ] \ + && [ -n "$B_EVIDENCE" ] && [ "$B_EVIDENCE" != "none" ] \ + && [ "$L_FINISH" = "exhausted" ] && [ "$L_STATUS" = "rolled_back" ] \ + && [ "$LOOP_TOOK" -lt 120 ]; then + proven "one request ran a program that listed a directory nobody had described, looped over it, read five files, changed the three that said to and left the two that did not, watched the real subvolume agree, compiled what the change reaches under a denying kernel and committed — $P_OPS machine operations and $P_ASSERTS checked premises for 1 external request, $P_INTERNAL bytes handled inside against $P_RETURNED returned. The same program over a tree with nothing to change changed nothing; the one whose check cannot pass put the subvolume back byte for byte with the diagnosis kept as $B_EVIDENCE; and an endless loop stopped in ${LOOP_TOOK}s and rolled back." + elif [ "$P_STATUS" != "committed" ]; then + failed "the program that should have committed answered '$P_STATUS' ($P_FINISH); see $WORK/programmable-good.log" + excerpt "$WORK/programmable-good.log" + elif [ "$P_CHANGED" != '["five.rs", "one.rs", "three.rs"]' ]; then + failed "the program changed $P_CHANGED, and the three files that use old_api are five.rs, one.rs and three.rs. Either the loop did not look, or it did not decide; see $WORK/programmable-good.log" + excerpt "$WORK/programmable-good.log" + elif [ "$N_CHANGED" != "[]" ] || [ "$N_COUNT" != "0" ]; then + failed "the same program over a tree with nothing to change changed $N_COUNT file(s) and reported $N_CHANGED — so the branch is not a branch. See $WORK/programmable-none.log" + excerpt "$WORK/programmable-none.log" + elif [ "$B_STATUS" != "rolled_back" ] || [ "$AFTER_ONE" != "$BEFORE_ONE" ]; then + failed "the program whose check cannot pass answered '$B_STATUS' ($B_FINISH) and left one.rs as '$AFTER_ONE'; see $WORK/programmable-bad.log" + excerpt "$WORK/programmable-bad.log" + elif [ "$L_FINISH" != "exhausted" ] || [ "$L_STATUS" != "rolled_back" ]; then + failed "an endless loop answered '$L_STATUS' ($L_FINISH) after ${LOOP_TOOK}s and left two.rs as '$L_TWO'; a program that cannot be stopped holds the session open forever. See $WORK/programmable-loop.log" + excerpt "$WORK/programmable-loop.log" + else + failed "the program reported $P_OPS operation(s), $P_ASSERTS assertion(s), $P_EXTERNAL external request(s), $P_INTERNAL internal bytes against $P_RETURNED returned; see $WORK/programmable-good.log" + excerpt "$WORK/programmable-good.log" + fi + + # The gap that only this machine can close, reported beside the result + # rather than folded into it: whether the semantic provider that answered + # was under Thalyx's confinement or was a host process. It is a separate + # claim from "the program worked", and merging them would let a green stage + # hide a compiler tree running with Thalyx's own reach. + if [ "$HAVE_ANALYZER" != 1 ]; then + unproven "the semantic provider was not exercised here, so nothing was said about confining it. Add it with: rustup component add rust-analyzer" + elif [ "$P_CONFINED" = "true" ]; then + proven "the semantic provider ran under Thalyx's confinement — its own cgroup, its own root filesystem, no network, and every cargo and rustc under it inside its pid namespace" + elif [ "$P_CONFINED" = "false" ]; then + unproven "the semantic provider ran as a host process on this machine, so rust-analyzer's Cargo — which compiles and runs build scripts — was not confined. It says so in analyzer_how. Demand it with THALYX_REQUIRE_CONFINED_ANALYZER=1 once the LSM is loaded and enforcing" + else + failed "the answer says '$P_CONFINED' about whether the semantic provider was confined; a run that cannot say is a run that must not be believed either way. See $WORK/programmable-good.log" + excerpt "$WORK/programmable-good.log" + fi + + rm -rf "$PROG_TREE" 2>/dev/null || btrfs subvolume delete "$PROG_TREE" > /dev/null 2>&1 || true +fi +} + +parallel_stages stage_49 stage_50 stage_51 stage_52 stage_53 stage_54 stage_55 stage_56 stage_57 stage_58 stage_59 # ------------------------------------------------- the machine, as it is left # diff --git a/vault/03-Primitivas/Ejecucion-Transaccional.md b/vault/03-Primitivas/Ejecucion-Transaccional.md index e862fdf..7b9cdf5 100644 --- a/vault/03-Primitivas/Ejecucion-Transaccional.md +++ b/vault/03-Primitivas/Ejecucion-Transaccional.md @@ -140,3 +140,34 @@ devuelva de verdad el árbol: `dev/verify.sh`, etapa **56**. Y `rust`/`program` no corren en ningún lado donde el kernel no deniegue, así que aquí sólo dicen `NOT PROVEN`. + +--- + +## Revisión 2026-08-30: la lista de pasos era batching + +Lo de arriba sigue valiendo entero —la frontera, la validación, el testigo, la +evidencia— y **la forma del trabajo cambió**. `steps` obligaba al modelo a saber +cada operación y cada argumento antes de que corriera nada, lo cual no puede +expresar «preguntar, mirar la respuesta, decidir qué sigue»: exactamente lo que +un agente gasta sus turnos haciendo. + +`hacer` toma ahora `run`, un programa. El decreto completo está en +[[Transaccion-Programable]]. + +`steps` **se queda y no está deprecado**: es la forma correcta cuando el trabajo +de verdad se conoce por adelantado, y es la columna de control de toda medición +de lo que la forma programable compra — un uso que no caduca. Se manda una o la +otra, nunca las dos: son dos ideas distintas de qué hacer, y cuál se quiso no es +algo que esta máquina vaya a decidir adentro de una transacción. + +Dos cosas de aquí cambiaron por el programa: + +- **El commit se decide por el último veredicto de cada check.** Un programa + valida, ve que falla, arregla y valida otra vez —patrón que una lista no puede + hacer—, y una transacción que devolviera el árbol porque un intento anterior + falló haría ese patrón imposible de escribir. `not_proven` sigue sin contar + como pasar. +- **`intento` y `hacer` se niegan desde adentro de un programa**, en el momento + de la llamada y no al leer el programa, porque un programa alcanza verbos por + nombre en tiempo de ejecución y no hay nada que inspeccionar por adelantado. + Ver [[Transaccion-Programable]] para lo que una prueba encontró ahí. diff --git a/vault/03-Primitivas/Semantica-Compilada.md b/vault/03-Primitivas/Semantica-Compilada.md index 9382732..67dfcb3 100644 --- a/vault/03-Primitivas/Semantica-Compilada.md +++ b/vault/03-Primitivas/Semantica-Compilada.md @@ -107,3 +107,126 @@ cambió dos archivos reportara dos. direcciones y la identidad), `edits` (rangos a texto). Los verbos que lo exponen son `contexto` y `renombrar-simbolo`, y el paso `rename` dentro de `hacer` — [[Contexto-Progresivo]]. + +--- + +## Revisión 2026-08-30: qué es un nombre cuando es tres nombres + +`Provider::ask_about` resolvía un nombre con «el primer símbolo exacto que +rust-analyzer haya listado». En un espacio de trabajo con `crate_a::Config`, +`crate_b::Config` y `crate_c::Config` eso es: tomar uno de los tres por orden de +índice, describirlo como *el* `Config`, y contestar `source: "rust-analyzer"` — +que es cierto, y es exactamente por lo que se le habría creído. + +Lo que actúa sobre esa respuesta es `renombrar-simbolo`. Así que la falla era: +uno de tres crates reescrito en todos sus usos, elegido por orden de índice, sin +que nada en ningún lado dijera que se había elegido. **Una respuesta equivocada +con confianza es la peor forma que una respuesta equivocada tiene.** + +El decreto ahora es de tres brazos y no de dos: + +- **uno** → se contesta normal; +- **varios** → se contesta una ambigüedad estructurada: cada candidato con su + especie, su crate, su contenedor, su firma y un asa `ruta:línea:columna` — + deliberadamente la forma que `renombrar` y `contexto` **ya** toman, para que + resolver una ambigüedad no obligue a aprender nada nuevo; +- **nada** → se contesta que nada lo declara, que no es lo mismo que «varios» + y tiene otro remedio. + +No hay ranking. Un candidato «más probable» arriba de la lista sería la +adivinanza que esta forma existe para quitar, con un descargo puesto encima. + +**Una mutación contra una ambigüedad se niega antes de escribir.** `place` corre +antes de `rename_texts`, `rename_texts` no escribe en ningún lado, y el ciclo que +abre archivos va después de los dos. La prueba afirma los bytes de los tres +archivos, no el conteo. + +Un programa ([[Transaccion-Programable]]) puede ramificar sobre esto: +`resolution === "ambiguous"` y `thalyx.needModel(candidatos)` devuelve el árbol +intacto y le pregunta al modelo, en lugar de confirmar una adivinanza. + +`contexto` carga `resolution` en **toda** respuesta: `one`, `ambiguous`, +`nothing`, `file`, o `matched` cuando vino del índice — que empareja texto y por +lo tanto nunca tiene derecho a afirmar una ambigüedad. Un campo que sólo aparece +el día interesante es un campo que nadie maneja el día interesante. + +Dos entradas en una misma posición son una declaración: rust-analyzer contesta +una consulta de símbolos desde varios índices y el mismo ítem puede volver dos +veces. Sin deduplicar por posición, un espacio de trabajo con exactamente un +`Config` recibiría una negativa sobre la que nadie puede actuar. + +## Revisión 2026-08-30: el proveedor corre donde corre un huésped + +Este documento y el encabezado del módulo decían que el proveedor es **un +lector**: nunca aplica una edición, un rename vuelve como descripción, y Thalyx +es quien escribe. Todo eso es cierto del protocolo LSP y nada de eso es cierto +del árbol de procesos. + +**rust-analyzer corre Cargo.** Para contestar cualquier cosa sobre un espacio de +trabajo con un proc-macro o un build script adentro, los **compila y los +ejecuta**: código arbitrario de un registro, ejecutándose en tiempo de análisis, +con el alcance que tuviera el proceso que lo arrancó. Que era el de Thalyx: el +sistema de archivos entero, y la red. + +*«No aplica ediciones, por lo tanto es de sólo lectura»* fue la conclusión +equivocada de una premisa cierta, y estuvo escrita una semana. + +Ahora arranca por `thalyx_core::start_foreign` — el mismo establecimiento que +usa `ejecutar`, con una sola puerta de aplicación de política y una sola +asignación de uid. El proveedor recibe: + +- su propio usuario; +- su propio cgroup con una política en el kernel; +- su propio sistema de archivos raíz, con el espacio de trabajo, el toolchain y + el registro, y nada más; +- su propio namespace de pid, así que matar el único proceso que Thalyx sostiene + mata cada `cargo`, `rustc` y build script debajo; +- su propio namespace de red — que es lo que «red denegada por defecto» quiere + decir aquí; +- el mismo filtro seccomp. + +Lo que se le concede: el espacio de trabajo de lectura **y escritura** —lo +primero que rust-analyzer hace sobre un árbol sin `Cargo.lock` es escribir +uno—, el toolchain y el registro de sólo lectura, y un directorio de compilación +**fuera del árbol**. + +El perfil es `semantic_provider`: `module_standard` con dos números cambiados — +6 GiB en lugar de 1, porque un proveedor muerto a media indexación se reporta +como «el analizador expiró», que es una oración cierta sobre la cosa +equivocada; y 2048 procesos en lugar de 512, porque esto es un *árbol* de +compiladores. No es un framework de servicios y hay exactamente uno. + +**Cae de vuelta al anfitrión, lo dice, y se le puede exigir que no.** +`start_foreign` se niega en una máquina cuyo kernel no deniega — decreto de +[[Programas-Ajenos]], y correcto — y un Thalyx que por eso no pudiera resolver +un símbolo sería una máquina donde la cara de programación no existe. Así que en +esa máquina corre en el anfitrión y **toda** respuesta carga +`analyzer_confined: false` con la razón en `analyzer_how`. +`THALYX_REQUIRE_CONFINED_ANALYZER=1` convierte la caída en una negativa: regla 3, +una variable por requisito, para que una máquina que sí puede confinar exija que +lo hizo en lugar de recibir en silencio un proceso de anfitrión el día que el LSM +no cargó. + +Lo que las pruebas exigen es la honestidad, que vale en toda máquina: cada +respuesta semántica dice cuál de las dos ocurrió, los dos campos concuerdan, y +ninguno falta nunca. **Si esta máquina de veras lo confina es pregunta de +`dev/verify.sh`, etapa 59.** + +### Y una lectura que muta el árbol + +Lo encontró la aserción del propio programa de la etapa 59, el 2026-08-30: *«el +árbol muestra 4 cambios y el programa hizo 3»*. El cuarto era `Cargo.lock`. + +rust-analyzer resuelve el grafo de dependencias completo, y resolver escribe un +candado. Así que un espacio de trabajo **sin** `Cargo.lock` gana un archivo +*por haber sido interrogado*: una lectura que muta el árbol, adentro de la +transacción, atribuida a nadie. + +No se esconde ni se filtra. `changed()` lo reporta —es un cambio real— y un +rollback lo quita, que es correcto. Lo que se arregló son las fixtures: un +espacio de trabajo de Rust de verdad tiene su candado versionado, y una fixture +sin él no es un caso pequeño del mundo real, es otro sistema (regla 8). + +Queda escrito porque es la misma familia que el `target/` adentro del snapshot +que se arregló con `CARGO_TARGET_DIR`: **el proveedor semántico tiene efectos en +el sistema de archivos, y "es un lector" no los describe.** diff --git a/vault/03-Primitivas/Transaccion-Programable.md b/vault/03-Primitivas/Transaccion-Programable.md new file mode 100644 index 0000000..e26468d --- /dev/null +++ b/vault/03-Primitivas/Transaccion-Programable.md @@ -0,0 +1,227 @@ +--- +tipo: primitiva +estado: decretado +fecha-decreto: 2026-08-30 +tags: [primitiva, agentes, transaccion, programa, control-de-flujo, fase-1] +--- + +# Transacción programable: el programa que corre aquí + +## Función + +Que **una sola inferencia del modelo entregue un programa corto cuyo flujo de +control continúa localmente** —variables, ciclos, condiciones, filtrado, +aserciones y operaciones dependientes— y que el modelo sólo vuelva a +participar cuando el programa termina, cuando pide explícitamente un juicio, o +cuando de verdad no puede seguir. + +Es la P de TPV. [[Ejecucion-Transaccional]] ya daba la T y la V. + +## Lo que faltaba, dicho exactamente + +`hacer` tomaba un `Vec`: **el modelo tenía que saber cada operación y +cada argumento antes de que corriera nada.** Eso es *batching*, y el batching +no puede expresar lo que un agente de verdad gasta sus turnos haciendo: + +> preguntar, mirar la respuesta, decidir qué sigue. + +Un rename sobre las referencias que una consulta acaba de devolver. Una edición +aplicada sólo a las que tienen cierta cosa alrededor. Una validación cuyo +resultado decide si lo siguiente ocurre. Nada de eso se puede escribir por +adelantado —para escribirlo habría que ya tener la respuesta, que es +precisamente el trabajo—. Así que cada una de esas decisiones era un viaje +redondo al modelo frontera, arrastrando la conversación entera. + +## Qué es + +``` +hacer {"label":"sólo lo que hace falta", + "run":"const listado = thalyx.list('src'); + const tocados = []; + for (const entrada of listado.entries) { + const fuente = thalyx.read('src/' + entrada.name); + if (fuente.text.includes('old_api')) { + thalyx.mustWork( + thalyx.substitute('src/' + entrada.name, 'old_api', 'new_api'), + 'la sustitución no ocurrió'); + tocados.push(entrada.name); + } + } + const visto = thalyx.changed(); + thalyx.assert(visto.count === tocados.length, 'el árbol dice otra cosa'); + const check = thalyx.validate({check: 'rust'}); + if (check.verdict !== 'passed') { return {rendido: check.summary}; } + return {cambiados: tocados};"} +``` + +Una petición externa. Adentro: un listado que nadie conocía, un ciclo sobre él, +una lectura por entrada, una decisión por lectura, mutaciones sólo donde la +lectura lo dice, una observación de lo que el árbol de verdad muestra, una +validación, una rama sobre la validación, y una respuesta chica. Ni una sola +inferencia en medio. + +## El lenguaje: JavaScript sobre QuickJS + +**El lenguaje no es sagrado; las propiedades sí.** Lo que decidió: + +1. **Un modelo frontera ya lo escribe.** Lo único que un lenguaje de scripting + inventado para Thalyx garantiza es que todo modelo que lo use está + escribiendo un lenguaje que nunca vio, a partir de una descripción en un + esquema de herramienta. Ésa es la forma más cara posible de gastar + exactamente la atención que este mecanismo existe para ahorrar. JavaScript + cuesta cero prompt. +2. **QuickJS no tiene autoridad ambiente que quitarle.** Su núcleo es un + lenguaje y nada más: no hay `fs`, no hay `net`, no hay `process`, no hay + `require`, no hay `fetch`. Un programa arranca pudiendo hacer aritmética, y + lo único que alcanza es lo que Thalyx le ata. Eso es **lo contrario** de + incrustar un shell, donde el trabajo sería *restarle* autoridad a algo que + empieza con toda — y restar es la dirección que falla en silencio. +3. **Es C99 sin dependencias**, así que se compila dentro del binario estático + de musl. Regla 12: el binario que se verifica tiene que ser el binario que + se envía, y un runtime que necesitara una biblioteca compartida no podría + estar en la imagen. Es el mismo argumento que el workspace ya hace para + compilar SQLite adentro. +4. **Se puede detener.** Un manejador de interrupción, un techo de memoria y un + techo de pila son parte del motor, así que `while (true) {}` termina por una + razón y no por suerte. + +**No se inventó un DSL, y no se corre un shell.** Ninguna de las dos era una +opción: la primera por (1), la segunda por (2). + +## El programa no es la autoridad + +Es código no confiable escrito por un modelo de lenguaje. Todo lo que puede +hacer lo hace llamando a `Machine`, que se implementa **arriba** de +`thalyx-program`, en quien es dueño de la transacción: + +- **`request`** es `external::one` — la misma función, el mismo chequeo de + argumentos, la misma frontera del espacio de trabajo por la que pasa una + petición sola. Un programa no es una manera de alcanzar un verbo que no está + expuesto, ni una ruta afuera, ni un slot de argumento con el contenido de + otro. +- **`validate`** es `run_check` — el mismo verificador de la lista declarativa. +- **`changed`** es `thalyx_snapshot::difference` contra el mismo snapshot. + +No hay un segundo store, ni un segundo verificador, ni una segunda frontera. Lo +que un programa alcanza es exactamente la unión de lo que sus llamadas habrían +alcanzado una por una. + +El motor corre en **su propio hilo** y le habla a la máquina por un canal. Dos +razones, y la segunda es la que decidió: `rquickjs` ata cerraduras `'static` y +la máquina que una transacción real entrega presta un store, una sesión y un +espacio de trabajo — un canal es la manera ordinaria de prestarle un préstamo a +un hilo, y la alternativa era `unsafe`, que en este repositorio vive en +`thalyx-syscall` y en ningún otro lado. Y: el código no confiable tiene su +propia pila. + +### Dos verbos que un programa no alcanza + +`hacer` y `intento`. La forma estática los rechaza *por nombre* antes de tomar +el snapshot, que es el lugar correcto para una lista: una lista es un valor que +algo puede mirar. **Un programa no.** Alcanza verbos por nombre en tiempo de +ejecución, así que el chequeo tiene que estar en el momento de la llamada. + +Encontrado el 2026-08-30 por una prueba que pidió `intento abandonar` desde +adentro de un programa y recibió `ok: true` con una línea `confirm_with` que +cargaba el nombre del snapshot y el testigo de estado exacto que la máquina +acababa de calcular. Dos líneas más y la transacción se habría abandonado a sí +misma, a media corrida, con el runtime todavía sosteniendo una frontera que ya +no existía. La negativa ahora no entrega nada con qué reintentar. + +## Detenerse se hace cumplir dos veces + +Una aserción que sólo lanzara una excepción de JavaScript puede ser atrapada por +el programa que la falló —`try { thalyx.assert(false) } catch {}`— y la corrida +seguiría más allá de la cosa que debía terminarla. Un programa escrito por un +modelo de lenguaje es exactamente el tipo de programa que envuelve todo en +`try`/`catch`. + +Así que una aserción fallida **queda trabada**: se registra del lado de Rust, +lanza, y desde ese momento el manejador de interrupción detiene el motor y toda +llamada al anfitrión se niega. Un programa no puede atrapar su camino más allá +de una detención, porque lo que lo detiene no está en el lenguaje. + +`thalyx.needModel(valor)` funciona igual, y **no es un fracaso ni un éxito**: la +transacción devuelve el árbol, así que un programa que se topó con una decisión +que no iba a tomar no deja nada atrás. Es la forma en que se contesta una +ambigüedad de [[Semantica-Compilada]]. + +## Techos, porque un lenguaje puede dar vueltas + +`MOST_STEPS` acotaba la forma vieja por construcción. A un programa no lo acota +nada más que contar, y cada recurso lleva su propio techo porque una máquina que +tiene tiempo de sobra y no memoria debe poder decir cuál: + +| techo | qué detiene | +|---|---| +| `wall` | `while (true) {}`, por el manejador de interrupción del motor | +| `ticks` | lo mismo, en unidades que no dependen de qué tan ocupada está la máquina | +| `memory_bytes` | un programa que asigna sin parar | +| `stack_bytes` | recursión sin fin, como negativa y no como este proceso cayéndose | +| `calls` | un ciclo sobre peticiones — el sucesor de `MOST_STEPS` | +| `process_launches` | una explosión de procesos por la vía lenta: validar en un ciclo | +| `answer_bytes` | cuánto puede *entrar* el programa; no es el presupuesto del modelo | +| `returned_bytes` | cuánto puede *salir* — **negado, nunca cortado** | + +Lo último es una decisión y no un detalle: una respuesta cortada a la mitad es +una respuesta sobre la que un modelo actúa creyendo que está completa. Toda +está en la evidencia de cualquier manera. + +Un techo alcanzado produce evidencia estructurada y **rollback por defecto**. + +### La interrupción es atrapable, y eso importó + +QuickJS levanta una interrupción como una excepción ordinaria de JavaScript, o +sea que el programa —o el propio `catch` del envoltorio— la puede atrapar. La +primera versión reportaba `while (true) {}` como *«el programa lanzó»*, que es +una oración sobre el programa en lugar de sobre el techo que alcanzó. Ahora el +manejador marca que decidió detener, y esa marca le gana a lo que el motor haya +alcanzado a decir. Está en [[Estrategia-de-Pruebas]] como regla. + +## Qué cruza de vuelta + +`returned`, y nada más: lo que el programa decidió que importaba. Todo lo demás +—archivos enteros, cada referencia, toda la salida del compilador, la respuesta +completa de cada llamada— se queda adentro bajo el asa de `evidencia`. + +Cada llamada del programa queda en la evidencia como un **paso**, con la misma +forma que produce la lista estática, así que `evidencia paso=N` trae la +novena operación de un programa igual que trae el noveno paso de una lista. Una +forma de «qué pasó», no dos. + +## Cómo se decide confirmar o devolver + +1. El programa tiene que haber **terminado** — `returned`. `needs_model`, + una aserción, una excepción o un techo, no. +2. Toda validación tiene que haber pasado, contando **el último veredicto por + check**. Lo último es por el patrón que un programa hace y una lista no + puede: validar, ver que falla, arreglar, validar otra vez. Una transacción + que devolviera el árbol porque un intento anterior falló haría ese patrón + imposible de escribir. +3. `not_proven` nunca cuenta como pasar. + +`on_failure: "keep"` deja el árbol fallado en su lugar, y se nombra distinto de +un commit. + +## Lo que esto NO es + +- **No es aprendizaje de tasklets.** Nada se promueve, nada se recuerda como + habilidad, no hay ejecución especulativa ni varios rollouts. Primero una + ejecución correcta y poderosa. +- **No es un framework de plugins.** Hay un runtime y un proveedor semántico. +- **No es una manera de agregar verbos.** La manera de darle más alcance a un + programa es exponer más verbos a `external::one`, que es una lista en un lugar + que una persona puede leer. + +## Lo que está probado y lo que es hipótesis + +**Probado**, con contadores y con bytes: que el flujo de control depende de los +datos (`the_answer_of_one_operation_decides_whether_a_third_runs`, corrido sobre +dos árboles donde el programa es idéntico y la rama es distinta); que un árbol +de cinco módulos donde tres usan `old_api` —y cuáles tres no se ve en los +nombres— se resuelve en una petición; que una validación fallida devuelve el +árbol byte por byte; que un programa que pide el modelo no cambió nada; que los +techos detienen; que una aserción no se puede atrapar. + +**Hipótesis**: que todo esto haga que Claude o Codex hagan más trabajo correcto +con menos esfuerzo de modelo. No hay banco pagado corrido contra esto. diff --git a/vault/06-Pendientes/Punto-Actual.md b/vault/06-Pendientes/Punto-Actual.md index f96eef1..4fd5e29 100644 --- a/vault/06-Pendientes/Punto-Actual.md +++ b/vault/06-Pendientes/Punto-Actual.md @@ -1,7 +1,7 @@ --- tipo: estado-vivo estado: activo -fecha-actualizacion: 2026-08-29 +fecha-actualizacion: 2026-08-30 tags: [continuidad, punto-actual, sesiones] --- @@ -14,8230 +14,8329 @@ tags: [continuidad, punto-actual, sesiones] > > Para *cómo* trabajar en el proyecto, ver `CLAUDE.md` en la raíz del repo. -## La cara de programación: qué es un nombre, y qué hay que volver a compilar — 2026-08-29 +## La transacción programable: el modelo entrega un programa, no una lista — 2026-08-30 **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -El sprint absorbió mecanismos que ya funcionan en otro lado y los puso bajo el -modelo de estado, autoridad y transacción de Thalyx. Nada de esto se inventó -aquí; lo que se construyó es la máquina que los junta. +`hacer` tomaba un `Vec`, o sea que el modelo tenía que saber cada +operación y cada argumento **antes** de que corriera nada. Eso es batching, y el +batching no puede expresar lo que un agente de verdad gasta sus turnos haciendo: +preguntar, mirar la respuesta, decidir qué sigue. + +`hacer` toma ahora **un programa**. La P de TPV. Decreto entero en +[[Transaccion-Programable]]. ### Lo que hay ahora -- **`thalyx-know`** — conocimiento persistente por árbol. Cada dato trae la - identidad del estado del que salió y se recuerda como `current`, `stale` o - `unknown`. **No hay forma de sacar el valor sin la postura.** El testigo es - sólo de contenido y acotado, así que un `intento abandonar` no vacía el cache y - un cambio en un paquete ajeno no invalida nada. [[Conocimiento-con-Testigo]]. -- **`thalyx-rust`** — el proveedor semántico. Cargo dice qué es el espacio de - trabajo; rust-analyzer dice qué *es* un nombre. Resuelve el caso que - `corpus/05-alias` lleva meses declarando imposible: `Keys` en `boot.rs` **es** - `Keystore`. [[Semantica-Compilada]]. -- **`contexto`** — una descripción en lugar del archivo. Doscientos bytes contra - diez mil, medido, con un asa que trae exactamente las líneas de esa declaración - cuando el modelo decide que las necesita. Presupuesto explícito y nada se - pierde en silencio. [[Contexto-Progresivo]]. -- **`renombrar-simbolo`** — cambia el nombre donde de veras se usa. La - importación que lo renombra tres archivos más allá sí; el comentario que lo - menciona y la cadena que lo contiene, no. -- **`hacer` con el check `rust` de verdad** — compila los crates que el cambio - **alcanza**, del grafo de Cargo, y **no vuelve a compilar bytes que esta - máquina ya compiló** con este toolchain. +- **`thalyx-program`** — QuickJS compilado adentro. El programa tiene variables, + `if`, ciclos, arreglos, texto, JSON, aserciones, y llama a la máquina; lo que + una llamada devuelve decide qué hace la siguiente. **Corre en su propio hilo, + sin autoridad ambiente**: QuickJS no trae `fs`, ni `net`, ni `process`, ni + `require`, ni `fetch`, y lo único que alcanza es lo que Thalyx le ata. +- **La misma puerta** — `request` es `external::one`, `validate` es `run_check`, + `changed` es la diferencia contra el mismo snapshot. No hay un segundo store + ni una segunda frontera. Un programa alcanza exactamente la unión de lo que sus + llamadas habrían alcanzado una por una. +- **Ocho techos** — reloj, instrucciones, memoria, pila, llamadas, procesos, + bytes que entran, bytes que salen. `while (true) {}` termina. Un techo + alcanzado hace rollback. +- **Aserciones que no se pueden atrapar** — quedan trabadas del lado de Rust; un + `try`/`catch` no pasa por encima. +- **`thalyx.needModel(...)`** — ni éxito ni fracaso: devuelve el árbol y le + pregunta al modelo. Es cómo se contesta una ambigüedad. +- **Ambigüedad semántica** — `Config` declarado en tres crates ya **no** se + resuelve por orden de índice. Tres candidatos con asa `ruta:línea:columna`, y + `renombrar-simbolo` se niega **antes de escribir**. [[Semantica-Compilada]]. +- **rust-analyzer confinado** — corre Cargo, que compila y ejecuta build + scripts, así que corre donde corre un huésped: cgroup, política en el kernel, + raíz propia, sin red, namespace de pid propio, seccomp. Cae al anfitrión donde + nada puede denegar, **lo dice en cada respuesta**, y + `THALYX_REQUIRE_CONFINED_ANALYZER=1` convierte la caída en negativa. +- **Superficie MCP de tres herramientas** — `thalyx_context`, `thalyx_exec`, + `thalyx_evidence`. Las otras once siguen ahí, detrás de + `--surface legacy`, porque son la columna de control. ### El vertical, que es el punto -Una sola petición externa: resolver el símbolo, reescribir cada uso real, -observar el árbol, derivar qué compilar, reutilizar lo que sigue valiendo, -compilar lo que falta, confirmar o devolver todo — **sin una sola inferencia del -modelo en medio**. La prueba lo afirma contando: `external_requests == 1`, -`analyzer_starts == 1`, y su gemela con una validación que no puede pasar deja los -dos archivos byte por byte como estaban con el diagnóstico intacto en el store. +Un árbol de cinco módulos; tres usan `old_api` y **cuáles tres no se ve en los +nombres**. Una petición externa: lista el directorio, cicla, lee los cinco, +cambia los tres que lo dicen, deja los dos que no, observa que el árbol de veras +coincide, valida, ramifica sobre el veredicto, y contesta tres nombres. Cero +inferencias en medio. -### Lo que encontró el camino +Una lista de pasos no puede expresarlo: para producir el mismo resultado tendría +que ya contener los tres nombres, que son la respuesta. -Tres defectos, los tres cachados por pruebas que **cuentan** en lugar de mirar el -veredicto, y los tres escritos en [[Estrategia-de-Pruebas]]: +Y las tres columnas que lo hacen creíble: el mismo programa sobre un árbol sin +nada que cambiar no cambia nada; el que valida y no pasa devuelve el árbol byte +por byte con el diagnóstico guardado; y `while (true) {}` se detiene y hace +rollback. -1. Una ruta que no está se contaba como ilegible, el testigo salía incompleto, y - el cache de validación no acertó ni una vez — en silencio, con todos los - veredictos correctos y el costo entero. -2. rust-analyzer corre Cargo, y Cargo sin `CARGO_TARGET_DIR` construye **dentro - del espacio de trabajo**: el snapshot llevaba un árbol de compilación y el - rollback lo destruía. -3. Un solo proveedor global por proceso hacía que dos pruebas se desalojaran por - turnos, y la métrica «un arranque por petición» quedaba midiendo al - planificador. Regla 11 donde nadie había mirado. +### Lo que encontró el camino -### Lo que sólo la máquina de Cesar puede decir +Siete defectos, todos escritos en [[Estrategia-de-Pruebas]]. Los tres que más +cuestan: + +1. **Un campo que desaparece el día interesante.** Las dos pruebas que fallaron + en Fedora afirmaban `cached == false` y recibían `Null` — porque el brazo + *«no hay cargo aquí»* nunca escribía el campo, y ése era el brazo que tomaba + una máquina donde `sudo` había puesto `HOME=/root` delante del toolchain. Dos + personas leyeron dos aserciones sobre un cache; lo que había pasado era un + `HOME`. +2. **`intento` alcanzable desde adentro de un programa.** Una prueba pidió + `intento abandonar` y recibió `ok: true` con el nombre del snapshot y el + testigo de estado exacto. Dos líneas más y la transacción se abandona a sí + misma a media corrida. Lo que se revisa por nombre en una lista hay que + revisarlo por llamada en un programa. +3. **Una lectura que muta el árbol.** rust-analyzer escribe `Cargo.lock` al + resolver, así que un espacio de trabajo sin candado gana un archivo por haber + sido interrogado. -`dev/verify.sh` tiene dos etapas nuevas. La **57** no necesita Btrfs y ya pasa en -el contenedor. La **58** es el vertical entero sobre un subvolumen de verdad, con -el compilador corriendo confinado bajo un kernel que sí deniega, y con la tercera -columna que ninguna otra máquina puede dar: la segunda petición sobre los mismos -bytes **no arranca ningún compilador**. +### Lo que sólo la máquina de Cesar puede decir -Antes de correrla: +La etapa **59** de `dev/verify.sh`: el vertical programable sobre un subvolumen +de Btrfs de verdad, con el compilador corriendo bajo un kernel que sí deniega, y +una columna aparte que dice si el proveedor semántico quedó confinado o no. ``` -rustup component add rust-analyzer git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh ``` -Sin rust-analyzer las dos etapas dicen `NOT PROVEN` y nombran el comando; no se -callan y no fingen. +Las etapas 57 y 58 deberían pasar ahora sin cambiar una sola aserción: lo que +las tenía en `NOT PROVEN` era la búsqueda del toolchain bajo `$HOME`. ### Lo que sigue siendo hipótesis Que todo esto haga que Claude o Codex hagan más trabajo correcto con menos -esfuerzo de modelo. Está construido y probado pieza por pieza; **no está -medido**. No se corrió ningún banco pagado en este sprint, a propósito: primero -se construye el contendiente. +esfuerzo de modelo. **No está medido.** No se corrió ningún banco pagado en este +sprint, a propósito. --- -> ## Lo que Fedora encontró: el testigo, la unión, y el candado — 2026-08-29 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> La primera corrida del vertical nuevo en la máquina de Cesar. La etapa **56 -> pasó**: una petición corrió ocho operaciones internas, cambió cuatro cosas y -> confirmó; la variante con validación fallida hizo rollback byte por byte y -> conservó el diagnóstico. `hacer`/`thalyx_exec` existe de verdad sobre Btrfs. -> -> La etapa **55 falló**, y encontró tres cosas. -> -> ### 1. El rechazo nunca llegaba a decir `workspace_moved` -> -> **Ésta es la causa exacta de la etapa 55, y no era el testigo.** El rollback -> caduco no destruyó nada: contestó `done: false` con una línea `confirm_with` -> **nueva**. `consent`, en `thalyx-cli`, comparaba la declaración del llamador -> contra el testigo con el que se había hecho el plan y devolvía el objeto de -> costo si no coincidían — así que la llamada nunca llegaba a la comprobación -> bajo el candado, que es la única que produce esa palabra. -> -> Un agente en un ciclo copia esa línea nueva y en la llamada siguiente sí pierde -> el trabajo de la persona. Ahora `consent` no compara nada: nombrar el intento y -> nombrar un estado **es** la autorización, y si es cierta se decide bajo el -> candado. Las dos mitades estaban probadas y la unión no; la regla quedó en -> [[Estrategia-de-Pruebas]]. -> -> ### 2. Un testigo hecho de timestamps no es una identidad -> -> El propio `state_identity.rs` dormía veinte milisegundos entre dos escrituras -> porque dos escrituras seguidas caben en un tic del sistema de archivos. **Esa -> espera era el caso real**, no un detalle del arnés: el agente escribe, toma el -> estado, y una persona escribe el mismo archivo, del mismo largo, enseguida. -> -> El testigo es ahora `w2` y cubre **lo que cada ruta contiene** —los bytes de un -> archivo regular, el destino de un enlace, la especie de un fifo, que nunca se -> abre—. Cuesta leer el árbol entero en cada comprobación y lo dice: `state_bytes`. -> El contador de mutaciones del kernel se inspeccionó como la vía barata y **se -> descartó**: su gancho de escritura es `lsm/file_permission` y una página sucia -> de `mmap` no pasa por ahí, además de que exige `bpftool` y privilegio que dentro -> de la imagen no existen. Todo escrito en [[Identidad-de-Estado]]. -> -> No hay ningún `sleep` nuevo. La etapa 55 quedó **más** agresiva: mismo archivo, -> mismo tamaño, por un descriptor abierto antes de tomar el estado, sin esperar -> nada, más una columna de trabajo fuera del árbol que no debe invalidar nada. -> -> ### 3. La ventana entre comprobar y reemplazar, y el candado que no se soltaba -> -> La comprobación se hacía bajo el candado pero **antes** de que el restore se -> preparara, así que entre la respuesta y el intercambio quedaban el diario y la -> copia escribible del snapshot. Ahora `Snapshots::prepare_restore` arma todo y -> `Prepared::commit` es sólo el `RENAME_EXCHANGE`; la última mirada va en medio. -> La ventana que queda —un recorrido más un `renameat2`— no se niega: lo que cae -> dentro no se destruye, se desplaza al árbol que el restore conserva. -> -> Y la única prueba que falló en la suite completa y nunca aislada, -> `the_lock_is_released_when_it_goes_out_of_scope`: `flock` vive en la -> descripción de archivo, `fork` copia todos los descriptores, y cerrar no suelta -> un candado que un hijo a medio camino de su `exec` todavía referencia. Thalyx -> lanza `btrfs` y `bpftool` **con el candado tomado**, así que era un defecto del -> producto y no de la prueba. `ContractLock::drop` ahora hace `flock(LOCK_UN)`. -> -> ### Lo que sigue -> -> Correr `sudo ./dev/verify.sh` en Fedora. **El banco pagado de Claude no se -> corrió y no debe correrse todavía.** -> -> Dos cosas, y la primera es un defecto sobre el que la segunda no se podía -> construir. -> -> ### 1. Contar archivos no es decir cuál árbol -> -> El abandono en una llamada del 2026-08-28 se autorizaba con una declaración -> sobre los **conteos** — `delete= revert=` —. El argumento era que si una -> persona escribe en el árbol compartido, uno de los dos números se mueve. -> -> **No se mueve.** Alguien que edita un archivo que el agente *ya* había editado -> no mueve ninguno: un archivo modificado antes, un archivo modificado después. -> La declaración seguía coincidiendo y la edición de esa persona volvía al -> snapshot. -> -> Lo reemplaza `thalyx_snapshot::Witness`: un digest sobre cada ruta del árbol -> con su tamaño, su mtime, su ctime y su inodo, tomado con el mismo recorrido con -> el que se planea el restore. Cualquier escritura lo mueve. La declaración ahora -> es `state=` y se comprueba **dentro del candado**, en el instante -> anterior a reemplazar el árbol — una comprobación afuera del candado es una -> comparación con un momento que ya pasó. `delete=`/`revert=` se rechazan -> nombrando lo que los reemplazó, no se ignoran. Todo en -> [[Identidad-de-Estado]]. -> -> El contraejemplo está escrito dos veces como aserción, con su control positivo -> al lado: en `thalyx-snapshot/tests/state_identity.rs` y, de punta a punta sobre -> un árbol real, en `thalyx_core::attempt`. -> -> ### 2. `hacer`: varias peticiones como una transacción -> -> [[Ejecucion-Transaccional]], y la hipótesis que lo pide está en -> [[Trabajo-Entre-Inferencias]]. El verbo toma un **programa** —varias -> peticiones, qué tiene que ser cierto al final, y qué hacer si no lo es—, abre -> la frontera reversible, las corre en orden, observa lo que de verdad cambió, -> valida, y confirma o devuelve el árbol. Todo antes de contestar. -> -> - **No es un shell** y no arranca uno: lo que se compone son las peticiones -> propias de Thalyx. -> - **No es una segunda autoridad**: cada paso pasa por `external::one`, la misma -> función y la misma tabla que una petición suelta. Probado con una ruta fuera -> del espacio de trabajo y con cuatro verbos que no están expuestos. -> - **No es una etiqueta**: la frontera es un snapshot, el rollback es un restore -> y lo autoriza el testigo de arriba. -> - **No es validación que siempre pasa**: `text`, `parses`, `rust` y `program` -> establecen algo o contestan `not_proven`, y `not_proven` nunca es `passed` — -> una comprobación que no se pudo correr devuelve el trabajo. -> -> La respuesta es chica a propósito; todo lo crudo queda en el store —fuera del -> espacio de trabajo, porque el rollback reemplaza el espacio de trabajo— y se -> pide con `evidencia [paso=N]`. -> -> Salió también un verbo nuevo del parser: `unbalanced`, que dice si una edición -> mecánica se comió una llave. Lo encontró el propio archivo del parser: la -> primera versión estaba hecha sobre `scrub`, que ante un `'` suelto blanquea el -> resto del renglón —porque en Rust eso es un lifetime— y se comía la llave de -> `pub fn name(self) -> &'static str {`. La prueba es cada `.rs` de este -> repositorio, noventa mil renglones que nadie escribió para ella. -> -> ### Lo que está probado aquí, sin gastar API -> -> En el fixture, en este contenedor: **una petición externa, diez operaciones -> adentro de la máquina**, escrito como igualdad exacta y no como piso. Rollback -> automático con el árbol byte por byte. Estado obsoleto que falla cerrado, con -> control positivo y negativo. Compresión: la respuesta mide menos de un cuarto -> de lo que la máquina produjo adentro. -> -> ### Lo que falta comprobar, y es de Cesar -> -> Btrfs. Aquí la frontera se ejercita contra el falso de directorios, y `rust`/ -> `program` no corren donde el kernel no deniega. -> -> ``` -> git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh -> ``` -> -> Las etapas nuevas son la **55** —un rollback autorizado contra un árbol en el -> que alguien más escribió, que debe rechazarse y no destruir nada, con los -> conteos impresos al lado para mostrar que no se movieron— y la **56** —una -> llamada que cambia cuatro cosas y confirma, y la misma forma con una -> comprobación que falla devolviendo un subvolumen real byte por byte—. -> -> ### Lo que NO se puede decir todavía -> -> Que esto sea más barato, más rápido, o que reduzca los pasos de inferencia de -> un agente real. Eso lo contesta el siguiente banco controlado. Lo único -> afirmable hoy es estructural, y el instrumento para medirlo ya existe: -> `thalyx-mcp --metrics` escribe `programs.operations_per_request` y -> `dev/bench-summary.py` lo reporta. - -> ## Menos rondas por tarea reversible: el intento se abre con la mutación y se abandona en una llamada — 2026-08-29 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> **Corregido al día siguiente**, y hay que leerlo sabiéndolo: el abandono en una -> llamada sigue existiendo, y **los dos conteos que este bloque describe se -> retiraron** — no alcanzaban para decir qué árbol se está destruyendo. Ver el -> bloque de arriba y [[Identidad-de-Estado]]. -> -> Salió de las tres corridas reales del banco reversible —#4, #5 y #6, escritas -> enteras en [[Evidencia-de-Agentes]]—. `sustituir-lote` quedó comprobado: **1 -> edición mutante, 0 lecturas de archivo y restore byte a byte en las tres**. Y -> justo por eso el cuello se movió: ya no es la edición, es **el número de -> rondas**, porque cada ronda vuelve a arrastrar contexto. Thalyx ganó reloj, -> API y output en 3/3, y costo en 1/3. -> -> Las corridas 5 y 6 son idénticas: B gasta 6 llamadas donde Linux gasta 2. Esas -> 4 de diferencia se repartieron en tres mecanismos, y **uno quedó descartado por -> la evidencia**: -> -> - **descubrimiento de herramientas: 2 llamadas** —`ToolSearch` dos veces, y la -> primera es una *selección fallida*; -> - **protocolo del intento: 2 llamadas** —`begin` como viaje propio, y `abandon` -> repetido; -> - **composición de Bash: 0 llamadas.** En esas dos corridas B no localizó ni -> verificó nada, así que no hubo nada que componer. Por eso **no se hizo un -> verbo de lote genérico y no se metió shell**: no hay evidencia que lo pida. -> -> ### Lo que se construyó -> -> **1. El intento se abre en la misma llamada que la primera mutación.** -> `thalyx_edit` y `thalyx_file` aceptan `attempt: "begin"`, y el adaptador manda -> dos preguntas en un solo viaje —el snapshot primero, siempre—. Es la -> composición que ese crate ya hacía para `thalyx_state`, que son tres. Si el -> intento no se puede abrir, la mutación no ocurre: nada cambia. -> -> **2. Abandonar en una llamada, y más fuerte que antes.** -> -> ``` -> intento abandonar snapshot= delete= revert= -> ``` -> -> Procede sólo si el intento nombrado es el que está en el registro **y** los dos -> números son exactamente lo que el árbol tiene en ese momento. La respuesta que -> niega el permiso entrega esa línea ya armada, así que decir que sí cuesta una -> llamada y nunca una adivinanza. -> -> Esto **no debilita** [[Camino-Confiable]]: lo aprieta. Si una persona escribió -> en el árbol compartido mientras el intento estaba abierto, uno de esos dos -> números se mueve, la declaración deja de coincidir y **no se destruye nada** — -> que es justo el caso que la protección existía para cubrir y que `confirm: -> true` a ciegas nunca cubrió. El camino viejo (`si`, `confirm: true`) sigue -> intacto, palabra por palabra. -> -> **3. Las instrucciones nombran todas las herramientas.** La primera -> `ToolSearch` de las corridas 5 y 6 fue una selección fallida: el agente pidió -> herramientas por nombre y las nombró mal. Lo único que Thalyx controla ahí es -> el texto que el modelo lee **antes** de buscar, así que ahora lleva la lista -> exacta, generada de lo que la máquina ofrece de verdad. -> -> ### Lo que se midió, sin gastar API -> -> El puente, con `dev/bridge-cost.sh` —sin QEMU y sin modelo—: -> -> ``` -> en la máquina 0.40–0.55 ms por pregunta -> en el adaptador 0.08–0.10 ms por pregunta -> ``` -> -> **Los ~6 s de diferencia entre reloj total y API no son el puente.** Ni con un -> factor de cien por virtio darían seis segundos en nueve preguntas. No se forzó -> ninguna optimización ahí. `thalyx-mcp --metrics` ahora escribe -> `machine_requests` y `machine_seconds`, así que la próxima corrida real -> contesta esto sobre sí misma sin costar nada. -> -> ### Lo que falta comprobar -> -> Las pruebas nuevas que corren acá son de decisión y de forma: la lógica de -> consentimiento entera —siete pruebas, sin filesystem—, el conteo de viajes en -> el adaptador, la frontera externa y las palabras llegando al verbo en un prompt -> real. **Lo que este contenedor no puede correr es abandonar de verdad**, que -> necesita Btrfs: -> -> ``` -> git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh -> ``` -> -> Y **la reducción de rondas frente a un agente real no está medida**: lo que -> está probado es que 4 llamadas de esta superficie se vuelven 2. Que eso mueva -> costo o reloj es hipótesis hasta que el banco se vuelva a correr. -> -> ### Una decisión que es de Cesar -> -> `confirm: true` a ciegas **sigue funcionando** en el canal del agente. Se dejó -> a propósito: quitarlo haría obligatorio el camino fuerte, y es un decreto de -> [[Camino-Confiable]], no una decisión de implementación. Está en -> [[Tareas-Pendientes]]. - -> ## El fixture de Btrfs, aislado de verdad: una arena por prueba — 2026-08-29 -> -> Los bloques de abajo son cómo se llegó. -> -> Cesar corrió el control que cierra el diagnóstico anterior, en su Fedora, con -> Btrfs de verdad: -> -> ``` -> THALYX_REQUIRE_BTRFS_TESTS=1 THALYX_BTRFS_SCRATCH=/home/cesarmanzocode \ -> cargo test -p thalyx-snapshot --test natively -- --test-threads=1 --nocapture -> ``` -> -> **4 passed, 0 failed, en 0.06 s**, y `btrfs` estuvo de acuerdo con el kernel en -> todo lo que se le preguntó. La misma prueba que fallaba en paralelo pasa en -> serie: **la lógica Btrfs funciona y el fallo era interferencia entre las pruebas -> paralelas.** -> -> El recurso que compartían no era el nombre del subvolumen —eso ya se había -> arreglado— sino el que el producto **deriva** de él: `Snapshots::directory()` -> pone los snapshots en el **padre** de la fuente, así que cuatro fuentes con -> nombres distintos hechas en el mismo directorio compartían un solo -> `THALYX_BTRFS_SCRATCH/.thalyx-snapshots`, con nombres de snapshot que chocaban, -> y cada `clean()` se lo llevaba entero mientras las otras trabajaban adentro. -> -> Lo que quedó es **una arena por prueba**: un directorio ordinario privado con la -> fuente adentro, de modo que `directory()` caiga adentro también. -> -> ``` -> THALYX_BTRFS_SCRATCH/thalyx-native---/ -> source -> .thalyx-snapshots/ -> ``` -> -> Nada se serializa: el producto está hecho para tener varios árboles a la vez. -> `` es un contador atómico del proceso; la limpieza es por propiedad —sólo lo -> que hay bajo la raíz de la arena, explícita al final de cada prueba y otra vez -> en `Drop` para la ruta donde la prueba se cayó—; y nada se borra antes de -> crearse, porque el ayudante viejo empezaba borrando la ruta que iba a usar, que -> es el acto que destruía el árbol ajeno. -> -> Lo mismo se aplicó a `taking.rs` y a las pruebas de `intento` en `thalyx-cli`, -> que escribían en ese mismo directorio compartido desde **otros binarios que -> `cargo test` corre a la vez**; `taking.rs` incluso terminaba haciéndole -> `remove_dir_all`. -> -> El control nuevo es determinista, sin hilos y sin `sleep`: -> `cleaning_one_arena_leaves_the_other_arenas_snapshot_untouched` hace dos arenas -> con la misma etiqueta, toma en las dos un snapshot con el mismo nombre, limpia -> una y comprueba que la otra sigue entera. Al lado, -> `two_arenas_asked_for_under_one_label_are_never_given_the_same_name` es puro -> nombre y corre **también en este contenedor**, que no tiene Btrfs. La regla -> quedó escrita en [[Estrategia-de-Pruebas]]. -> -> **Lo que falta comprobar:** aquí las cinco pruebas que necesitan Btrfs sólo -> dicen `NOT PROVEN`. En la máquina de Cesar, primero en serie y después en -> paralelo —que es lo que estaba roto—: -> -> ``` -> cargo test -p thalyx-snapshot --test natively -- --test-threads=1 --nocapture -> cargo test -p thalyx-snapshot --test natively -- --nocapture -> ``` +> ## La cara de programación: qué es un nombre, y qué hay que volver a compilar — 2026-08-29 > -> con `THALYX_REQUIRE_BTRFS_TESTS=1` y `THALYX_BTRFS_SCRATCH=/home/cesarmanzocode`. -> `sudo ./dev/verify.sh` va **después**, una sola vez, cuando el paralelo pase -> repetido. - -> ## El único `FAILED` de la corrida real: una prueba que medía `rmdir` — 2026-08-29 -> -> La corrida de Fedora sobre `cffb4f8` quedó en **202 PROVEN, 13 NOT PROVEN, 1 -> FAILED**. El único fallo era -> `restoring_makes_a_writable_copy_and_deleting_takes_it_away_again`, de -> `crates/thalyx-snapshot/tests/natively.rs`, y **no era de Thalyx**: la copia -> escribible era un subvolumen de verdad —el `BTRFS_IOC_SUBVOL_GETFLAGS` de tres -> renglones antes ya lo había dicho, y ese ioctl contesta `EINVAL` en cualquier -> cosa que no sea la raíz de uno—. Lo que estaba mal era la premisa de la -> comprobación: decía que `remove_dir_all` no puede llevarse un subvolumen, y -> desde Linux 4.18 (`a79a464d5675`) `rmdir(2)` sí se lleva uno **vacío**. La -> prueba medía la política de `rmdir` del kernel, no el objeto — la misma forma -> que `chrt --other` midiendo util-linux. -> -> En su lugar la prueba le pregunta a `stat(2)`, que es una fuente distinta de la -> que usa el código: la raíz de todo subvolumen es el inodo 256 -> (`BTRFS_FIRST_FREE_OBJECTID`) y tiene su propio dispositivo anónimo, que es el -> par que mira `libbtrfsutil`. Con el control negativo al lado —un directorio -> ordinario en el mismo directorio, que tiene que fallar las dos mitades— y la -> segunda opinión de `btrfs subvolume show` donde haya btrfs-progs. La regla -> quedó escrita en [[Estrategia-de-Pruebas]]. -> -> **Lo que falta comprobar:** este contenedor no tiene Btrfs, así que la prueba -> aquí sólo dice `NOT PROVEN`. La corrección se ejerce en la máquina de Cesar: +> Cómo se llegó a lo de arriba. > -> ``` +> El sprint absorbió mecanismos que ya funcionan en otro lado y los puso bajo el +> modelo de estado, autoridad y transacción de Thalyx. Nada de esto se inventó +> aquí; lo que se construyó es la máquina que los junta. +> +> ### Lo que hay ahora +> +> - **`thalyx-know`** — conocimiento persistente por árbol. Cada dato trae la +> identidad del estado del que salió y se recuerda como `current`, `stale` o +> `unknown`. **No hay forma de sacar el valor sin la postura.** El testigo es +> sólo de contenido y acotado, así que un `intento abandonar` no vacía el cache y +> un cambio en un paquete ajeno no invalida nada. [[Conocimiento-con-Testigo]]. +> - **`thalyx-rust`** — el proveedor semántico. Cargo dice qué es el espacio de +> trabajo; rust-analyzer dice qué *es* un nombre. Resuelve el caso que +> `corpus/05-alias` lleva meses declarando imposible: `Keys` en `boot.rs` **es** +> `Keystore`. [[Semantica-Compilada]]. +> - **`contexto`** — una descripción en lugar del archivo. Doscientos bytes contra +> diez mil, medido, con un asa que trae exactamente las líneas de esa declaración +> cuando el modelo decide que las necesita. Presupuesto explícito y nada se +> pierde en silencio. [[Contexto-Progresivo]]. +> - **`renombrar-simbolo`** — cambia el nombre donde de veras se usa. La +> importación que lo renombra tres archivos más allá sí; el comentario que lo +> menciona y la cadena que lo contiene, no. +> - **`hacer` con el check `rust` de verdad** — compila los crates que el cambio +> **alcanza**, del grafo de Cargo, y **no vuelve a compilar bytes que esta +> máquina ya compiló** con este toolchain. +> +> ### El vertical, que es el punto +> +> Una sola petición externa: resolver el símbolo, reescribir cada uso real, +> observar el árbol, derivar qué compilar, reutilizar lo que sigue valiendo, +> compilar lo que falta, confirmar o devolver todo — **sin una sola inferencia del +> modelo en medio**. La prueba lo afirma contando: `external_requests == 1`, +> `analyzer_starts == 1`, y su gemela con una validación que no puede pasar deja los +> dos archivos byte por byte como estaban con el diagnóstico intacto en el store. +> +> ### Lo que encontró el camino +> +> Tres defectos, los tres cachados por pruebas que **cuentan** en lugar de mirar el +> veredicto, y los tres escritos en [[Estrategia-de-Pruebas]]: +> +> 1. Una ruta que no está se contaba como ilegible, el testigo salía incompleto, y +> el cache de validación no acertó ni una vez — en silencio, con todos los +> veredictos correctos y el costo entero. +> 2. rust-analyzer corre Cargo, y Cargo sin `CARGO_TARGET_DIR` construye **dentro +> del espacio de trabajo**: el snapshot llevaba un árbol de compilación y el +> rollback lo destruía. +> 3. Un solo proveedor global por proceso hacía que dos pruebas se desalojaran por +> turnos, y la métrica «un arranque por petición» quedaba midiendo al +> planificador. Regla 11 donde nadie había mirado. +> +> ### Lo que sólo la máquina de Cesar puede decir +> +> `dev/verify.sh` tiene dos etapas nuevas. La **57** no necesita Btrfs y ya pasa en +> el contenedor. La **58** es el vertical entero sobre un subvolumen de verdad, con +> el compilador corriendo confinado bajo un kernel que sí deniega, y con la tercera +> columna que ninguna otra máquina puede dar: la segunda petición sobre los mismos +> bytes **no arranca ningún compilador**. +> +> Antes de correrla: +> +> ``` +> rustup component add rust-analyzer > git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh > ``` - -> ## Cuatro fallas de `verify.sh`, y catorce etapas que ya no se esperan unas a otras — 2026-08-29 -> -> ### Las cuatro fallas, y ninguna era de Thalyx -> -> Las cuatro venían del instrumento. Están escritas enteras en -> [[Estrategia-de-Pruebas]]; en corto: -> -> - **Dos pruebas de `intento` se peleaban por un subvolumen.** `btrfs_scratch()` -> nombraba su subvolumen `thalyx-substitute-` y empezaba borrándolo —y -> `cargo test` corre las pruebas de un archivo como hilos de **un** proceso, así -> que las dos tenían el mismo nombre. La segunda destruía el árbol de la primera -> y después no podía crear lo que ya existía. Ahora el nombre lleva la etiqueta -> de la prueba. `natively.rs` tenía lo mismo, con cuatro. -> - **`replacements` esperaba 4 sobre un fixture con 3.** Aritmética escrita a -> mano en una prueba que este contenedor nunca corre. -> - **La línea del lote estaba mal formada.** Sólo la primera operación toma -> prestado el archivo de antes del subverbo; las demás listan los suyos. La -> máquina leía `'(SlotTable, usize)'` como nombre de archivo y rechazaba el lote -> entero. Las dos constantes salieron ahora de correr los verbos de verdad. -> - **El auto-test del arnés medía el PATH de root.** Sus dos comprobaciones de -> «esto se niega **antes** de arrancar un agente» eran las dos únicas -> sub-invocaciones sin un `claude` sustituto en el PATH, y `sudo` no le da uno a -> root. Morían con `no claude on this host` antes de llegar a la negativa que -> probaban. Ahora tienen un sustituto que **escribe un archivo si alguien lo -> llama**, así que «no se arrancó ningún agente» es un testigo y no una -> inferencia sobre un `.ndjson` vacío. -> -> ### Y catorce etapas corren de a grupos -> -> `parallel_stages` corre un grupo de etapas a la vez, guarda la salida de cada -> una en su archivo y la reimprime **en el orden en que se lanzaron**, así que el -> reporte se lee igual que antes. Los veredictos vuelven por archivo, porque un -> trabajo en segundo plano es una subshell y lo que cuenta se muere con ella; una -> etapa que vuelve sin veredicto es un `FAILED` aquí, nunca una etapa que -> silenciosamente no pasó. -> -> Los cuatro grupos son **21-24**, **28-30**, **33-35** y **49-52**: cada una de -> esas etapas se construye su propio almacén bajo `$WORK`, le pregunta a `$THALYX` -> y lo tira. **Todo lo demás sigue serial**, y por dos razones distintas: lo que -> escribe algo global —BPF, cgroups, montajes, loop, Btrfs, QEMU, `/dev/fb0`— es -> la regla 11, y lo que mide tiempos o carreras —31, 32— no tiene defensa contra -> el ruido que este mismo mecanismo produce (regla 7). -> -> Medido en el contenedor, en caliente: **104.7s -> 94.7s, 9.6% menos**, con el -> reporte idéntico línea por línea y los mismos 98 / 43 / 0. En la máquina de -> Cesar la fracción será menor: lo que domina ahí —QEMU, Btrfs, el LSM— es -> justamente lo que se quedó serial. -> -> ### Lo que falta -> -> **La verificación de siempre**, que es lo único que puede cerrar las dos -> pruebas de Btrfs: aquí no hay Btrfs y las dos se saltan. -> -> ```sh -> cd ~/thalyx && git pull -> cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh -> ``` -> -> Y sigue pendiente, sin tocar, **el regrade de la corrida que ya existe** que -> pide el bloque de abajo. - ---- - -> ## Cinco llamadas eran un solo plan, y el brazo B nunca había salido de ningún lado — 2026-08-29 -> -> ### Lo que quedó hecho -> -> **Dos errores del instrumento, ninguno del sistema medido.** -> -> - `--out` relativo. `run_arm` hace `cd` al directorio del agente antes de -> ejecutar `claude`, así que un `--out target/…` hacía que -> `--settings $OUT/armA.settings.json` se resolviera contra ese directorio y -> no contra donde estaba parado quien lo escribió. El brazo A de la corrida -> del 2026-08-29 contestó `Settings file not found.` por eso. Arreglado en un -> solo lugar, `normalise_paths`, con self-test que corre desde un directorio -> que no es ninguno de los dos. -> - **El scope del brazo B era un falso positivo.** El grader comparaba -> `/home/bench-thalyx` —el espacio de trabajo, adentro de la máquina— con el -> directorio host donde arrancó el proceso `claude`, y los declaraba -> distintos. Lo son: no están en el mismo espacio de nombres. Ahora -> `host_control_cwd` y `guest_project_workspace` son dos palabras distintas -> para siempre, y cada brazo se juzga bajo la frontera que ese brazo tiene: la -> del A es que arrancó adentro del árbol y no salió; la del B es el canal —cero -> herramientas host que puedan tocar el proyecto, toda ruta que la máquina -> *aceptó* bajo el espacio guest, toda ruta con la que *contestó* también, y el -> preflight probando de antemano a qué árbol apuntaba el canal. Una ruta que la -> máquina **rechazó** no es una brecha: es la frontera funcionando. -> - Y de paso, un hueco más viejo: `paths` en plural no era un campo que el -> grader conociera y su barrido de respaldo sólo miraba cadenas, así que toda -> ruta nombrada dentro de una lista era una ruta que nadie revisaba — que es -> exactamente cómo `thalyx_edit` nombra sus archivos. -> -> **`editar … sustituir-lote`**: varias sustituciones exactas, cada una con sus -> archivos, en una llamada. La corrida post-`sustituir` bajó de 16 mutaciones a -> 5, y las cinco eran el mismo plan — la ruta calificada, la definición, el -> `impl`, un tipo dentro de una tupla, el nombre pelado. Se aplican en el orden -> dado, cada una sobre lo que dejó la anterior (que es lo que significan esas -> llamadas hechas en fila); una composición que el orden no puede resolver -> —`A -> B` y luego `B -> C`— se rechaza con las dos cadenas nombradas. Preflight -> completo: cada archivo se abre una vez por inodo, todo se aplica en memoria, y -> sólo entonces se escribe. -> -> Medido local, sin Claude: 5 llamadas -> 1, 5 mutaciones -> 1, 569 -> 508 bytes -> de petición, 1499 -> 1452 de respuesta. **No dice nada de costo ni de reloj.** -> -> ### Lo que falta, y en este orden -> -> **1. El regrade de la corrida que ya existe**, en tu máquina, sin gastar nada: -> -> ```sh -> cd ~/thalyx && git pull -> dev/bench-external-agent.sh --task reversible --symbol UidRegistry \ -> --out ~/thalyx/target/bench-external-agent-3 --regrade -> ``` -> -> Escribe `summary-regraded.json`, no toca `summary.json`, e imprime una línea -> por brazo: `VALID` / `INVALID` / `NOT PROVEN`, con la frontera bajo la que lo -> decidió. Hasta que se corra, REVERSIBLE #2 es **PENDIENTE**. -> -> **2. La verificación de siempre**, que es donde se comprueba lo que este -> contenedor no puede: el lote adentro de un `intento` sobre Btrfs de verdad. -> -> ```sh -> cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh -> ``` > -> **3. Una corrida limpia del banco**, cuando las dos de arriba salgan bien. El -> comando exacto está en [[Evidencia-de-Agentes]]. Ésa es la que dice si el lote -> mueve costo y reloj; nada de lo escrito hasta ahora lo afirma. +> Sin rust-analyzer las dos etapas dicen `NOT PROVEN` y nombran el comando; no se +> callan y no fingen. > -> ### Una decisión que es tuya y no se tomó +> ### Lo que sigue siendo hipótesis > -> `attempt abandon` sigue costando dos llamadas: la primera contesta qué se -> perdería, la segunda con `confirm` lo hace. Se revisó si eso es seguridad real -> o UX humana reciclada y **es real**: abandonar reemplaza el árbol, el árbol es -> compartido, y lo que se destruye no tiene otro snapshot que lo recupere. Es un -> decreto de [[Camino-Confiable]], así que se quedó como está. Si alguna vez -> quieres que el canal del agente pueda saltárselo, esa decisión es tuya. -> -> El detalle completo —la traza, por qué el brazo B nunca emitió más de una -> herramienta por mensaje, y qué parte de eso es de Thalyx— está en -> [[Evidencia-de-Agentes]]. - ---- - -> ## El brazo A no estaba anclado a `--project`, y ahora no puede no estarlo — 2026-08-29 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó, y el -> primero de ellos —REVERSIBLE #1— hay que leerlo con esto encima. -> -> Una revisión de la forense de REVERSIBLE #1 encontró al brazo A ejecutando -> `cd /home/cesarmanzocode/thalyx` mientras el arnés tenía -> `--project /tmp/bench-thalyx`. **La causa es una línea, y estuvo desde el -> primer commit del arnés:** `--out` valía por omisión -> `$ROOT/target/bench-external-agent`, la copia del brazo A se hacía en `$OUT/a`, -> y `$ROOT` es el checkout donde vive el script. Así que `claude` arrancaba -> dentro del clon de trabajo de Cesar, y Claude Code recoge el `CLAUDE.md` de -> cada ancestro de su directorio de trabajo — el de este proyecto, que empieza -> con «lee esto antes que nada». El agente recibió instrucciones sobre -> `~/thalyx` y se fue a trabajar a `~/thalyx`. -> -> **Están afectadas todas las corridas anteriores** — la histórica, READ #1, -> READ #2, CHANGE #1 y REVERSIBLE #1 — porque el mecanismo es idéntico en todas. -> Que además *saliera* de su copia sólo está **observado** en REVERSIBLE #1, que -> es la única cuya forense se leyó con esa pregunta. De las otras no se afirma ni -> que sí ni que no: nadie ha mirado, y mirar es gratis -> (`dev/bench-summary.py --scope-check --arm A` sobre cada `--out` que -> sobreviva). Los datos se conservan; lo que se degradó es su fuerza, de -> comparación controlada a observación. Está escrito entero en -> [[Evidencia-de-Agentes]]. -> -> **Los otros dos defectos de la misma revisión:** -> -> - la tabla forense imprimía `write=False` para un `Bash` cuyo comando era -> `git checkout -- `. Un campo de dos valores no sabe decir «no sé», -> así que le daba a lo que no podía ver la misma respuesta que a lo que sí -> comprobaba. Hoy hay tres clases —`writes`, `reads`, `unknown`—, `Bash` es -> siempre `unknown`, y un testigo que no vio nada con llamadas `unknown` en el -> stream contesta `not_proven` en vez de `false`; -> - el brazo B devolvió `0s` y cero eventos **después** de haber pagado el brazo -> A entero, porque el único control era `[ -S "$SOCKET" ]`, que pregunta si -> existe un archivo. Hoy hay un preflight del canal real —hello, `where`, -> `list .` comparado con `--project`, todo de sólo lectura— que corre **antes** -> de llamar a Claude en cualquier brazo. -> -> **Lo que corre ahora antes de gastar un centavo**, en este orden: el brazo B se -> prueba vivo; los dos brazos se comparan de entrada (`provenance.json`, con -> commit de origen, manifiesto de entrada, exclusiones y directorio efectivo de -> cada uno, y los dos resúmenes hechos por el mismo programa); el brazo A se -> escenifica fuera del checkout, con cada ancestro revisado por `CLAUDE.md`, -> `.claude/`, `.mcp.json` y `.git`, y un hook `PreToolUse` que rechaza cualquier -> ruta de afuera; y después de correr se lee del stream el `system init` y todas -> las rutas de todas las llamadas — una sola afuera deja la corrida `INVALID`, y -> se comprueba **entre los dos brazos**, que es el último momento en que saberlo -> cuesta menos que el brazo B. -> -> Nada de eso toca el prompt ni las métricas: el arnés sigue congelado en lo que -> mide. -> -> **Las pruebas, todas gratis y todas en la etapa 50 de `verify.sh`:** el -> `--self-test` del arnés arranca un sustituto de `claude` que imprime streams de -> la forma real y comprueba que un brazo A que se sale es `INVALID` antes del -> brazo B, que uno que arrancó en otro lado también, que uno que se quedó adentro -> no se rechaza, que un brazo B muerto detiene la corrida antes del brazo A, que -> uno vivo la deja seguir, y que dos brazos con árboles distintos la detienen -> antes de llamar a nadie. El `--self-test` del parser comprueba la clasificación -> de tres valores con `git checkout -- ` y con un `Bash` genuinamente de -> sólo lectura, y que ninguno de los dos se acredita como lectura probada. -> -> **La repetición limpia, en dos órdenes:** -> -> ```sh -> make -C image agent PROJECT=/tmp/bench-thalyx -> -> dev/bench-external-agent.sh --task reversible --symbol UidRegistry \ -> --project /tmp/bench-thalyx \ -> --expect-file dev/bench-expect/reversible-UidRegistry.txt \ -> --workspace /tmp/thalyx-bench-arm-a \ -> --out target/bench-external-agent-3 -> ``` +> Que todo esto haga que Claude o Codex hagan más trabajo correcto con menos +> esfuerzo de modelo. Está construido y probado pieza por pieza; **no está +> medido**. No se corrió ningún banco pagado en este sprint, a propósito: primero +> se construye el contendiente. > > --- > -> ## REVERSIBLE #1 salió válido y mixto, y la escritura ya no cuesta dieciséis llamadas — 2026-08-29 -> -> **Leer con el bloque de arriba.** Este resultado se conserva como observación: -> su brazo A no estaba anclado a `--project`, así que no es una comparación -> controlada. -> -> El banco reversible se regradó con el grader corregido, sobre los mismos -> artefactos, sin correr ningún agente y sin gastar nada. **Los dos brazos salen -> `VALID`**: los dos modificaron de verdad, los dos contestaron bien, los dos -> devolvieron el árbol. Es el primer resultado mixto con veredicto válido del -> proyecto y hay que leerlo entero: -> -> | | brazo A (Linux) | brazo B (Thalyx) | -> | --- | --- | --- | -> | costo | $0.2597362 | $0.2255152 — **−13.2 %** | -> | reloj | 47.909 s | 63.805 s — **+33.2 % peor** | -> | llamadas | 16 | 36 | -> | mutaciones | 6 | 16 | -> | archivos leídos | 7 | **0** | -> | bytes al modelo | 19 774 | 14 365 — −27.4 % | -> | tokens de salida | 3 880 | 5 859 — **+51 %** | -> -> La navegación semántica volvió a ganar y la frontera reversible funcionó de -> punta a punta. Lo que perdió es el reloj, y el trace dice por qué sin ningún -> misterio: el editor del brazo A reemplaza todas las apariciones de un archivo -> en **una** llamada, así que hizo una por archivo; el brazo B sólo sabía -> direccionar líneas, así que hizo una por línea —56, 61, 166, 168…— y en cada -> una tuvo que escribir el texto nuevo completo de la línea. -> -> **Lo que se construyó por eso, y es una operación y no una capa:** -> `editar sustituir [más archivos…]`, expuesta al -> agente como `thalyx_edit` con `action: "substitute"`. Una cadena exacta, en -> todas partes, en todos los archivos nombrados, en una llamada. Precomprueba -> todos los archivos antes de escribir un byte —un archivo que no contiene el -> texto detiene la llamada entera con nada cambiado—, dice `wrote: false` en -> cada rechazo, rechaza el mismo archivo nombrado dos veces por inodo, y -> contesta con cuentas en vez de contenido. -> -> Y **es sustitución, no renombrado**. El índice de hoy no distingue el símbolo -> del comentario, del homónimo, de la cadena ni del identificador más largo que -> lo contiene, y llamarle «renombrado semántico» a una sustitución léxica sería -> una abstracción falsa. LSP/SCIP van debajo de esta misma API el día que -> existan. -> -> La regresión que lo sostiene no gasta un centavo — -> `crates/thalyx-cli/tests/a_mechanical_rename_costs_one_call.rs`, dos crates, -> 19 apariciones en 16 líneas de 6 archivos, el mismo renombrado hecho de las -> dos maneras y los dos árboles comparados byte a byte: -> -> ``` -> línea por línea 16 llamadas 1523 bytes enviados 2956 de vuelta -> sustitución 1 llamada 252 bytes enviados 742 de vuelta -> ``` -> -> **Lo que falta, y es de Cesar porque cuesta dinero:** volver a correr el banco -> reversible **una vez**, con el arnés congelado, que es la prueba de la -> hipótesis y no su confirmación. Nada dice todavía que esto mejore el banco: -> -> ```sh -> dev/bench-external-agent.sh --task reversible --symbol UidRegistry \ -> --expect-file dev/bench-expect/reversible-UidRegistry.txt \ -> --out target/bench-external-agent-2 -> ``` -> -> El detalle entero está en [[Evidencia-de-Agentes]], sección «Lo que la corrida -> encontró, y el cambio que provocó». - -> ## El banco reversible se corrió, y el instrumento estaba mal — 2026-08-29 -> -> Los bloques de abajo son cómo se llegó. -> -> Cesar corrió `--task reversible` sobre `UidRegistry`. La corrida terminó, los -> dos brazos contestaron bien, y el resumen los reprobó a los dos por razones -> que no tenían que ver con lo que hicieron. **El veredicto de esa corrida no -> vale y la corrida no está perdida**: los dos defectos eran del grader y los -> dos se arreglaron leyendo el grader, sin volver a pagar nada. -> -> **1. El socket de QEMU contaba como espacio de trabajo.** La única diferencia -> que el brazo B reportó entre el árbol del que salió y el que volvió fue -> `image/build/agent.sock`, que abre QEMU para el canal del agente y que ningún -> agente pudo crear ni borrar. `image/build` es maquinaria del banco —lo dice -> `.gitignore` desde antes de que el banco existiera— y faltaba en la lista de -> exclusiones. Ahora está, en un solo lugar que usan la caminata inicial y la -> final, y **lo que se deja afuera se reporta** (`set_aside`) para que una -> exclusión no pueda ser un escondite. -> -> **2. El testigo del estado intermedio era el único que la tarea correcta -> apaga.** Era el `mtime`, y la quinta parte de la tarea es *devolver todo -> exactamente*; un agente que restaura desde una copia con `cp -a` devuelve el -> contenido **y la fecha**. El brazo A hizo seis `Edit` y quedó registrado como -> si nunca hubiera pasado nada. Ahora son tres testigos —el `ctime`, que nada en -> espacio de usuario puede poner para atrás; la respuesta de la herramienta, que -> ya está escrita en el stream y ninguna restauración alcanza; y el contador del -> adaptador para el brazo B— y cuatro campos donde había dos: **pidió**, -> **la herramienta contestó**, **algo de afuera lo vio**, **así quedó el árbol**. -> -> Y `turns: 37` bajo `--max-turns 30` no era una corrida cortada: `turns` cuenta -> mensajes de usuario, `--max-turns` acota viajes a la API, y se separan en -> cuanto el modelo pide dos herramientas en un mismo mensaje. Queda documentado -> y fijado con `--self-test`. -> -> **Lo que falta, y es de Cesar porque los artefactos están en su máquina:** -> volver a leer esa corrida con el grader corregido. No corre ningún agente, no -> cuesta nada, y no toca el `summary.json` original: -> -> ```sh -> dev/bench-external-agent.sh --task reversible --symbol UidRegistry \ -> --expect-file dev/bench-expect/reversible-UidRegistry.txt \ -> --out target/bench-external-agent --regrade -> ``` -> -> Escribe `summary-regraded.json`, y cada brazo sale `VALID`, `NOT PROVEN` o -> `INVALID` con la razón. Antes de eso, `--forensics` imprime qué contestó cada -> herramienta a cada una de las seis `Edit` del brazo A, que es lo único que -> puede distinguir «seis ediciones que se deshicieron» de «seis ediciones que -> fallaron». El incidente entero está en [[Evidencia-de-Agentes]] y la regla en -> [[Estrategia-de-Pruebas]]. -> -> **La corrida NO está registrada como resultado válido.** No hay número de esta -> corrida en ninguna nota como si fuera evidencia; lo que hay es lo que imprimió, -> marcado como lo que es. - -> ## Los cuatro P0 de la auditoría, cerrados, y ninguno se arregló por lectura — 2026-08-29 -> -> Los bloques de abajo son cómo se llegó. -> -> Una auditoría había señalado cuatro defectos. Los cuatro eran reales, y **tres -> eran el mismo defecto** en tres subsistemas que nadie habría puesto juntos: -> una regla correcta, escrita, con su comentario explicando por qué importa, y -> **comprobada en vez de impuesta**. La regla nueva está en -> [[Estrategia-de-Pruebas]], 2026-08-29. -> -> **1. El oráculo del benchmark reversible daba tres falsos positivos.** -> `really_changed` se leía como «el nombre nuevo apareció en alguna llamada», -> que es una frase que escribe el agente: un `Grep` de ese nombre la satisface, -> un `Edit` que falló la satisface, y una corrida muerta en su límite de turnos -> no se miraba. Cualquiera de los tres, con el árbol intacto porque nadie lo -> tocó, salía `passed: true`. Y el digest era `find -type f | xargs sha256sum` -> —contenido y nada más— mientras el prompt promete *byte por byte, ningún -> archivo agregado ni quitado*: un symlink a `/etc/passwd` donde había un -> fuente restauraba «perfecto». Ahora son cinco propiedades de cinco -> instrumentos, con testigo externo del estado intermedio, y el digest es sobre -> un manifiesto —tipo, permisos, contenido, destino de symlink— con **una sola -> implementación**. Los siete falsos positivos están nombrados en -> `bench-summary.py --self-test`. Ver [[Agentes-Externos]], revisión del -> 2026-08-28. -> -> **2. `intento` comprobaba la exclusión en vez de imponerla.** Dos clientes que -> llegan juntos ven los dos «no hay ninguno abierto», los dos toman snapshot, y -> el segundo pisa el registro del primero — dejando un snapshot que nada nombra -> y un `abandonar` que devuelve el árbol a un punto que no es el que el primer -> cliente cree. Las tres transiciones toman ahora `Store::lock()`, el mismo -> `flock` de siempre, y `attempt.json` se publica con `write_durably` en vez de -> `std::fs::write`. Ver [[Concurrencia]], revisión del 2026-08-28. -> -> **3. La frontera del espacio de trabajo era una comparación.** -> `canonicalize → comparar → el verbo abre el nombre original`, que es la -> secuencia que `api.rs` fue reescrito para dejar de usar hace semanas. Se midió -> antes de arreglar nada: un hilo que cambia `src` por un enlace a otro árbol -> mientras un agente lee `src/main.rs` sacó **57 archivos de fuera del espacio -> de trabajo en 4000 lecturas**. Ahora es `openat2` con `RESOLVE_BENEATH` contra -> un descriptor del espacio de trabajo, y el verbo abre ese descriptor. -> Ver [[Agentes-Externos]], revisión del 2026-08-28. -> -> **4. El desmontaje del sandbox preguntaba si el cgroup estaba vacío.** Esa -> pregunta no se puede contestar mirando: `spawn` vuelve antes de que el -> auxiliar se una al cgroup, así que una corrida que ya arrancó su módulo es -> invisible durante una ventana — y el LSM falla abierto para un cgroup sin -> entrada. Se cuenta en vez de mirar. Ver [[Sandbox-Ejecucion]], revisión del -> 2026-08-29. -> -> **Cada arreglo se comprobó quitándolo.** Los cuatro tests adversariales de la -> frontera fallan sin el anclaje; el de la carrera de `intento` falla cinco de -> cinco corridas sin el candado; el del cgroup falla sin el contador. -> -> **Lo siguiente, y lo que no se hizo.** El cuello de botella que queda es que -> el agente no puede compilar ni probar lo que cambió. Se midió el costo de -> `validar` y no cabía honestamente en esta sesión —hace falta un perfil de -> seccomp derivado corriendo un toolchain real, que es justo lo que no se puede -> adivinar— así que quedó **una propuesta concreta** en -> [[Validacion-Confiable]] en vez de arquitectura a medio construir. -> -> **El benchmark sigue sin correrse.** *(Se corrió el mismo día; ver el bloque -> de hasta arriba.)* -> - -> ## La prioridad de la etapa se reordena: demostrar y medir la ventaja para agentes — 2026-08-28 -> -> **Cómo se llegó.** -> -> **Nada se construyó en este paso.** Es documentación, estrategia y -> formalización: dos notas nuevas y revisiones fechadas en las que ya existían. -> -> **Lo que cambia**, decretado por Cesar el 2026-08-28 y escrito entero en -> [[Prioridad-Operativa]]: durante esta etapa la prioridad operativa es -> demostrar que Thalyx mejora el trabajo de agentes reales, medirlo, hacer -> dogfooding, mejorar sólo lo que la evidencia pida, absorber mecanismos ya -> demostrados por otras herramientas, bajar la fricción de adopción -> —proyecto → VM → el mismo agente → trabajar— y **posponer la habitabilidad -> general que no ayude a eso**. En una frase: -> -> > Primero demostrar que Thalyx hace mejor al agente. Después hacer que Thalyx -> > pueda reemplazar al resto de la máquina. -> -> **Lo que NO cambia, y es la mitad importante:** la visión. [[Filosofia-Fundacional]] -> sigue intacta, la imagen sigue siendo el kernel y un programa, MCP sigue siendo -> un adaptador y no la API interna, y el agente externo sigue siendo software no -> confiable. Navegador, paquetes, catálogo de aplicaciones, escritorio, -> multimedia y compatibilidad por completitud quedan **diferidos, no -> abandonados**. Nada de eso estaba decretado, así que lo que se defiere es una -> aspiración de orden, no un decreto: [[Construccion-del-ISO]] ya decía que no -> hay gestor de paquetes y [[La-Pantalla]] que no hay escritorio. -> -> **La regla de prioridad**, para no tener que releer la nota: una tarea va -> primero si aumenta de forma medible el éxito del agente, o baja costo/tokens/ -> contexto, o baja tiempo/trabajo, o mejora seguridad/reversibilidad, o facilita -> la adopción. Si no toca ninguna, normalmente espera. No sustituye a los cinco -> costos de [[Superficie-para-el-LLM]]: los cinco deciden qué merece existir, y -> esta regla decide qué merece el tiempo de esta etapa, exigiendo además que el -> efecto se vea en una corrida. -> -> **Y la evidencia queda en un solo lugar**: [[Evidencia-de-Agentes]], con las -> tres corridas completas —READ #1, READ #2 y **CHANGE #1, que no favorece a -> Thalyx y está escrito igual de completo**—, la corrida histórica etiquetada -> como anterior al endurecimiento del índice, los límites de lo que tres -> observaciones pueden decir, y el **protocolo del bug real** para cuando -> aparezca uno: congelar el SHA, describirlo, una sola comparación seria con los -> dos brazos, y guardar streams, costo, parches y pruebas. Un bug cuyo problema -> central sea el puente o el índice roto **no sirve** para esa comparación. -> -> **Lo que sigue pendiente y es de Cesar:** correr -> `dev/bench-external-agent.sh --task reversible` sobre `UidRegistry`. *(Se -> corrió el 2026-08-29; ver el bloque de hasta arriba.)* - -> ## El arnés tiene la tarea que sí ejercita la frontera reversible, y no se ha corrido — 2026-08-28 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Ya hay tres corridas reales de Claude Code, misma tarea y mismo modelo en los -> dos brazos, los dos contestando correcto: -> -> | tarea | costo | tiempo de pared | -> |---|---|---| -> | lectura #1 | Thalyx **−46 %** | Thalyx **−18 %** | -> | lectura #2 | Thalyx **−62 %** | Thalyx **−36 %** | -> | edición simple, un archivo | Thalyx −4 % | Thalyx **+24 %** | -> -> La tercera salió empatada: los mismos 6 turnos y las mismas 5 llamadas en los -> dos brazos, y el brazo B **nunca abrió un intento**. Eso no es una derrota, -> **es una tarea que no medía la apuesta**: un archivo cambiado una vez no tiene -> nada que revertir, así que no hay frontera reversible que ejercitar y el agente -> hizo bien en no abrir una. Lo que midió fue el editor. -> -> Lo nuevo es `dev/bench-external-agent.sh --task reversible`, la tarea que sí la -> ejercita: localizar un símbolo, cambiarlo en su definición y en todos sus -> dependientes, comprobar qué se tocó, y **dejar el árbol exactamente como -> estaba, byte por byte**. La pregunta, escrita antes de correrla, está en -> [[Agentes-Externos]]. -> -> **Nada se corrió todavía.** Esto es instrumento, no resultado, y se probó -> entero sin gastar una sola corrida: `dev/bench-summary.py --self-test` y -> `dev/bench-external-agent.sh --self-test`, los dos en la etapa 50 de -> `verify.sh`. -> -> Cuatro cosas la mantienen honesta: -> -> 1. **Un solo prompt** para los dos brazos, que no nombra ninguna herramienta, -> ni MCP, ni Thalyx. El self-test lo comprueba leyendo el propio archivo: -> cuenta que haya exactamente un `claude -p` y busca las palabras prohibidas. -> 2. **El cambio es mecánico**: un sufijo, `UidRegistry` → `UidRegistryRenamed`. -> No hay criterio que ejercer, así que no hay diferencia de criterio. -> 3. **El brazo A restaura como quiera**: su copia trae el `.git` y tiene `Bash`, -> y `git checkout -- .` es una respuesta válida. Lo único prohibido —en los -> dos brazos— es compilar y probar, porque el brazo B no tiene shell. -> 4. **"Restaurado" se comprueba desde afuera**, con `sha256` sobre el árbol, no -> preguntándole a la máquina que hizo la afirmación. -> -> **Y la trampa que trae adentro, que es lo que más trabajo costó:** un agente -> que no hace nada restaura el árbol perfecto. Un veredicto leído del hash solo -> pondría a un agente que se rehusó por encima de todos los que lo intentaron, y -> más alto en el brazo B — la dirección en la que esto no puede equivocarse -> nunca. Por eso `reversible.passed` es una conjunción de tres instrumentos -> distintos: **cambió de verdad** (el nombre nuevo apareció en alguna llamada, -> según el stream del agente), **restauró** (los bytes, según el anfitrión), y -> **contestó bien** (nombró los archivos de `--expect-file`). Si alguna se -> desconoce no hay veredicto; no es `false`. Es la regla 4 otra vez, en un lugar -> donde nadie la había buscado, y quedó escrita en [[Estrategia-de-Pruebas]]. -> -> El brazo B se comprueba en **dos pasos a propósito**: su espacio de trabajo -> vive en una imagen Btrfs que QEMU tiene abierta, y montarla mientras la máquina -> corre es como se corrompe un store. Así que el hash de después va con la -> máquina apagada, `sudo make -C image agent-export`, y una segunda pasada con -> `--arms none --restored-b`. Mientras no se haga, el resumen dice -> `not_proven`; `THALYX_REQUIRE_RESTORE_CHECK=1` lo vuelve falla. -> -> **Lo que falta y es de Cesar:** correrla, y decidir antes qué hacer con el -> `CLAUDE.md`. El brazo A trabaja adentro de la copia, así que Claude Code se lo -> carga, y el brazo B trabaja en un directorio vacío y no lo ve — le suma tokens -> al brazo A por algo que no es la tarea, **o sea al lado que favorece a -> Thalyx**. Se evita apuntando `--project` a una copia sin `CLAUDE.md`. - -> ## El índice encuentra al dependiente que se llega por un campo, y las consultas se reparan solas — 2026-08-28 -> -> Endurecimiento de la superficie **antes** del primer benchmark, para que lo que -> se mida sea Thalyx y no defectos que ya conocíamos. Todo salió de evidencia que -> ya teníamos: la primera comparación y la primera corrida real de Claude. -> -> **1. `dependencias` significaba `imports`, y la palabra es más ancha.** -> Preguntado qué depende de `src/store.rs`, el índice nombraba los dos archivos -> que escriben `use crate::store::…` y se le escapaba un tercero que llega al -> mismo código como `server.store.persist()`. La evidencia ya estaba adentro — la -> mención estaba registrada — y nada la convertía en arista. Ahora hay dos clases -> de arista y **cada fila dice cuál es**: `via: import` (el archivo la declaró) y -> `via: symbol` (usa un nombre que **exactamente un** archivo del árbol declara). -> La regla de "exactamente uno" es toda la precisión: un nombre que declaran dos -> archivos no se convierte en arista nunca, porque cuál de los dos se quiso decir -> es una adivinanza. El decreto está en [[FS-en-Grafo]]. -> -> Con el mismo mecanismo quedan resueltos la llamada directa por ruta, el acceso -> por campo, el método, el trait en una cota, el módulo de directorio y el -> **re-export** — que es el caso donde el import no resolvía a nada, porque -> `crate::Engine` no es un archivo. Sigue sin saberse **el alias**: `use X as Y` -> da la dependencia entre archivos, y que `Y` sea `X` es un compilador. -> -> Y la primera versión, con sólo esa regla, daba **41 dependientes** para -> `thalyx-snapshot/src/lib.rs`. Correrla sobre este repositorio y leer las filas -> —que es de donde salen todos los defectos— agregó tres condiciones más, cada -> una de un defecto distinto: el nombre tiene que ser **visible desde afuera** -> (`fn place` y `fn relative` son privadas, y ninguna arista hacia ellas puede -> existir); el archivo que lo usa **no debe atarlo** (`for directory in …` habla -> de su propia atadura, no de `pub fn directory`); y **no debe declararlo él -> mismo** a ninguna visibilidad. Quedaron 19, de los cuales 17 son referencias -> reales entre crates que **ningún import podía resolver**. Las dos que sobran -> están contadas en [[FS-en-Grafo]]: necesitan saber de qué tipo es el receptor, -> que es un compilador. -> -> **2. Tres falsos positivos que llevaban ahí desde siempre.** Al medir lo -> anterior sobre este repositorio aparecieron: un comentario `/* … */`, una cadena -> que sigue en el renglón siguiente, y un `r#"…"#` de veinte renglones — los tres -> metían palabras al índice como si fueran código. Existían desde el principio y -> no se veían, porque una mención de más es una fila que nadie mira; se hicieron -> visibles el día que una mención pasó a ser una arista. El arreglo es **un solo -> escaneo con estado** para las tres entradas del parser. Sobre `crates/`: 61 047 -> menciones antes, 58 390 después — 2 657 eran falsas. -> -> **3. Cuatro turnos que ya no se pagan.** En la corrida real, Claude preguntó por -> un árbol que acababa de cambiar, dedujo del campo `fresh` que el índice estaba -> atrasado, llamó a `state`, llamó a `indexar`, y volvió a preguntar. Ahora -> `buscar`, `depende` y `usan` reconstruyen el índice antes de contestar cuando es -> barato — techo de 2 000 archivos, sacado de la medición — y cuando no, contestan -> `refreshed: declined_too_large` con el tamaño del árbol y el verbo que hay que -> llamar. **La regla de honestidad no se movió**: nada reporta `current` por -> haberlo intentado, y `refrescar=no` devuelve lo que el índice tenía. -> -> **4. Un corpus determinista, sin modelo.** `crates/thalyx-graph/corpus/` son -> doce árboles chiquitos con la respuesta correcta escrita al lado, sacada de leer -> el código. 44 respuestas exactas, en milisegundos, gratis. Cuatro de los doce -> existen para ser contestados **angostamente**, porque un índice de símbolos -> falla devolviendo de más. Encontró los tres defectos del punto 2 antes de que -> ningún modelo mirara nada, y pasó entero mientras el mecanismo debajo cambiaba -> tres veces en una tarde — que es para lo que existe una red de regresión. -> -> **5. El arnés recoge las dos mitades.** `dev/bench-external-agent.sh` usa -> `--output-format stream-json`, así que ahora las **dos** ramas se miden en las -> mismas unidades: llamadas a herramientas por nombre, bytes que cada una le -> devolvió al modelo, archivos leídos, búsquedas de texto, además de turnos, -> tiempo, tokens y costo. Antes sólo la rama B era contable. El parser es -> `dev/bench-summary.py`, aparte a propósito, con `--self-test` contra una sesión -> real capturada en `dev/samples/` — regla 6. Y `--expect-file` da un veredicto de -> éxito de la tarea; sin él no hay veredicto, nunca uno adivinado. -> -> **Costo medido**, mismo árbol, mejor de siete, release: indexar `crates/` pasa -> de 368,7 ms a 480,4 ms (+30 %) y devuelve 2 803 aristas que antes no existían, -> con 2 657 menciones falsas menos. Las consultas no cambiaron: frescura 2,1 ms, -> dependientes 1,8 ms. Dos optimizaciones baratas ya pagaron la mayor parte de -> ese costo: sentencias preparadas en los dos `INSERT` que corren decenas de -> miles de veces (588,2 → 480,4 ms) y un solo escaneo por archivo en vez de dos -> (533,7 → 480,4 ms). -> -> Etapas 49 y 50 nuevas en `dev/verify.sh`. Nada de esto necesita hierro: corre -> entero en el contenedor. -> -> ## `intento` ya no le pregunta a un binario que no existe — 2026-08-28 -> -> -> Encontrado corriendo la máquina, que es de donde salen todos: `make -C image -> agent` **sí** crea el workspace como subvolumen Btrfs, y adentro de Thalyx -> `thalyx_attempt` contestaba `not_a_subvolume` de todos modos. -> -> La causa es la quinta vez que aparece la misma: `thalyx-snapshot::Btrfs` -> preguntaba corriendo `btrfs subvolume show`, y la imagen lleva el kernel y un -> programa. El spawn fallaba, y `is_subvolume` no tiene forma de decir *no pude -> preguntar* — así que la falta de un binario se reportaba como un hecho sobre el -> sistema de archivos. **Regla 10 al revés**, en el único verbo del que depende -> la ventaja que ningún otro sistema operativo tiene. -> -> Ahora existe `thalyx-snapshot::Native`, que son las cuatro operaciones contra -> los ioctls del kernel: `BTRFS_IOC_SUBVOL_GETFLAGS` para preguntar —el kernel -> contesta `EINVAL` si no es la raíz de un subvolumen y `ENOTTY` si ni siquiera -> es Btrfs, así que una sola llamada separa las tres respuestas, sin privilegios -> y sin el truco del inodo 256—, `SNAP_CREATE_V2` con `BTRFS_SUBVOL_RDONLY` para -> la instantánea, la misma sin la bandera para la copia escribible del restore, y -> `SNAP_DESTROY` para soltarla. `intento` usa ese backend; el backend por comando -> se queda para el anfitrión y como segunda opinión en las pruebas. -> -> Lo que falta correr en hierro: la etapa 26 de `dev/verify.sh` tiene ahora una -> columna más, la misma secuencia con `PATH` vacío, que es este anfitrión -> haciendo lo que hace la imagen. -> -> ## El puente no habría llevado un byte por virtio-serial — 2026-08-28 -> -> El bloque siguiente termina diciendo que virtio-serial no ha llevado un byte y -> que lo que corrió fue el mismo `serve` sobre un socket UNIX. Al preparar ese -> arranque aparecieron **dos defectos que sólo existen del lado del carácter**, y -> los dos habrían hecho que la máquina arrancara anunciando un canal que no -> contesta: -> -> 1. **`serve_port` abría el nodo dos veces**, una de lectura y otra de -> escritura, que es la forma que tiene un socket y no la que tiene un puerto -> virtio-serial. `port_fops_open`, en `drivers/char/virtio_console.c`, rechaza -> la segunda apertura con `EBUSY` —*"Allow only one process to open a -> particular port at a time"*—, así que cada vuelta del hilo moría en su -> segunda línea, el error se iba al `let _ = error` de siempre, y desde el -> anfitrión se veía como una VM que sigue arrancando. Ahora se abre **una vez** -> en lectura y escritura y el segundo extremo es un `try_clone`. -> 2. **La búsqueda en sysfs se abandonaba entera** ante un puerto sin nombre. El -> driver crea el atributo `name` sólo para los puertos que QEMU nombró, así -> que cualquier otro puerto virtio-serial al lado del nuestro podía esconder -> el canal, dependiendo nada más del orden en que `read_dir` los devolviera. -> Regla 10, y ahora tiene su prueba: `a_port_with_no_name_at_all_does_not_hide_the_one_that_has_one`. -> -> Los dos son la regla 12 otra vez: **el transporte que se verificó no era el -> transporte que se embarca.** Un socket UNIX nunca pudo mostrar ninguno de los -> dos, y ninguna prueba de este contenedor puede — se prueban arrancando. -> -> Del lado del anfitrión, `Machine::connect` esperaba el saludo **sin plazo**. -> Con `wait=off` el socket existe desde que QEMU arranca, así que el `connect` -> nunca es lo lento; lo lento es el huésped, y el presupuesto de espera se -> gastaba en lo único que jamás tarda. Un cliente arrancado antes que la máquina -> —el orden que dice el README— se quedaba ahí para siempre. Ahora el plazo cubre -> el saludo y dice qué encontró; y `dev/agent-connect.sh` ya no mata la sonda a -> los 10 s cuando el adaptador espera 30. -> -> Y `a_question_has_one_answer.rs` medía la máquina en vez de la respuesta: -> buscaba la frase *"the kernel policy map is not loaded"* como marca de que un -> `sí` fue leído, y esa frase sólo se imprime donde no hay nada cargado. En la -> máquina de Cesar, con el LSM cargado en observe mode, el `sí` se leía bien, el -> verbo pasaba la pregunta como debe, y la prueba lo llamaba rechazo. La marca -> ahora es lo que el otro lado tiene en común en cualquier estado del kernel: -> `refusing to run \`…\`` o `ran:`. -> -> ## El primer agente de programación real usa las primitivas — 2026-08-28 -> -> Hasta hoy la apuesta de [[Filosofia-Fundacional]] —que un sistema construido -> alrededor de respuestas estructuradas, un índice semántico y una frontera -> reversible hace que una IA trabaje mejor— **nunca se había medido**, y no había -> forma de medirla: el único agente que podía usar las primitivas era el Qwen de -> 3B de adentro, y compararlo con Claude sobre Linux mide el tamaño del modelo, -> no la superficie. -> -> Ahora hay puente. **Claude Code real, corriendo en el anfitrión, hizo una tarea -> de lectura usando sólo verbos de Thalyx**: cuatro llamadas —un `indexar`, dos -> `buscar`, un `usan`— sin abrir un archivo y sin una sola búsqueda de texto. El -> decreto está en [[Agentes-Externos]] y lo importante de él es dónde vive cada -> cosa: **MCP es un adaptador, en el anfitrión; la superficie de Thalyx sigue -> siendo la autoridad.** -> -> La cadena es -> `Claude Code → thalyx-mcp → socket de QEMU → virtio-serial → un hilo de la -> sesión → el MISMO dispatch que un teclado → índice, intento y journal reales`. -> Sin red, sin TCP, sin dirección: `CONFIG_VIRTIO_CONSOLE=y` es todo el costo en -> el kernel. -> -> **Un agente externo no es root remoto.** `crates/thalyx-cli/src/external.rs` -> es una lista de verbos y un guardián de rutas que resuelve cada una dos veces -> —como la resuelve el verbo y como la resuelve el kernel— y exige que las dos -> caigan adentro del workspace. `apagar`, `instalar-en`, `correr`, `ejecutar`, -> `negar` y `matar` no son alcanzables. Lo que un agente externo cambió, y todo -> intento suyo de salirse, quedan en el journal marcados `untrusted_content`. -> -> **Lo que la primera medición dio**, con Sonnet, la misma tarea, dos copias -> idénticas de un proyecto de 35 archivos: 8 turnos y 32.8 s con `Read`/`grep` -> contra 7 turnos y 17.3 s con las herramientas de Thalyx. **Es una anécdota, no -> un resultado** — una corrida de una tarea. Lo que existe es el arnés, -> `dev/bench-external-agent.sh`, y una corrida real que lo prueba. -> -> Y enseñó algo en contra, que está escrito en el decreto: el brazo de Linux -> encontró un dependiente que el índice no, porque usa el símbolo a través de un -> campo y nunca lo nombra. El índice contesta *quién nombra esto*, no *a quién le -> afecta*. -> -> **Lo que falta arrancar para creerlo del todo.** virtio-serial no ha llevado un -> byte: este contenedor no tiene QEMU, y lo que corrió fue el mismo `serve` sobre -> un socket UNIX. Y el ciclo `intento` completo a través del puente necesita -> Btrfs. Los dos son `dev/verify.sh` §47 y §48, y los dos corren en la máquina de -> Cesar: -> -> ``` -> make -C image agent PROJECT=/ruta/a/un/proyecto -> # en otra terminal, cuando la máquina esté arriba: -> dev/agent-connect.sh -> claude -> ``` -> -> ## El motor se queda vivo, y la pantalla deja de congelarse — 2026-08-28 -> -> El arranque en QEMU del bloque siguiente probó que la cadena entera existe: -> una frase en español llegó a un Qwen2.5-3B confinado y `ls` mostró la carpeta. -> Lo que ese arranque también mostró es que **el motor era por lotes**. -> `llama-completion` es de una sola respuesta por construcción, así que la -> segunda frase volvía a leer dos gigabytes de disco y a construir el contexto -> otra vez: la mayor parte de lo que cuesta un modelo local, gastada de nuevo en -> trabajo ya hecho. Y la llamada ocurría dentro de la pulsación de Enter, así -> que durante esos segundos el marco no se redibujaba — ni el reloj. -> -> Las dos cosas están cerradas. **El decreto de que el motor es un módulo no -> cambió en nada**: sigue firmado, instalado, corrido por `thalyx_core::run` -> bajo `module_standard`, con su uid, su cgroup, su seccomp y su raíz pivotada. -> Lo que cambió es la forma del programa que hay adentro. -> -> **`engine/thalyx-engine.cpp`.** El mismo `llama.cpp` en la misma etiqueta -> fijada (`b10665`), con las mismas banderas y el mismo enlace estático; se copia -> dentro de `tools/` del checkout y lo compila el mismo `cmake`, para que el -> orden de enlace de los backends de ggml no sea algo que este repositorio -> resuelva a mano. Carga el GGUF una vez, anuncia que está listo, y contesta -> peticiones enmarcadas por una tubería hasta que Thalyx cierra el otro extremo. -> No reimplementa inferencia: debajo del protocolo todo es la librería `common` -> de `llama.cpp`. -> -> **Nada de red.** Ni HTTP, ni TCP, ni el servidor que `llama.cpp` ya trae: -> conceder `net/outbound` al programa menos confiable de la máquina para que dos -> procesos del mismo anfitrión se hablen es debilitar el aislamiento por -> comodidad. El protocolo es little-endian con longitud por delante, y se mandan -> **rutas y no texto** — los archivos ya están donde al módulo se le concedió -> leerlos, así que la inferencia sigue siendo inspeccionable en disco. -> -> **Un solo lanzador.** `thalyx_core::run` se partió por dentro en `run::start` -> → `RunningModule` → `wait`/`shutdown`, y `run()` es `start` seguido de `wait`. -> El camino ordinario ejerce el mismo código que el residente mantiene abierto, -> así que no hay un segundo lugar donde acertar con el cgroup, la política, el -> filtro seccomp, la raíz y el uid. -> -> **La pantalla ya no se bloquea.** Una línea que no es un verbo vuelve como -> `Flow::Thinking` en vez de gastar segundos dentro de la pulsación; la pantalla -> pregunta en un hilo, dibuja `⠋ pensando…` con el reloj corriendo cada 120 ms, y -> cuando llega la respuesta la corre por el **mismo** dispatch, en el hilo que -> tiene el teclado. El trabajador propone y no actúa. Y al arrancar, la sesión -> gráfica lanza un hilo que precalienta los pesos, así que la primera frase -> probablemente encuentra el modelo ya adentro. -> -> **La evidencia se cuenta en procesos, no en objetos.** Bajo cada propuesta la -> sesión imprime `motor ▪ frío|tibio ▪ `: el mismo pid dos veces son dos -> frases contestadas por un proceso. Y la prueba se escribió igual — -> `the_engine_stays_alive` empaqueta el binario de la propia prueba como módulo -> del motor y ese motor de mentira anota su pid al arrancar; lo que se afirma es -> cuántas líneas tiene ese archivo. -> -> **Eso agarró un defecto que nada más habría agarrado.** -> `if let (false, Some(stale)) = (usable, held.take())` evalúa las dos mitades de -> la tupla antes de comparar con el patrón, así que `take()` corría siempre y el -> residente vivo se tiraba en cada llamada. Todas las frases se contestaban bien; -> lo único que cambiaba era el costo, que es justo lo que esta fase existía para -> bajar. Y como `RunningModule` no tenía `Drop`, el proceso tirado seguía vivo: -> la máquina acumulaba un motor por frase con el modelo cargado en cada uno. -> Ahora tiene `Drop` y la regla está en [[Estrategia-de-Pruebas]]. -> -> De paso, dos cosas de herramienta: **`make -C image run` abre la interfaz -> gráfica** —era `-nographic`, que fue correcto hasta el día en que la pantalla -> se volvió la cara del sistema y siguió *funcionando* después, que es por lo que -> nadie lo notó— y `run-serial` es la ruta vieja con un nombre que dice lo que -> es. `CPUS ?= 4`, porque el motor escoge sus hilos con -> `available_parallelism` y `-smp 2` era un modelo a media velocidad sin que -> nadie lo hubiera decidido. -> -> **Lo que falta y lo dice `verify.sh`:** §45 y §46 en el hierro de Cesar — -> residencia *y* confinamiento los establece la misma llamada, pero este -> contenedor no tiene BPF LSM y lo medido aquí corrió `--unconfined`. Y los dos -> números, frío contra tibio, con un Qwen2.5-3B real. Ver [[Motor-Residente]]. +> > ## Lo que Fedora encontró: el testigo, la unión, y el candado — 2026-08-29 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > La primera corrida del vertical nuevo en la máquina de Cesar. La etapa **56 +> > pasó**: una petición corrió ocho operaciones internas, cambió cuatro cosas y +> > confirmó; la variante con validación fallida hizo rollback byte por byte y +> > conservó el diagnóstico. `hacer`/`thalyx_exec` existe de verdad sobre Btrfs. +> > +> > La etapa **55 falló**, y encontró tres cosas. +> > +> > ### 1. El rechazo nunca llegaba a decir `workspace_moved` +> > +> > **Ésta es la causa exacta de la etapa 55, y no era el testigo.** El rollback +> > caduco no destruyó nada: contestó `done: false` con una línea `confirm_with` +> > **nueva**. `consent`, en `thalyx-cli`, comparaba la declaración del llamador +> > contra el testigo con el que se había hecho el plan y devolvía el objeto de +> > costo si no coincidían — así que la llamada nunca llegaba a la comprobación +> > bajo el candado, que es la única que produce esa palabra. +> > +> > Un agente en un ciclo copia esa línea nueva y en la llamada siguiente sí pierde +> > el trabajo de la persona. Ahora `consent` no compara nada: nombrar el intento y +> > nombrar un estado **es** la autorización, y si es cierta se decide bajo el +> > candado. Las dos mitades estaban probadas y la unión no; la regla quedó en +> > [[Estrategia-de-Pruebas]]. +> > +> > ### 2. Un testigo hecho de timestamps no es una identidad +> > +> > El propio `state_identity.rs` dormía veinte milisegundos entre dos escrituras +> > porque dos escrituras seguidas caben en un tic del sistema de archivos. **Esa +> > espera era el caso real**, no un detalle del arnés: el agente escribe, toma el +> > estado, y una persona escribe el mismo archivo, del mismo largo, enseguida. +> > +> > El testigo es ahora `w2` y cubre **lo que cada ruta contiene** —los bytes de un +> > archivo regular, el destino de un enlace, la especie de un fifo, que nunca se +> > abre—. Cuesta leer el árbol entero en cada comprobación y lo dice: `state_bytes`. +> > El contador de mutaciones del kernel se inspeccionó como la vía barata y **se +> > descartó**: su gancho de escritura es `lsm/file_permission` y una página sucia +> > de `mmap` no pasa por ahí, además de que exige `bpftool` y privilegio que dentro +> > de la imagen no existen. Todo escrito en [[Identidad-de-Estado]]. +> > +> > No hay ningún `sleep` nuevo. La etapa 55 quedó **más** agresiva: mismo archivo, +> > mismo tamaño, por un descriptor abierto antes de tomar el estado, sin esperar +> > nada, más una columna de trabajo fuera del árbol que no debe invalidar nada. +> > +> > ### 3. La ventana entre comprobar y reemplazar, y el candado que no se soltaba +> > +> > La comprobación se hacía bajo el candado pero **antes** de que el restore se +> > preparara, así que entre la respuesta y el intercambio quedaban el diario y la +> > copia escribible del snapshot. Ahora `Snapshots::prepare_restore` arma todo y +> > `Prepared::commit` es sólo el `RENAME_EXCHANGE`; la última mirada va en medio. +> > La ventana que queda —un recorrido más un `renameat2`— no se niega: lo que cae +> > dentro no se destruye, se desplaza al árbol que el restore conserva. +> > +> > Y la única prueba que falló en la suite completa y nunca aislada, +> > `the_lock_is_released_when_it_goes_out_of_scope`: `flock` vive en la +> > descripción de archivo, `fork` copia todos los descriptores, y cerrar no suelta +> > un candado que un hijo a medio camino de su `exec` todavía referencia. Thalyx +> > lanza `btrfs` y `bpftool` **con el candado tomado**, así que era un defecto del +> > producto y no de la prueba. `ContractLock::drop` ahora hace `flock(LOCK_UN)`. +> > +> > ### Lo que sigue +> > +> > Correr `sudo ./dev/verify.sh` en Fedora. **El banco pagado de Claude no se +> > corrió y no debe correrse todavía.** +> > +> > Dos cosas, y la primera es un defecto sobre el que la segunda no se podía +> > construir. +> > +> > ### 1. Contar archivos no es decir cuál árbol +> > +> > El abandono en una llamada del 2026-08-28 se autorizaba con una declaración +> > sobre los **conteos** — `delete= revert=` —. El argumento era que si una +> > persona escribe en el árbol compartido, uno de los dos números se mueve. +> > +> > **No se mueve.** Alguien que edita un archivo que el agente *ya* había editado +> > no mueve ninguno: un archivo modificado antes, un archivo modificado después. +> > La declaración seguía coincidiendo y la edición de esa persona volvía al +> > snapshot. +> > +> > Lo reemplaza `thalyx_snapshot::Witness`: un digest sobre cada ruta del árbol +> > con su tamaño, su mtime, su ctime y su inodo, tomado con el mismo recorrido con +> > el que se planea el restore. Cualquier escritura lo mueve. La declaración ahora +> > es `state=` y se comprueba **dentro del candado**, en el instante +> > anterior a reemplazar el árbol — una comprobación afuera del candado es una +> > comparación con un momento que ya pasó. `delete=`/`revert=` se rechazan +> > nombrando lo que los reemplazó, no se ignoran. Todo en +> > [[Identidad-de-Estado]]. +> > +> > El contraejemplo está escrito dos veces como aserción, con su control positivo +> > al lado: en `thalyx-snapshot/tests/state_identity.rs` y, de punta a punta sobre +> > un árbol real, en `thalyx_core::attempt`. +> > +> > ### 2. `hacer`: varias peticiones como una transacción +> > +> > [[Ejecucion-Transaccional]], y la hipótesis que lo pide está en +> > [[Trabajo-Entre-Inferencias]]. El verbo toma un **programa** —varias +> > peticiones, qué tiene que ser cierto al final, y qué hacer si no lo es—, abre +> > la frontera reversible, las corre en orden, observa lo que de verdad cambió, +> > valida, y confirma o devuelve el árbol. Todo antes de contestar. +> > +> > - **No es un shell** y no arranca uno: lo que se compone son las peticiones +> > propias de Thalyx. +> > - **No es una segunda autoridad**: cada paso pasa por `external::one`, la misma +> > función y la misma tabla que una petición suelta. Probado con una ruta fuera +> > del espacio de trabajo y con cuatro verbos que no están expuestos. +> > - **No es una etiqueta**: la frontera es un snapshot, el rollback es un restore +> > y lo autoriza el testigo de arriba. +> > - **No es validación que siempre pasa**: `text`, `parses`, `rust` y `program` +> > establecen algo o contestan `not_proven`, y `not_proven` nunca es `passed` — +> > una comprobación que no se pudo correr devuelve el trabajo. +> > +> > La respuesta es chica a propósito; todo lo crudo queda en el store —fuera del +> > espacio de trabajo, porque el rollback reemplaza el espacio de trabajo— y se +> > pide con `evidencia [paso=N]`. +> > +> > Salió también un verbo nuevo del parser: `unbalanced`, que dice si una edición +> > mecánica se comió una llave. Lo encontró el propio archivo del parser: la +> > primera versión estaba hecha sobre `scrub`, que ante un `'` suelto blanquea el +> > resto del renglón —porque en Rust eso es un lifetime— y se comía la llave de +> > `pub fn name(self) -> &'static str {`. La prueba es cada `.rs` de este +> > repositorio, noventa mil renglones que nadie escribió para ella. +> > +> > ### Lo que está probado aquí, sin gastar API +> > +> > En el fixture, en este contenedor: **una petición externa, diez operaciones +> > adentro de la máquina**, escrito como igualdad exacta y no como piso. Rollback +> > automático con el árbol byte por byte. Estado obsoleto que falla cerrado, con +> > control positivo y negativo. Compresión: la respuesta mide menos de un cuarto +> > de lo que la máquina produjo adentro. +> > +> > ### Lo que falta comprobar, y es de Cesar +> > +> > Btrfs. Aquí la frontera se ejercita contra el falso de directorios, y `rust`/ +> > `program` no corren donde el kernel no deniega. +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh +> > ``` +> > +> > Las etapas nuevas son la **55** —un rollback autorizado contra un árbol en el +> > que alguien más escribió, que debe rechazarse y no destruir nada, con los +> > conteos impresos al lado para mostrar que no se movieron— y la **56** —una +> > llamada que cambia cuatro cosas y confirma, y la misma forma con una +> > comprobación que falla devolviendo un subvolumen real byte por byte—. +> > +> > ### Lo que NO se puede decir todavía +> > +> > Que esto sea más barato, más rápido, o que reduzca los pasos de inferencia de +> > un agente real. Eso lo contesta el siguiente banco controlado. Lo único +> > afirmable hoy es estructural, y el instrumento para medirlo ya existe: +> > `thalyx-mcp --metrics` escribe `programs.operations_per_request` y +> > `dev/bench-summary.py` lo reporta. +> +> > ## Menos rondas por tarea reversible: el intento se abre con la mutación y se abandona en una llamada — 2026-08-29 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > **Corregido al día siguiente**, y hay que leerlo sabiéndolo: el abandono en una +> > llamada sigue existiendo, y **los dos conteos que este bloque describe se +> > retiraron** — no alcanzaban para decir qué árbol se está destruyendo. Ver el +> > bloque de arriba y [[Identidad-de-Estado]]. +> > +> > Salió de las tres corridas reales del banco reversible —#4, #5 y #6, escritas +> > enteras en [[Evidencia-de-Agentes]]—. `sustituir-lote` quedó comprobado: **1 +> > edición mutante, 0 lecturas de archivo y restore byte a byte en las tres**. Y +> > justo por eso el cuello se movió: ya no es la edición, es **el número de +> > rondas**, porque cada ronda vuelve a arrastrar contexto. Thalyx ganó reloj, +> > API y output en 3/3, y costo en 1/3. +> > +> > Las corridas 5 y 6 son idénticas: B gasta 6 llamadas donde Linux gasta 2. Esas +> > 4 de diferencia se repartieron en tres mecanismos, y **uno quedó descartado por +> > la evidencia**: +> > +> > - **descubrimiento de herramientas: 2 llamadas** —`ToolSearch` dos veces, y la +> > primera es una *selección fallida*; +> > - **protocolo del intento: 2 llamadas** —`begin` como viaje propio, y `abandon` +> > repetido; +> > - **composición de Bash: 0 llamadas.** En esas dos corridas B no localizó ni +> > verificó nada, así que no hubo nada que componer. Por eso **no se hizo un +> > verbo de lote genérico y no se metió shell**: no hay evidencia que lo pida. +> > +> > ### Lo que se construyó +> > +> > **1. El intento se abre en la misma llamada que la primera mutación.** +> > `thalyx_edit` y `thalyx_file` aceptan `attempt: "begin"`, y el adaptador manda +> > dos preguntas en un solo viaje —el snapshot primero, siempre—. Es la +> > composición que ese crate ya hacía para `thalyx_state`, que son tres. Si el +> > intento no se puede abrir, la mutación no ocurre: nada cambia. +> > +> > **2. Abandonar en una llamada, y más fuerte que antes.** +> > +> > ``` +> > intento abandonar snapshot= delete= revert= +> > ``` +> > +> > Procede sólo si el intento nombrado es el que está en el registro **y** los dos +> > números son exactamente lo que el árbol tiene en ese momento. La respuesta que +> > niega el permiso entrega esa línea ya armada, así que decir que sí cuesta una +> > llamada y nunca una adivinanza. +> > +> > Esto **no debilita** [[Camino-Confiable]]: lo aprieta. Si una persona escribió +> > en el árbol compartido mientras el intento estaba abierto, uno de esos dos +> > números se mueve, la declaración deja de coincidir y **no se destruye nada** — +> > que es justo el caso que la protección existía para cubrir y que `confirm: +> > true` a ciegas nunca cubrió. El camino viejo (`si`, `confirm: true`) sigue +> > intacto, palabra por palabra. +> > +> > **3. Las instrucciones nombran todas las herramientas.** La primera +> > `ToolSearch` de las corridas 5 y 6 fue una selección fallida: el agente pidió +> > herramientas por nombre y las nombró mal. Lo único que Thalyx controla ahí es +> > el texto que el modelo lee **antes** de buscar, así que ahora lleva la lista +> > exacta, generada de lo que la máquina ofrece de verdad. +> > +> > ### Lo que se midió, sin gastar API +> > +> > El puente, con `dev/bridge-cost.sh` —sin QEMU y sin modelo—: +> > +> > ``` +> > en la máquina 0.40–0.55 ms por pregunta +> > en el adaptador 0.08–0.10 ms por pregunta +> > ``` +> > +> > **Los ~6 s de diferencia entre reloj total y API no son el puente.** Ni con un +> > factor de cien por virtio darían seis segundos en nueve preguntas. No se forzó +> > ninguna optimización ahí. `thalyx-mcp --metrics` ahora escribe +> > `machine_requests` y `machine_seconds`, así que la próxima corrida real +> > contesta esto sobre sí misma sin costar nada. +> > +> > ### Lo que falta comprobar +> > +> > Las pruebas nuevas que corren acá son de decisión y de forma: la lógica de +> > consentimiento entera —siete pruebas, sin filesystem—, el conteo de viajes en +> > el adaptador, la frontera externa y las palabras llegando al verbo en un prompt +> > real. **Lo que este contenedor no puede correr es abandonar de verdad**, que +> > necesita Btrfs: +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh +> > ``` +> > +> > Y **la reducción de rondas frente a un agente real no está medida**: lo que +> > está probado es que 4 llamadas de esta superficie se vuelven 2. Que eso mueva +> > costo o reloj es hipótesis hasta que el banco se vuelva a correr. +> > +> > ### Una decisión que es de Cesar +> > +> > `confirm: true` a ciegas **sigue funcionando** en el canal del agente. Se dejó +> > a propósito: quitarlo haría obligatorio el camino fuerte, y es un decreto de +> > [[Camino-Confiable]], no una decisión de implementación. Está en +> > [[Tareas-Pendientes]]. +> +> > ## El fixture de Btrfs, aislado de verdad: una arena por prueba — 2026-08-29 +> > +> > Los bloques de abajo son cómo se llegó. +> > +> > Cesar corrió el control que cierra el diagnóstico anterior, en su Fedora, con +> > Btrfs de verdad: +> > +> > ``` +> > THALYX_REQUIRE_BTRFS_TESTS=1 THALYX_BTRFS_SCRATCH=/home/cesarmanzocode \ +> > cargo test -p thalyx-snapshot --test natively -- --test-threads=1 --nocapture +> > ``` +> > +> > **4 passed, 0 failed, en 0.06 s**, y `btrfs` estuvo de acuerdo con el kernel en +> > todo lo que se le preguntó. La misma prueba que fallaba en paralelo pasa en +> > serie: **la lógica Btrfs funciona y el fallo era interferencia entre las pruebas +> > paralelas.** +> > +> > El recurso que compartían no era el nombre del subvolumen —eso ya se había +> > arreglado— sino el que el producto **deriva** de él: `Snapshots::directory()` +> > pone los snapshots en el **padre** de la fuente, así que cuatro fuentes con +> > nombres distintos hechas en el mismo directorio compartían un solo +> > `THALYX_BTRFS_SCRATCH/.thalyx-snapshots`, con nombres de snapshot que chocaban, +> > y cada `clean()` se lo llevaba entero mientras las otras trabajaban adentro. +> > +> > Lo que quedó es **una arena por prueba**: un directorio ordinario privado con la +> > fuente adentro, de modo que `directory()` caiga adentro también. +> > +> > ``` +> > THALYX_BTRFS_SCRATCH/thalyx-native---/ +> > source +> > .thalyx-snapshots/ +> > ``` +> > +> > Nada se serializa: el producto está hecho para tener varios árboles a la vez. +> > `` es un contador atómico del proceso; la limpieza es por propiedad —sólo lo +> > que hay bajo la raíz de la arena, explícita al final de cada prueba y otra vez +> > en `Drop` para la ruta donde la prueba se cayó—; y nada se borra antes de +> > crearse, porque el ayudante viejo empezaba borrando la ruta que iba a usar, que +> > es el acto que destruía el árbol ajeno. +> > +> > Lo mismo se aplicó a `taking.rs` y a las pruebas de `intento` en `thalyx-cli`, +> > que escribían en ese mismo directorio compartido desde **otros binarios que +> > `cargo test` corre a la vez**; `taking.rs` incluso terminaba haciéndole +> > `remove_dir_all`. +> > +> > El control nuevo es determinista, sin hilos y sin `sleep`: +> > `cleaning_one_arena_leaves_the_other_arenas_snapshot_untouched` hace dos arenas +> > con la misma etiqueta, toma en las dos un snapshot con el mismo nombre, limpia +> > una y comprueba que la otra sigue entera. Al lado, +> > `two_arenas_asked_for_under_one_label_are_never_given_the_same_name` es puro +> > nombre y corre **también en este contenedor**, que no tiene Btrfs. La regla +> > quedó escrita en [[Estrategia-de-Pruebas]]. +> > +> > **Lo que falta comprobar:** aquí las cinco pruebas que necesitan Btrfs sólo +> > dicen `NOT PROVEN`. En la máquina de Cesar, primero en serie y después en +> > paralelo —que es lo que estaba roto—: +> > +> > ``` +> > cargo test -p thalyx-snapshot --test natively -- --test-threads=1 --nocapture +> > cargo test -p thalyx-snapshot --test natively -- --nocapture +> > ``` +> > +> > con `THALYX_REQUIRE_BTRFS_TESTS=1` y `THALYX_BTRFS_SCRATCH=/home/cesarmanzocode`. +> > `sudo ./dev/verify.sh` va **después**, una sola vez, cuando el paralelo pase +> > repetido. +> +> > ## El único `FAILED` de la corrida real: una prueba que medía `rmdir` — 2026-08-29 +> > +> > La corrida de Fedora sobre `cffb4f8` quedó en **202 PROVEN, 13 NOT PROVEN, 1 +> > FAILED**. El único fallo era +> > `restoring_makes_a_writable_copy_and_deleting_takes_it_away_again`, de +> > `crates/thalyx-snapshot/tests/natively.rs`, y **no era de Thalyx**: la copia +> > escribible era un subvolumen de verdad —el `BTRFS_IOC_SUBVOL_GETFLAGS` de tres +> > renglones antes ya lo había dicho, y ese ioctl contesta `EINVAL` en cualquier +> > cosa que no sea la raíz de uno—. Lo que estaba mal era la premisa de la +> > comprobación: decía que `remove_dir_all` no puede llevarse un subvolumen, y +> > desde Linux 4.18 (`a79a464d5675`) `rmdir(2)` sí se lleva uno **vacío**. La +> > prueba medía la política de `rmdir` del kernel, no el objeto — la misma forma +> > que `chrt --other` midiendo util-linux. +> > +> > En su lugar la prueba le pregunta a `stat(2)`, que es una fuente distinta de la +> > que usa el código: la raíz de todo subvolumen es el inodo 256 +> > (`BTRFS_FIRST_FREE_OBJECTID`) y tiene su propio dispositivo anónimo, que es el +> > par que mira `libbtrfsutil`. Con el control negativo al lado —un directorio +> > ordinario en el mismo directorio, que tiene que fallar las dos mitades— y la +> > segunda opinión de `btrfs subvolume show` donde haya btrfs-progs. La regla +> > quedó escrita en [[Estrategia-de-Pruebas]]. +> > +> > **Lo que falta comprobar:** este contenedor no tiene Btrfs, así que la prueba +> > aquí sólo dice `NOT PROVEN`. La corrección se ejerce en la máquina de Cesar: +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh +> > ``` +> +> > ## Cuatro fallas de `verify.sh`, y catorce etapas que ya no se esperan unas a otras — 2026-08-29 +> > +> > ### Las cuatro fallas, y ninguna era de Thalyx +> > +> > Las cuatro venían del instrumento. Están escritas enteras en +> > [[Estrategia-de-Pruebas]]; en corto: +> > +> > - **Dos pruebas de `intento` se peleaban por un subvolumen.** `btrfs_scratch()` +> > nombraba su subvolumen `thalyx-substitute-` y empezaba borrándolo —y +> > `cargo test` corre las pruebas de un archivo como hilos de **un** proceso, así +> > que las dos tenían el mismo nombre. La segunda destruía el árbol de la primera +> > y después no podía crear lo que ya existía. Ahora el nombre lleva la etiqueta +> > de la prueba. `natively.rs` tenía lo mismo, con cuatro. +> > - **`replacements` esperaba 4 sobre un fixture con 3.** Aritmética escrita a +> > mano en una prueba que este contenedor nunca corre. +> > - **La línea del lote estaba mal formada.** Sólo la primera operación toma +> > prestado el archivo de antes del subverbo; las demás listan los suyos. La +> > máquina leía `'(SlotTable, usize)'` como nombre de archivo y rechazaba el lote +> > entero. Las dos constantes salieron ahora de correr los verbos de verdad. +> > - **El auto-test del arnés medía el PATH de root.** Sus dos comprobaciones de +> > «esto se niega **antes** de arrancar un agente» eran las dos únicas +> > sub-invocaciones sin un `claude` sustituto en el PATH, y `sudo` no le da uno a +> > root. Morían con `no claude on this host` antes de llegar a la negativa que +> > probaban. Ahora tienen un sustituto que **escribe un archivo si alguien lo +> > llama**, así que «no se arrancó ningún agente» es un testigo y no una +> > inferencia sobre un `.ndjson` vacío. +> > +> > ### Y catorce etapas corren de a grupos +> > +> > `parallel_stages` corre un grupo de etapas a la vez, guarda la salida de cada +> > una en su archivo y la reimprime **en el orden en que se lanzaron**, así que el +> > reporte se lee igual que antes. Los veredictos vuelven por archivo, porque un +> > trabajo en segundo plano es una subshell y lo que cuenta se muere con ella; una +> > etapa que vuelve sin veredicto es un `FAILED` aquí, nunca una etapa que +> > silenciosamente no pasó. +> > +> > Los cuatro grupos son **21-24**, **28-30**, **33-35** y **49-52**: cada una de +> > esas etapas se construye su propio almacén bajo `$WORK`, le pregunta a `$THALYX` +> > y lo tira. **Todo lo demás sigue serial**, y por dos razones distintas: lo que +> > escribe algo global —BPF, cgroups, montajes, loop, Btrfs, QEMU, `/dev/fb0`— es +> > la regla 11, y lo que mide tiempos o carreras —31, 32— no tiene defensa contra +> > el ruido que este mismo mecanismo produce (regla 7). +> > +> > Medido en el contenedor, en caliente: **104.7s -> 94.7s, 9.6% menos**, con el +> > reporte idéntico línea por línea y los mismos 98 / 43 / 0. En la máquina de +> > Cesar la fracción será menor: lo que domina ahí —QEMU, Btrfs, el LSM— es +> > justamente lo que se quedó serial. +> > +> > ### Lo que falta +> > +> > **La verificación de siempre**, que es lo único que puede cerrar las dos +> > pruebas de Btrfs: aquí no hay Btrfs y las dos se saltan. +> > +> > ```sh +> > cd ~/thalyx && git pull +> > cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh +> > ``` +> > +> > Y sigue pendiente, sin tocar, **el regrade de la corrida que ya existe** que +> > pide el bloque de abajo. > > --- > -> ## El primer arranque real en QEMU: el motor no encendía — 2026-08-28 -> -> El primer defecto real del motor no lo encontró ninguna prueba. Lo encontró -> arrancar la imagen en QEMU y hablarle: -> -> ``` -> the model failed: could not start module dev.thalyx.engine: -> permission `8GiB memory` cannot be expressed as kernel policy -> ``` -> -> La causa es una frontera que faltaba. `Confinement::establish` le entregaba a -> `thalyx_permd::apply` **todos** los permisos otorgados, y `thalyx-permd` sólo -> sabe expresar operaciones que el LSM revisa en un hook: `net/outbound` y -> lecturas y escrituras de rutas. Un techo de memoria no es una operación —es un -> número en `memory.max`— así que permd lo rechazaba, correctamente y de forma -> fail-closed, y el módulo con el que la máquina se deja hablar no arrancaba. -> -> **La corrección no fue ablandar a permd.** Enseñarle a `bit_for` a devolver -> `0` para `memory` habría convertido un permiso inejecutable en uno que *parece* -> ejecutado —exactamente la promesa que su error `Inexpressible` existe para -> negarse a hacer— y lo habría hecho para todo recurso no-LSM futuro al mismo -> tiempo. La frontera va donde se conoce el mecanismo: en el sandbox. -> `profile::for_kernel_policy` retira de la lista que va al kernel sólo lo que -> **este crate ya hizo cumplir por otro medio**, y todo lo demás sigue llegando a -> `bit_for` y sigue siendo rechazado si nada lo hace cumplir. La lista completa -> se conserva donde `RootFs` y `Profile::for_permissions` la necesitan. -> -> El detalle que hace que la frontera sea la correcta y no una lista de nombres: -> un permiso `memory` cuya acción no se lee como tamaño (`8Gib`) tampoco lo hace -> cumplir el cgroup, así que **no** se retira: llega a la política y se rechaza. -> Hay una prueba para cada mitad. -> -> Y `dev/stage-engine.sh` pedía 4 GiB fijos, así que `ENGINE_TIER=media` producía -> un store cuyo motor moría cargando sus propios pesos y la única salida era -> editar el archivo a mano antes de cada build. Ahora el techo se deriva de la -> gama: `ligera→4GiB`, `media→8GiB`, `alta→16GiB`, `máxima→32GiB`. -> -> Lo que enseña, y ya está en `Estrategia-de-Pruebas.md` como regla 1: **el -> defecto salió de correr el sistema.** 189 verificaciones pasaban con el motor -> incapaz de arrancar, porque ninguna preguntaba si un módulo con el permiso que -> el propio `stage-engine.sh` le escribe llega a existir. - -> ## La máquina tiene agente: una frase suya llega a un modelo y algo pasa — 2026-08-28 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Lo que faltaba desde que se decretó el agente mínimo estaba en el punto 3 del -> bloque de abajo: **no hay agente adentro**. Todo lo demás existía —el prompt, -> la gramática GBNF, el parser, el contrato, el router, la atribución, las -> gamas— y ninguna de esas piezas se podía alcanzar desde la única cara que -> tiene la máquina. Escribir algo que no fuera un verbo contestaba *«no tengo -> modelo cargado»*, lo cual era cierto y era también el agente entero siendo -> inalcanzable. -> -> Ahora la cadena está cerrada de punta a punta: -> -> ``` -> pantalla → session/dispatch → thalyx-agent → prompt + gramática -> → motor llama.cpp instalado como módulo → GGUF del store -> → inferencia real → contrato → router/validación → verbo de Thalyx -> ``` -> -> ### Las cuatro decisiones que la hacen así -> -> 1. **El motor es un módulo, no parte de Thalyx.** `llama-completion` de -> llama.cpp, empaquetado en un `.thmod` firmado, instalado en el store, -> confinado bajo `module_standard` con el uid, el cgroup, el seccomp y la -> raíz pivotada que recibe cualquier otro módulo. La imagen sigue siendo el -> kernel y **un** programa: `make -C image count` lo dice. -> 2. **Un proceso por respuesta.** No hay demonio, no hay servidor, no hay API -> HTTP. `thalyx_core::run` —el mismo que ejecuta `correr`— lanza el motor, -> espera, y lo que el módulo escribió en su `stdout` es la respuesta. Una -> inferencia es un módulo corriendo, con su entrada en el diario. -> 3. **La costura es angosta a propósito.** `thalyx_agent::llama::Engine`: -> entra un vector de argumentos, salen bytes. Arriba de esa línea no cambió -> nada —el prompt, el marcador, la gramática, dónde termina una respuesta, -> qué es una respuesta rota— y abajo hay dos implementaciones: -> `ProcessEngine` (un programa en el `PATH`, que es lo que tiene una máquina -> de desarrollo) y `ModuleEngine` (el módulo instalado, que es lo que tiene -> la máquina). El crate del agente no puede lanzar procesos confinados y no -> debe poder: todo lo que sabe llegó de un modelo que no es de confiar. -> 4. **El disco arranca listo.** El motor se instala en el stage y la elección -> de gama queda escrita, así que la máquina arranca **pudiendo ser hablada**. -> El greeter sigue sin instalar a propósito —el paso 2 del criterio de salida -> es una persona instalándolo— y el motor es el requisito contrario. -> -> ### Lo que se arregló en el camino -> -> - **El prompt se escribía en `/tmp`.** Un módulo sólo ve lo que su manifiesto -> le concedió, así que un prompt fuera de esos directorios es un archivo que -> el motor tiene orden de leer y no puede ver — y vuelve como «llama.cpp no -> completó el prompt», culpando a llama.cpp de un error de Thalyx. El motor -> ahora dice **dónde** se le puede escribir (`Engine::scratch_root`) y hay una -> prueba que sólo falla si eso se rompe. -> - **`llama.cpp` no enlazaba estático.** Tres banderas, encontradas por tres -> enlaces fallidos, en este orden: `LLAMA_OPENSSL=OFF` (cpp-httplib encuentra -> el OpenSSL del sistema, que sólo existe como `.so`), `GGML_OPENMP=OFF` -> (`libgomp` igual) y `GGML_NATIVE=OFF` (la máquina que construye el store no -> es necesariamente la que lo arranca, y `-march=native` en un módulo es una -> instrucción ilegal en el CPU de otro). Están escritas en -> `dev/build-engine.sh` para que nadie las vuelva a encontrar. -> - **`agent model use` fallaba al grabar una ruta que todavía no existe.** El -> store se construye en una máquina y se arranca en otra: la ruta que se graba -> es la de adentro y los bytes que se miden son los del stage. `--reading`. -> - **El modelo diminuto declaraba 128 tokens de contexto** y el prompt real -> mide ~1800, así que rechazaba la única cosa para la que servía además de -> `engine-needs.sh`. Ahora declara 4096, que en un modelo de dos capas no -> cuesta nada. -> -> ### Qué está probado y qué no -> -> **Probado en el contenedor, con un llama.cpp real y un GGUF real** (dos capas, -> hecho por `gguf-py` de llama.cpp): -> -> - el binario es estático, sin intérprete y sin bibliotecas compartidas; -> - se empaqueta, se firma y se instala como módulo, con los 4 GiB que pide; -> - `thalyx agent model check` lo corre **a través del sistema de módulos**, el -> motor lee el prompt del directorio concedido, carga los pesos, obedece la -> gramática y lo que imprime vuelve a Thalyx; -> - una frase que no es un verbo, escrita en la sesión, se convierte en un verbo -> y **se ejecuta**: `crea una carpeta llamada pruebas` → `mkdir pruebas` → la -> carpeta existe. (Con un motor de reemplazo: es una afirmación sobre el -> cableado, no sobre el juicio de un modelo.) -> -> **No probado aquí, y nombrado en vez de supuesto:** -> -> - **confinado.** Este contenedor no tiene BPF LSM, así que la corrida fue -> `--unconfined` y el diario la anotó como degradada. La §45 de -> `dev/verify.sh` lo corre confinado en la máquina de Cesar, y dice NOT PROVEN -> si no puede; -> - **que un Qwen2.5 real acierte la intención.** El modelo de dos capas produce -> un objeto gramatical y vacío. Eso es una medición del modelo, no de la -> máquina, y `thalyx agent bench` es lo que la hace. - -> ## La máquina se puede usar: se puede escribir en ella, y se puede terminar lo que se empieza — 2026-08-28 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Cesar arrancó la imagen y preguntó qué le falta al sistema para que él, por -> voluntad propia, pueda pasar **un día completo adentro**. Medido contra el -> código, el hueco más grande no estaba escrito en ningún lado. -> -> ### Lo que se midió, y no es lo que la bóveda decía -> -> Seis cosas separan el arranque de la foto de un día de uso. Dos de ellas nadie -> las había notado: -> -> 1. **La pantalla que arranca es una ventana, no un taller.** Bajo -> `thalyx-capture` el descriptor 0 es `/dev/null` —a propósito, si no la -> máquina se cuelga con una pregunta que nadie ve— así que los ocho lugares -> que se detienen a preguntar encuentran que no hay terminal y se niegan. -> `instalar`, `ejecutar`, `observar`, `instalar-en` **y `editar`** se pueden -> leer ahí y no se pueden acabar. La bóveda tenía escrita sólo «la -> confirmación dibujada»; `editar` no lo nombraba nadie. -> 2. **No se podía teclear español.** Un `grep` de `keymap` en todo el repo -> salía vacío. El kernel lleva un mapa compilado adentro y es US QWERTY, y -> `loadkeys` no cabe en la imagen. La tecla que en un teclado latinoamericano -> dice `ñ` mandaba `;`. Un SO cuya bóveda entera está en español, en el que -> no se podía escribir en español. -> 3. **No hay agente adentro** — decretado el 2026-08-28. **Cerrado** el -> mismo día: ver el bloque de arriba. -> 4. **Una cosa a la vez** bajo la pantalla. -> 5. **Nada entra ni sale** (`red` lee y no manda), y un store recién instalado -> está vacío. -> 6. **El techo de 1 GiB** por módulo, que el motor no cabe. -> -> ### Sus dos decisiones -> -> Preguntadas con opciones, contestadas el mismo día: -> -> - **El día es escribir y pensar con el agente, y además operar la máquina.** -> No desarrollar Thalyx desde adentro, que abriría la Fase 2. -> - **El techo de memoria: lo que pida el manifiesto, aprobado por él al -> instalar.** No un número fijo más grande. -> -> ### Lo entregado -> -> **A — una pregunta, dos caras** (`crates/thalyx-cli/src/ask.rs`). Las ocho -> confirmaciones tenían los mismos cinco renglones escritos a mano, y habían -> derivado sobre qué es un sí: `intento abandonar` tomaba `si` y `sí`, y el -> verbo que le quita el guardián al kernel no tomaba ninguno. Ahora hay una sola -> comparación y las dos caras la llaman; lo que no se comparte es la negativa, -> que es del verbo. Y el orden cambió: **decir de qué se trata va antes de -> revisar si hay terminal**, porque el contexto *es* la confirmación y una -> negativa emitida antes de que exista no deja nada que dibujar. -> -> **B — el teclado** (`crates/thalyx-term/src/keymap.rs`). Las tablas se -> generan de `kbd` con `dev/keymap-table.py`, nunca se escriben: una -> distribución es un dato sobre el mundo y la regla 6 aplica. `teclado` dice qué -> hay —preguntándole al kernel, no a Thalyx— y `teclado latino|ingles` lo -> cambia. Se carga en el arranque, con `thalyx.teclado=no` de salida de -> emergencia, y hay prueba de que las letras de `teclado ingles` están en la -> misma tecla en las dos distribuciones, o sea que el camino de regreso se -> teclea desde donde haría falta. -> -> **C — el techo por manifiesto.** Una petición de memoria es un permiso -> `persistent`: sale por el camino confiable que ya existe, se guarda donde ya -> se guarda, y `for_permissions` sube el techo. El gigabyte pasa de techo a -> piso. Con unidad siempre (`4GiB`, nunca `4294967296`), nunca `jit`, negado si -> no cabe en la máquina, y dos concesiones dan la mayor y no su suma. Entero en -> [[Motor-de-Inferencia-como-Modulo]]. -> -> **1506 pruebas verdes**, `clippy` limpio, y la compilación de musl —la regla -> 12— corrida de verdad: este contenedor no la podía hacer y ahora sí, con -> `apt-get install musl-tools`. -> -> ### Lo que le toca correr a Cesar -> -> ``` -> git pull && cargo install --path crates/thalyx-cli && make -C image image -> ``` -> -> Y **arrancar la imagen**, que es lo único que contesta las dos mitades que -> este contenedor no tiene: -> -> - **Teclear `ñ`.** Si sale `ñ`, la pieza B funciona en hierro. Si sale otra -> cosa, `teclado ingles` regresa el mapa del kernel; si el teclado quedó -> inservible, `thalyx.teclado=no` en la entrada de arranque. -> - **Teclear `instalar ` o `ejecutar ` en la pantalla.** Antes -> rechazaba; ahora debe dibujar la confirmación con el contexto arriba y -> tomar la respuesta. **Ctrl-C cancela** — el aviso decía «Escape» y era -> imposible: un Escape solo es el prefijo de toda flecha. -> -> `sudo ./dev/verify.sh` trae **tres etapas nuevas** (42, 43, 44) sobre las 189 -> de la corrida anterior. -> -> ### Lo que sigue, y por qué está en ese orden -> -> **D — el motor adentro de la máquina.** Es lo único que queda de lo que él -> decidió, y es lo más grande. Ahora no lo bloquea nada: el confinamiento le -> alcanza (31 de 31 llamadas), el techo ya se puede pedir, y `ejecutar` ya se -> puede confirmar desde la pantalla — que era el hueco por el que el motor no -> se podía ni arrancar desde la cara con la que la máquina viene. +> > ## Cinco llamadas eran un solo plan, y el brazo B nunca había salido de ningún lado — 2026-08-29 +> > +> > ### Lo que quedó hecho +> > +> > **Dos errores del instrumento, ninguno del sistema medido.** +> > +> > - `--out` relativo. `run_arm` hace `cd` al directorio del agente antes de +> > ejecutar `claude`, así que un `--out target/…` hacía que +> > `--settings $OUT/armA.settings.json` se resolviera contra ese directorio y +> > no contra donde estaba parado quien lo escribió. El brazo A de la corrida +> > del 2026-08-29 contestó `Settings file not found.` por eso. Arreglado en un +> > solo lugar, `normalise_paths`, con self-test que corre desde un directorio +> > que no es ninguno de los dos. +> > - **El scope del brazo B era un falso positivo.** El grader comparaba +> > `/home/bench-thalyx` —el espacio de trabajo, adentro de la máquina— con el +> > directorio host donde arrancó el proceso `claude`, y los declaraba +> > distintos. Lo son: no están en el mismo espacio de nombres. Ahora +> > `host_control_cwd` y `guest_project_workspace` son dos palabras distintas +> > para siempre, y cada brazo se juzga bajo la frontera que ese brazo tiene: la +> > del A es que arrancó adentro del árbol y no salió; la del B es el canal —cero +> > herramientas host que puedan tocar el proyecto, toda ruta que la máquina +> > *aceptó* bajo el espacio guest, toda ruta con la que *contestó* también, y el +> > preflight probando de antemano a qué árbol apuntaba el canal. Una ruta que la +> > máquina **rechazó** no es una brecha: es la frontera funcionando. +> > - Y de paso, un hueco más viejo: `paths` en plural no era un campo que el +> > grader conociera y su barrido de respaldo sólo miraba cadenas, así que toda +> > ruta nombrada dentro de una lista era una ruta que nadie revisaba — que es +> > exactamente cómo `thalyx_edit` nombra sus archivos. +> > +> > **`editar … sustituir-lote`**: varias sustituciones exactas, cada una con sus +> > archivos, en una llamada. La corrida post-`sustituir` bajó de 16 mutaciones a +> > 5, y las cinco eran el mismo plan — la ruta calificada, la definición, el +> > `impl`, un tipo dentro de una tupla, el nombre pelado. Se aplican en el orden +> > dado, cada una sobre lo que dejó la anterior (que es lo que significan esas +> > llamadas hechas en fila); una composición que el orden no puede resolver +> > —`A -> B` y luego `B -> C`— se rechaza con las dos cadenas nombradas. Preflight +> > completo: cada archivo se abre una vez por inodo, todo se aplica en memoria, y +> > sólo entonces se escribe. +> > +> > Medido local, sin Claude: 5 llamadas -> 1, 5 mutaciones -> 1, 569 -> 508 bytes +> > de petición, 1499 -> 1452 de respuesta. **No dice nada de costo ni de reloj.** +> > +> > ### Lo que falta, y en este orden +> > +> > **1. El regrade de la corrida que ya existe**, en tu máquina, sin gastar nada: +> > +> > ```sh +> > cd ~/thalyx && git pull +> > dev/bench-external-agent.sh --task reversible --symbol UidRegistry \ +> > --out ~/thalyx/target/bench-external-agent-3 --regrade +> > ``` +> > +> > Escribe `summary-regraded.json`, no toca `summary.json`, e imprime una línea +> > por brazo: `VALID` / `INVALID` / `NOT PROVEN`, con la frontera bajo la que lo +> > decidió. Hasta que se corra, REVERSIBLE #2 es **PENDIENTE**. +> > +> > **2. La verificación de siempre**, que es donde se comprueba lo que este +> > contenedor no puede: el lote adentro de un `intento` sobre Btrfs de verdad. +> > +> > ```sh +> > cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh +> > ``` +> > +> > **3. Una corrida limpia del banco**, cuando las dos de arriba salgan bien. El +> > comando exacto está en [[Evidencia-de-Agentes]]. Ésa es la que dice si el lote +> > mueve costo y reloj; nada de lo escrito hasta ahora lo afirma. +> > +> > ### Una decisión que es tuya y no se tomó +> > +> > `attempt abandon` sigue costando dos llamadas: la primera contesta qué se +> > perdería, la segunda con `confirm` lo hace. Se revisó si eso es seguridad real +> > o UX humana reciclada y **es real**: abandonar reemplaza el árbol, el árbol es +> > compartido, y lo que se destruye no tiene otro snapshot que lo recupere. Es un +> > decreto de [[Camino-Confiable]], así que se quedó como está. Si alguna vez +> > quieres que el canal del agente pueda saltárselo, esa decisión es tuya. +> > +> > El detalle completo —la traza, por qué el brazo B nunca emitió más de una +> > herramienta por mensaje, y qué parte de eso es de Thalyx— está en +> > [[Evidencia-de-Agentes]]. > -> Los dos huecos que quedan del día y **no** están decididos: que se pueda hacer -> más de una cosa a la vez, y por dónde entra y sale algo de la máquina. - -> ## El agente no está en la máquina, y por dónde entra — 2026-08-28 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Cesar arrancó la imagen, vio la pantalla, y preguntó qué le falta al sistema -> para ser *«algo real que pueda usar durante un buen tiempo de verdad»*. La -> respuesta, medida contra el código, es más grande de lo que esta bóveda decía. -> -> ### Una máquina Thalyx arrancada no tiene agente -> -> Y no por un pendiente: por cómo está construido. `llama.rs` arranca -> `llama.cpp` con `Command::new`, un binario aparte buscado en el `PATH`, y la -> imagen lleva `/init` y `/dev/console` y nada más. Así que el motor **no existe -> en la máquina que arranca y no puede existir ahí** mientras la imagen sea el -> kernel y un programa. El router, la gramática y las tres gamas medidas han -> corrido siempre en el Fedora de Cesar, nunca sobre Thalyx. -> -> Un sistema operativo donde la IA es ciudadana de primera arrancó sin ella, y -> eso no estaba escrito en ningún lado como hueco. -> -> ### El decreto: el motor es el primer módulo real -> -> Decidido por Cesar el 2026-08-28. Un binario estático, firmado, en el store, -> confinado bajo `module_standard` como cualquier módulo; el `.gguf` llega al -> disco de store por donde `greeter` ya llega. **No contradice el decreto de la -> imagen**: un módulo vive en el store, no en la imagen, y `make -C image count` -> sigue diciendo uno. Lo que sí lo contradice es lo de hoy, un `PATH` del que se -> saca un programa sin manifiesto, sin firma, sin permisos y sin confinamiento. -> Entero en [[Motor-de-Inferencia-como-Modulo]]. -> -> ### Y se midió antes de construir nada, porque una pregunta podía matarlo -> -> Si un motor real necesitara algo que `module_standard` niega, el decreto sería -> inconstruible. `dev/engine-needs.sh` lo preguntó —es -> `dev/foreign-agent-needs.sh` apuntado a un motor, la misma comparación contra -> la misma lista, una sola y no dos— con llama.cpp de verdad y un modelo escrito -> por el propio `gguf-py` de llama.cpp, no por nosotros (regla 6). -> -> **31 llamadas al sistema distintas para cargar, tokenizar, correr el grafo y -> generar. Las 31 ya están permitidas.** El confinamiento que ya existe alcanza -> para un motor de inferencia, y eso no se sabía. De 13 rutas abiertas, 9 caen -> dentro de lo que un módulo ve; de las otras cuatro, una es el `.gguf` —los -> datos del módulo, como el `notes.txt` de `greeter`— y las tres restantes son -> `/dev/tty` y dos de conteo de núcleos bajo `/sys`. -> -> **Lo que no contesta es el tamaño, y ahí está el hueco.** El modelo medido -> pesa menos de un megabyte. `module_standard` topa un módulo en **1 GiB** -> (`profile.rs`), ningún manifiesto puede pedir más, y aunque los pesos por -> `mmap` sean caché reclamable, el KV cache y los búferes de cómputo no lo son. -> Ese número es de Cesar: es política y cuesta su hierro. Tampoco contesta el -> libc — se midió glibc y embarcaría musl estático, que es la regla 12. -> -> ### Tres defectos que su foto agarró, arreglados -> -> Los tres salieron de mirar la pantalla arrancada, no el código, y ninguno era -> un error de lo que la pantalla *dice*. -> -> - **Un renglón se salía de su panel.** `Row::Pair` medía el valor entero y -> luego lo alineaba a la derecha; uno más ancho que la columna quedaba con el -> lápiz a la izquierda del panel, y `draw` no recorta. El recorte salió a -> `Typography::fit`, porque un texto alineado a la derecha hay que medirlo **ya -> recortado**. -> - **`clear` mandaba un escape a una tubería.** Bajo la pantalla la salida se -> atrapa en el descriptor, así que la pantalla dibujó `[2J[H` como texto. Ahora -> el escape sólo sale a una terminal de verdad y hay `Flow::Emptied`. Probado -> por tubería **y con control en una terminal hecha con `thalyx dev pty`**. -> - **La barra decía las opciones de un tmpfs donde va el store.** Dos errores -> independientes: el respaldo le ganaba a lo que respaldaba —un `find` pedía -> `/var/thalyx` **o** `/`, y `/` se monta primero, así que en una máquina con -> store el store no se consultaba nunca— y el último campo de un renglón de -> `mountinfo` no es una etiqueta. Por eso la barra decía tmpfs mientras el panel -> dos pulgadas a la derecha decía `btrfs`: **la pantalla se contradecía a sí -> misma**, y ésa fue la pista. Ahora dice el disco, o «sin disco — no -> recuerda», o `store ?` cuando no se pudo leer (regla 10). -> -> ### Lo que le toca correr a Cesar -> -> ``` -> git pull && cargo install --path crates/thalyx-cli && make -C image image -> ``` +> --- > -> Y arrancar la imagen para ver la pantalla otra vez: los tres defectos son de -> vidrio y su Fedora no tiene framebuffer que los enseñe. `sudo ./dev/verify.sh` -> no trae etapas nuevas todavía — la del motor llega cuando el motor exista. - -> ## La corrida en hierro salió limpia, y la imagen no compilaba — 2026-08-28 -> -> Los bloques de abajo son cómo se llegó. -> -> **La corrida de Cesar: `proven 185 · not proven 4 · failed 0`.** Cero fallas, y -> el conteo cuadra: 189 comprobaciones contra las 184 de la corrida anterior -> (`181 · 2 · 1`), y las cinco de diferencia son exactamente las cinco que agregó -> la entrega de la pantalla. Nada dejó de correr en silencio, que es la única -> forma de leer un marcador. -> -> Lo que eso deja probado en hierro: -> -> - **la suite ya no arma el kernel de la máquina que está midiendo** — la falla -> de la §5 desapareció, y con ella queda comprobada en fierro la regla 11, que -> aquí no se puede comprobar porque este contenedor no tiene guardián; -> - **el color del camino confiable está en una confirmación y en nada más**, -> leído por un decodificador de PNG que no es Thalyx, con su control y el -> control del control; -> - **`pantalla` por tubería rechaza con `not_a_terminal` y la sesión sigue -> contestando** — una máquina sin monitor no se detiene; -> - **la línea de comandos de la imagen deja `thalyx.pantalla` sin contestar**, -> así que `thalyx.pantalla=no` sigue siendo la salida de una máquina negra. -> -> **Los cuatro `NOT PROVEN`: dos son nuevos y son el mismo hueco.** De las cinco -> comprobaciones nuevas sólo dos tienen rama de `NOT PROVEN`, y las dos son la -> mitad de la etapa 40 que necesita `/dev/fb0`: la comparación del `ioctl` contra -> sysfs, y el tracer que vigila que `pantalla` por tubería no llegue a tocar el -> framebuffer. Cesar lo confirmó a mano: **su Fedora no tiene `/dev/fb0`** ni -> `strace` instalado. Los otros dos son los de siempre, los que ya venían del 27. -> -> Eso **no es un hueco de la pantalla de Thalyx**, es de la máquina que verifica: -> la imagen lleva `CONFIG_FB`, `CONFIG_FB_EFI` y `CONFIG_FRAMEBUFFER_CONSOLE`, y -> la consola de texto con la que arrancó en agosto ya era prueba de que ahí sí -> hay framebuffer. La pantalla sólo se puede *ver* arrancando la imagen, no desde -> Fedora. -> -> ### Y lo siguiente que tecleó no compiló -> -> `make -C image image` murió con cinco errores de tipo, todos de la pantalla: -> las peticiones de `ioctl` escritas `as libc::c_ulong`. Ése es el tipo que toma -> `libc::ioctl` **contra glibc**; contra musl toma `c_int`, y la imagen es un -> binario estático de musl. -> -> **Por qué 189 comprobaciones no vieron nada.** `verify.sh` compila contra glibc -> de principio a fin, y la etapa 11 —la que se llama «la imagen»— arma el -> initramfs **con ese binario de glibc** para contar cuántos programas lleva -> adentro. El único lugar del proyecto donde se compilaba lo que de verdad -> arranca era un comando que corre Cesar, a mano, después de que todo dijo que -> estaba bien. -> -> **El arreglo son cinco palabras** —`as libc::Ioctl`, el alias que ya vale en -> los dos objetivos— y la regla ya estaba escrita en el propio crate, en el -> comentario de `BTRFS_IOC_SUBVOL_CREATE`, desde que se construyó Btrfs. Una -> convención que vive sólo en un comentario la obedece quien lo lee. -> -> **Así que lo que se entrega no es el arreglo, es la comprobación.** La etapa 2 -> corre ahora la línea exacta del `Makefile` de la imagen: +> > ## El brazo A no estaba anclado a `--project`, y ahora no puede no estarlo — 2026-08-29 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó, y el +> > primero de ellos —REVERSIBLE #1— hay que leerlo con esto encima. +> > +> > Una revisión de la forense de REVERSIBLE #1 encontró al brazo A ejecutando +> > `cd /home/cesarmanzocode/thalyx` mientras el arnés tenía +> > `--project /tmp/bench-thalyx`. **La causa es una línea, y estuvo desde el +> > primer commit del arnés:** `--out` valía por omisión +> > `$ROOT/target/bench-external-agent`, la copia del brazo A se hacía en `$OUT/a`, +> > y `$ROOT` es el checkout donde vive el script. Así que `claude` arrancaba +> > dentro del clon de trabajo de Cesar, y Claude Code recoge el `CLAUDE.md` de +> > cada ancestro de su directorio de trabajo — el de este proyecto, que empieza +> > con «lee esto antes que nada». El agente recibió instrucciones sobre +> > `~/thalyx` y se fue a trabajar a `~/thalyx`. +> > +> > **Están afectadas todas las corridas anteriores** — la histórica, READ #1, +> > READ #2, CHANGE #1 y REVERSIBLE #1 — porque el mecanismo es idéntico en todas. +> > Que además *saliera* de su copia sólo está **observado** en REVERSIBLE #1, que +> > es la única cuya forense se leyó con esa pregunta. De las otras no se afirma ni +> > que sí ni que no: nadie ha mirado, y mirar es gratis +> > (`dev/bench-summary.py --scope-check --arm A` sobre cada `--out` que +> > sobreviva). Los datos se conservan; lo que se degradó es su fuerza, de +> > comparación controlada a observación. Está escrito entero en +> > [[Evidencia-de-Agentes]]. +> > +> > **Los otros dos defectos de la misma revisión:** +> > +> > - la tabla forense imprimía `write=False` para un `Bash` cuyo comando era +> > `git checkout -- `. Un campo de dos valores no sabe decir «no sé», +> > así que le daba a lo que no podía ver la misma respuesta que a lo que sí +> > comprobaba. Hoy hay tres clases —`writes`, `reads`, `unknown`—, `Bash` es +> > siempre `unknown`, y un testigo que no vio nada con llamadas `unknown` en el +> > stream contesta `not_proven` en vez de `false`; +> > - el brazo B devolvió `0s` y cero eventos **después** de haber pagado el brazo +> > A entero, porque el único control era `[ -S "$SOCKET" ]`, que pregunta si +> > existe un archivo. Hoy hay un preflight del canal real —hello, `where`, +> > `list .` comparado con `--project`, todo de sólo lectura— que corre **antes** +> > de llamar a Claude en cualquier brazo. +> > +> > **Lo que corre ahora antes de gastar un centavo**, en este orden: el brazo B se +> > prueba vivo; los dos brazos se comparan de entrada (`provenance.json`, con +> > commit de origen, manifiesto de entrada, exclusiones y directorio efectivo de +> > cada uno, y los dos resúmenes hechos por el mismo programa); el brazo A se +> > escenifica fuera del checkout, con cada ancestro revisado por `CLAUDE.md`, +> > `.claude/`, `.mcp.json` y `.git`, y un hook `PreToolUse` que rechaza cualquier +> > ruta de afuera; y después de correr se lee del stream el `system init` y todas +> > las rutas de todas las llamadas — una sola afuera deja la corrida `INVALID`, y +> > se comprueba **entre los dos brazos**, que es el último momento en que saberlo +> > cuesta menos que el brazo B. +> > +> > Nada de eso toca el prompt ni las métricas: el arnés sigue congelado en lo que +> > mide. +> > +> > **Las pruebas, todas gratis y todas en la etapa 50 de `verify.sh`:** el +> > `--self-test` del arnés arranca un sustituto de `claude` que imprime streams de +> > la forma real y comprueba que un brazo A que se sale es `INVALID` antes del +> > brazo B, que uno que arrancó en otro lado también, que uno que se quedó adentro +> > no se rechaza, que un brazo B muerto detiene la corrida antes del brazo A, que +> > uno vivo la deja seguir, y que dos brazos con árboles distintos la detienen +> > antes de llamar a nadie. El `--self-test` del parser comprueba la clasificación +> > de tres valores con `git checkout -- ` y con un `Bash` genuinamente de +> > sólo lectura, y que ninguno de los dos se acredita como lectura probada. +> > +> > **La repetición limpia, en dos órdenes:** +> > +> > ```sh +> > make -C image agent PROJECT=/tmp/bench-thalyx +> > +> > dev/bench-external-agent.sh --task reversible --symbol UidRegistry \ +> > --project /tmp/bench-thalyx \ +> > --expect-file dev/bench-expect/reversible-UidRegistry.txt \ +> > --workspace /tmp/thalyx-bench-arm-a \ +> > --out target/bench-external-agent-3 +> > ``` +> > +> > --- +> > +> > ## REVERSIBLE #1 salió válido y mixto, y la escritura ya no cuesta dieciséis llamadas — 2026-08-29 +> > +> > **Leer con el bloque de arriba.** Este resultado se conserva como observación: +> > su brazo A no estaba anclado a `--project`, así que no es una comparación +> > controlada. +> > +> > El banco reversible se regradó con el grader corregido, sobre los mismos +> > artefactos, sin correr ningún agente y sin gastar nada. **Los dos brazos salen +> > `VALID`**: los dos modificaron de verdad, los dos contestaron bien, los dos +> > devolvieron el árbol. Es el primer resultado mixto con veredicto válido del +> > proyecto y hay que leerlo entero: +> > +> > | | brazo A (Linux) | brazo B (Thalyx) | +> > | --- | --- | --- | +> > | costo | $0.2597362 | $0.2255152 — **−13.2 %** | +> > | reloj | 47.909 s | 63.805 s — **+33.2 % peor** | +> > | llamadas | 16 | 36 | +> > | mutaciones | 6 | 16 | +> > | archivos leídos | 7 | **0** | +> > | bytes al modelo | 19 774 | 14 365 — −27.4 % | +> > | tokens de salida | 3 880 | 5 859 — **+51 %** | +> > +> > La navegación semántica volvió a ganar y la frontera reversible funcionó de +> > punta a punta. Lo que perdió es el reloj, y el trace dice por qué sin ningún +> > misterio: el editor del brazo A reemplaza todas las apariciones de un archivo +> > en **una** llamada, así que hizo una por archivo; el brazo B sólo sabía +> > direccionar líneas, así que hizo una por línea —56, 61, 166, 168…— y en cada +> > una tuvo que escribir el texto nuevo completo de la línea. +> > +> > **Lo que se construyó por eso, y es una operación y no una capa:** +> > `editar sustituir [más archivos…]`, expuesta al +> > agente como `thalyx_edit` con `action: "substitute"`. Una cadena exacta, en +> > todas partes, en todos los archivos nombrados, en una llamada. Precomprueba +> > todos los archivos antes de escribir un byte —un archivo que no contiene el +> > texto detiene la llamada entera con nada cambiado—, dice `wrote: false` en +> > cada rechazo, rechaza el mismo archivo nombrado dos veces por inodo, y +> > contesta con cuentas en vez de contenido. +> > +> > Y **es sustitución, no renombrado**. El índice de hoy no distingue el símbolo +> > del comentario, del homónimo, de la cadena ni del identificador más largo que +> > lo contiene, y llamarle «renombrado semántico» a una sustitución léxica sería +> > una abstracción falsa. LSP/SCIP van debajo de esta misma API el día que +> > existan. +> > +> > La regresión que lo sostiene no gasta un centavo — +> > `crates/thalyx-cli/tests/a_mechanical_rename_costs_one_call.rs`, dos crates, +> > 19 apariciones en 16 líneas de 6 archivos, el mismo renombrado hecho de las +> > dos maneras y los dos árboles comparados byte a byte: +> > +> > ``` +> > línea por línea 16 llamadas 1523 bytes enviados 2956 de vuelta +> > sustitución 1 llamada 252 bytes enviados 742 de vuelta +> > ``` +> > +> > **Lo que falta, y es de Cesar porque cuesta dinero:** volver a correr el banco +> > reversible **una vez**, con el arnés congelado, que es la prueba de la +> > hipótesis y no su confirmación. Nada dice todavía que esto mejore el banco: +> > +> > ```sh +> > dev/bench-external-agent.sh --task reversible --symbol UidRegistry \ +> > --expect-file dev/bench-expect/reversible-UidRegistry.txt \ +> > --out target/bench-external-agent-2 +> > ``` +> > +> > El detalle entero está en [[Evidencia-de-Agentes]], sección «Lo que la corrida +> > encontró, y el cambio que provocó». +> +> > ## El banco reversible se corrió, y el instrumento estaba mal — 2026-08-29 +> > +> > Los bloques de abajo son cómo se llegó. +> > +> > Cesar corrió `--task reversible` sobre `UidRegistry`. La corrida terminó, los +> > dos brazos contestaron bien, y el resumen los reprobó a los dos por razones +> > que no tenían que ver con lo que hicieron. **El veredicto de esa corrida no +> > vale y la corrida no está perdida**: los dos defectos eran del grader y los +> > dos se arreglaron leyendo el grader, sin volver a pagar nada. +> > +> > **1. El socket de QEMU contaba como espacio de trabajo.** La única diferencia +> > que el brazo B reportó entre el árbol del que salió y el que volvió fue +> > `image/build/agent.sock`, que abre QEMU para el canal del agente y que ningún +> > agente pudo crear ni borrar. `image/build` es maquinaria del banco —lo dice +> > `.gitignore` desde antes de que el banco existiera— y faltaba en la lista de +> > exclusiones. Ahora está, en un solo lugar que usan la caminata inicial y la +> > final, y **lo que se deja afuera se reporta** (`set_aside`) para que una +> > exclusión no pueda ser un escondite. +> > +> > **2. El testigo del estado intermedio era el único que la tarea correcta +> > apaga.** Era el `mtime`, y la quinta parte de la tarea es *devolver todo +> > exactamente*; un agente que restaura desde una copia con `cp -a` devuelve el +> > contenido **y la fecha**. El brazo A hizo seis `Edit` y quedó registrado como +> > si nunca hubiera pasado nada. Ahora son tres testigos —el `ctime`, que nada en +> > espacio de usuario puede poner para atrás; la respuesta de la herramienta, que +> > ya está escrita en el stream y ninguna restauración alcanza; y el contador del +> > adaptador para el brazo B— y cuatro campos donde había dos: **pidió**, +> > **la herramienta contestó**, **algo de afuera lo vio**, **así quedó el árbol**. +> > +> > Y `turns: 37` bajo `--max-turns 30` no era una corrida cortada: `turns` cuenta +> > mensajes de usuario, `--max-turns` acota viajes a la API, y se separan en +> > cuanto el modelo pide dos herramientas en un mismo mensaje. Queda documentado +> > y fijado con `--self-test`. +> > +> > **Lo que falta, y es de Cesar porque los artefactos están en su máquina:** +> > volver a leer esa corrida con el grader corregido. No corre ningún agente, no +> > cuesta nada, y no toca el `summary.json` original: +> > +> > ```sh +> > dev/bench-external-agent.sh --task reversible --symbol UidRegistry \ +> > --expect-file dev/bench-expect/reversible-UidRegistry.txt \ +> > --out target/bench-external-agent --regrade +> > ``` +> > +> > Escribe `summary-regraded.json`, y cada brazo sale `VALID`, `NOT PROVEN` o +> > `INVALID` con la razón. Antes de eso, `--forensics` imprime qué contestó cada +> > herramienta a cada una de las seis `Edit` del brazo A, que es lo único que +> > puede distinguir «seis ediciones que se deshicieron» de «seis ediciones que +> > fallaron». El incidente entero está en [[Evidencia-de-Agentes]] y la regla en +> > [[Estrategia-de-Pruebas]]. +> > +> > **La corrida NO está registrada como resultado válido.** No hay número de esta +> > corrida en ninguna nota como si fuera evidencia; lo que hay es lo que imprimió, +> > marcado como lo que es. +> +> > ## Los cuatro P0 de la auditoría, cerrados, y ninguno se arregló por lectura — 2026-08-29 +> > +> > Los bloques de abajo son cómo se llegó. +> > +> > Una auditoría había señalado cuatro defectos. Los cuatro eran reales, y **tres +> > eran el mismo defecto** en tres subsistemas que nadie habría puesto juntos: +> > una regla correcta, escrita, con su comentario explicando por qué importa, y +> > **comprobada en vez de impuesta**. La regla nueva está en +> > [[Estrategia-de-Pruebas]], 2026-08-29. +> > +> > **1. El oráculo del benchmark reversible daba tres falsos positivos.** +> > `really_changed` se leía como «el nombre nuevo apareció en alguna llamada», +> > que es una frase que escribe el agente: un `Grep` de ese nombre la satisface, +> > un `Edit` que falló la satisface, y una corrida muerta en su límite de turnos +> > no se miraba. Cualquiera de los tres, con el árbol intacto porque nadie lo +> > tocó, salía `passed: true`. Y el digest era `find -type f | xargs sha256sum` +> > —contenido y nada más— mientras el prompt promete *byte por byte, ningún +> > archivo agregado ni quitado*: un symlink a `/etc/passwd` donde había un +> > fuente restauraba «perfecto». Ahora son cinco propiedades de cinco +> > instrumentos, con testigo externo del estado intermedio, y el digest es sobre +> > un manifiesto —tipo, permisos, contenido, destino de symlink— con **una sola +> > implementación**. Los siete falsos positivos están nombrados en +> > `bench-summary.py --self-test`. Ver [[Agentes-Externos]], revisión del +> > 2026-08-28. +> > +> > **2. `intento` comprobaba la exclusión en vez de imponerla.** Dos clientes que +> > llegan juntos ven los dos «no hay ninguno abierto», los dos toman snapshot, y +> > el segundo pisa el registro del primero — dejando un snapshot que nada nombra +> > y un `abandonar` que devuelve el árbol a un punto que no es el que el primer +> > cliente cree. Las tres transiciones toman ahora `Store::lock()`, el mismo +> > `flock` de siempre, y `attempt.json` se publica con `write_durably` en vez de +> > `std::fs::write`. Ver [[Concurrencia]], revisión del 2026-08-28. +> > +> > **3. La frontera del espacio de trabajo era una comparación.** +> > `canonicalize → comparar → el verbo abre el nombre original`, que es la +> > secuencia que `api.rs` fue reescrito para dejar de usar hace semanas. Se midió +> > antes de arreglar nada: un hilo que cambia `src` por un enlace a otro árbol +> > mientras un agente lee `src/main.rs` sacó **57 archivos de fuera del espacio +> > de trabajo en 4000 lecturas**. Ahora es `openat2` con `RESOLVE_BENEATH` contra +> > un descriptor del espacio de trabajo, y el verbo abre ese descriptor. +> > Ver [[Agentes-Externos]], revisión del 2026-08-28. +> > +> > **4. El desmontaje del sandbox preguntaba si el cgroup estaba vacío.** Esa +> > pregunta no se puede contestar mirando: `spawn` vuelve antes de que el +> > auxiliar se una al cgroup, así que una corrida que ya arrancó su módulo es +> > invisible durante una ventana — y el LSM falla abierto para un cgroup sin +> > entrada. Se cuenta en vez de mirar. Ver [[Sandbox-Ejecucion]], revisión del +> > 2026-08-29. +> > +> > **Cada arreglo se comprobó quitándolo.** Los cuatro tests adversariales de la +> > frontera fallan sin el anclaje; el de la carrera de `intento` falla cinco de +> > cinco corridas sin el candado; el del cgroup falla sin el contador. +> > +> > **Lo siguiente, y lo que no se hizo.** El cuello de botella que queda es que +> > el agente no puede compilar ni probar lo que cambió. Se midió el costo de +> > `validar` y no cabía honestamente en esta sesión —hace falta un perfil de +> > seccomp derivado corriendo un toolchain real, que es justo lo que no se puede +> > adivinar— así que quedó **una propuesta concreta** en +> > [[Validacion-Confiable]] en vez de arquitectura a medio construir. +> > +> > **El benchmark sigue sin correrse.** *(Se corrió el mismo día; ver el bloque +> > de hasta arriba.)* +> > +> +> > ## La prioridad de la etapa se reordena: demostrar y medir la ventaja para agentes — 2026-08-28 +> > +> > **Cómo se llegó.** +> > +> > **Nada se construyó en este paso.** Es documentación, estrategia y +> > formalización: dos notas nuevas y revisiones fechadas en las que ya existían. +> > +> > **Lo que cambia**, decretado por Cesar el 2026-08-28 y escrito entero en +> > [[Prioridad-Operativa]]: durante esta etapa la prioridad operativa es +> > demostrar que Thalyx mejora el trabajo de agentes reales, medirlo, hacer +> > dogfooding, mejorar sólo lo que la evidencia pida, absorber mecanismos ya +> > demostrados por otras herramientas, bajar la fricción de adopción +> > —proyecto → VM → el mismo agente → trabajar— y **posponer la habitabilidad +> > general que no ayude a eso**. En una frase: +> > +> > > Primero demostrar que Thalyx hace mejor al agente. Después hacer que Thalyx +> > > pueda reemplazar al resto de la máquina. +> > +> > **Lo que NO cambia, y es la mitad importante:** la visión. [[Filosofia-Fundacional]] +> > sigue intacta, la imagen sigue siendo el kernel y un programa, MCP sigue siendo +> > un adaptador y no la API interna, y el agente externo sigue siendo software no +> > confiable. Navegador, paquetes, catálogo de aplicaciones, escritorio, +> > multimedia y compatibilidad por completitud quedan **diferidos, no +> > abandonados**. Nada de eso estaba decretado, así que lo que se defiere es una +> > aspiración de orden, no un decreto: [[Construccion-del-ISO]] ya decía que no +> > hay gestor de paquetes y [[La-Pantalla]] que no hay escritorio. +> > +> > **La regla de prioridad**, para no tener que releer la nota: una tarea va +> > primero si aumenta de forma medible el éxito del agente, o baja costo/tokens/ +> > contexto, o baja tiempo/trabajo, o mejora seguridad/reversibilidad, o facilita +> > la adopción. Si no toca ninguna, normalmente espera. No sustituye a los cinco +> > costos de [[Superficie-para-el-LLM]]: los cinco deciden qué merece existir, y +> > esta regla decide qué merece el tiempo de esta etapa, exigiendo además que el +> > efecto se vea en una corrida. +> > +> > **Y la evidencia queda en un solo lugar**: [[Evidencia-de-Agentes]], con las +> > tres corridas completas —READ #1, READ #2 y **CHANGE #1, que no favorece a +> > Thalyx y está escrito igual de completo**—, la corrida histórica etiquetada +> > como anterior al endurecimiento del índice, los límites de lo que tres +> > observaciones pueden decir, y el **protocolo del bug real** para cuando +> > aparezca uno: congelar el SHA, describirlo, una sola comparación seria con los +> > dos brazos, y guardar streams, costo, parches y pruebas. Un bug cuyo problema +> > central sea el puente o el índice roto **no sirve** para esa comparación. +> > +> > **Lo que sigue pendiente y es de Cesar:** correr +> > `dev/bench-external-agent.sh --task reversible` sobre `UidRegistry`. *(Se +> > corrió el 2026-08-29; ver el bloque de hasta arriba.)* +> +> > ## El arnés tiene la tarea que sí ejercita la frontera reversible, y no se ha corrido — 2026-08-28 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Ya hay tres corridas reales de Claude Code, misma tarea y mismo modelo en los +> > dos brazos, los dos contestando correcto: +> > +> > | tarea | costo | tiempo de pared | +> > |---|---|---| +> > | lectura #1 | Thalyx **−46 %** | Thalyx **−18 %** | +> > | lectura #2 | Thalyx **−62 %** | Thalyx **−36 %** | +> > | edición simple, un archivo | Thalyx −4 % | Thalyx **+24 %** | +> > +> > La tercera salió empatada: los mismos 6 turnos y las mismas 5 llamadas en los +> > dos brazos, y el brazo B **nunca abrió un intento**. Eso no es una derrota, +> > **es una tarea que no medía la apuesta**: un archivo cambiado una vez no tiene +> > nada que revertir, así que no hay frontera reversible que ejercitar y el agente +> > hizo bien en no abrir una. Lo que midió fue el editor. +> > +> > Lo nuevo es `dev/bench-external-agent.sh --task reversible`, la tarea que sí la +> > ejercita: localizar un símbolo, cambiarlo en su definición y en todos sus +> > dependientes, comprobar qué se tocó, y **dejar el árbol exactamente como +> > estaba, byte por byte**. La pregunta, escrita antes de correrla, está en +> > [[Agentes-Externos]]. +> > +> > **Nada se corrió todavía.** Esto es instrumento, no resultado, y se probó +> > entero sin gastar una sola corrida: `dev/bench-summary.py --self-test` y +> > `dev/bench-external-agent.sh --self-test`, los dos en la etapa 50 de +> > `verify.sh`. +> > +> > Cuatro cosas la mantienen honesta: +> > +> > 1. **Un solo prompt** para los dos brazos, que no nombra ninguna herramienta, +> > ni MCP, ni Thalyx. El self-test lo comprueba leyendo el propio archivo: +> > cuenta que haya exactamente un `claude -p` y busca las palabras prohibidas. +> > 2. **El cambio es mecánico**: un sufijo, `UidRegistry` → `UidRegistryRenamed`. +> > No hay criterio que ejercer, así que no hay diferencia de criterio. +> > 3. **El brazo A restaura como quiera**: su copia trae el `.git` y tiene `Bash`, +> > y `git checkout -- .` es una respuesta válida. Lo único prohibido —en los +> > dos brazos— es compilar y probar, porque el brazo B no tiene shell. +> > 4. **"Restaurado" se comprueba desde afuera**, con `sha256` sobre el árbol, no +> > preguntándole a la máquina que hizo la afirmación. +> > +> > **Y la trampa que trae adentro, que es lo que más trabajo costó:** un agente +> > que no hace nada restaura el árbol perfecto. Un veredicto leído del hash solo +> > pondría a un agente que se rehusó por encima de todos los que lo intentaron, y +> > más alto en el brazo B — la dirección en la que esto no puede equivocarse +> > nunca. Por eso `reversible.passed` es una conjunción de tres instrumentos +> > distintos: **cambió de verdad** (el nombre nuevo apareció en alguna llamada, +> > según el stream del agente), **restauró** (los bytes, según el anfitrión), y +> > **contestó bien** (nombró los archivos de `--expect-file`). Si alguna se +> > desconoce no hay veredicto; no es `false`. Es la regla 4 otra vez, en un lugar +> > donde nadie la había buscado, y quedó escrita en [[Estrategia-de-Pruebas]]. +> > +> > El brazo B se comprueba en **dos pasos a propósito**: su espacio de trabajo +> > vive en una imagen Btrfs que QEMU tiene abierta, y montarla mientras la máquina +> > corre es como se corrompe un store. Así que el hash de después va con la +> > máquina apagada, `sudo make -C image agent-export`, y una segunda pasada con +> > `--arms none --restored-b`. Mientras no se haga, el resumen dice +> > `not_proven`; `THALYX_REQUIRE_RESTORE_CHECK=1` lo vuelve falla. +> > +> > **Lo que falta y es de Cesar:** correrla, y decidir antes qué hacer con el +> > `CLAUDE.md`. El brazo A trabaja adentro de la copia, así que Claude Code se lo +> > carga, y el brazo B trabaja en un directorio vacío y no lo ve — le suma tokens +> > al brazo A por algo que no es la tarea, **o sea al lado que favorece a +> > Thalyx**. Se evita apuntando `--project` a una copia sin `CLAUDE.md`. +> +> > ## El índice encuentra al dependiente que se llega por un campo, y las consultas se reparan solas — 2026-08-28 +> > +> > Endurecimiento de la superficie **antes** del primer benchmark, para que lo que +> > se mida sea Thalyx y no defectos que ya conocíamos. Todo salió de evidencia que +> > ya teníamos: la primera comparación y la primera corrida real de Claude. +> > +> > **1. `dependencias` significaba `imports`, y la palabra es más ancha.** +> > Preguntado qué depende de `src/store.rs`, el índice nombraba los dos archivos +> > que escriben `use crate::store::…` y se le escapaba un tercero que llega al +> > mismo código como `server.store.persist()`. La evidencia ya estaba adentro — la +> > mención estaba registrada — y nada la convertía en arista. Ahora hay dos clases +> > de arista y **cada fila dice cuál es**: `via: import` (el archivo la declaró) y +> > `via: symbol` (usa un nombre que **exactamente un** archivo del árbol declara). +> > La regla de "exactamente uno" es toda la precisión: un nombre que declaran dos +> > archivos no se convierte en arista nunca, porque cuál de los dos se quiso decir +> > es una adivinanza. El decreto está en [[FS-en-Grafo]]. +> > +> > Con el mismo mecanismo quedan resueltos la llamada directa por ruta, el acceso +> > por campo, el método, el trait en una cota, el módulo de directorio y el +> > **re-export** — que es el caso donde el import no resolvía a nada, porque +> > `crate::Engine` no es un archivo. Sigue sin saberse **el alias**: `use X as Y` +> > da la dependencia entre archivos, y que `Y` sea `X` es un compilador. +> > +> > Y la primera versión, con sólo esa regla, daba **41 dependientes** para +> > `thalyx-snapshot/src/lib.rs`. Correrla sobre este repositorio y leer las filas +> > —que es de donde salen todos los defectos— agregó tres condiciones más, cada +> > una de un defecto distinto: el nombre tiene que ser **visible desde afuera** +> > (`fn place` y `fn relative` son privadas, y ninguna arista hacia ellas puede +> > existir); el archivo que lo usa **no debe atarlo** (`for directory in …` habla +> > de su propia atadura, no de `pub fn directory`); y **no debe declararlo él +> > mismo** a ninguna visibilidad. Quedaron 19, de los cuales 17 son referencias +> > reales entre crates que **ningún import podía resolver**. Las dos que sobran +> > están contadas en [[FS-en-Grafo]]: necesitan saber de qué tipo es el receptor, +> > que es un compilador. +> > +> > **2. Tres falsos positivos que llevaban ahí desde siempre.** Al medir lo +> > anterior sobre este repositorio aparecieron: un comentario `/* … */`, una cadena +> > que sigue en el renglón siguiente, y un `r#"…"#` de veinte renglones — los tres +> > metían palabras al índice como si fueran código. Existían desde el principio y +> > no se veían, porque una mención de más es una fila que nadie mira; se hicieron +> > visibles el día que una mención pasó a ser una arista. El arreglo es **un solo +> > escaneo con estado** para las tres entradas del parser. Sobre `crates/`: 61 047 +> > menciones antes, 58 390 después — 2 657 eran falsas. +> > +> > **3. Cuatro turnos que ya no se pagan.** En la corrida real, Claude preguntó por +> > un árbol que acababa de cambiar, dedujo del campo `fresh` que el índice estaba +> > atrasado, llamó a `state`, llamó a `indexar`, y volvió a preguntar. Ahora +> > `buscar`, `depende` y `usan` reconstruyen el índice antes de contestar cuando es +> > barato — techo de 2 000 archivos, sacado de la medición — y cuando no, contestan +> > `refreshed: declined_too_large` con el tamaño del árbol y el verbo que hay que +> > llamar. **La regla de honestidad no se movió**: nada reporta `current` por +> > haberlo intentado, y `refrescar=no` devuelve lo que el índice tenía. +> > +> > **4. Un corpus determinista, sin modelo.** `crates/thalyx-graph/corpus/` son +> > doce árboles chiquitos con la respuesta correcta escrita al lado, sacada de leer +> > el código. 44 respuestas exactas, en milisegundos, gratis. Cuatro de los doce +> > existen para ser contestados **angostamente**, porque un índice de símbolos +> > falla devolviendo de más. Encontró los tres defectos del punto 2 antes de que +> > ningún modelo mirara nada, y pasó entero mientras el mecanismo debajo cambiaba +> > tres veces en una tarde — que es para lo que existe una red de regresión. +> > +> > **5. El arnés recoge las dos mitades.** `dev/bench-external-agent.sh` usa +> > `--output-format stream-json`, así que ahora las **dos** ramas se miden en las +> > mismas unidades: llamadas a herramientas por nombre, bytes que cada una le +> > devolvió al modelo, archivos leídos, búsquedas de texto, además de turnos, +> > tiempo, tokens y costo. Antes sólo la rama B era contable. El parser es +> > `dev/bench-summary.py`, aparte a propósito, con `--self-test` contra una sesión +> > real capturada en `dev/samples/` — regla 6. Y `--expect-file` da un veredicto de +> > éxito de la tarea; sin él no hay veredicto, nunca uno adivinado. +> > +> > **Costo medido**, mismo árbol, mejor de siete, release: indexar `crates/` pasa +> > de 368,7 ms a 480,4 ms (+30 %) y devuelve 2 803 aristas que antes no existían, +> > con 2 657 menciones falsas menos. Las consultas no cambiaron: frescura 2,1 ms, +> > dependientes 1,8 ms. Dos optimizaciones baratas ya pagaron la mayor parte de +> > ese costo: sentencias preparadas en los dos `INSERT` que corren decenas de +> > miles de veces (588,2 → 480,4 ms) y un solo escaneo por archivo en vez de dos +> > (533,7 → 480,4 ms). +> > +> > Etapas 49 y 50 nuevas en `dev/verify.sh`. Nada de esto necesita hierro: corre +> > entero en el contenedor. +> > +> > ## `intento` ya no le pregunta a un binario que no existe — 2026-08-28 +> > +> > +> > Encontrado corriendo la máquina, que es de donde salen todos: `make -C image +> > agent` **sí** crea el workspace como subvolumen Btrfs, y adentro de Thalyx +> > `thalyx_attempt` contestaba `not_a_subvolume` de todos modos. +> > +> > La causa es la quinta vez que aparece la misma: `thalyx-snapshot::Btrfs` +> > preguntaba corriendo `btrfs subvolume show`, y la imagen lleva el kernel y un +> > programa. El spawn fallaba, y `is_subvolume` no tiene forma de decir *no pude +> > preguntar* — así que la falta de un binario se reportaba como un hecho sobre el +> > sistema de archivos. **Regla 10 al revés**, en el único verbo del que depende +> > la ventaja que ningún otro sistema operativo tiene. +> > +> > Ahora existe `thalyx-snapshot::Native`, que son las cuatro operaciones contra +> > los ioctls del kernel: `BTRFS_IOC_SUBVOL_GETFLAGS` para preguntar —el kernel +> > contesta `EINVAL` si no es la raíz de un subvolumen y `ENOTTY` si ni siquiera +> > es Btrfs, así que una sola llamada separa las tres respuestas, sin privilegios +> > y sin el truco del inodo 256—, `SNAP_CREATE_V2` con `BTRFS_SUBVOL_RDONLY` para +> > la instantánea, la misma sin la bandera para la copia escribible del restore, y +> > `SNAP_DESTROY` para soltarla. `intento` usa ese backend; el backend por comando +> > se queda para el anfitrión y como segunda opinión en las pruebas. +> > +> > Lo que falta correr en hierro: la etapa 26 de `dev/verify.sh` tiene ahora una +> > columna más, la misma secuencia con `PATH` vacío, que es este anfitrión +> > haciendo lo que hace la imagen. +> > +> > ## El puente no habría llevado un byte por virtio-serial — 2026-08-28 +> > +> > El bloque siguiente termina diciendo que virtio-serial no ha llevado un byte y +> > que lo que corrió fue el mismo `serve` sobre un socket UNIX. Al preparar ese +> > arranque aparecieron **dos defectos que sólo existen del lado del carácter**, y +> > los dos habrían hecho que la máquina arrancara anunciando un canal que no +> > contesta: +> > +> > 1. **`serve_port` abría el nodo dos veces**, una de lectura y otra de +> > escritura, que es la forma que tiene un socket y no la que tiene un puerto +> > virtio-serial. `port_fops_open`, en `drivers/char/virtio_console.c`, rechaza +> > la segunda apertura con `EBUSY` —*"Allow only one process to open a +> > particular port at a time"*—, así que cada vuelta del hilo moría en su +> > segunda línea, el error se iba al `let _ = error` de siempre, y desde el +> > anfitrión se veía como una VM que sigue arrancando. Ahora se abre **una vez** +> > en lectura y escritura y el segundo extremo es un `try_clone`. +> > 2. **La búsqueda en sysfs se abandonaba entera** ante un puerto sin nombre. El +> > driver crea el atributo `name` sólo para los puertos que QEMU nombró, así +> > que cualquier otro puerto virtio-serial al lado del nuestro podía esconder +> > el canal, dependiendo nada más del orden en que `read_dir` los devolviera. +> > Regla 10, y ahora tiene su prueba: `a_port_with_no_name_at_all_does_not_hide_the_one_that_has_one`. +> > +> > Los dos son la regla 12 otra vez: **el transporte que se verificó no era el +> > transporte que se embarca.** Un socket UNIX nunca pudo mostrar ninguno de los +> > dos, y ninguna prueba de este contenedor puede — se prueban arrancando. +> > +> > Del lado del anfitrión, `Machine::connect` esperaba el saludo **sin plazo**. +> > Con `wait=off` el socket existe desde que QEMU arranca, así que el `connect` +> > nunca es lo lento; lo lento es el huésped, y el presupuesto de espera se +> > gastaba en lo único que jamás tarda. Un cliente arrancado antes que la máquina +> > —el orden que dice el README— se quedaba ahí para siempre. Ahora el plazo cubre +> > el saludo y dice qué encontró; y `dev/agent-connect.sh` ya no mata la sonda a +> > los 10 s cuando el adaptador espera 30. +> > +> > Y `a_question_has_one_answer.rs` medía la máquina en vez de la respuesta: +> > buscaba la frase *"the kernel policy map is not loaded"* como marca de que un +> > `sí` fue leído, y esa frase sólo se imprime donde no hay nada cargado. En la +> > máquina de Cesar, con el LSM cargado en observe mode, el `sí` se leía bien, el +> > verbo pasaba la pregunta como debe, y la prueba lo llamaba rechazo. La marca +> > ahora es lo que el otro lado tiene en común en cualquier estado del kernel: +> > `refusing to run \`…\`` o `ran:`. +> > +> > ## El primer agente de programación real usa las primitivas — 2026-08-28 +> > +> > Hasta hoy la apuesta de [[Filosofia-Fundacional]] —que un sistema construido +> > alrededor de respuestas estructuradas, un índice semántico y una frontera +> > reversible hace que una IA trabaje mejor— **nunca se había medido**, y no había +> > forma de medirla: el único agente que podía usar las primitivas era el Qwen de +> > 3B de adentro, y compararlo con Claude sobre Linux mide el tamaño del modelo, +> > no la superficie. +> > +> > Ahora hay puente. **Claude Code real, corriendo en el anfitrión, hizo una tarea +> > de lectura usando sólo verbos de Thalyx**: cuatro llamadas —un `indexar`, dos +> > `buscar`, un `usan`— sin abrir un archivo y sin una sola búsqueda de texto. El +> > decreto está en [[Agentes-Externos]] y lo importante de él es dónde vive cada +> > cosa: **MCP es un adaptador, en el anfitrión; la superficie de Thalyx sigue +> > siendo la autoridad.** +> > +> > La cadena es +> > `Claude Code → thalyx-mcp → socket de QEMU → virtio-serial → un hilo de la +> > sesión → el MISMO dispatch que un teclado → índice, intento y journal reales`. +> > Sin red, sin TCP, sin dirección: `CONFIG_VIRTIO_CONSOLE=y` es todo el costo en +> > el kernel. +> > +> > **Un agente externo no es root remoto.** `crates/thalyx-cli/src/external.rs` +> > es una lista de verbos y un guardián de rutas que resuelve cada una dos veces +> > —como la resuelve el verbo y como la resuelve el kernel— y exige que las dos +> > caigan adentro del workspace. `apagar`, `instalar-en`, `correr`, `ejecutar`, +> > `negar` y `matar` no son alcanzables. Lo que un agente externo cambió, y todo +> > intento suyo de salirse, quedan en el journal marcados `untrusted_content`. +> > +> > **Lo que la primera medición dio**, con Sonnet, la misma tarea, dos copias +> > idénticas de un proyecto de 35 archivos: 8 turnos y 32.8 s con `Read`/`grep` +> > contra 7 turnos y 17.3 s con las herramientas de Thalyx. **Es una anécdota, no +> > un resultado** — una corrida de una tarea. Lo que existe es el arnés, +> > `dev/bench-external-agent.sh`, y una corrida real que lo prueba. +> > +> > Y enseñó algo en contra, que está escrito en el decreto: el brazo de Linux +> > encontró un dependiente que el índice no, porque usa el símbolo a través de un +> > campo y nunca lo nombra. El índice contesta *quién nombra esto*, no *a quién le +> > afecta*. +> > +> > **Lo que falta arrancar para creerlo del todo.** virtio-serial no ha llevado un +> > byte: este contenedor no tiene QEMU, y lo que corrió fue el mismo `serve` sobre +> > un socket UNIX. Y el ciclo `intento` completo a través del puente necesita +> > Btrfs. Los dos son `dev/verify.sh` §47 y §48, y los dos corren en la máquina de +> > Cesar: +> > +> > ``` +> > make -C image agent PROJECT=/ruta/a/un/proyecto +> > # en otra terminal, cuando la máquina esté arriba: +> > dev/agent-connect.sh +> > claude +> > ``` +> > +> > ## El motor se queda vivo, y la pantalla deja de congelarse — 2026-08-28 +> > +> > El arranque en QEMU del bloque siguiente probó que la cadena entera existe: +> > una frase en español llegó a un Qwen2.5-3B confinado y `ls` mostró la carpeta. +> > Lo que ese arranque también mostró es que **el motor era por lotes**. +> > `llama-completion` es de una sola respuesta por construcción, así que la +> > segunda frase volvía a leer dos gigabytes de disco y a construir el contexto +> > otra vez: la mayor parte de lo que cuesta un modelo local, gastada de nuevo en +> > trabajo ya hecho. Y la llamada ocurría dentro de la pulsación de Enter, así +> > que durante esos segundos el marco no se redibujaba — ni el reloj. +> > +> > Las dos cosas están cerradas. **El decreto de que el motor es un módulo no +> > cambió en nada**: sigue firmado, instalado, corrido por `thalyx_core::run` +> > bajo `module_standard`, con su uid, su cgroup, su seccomp y su raíz pivotada. +> > Lo que cambió es la forma del programa que hay adentro. +> > +> > **`engine/thalyx-engine.cpp`.** El mismo `llama.cpp` en la misma etiqueta +> > fijada (`b10665`), con las mismas banderas y el mismo enlace estático; se copia +> > dentro de `tools/` del checkout y lo compila el mismo `cmake`, para que el +> > orden de enlace de los backends de ggml no sea algo que este repositorio +> > resuelva a mano. Carga el GGUF una vez, anuncia que está listo, y contesta +> > peticiones enmarcadas por una tubería hasta que Thalyx cierra el otro extremo. +> > No reimplementa inferencia: debajo del protocolo todo es la librería `common` +> > de `llama.cpp`. +> > +> > **Nada de red.** Ni HTTP, ni TCP, ni el servidor que `llama.cpp` ya trae: +> > conceder `net/outbound` al programa menos confiable de la máquina para que dos +> > procesos del mismo anfitrión se hablen es debilitar el aislamiento por +> > comodidad. El protocolo es little-endian con longitud por delante, y se mandan +> > **rutas y no texto** — los archivos ya están donde al módulo se le concedió +> > leerlos, así que la inferencia sigue siendo inspeccionable en disco. +> > +> > **Un solo lanzador.** `thalyx_core::run` se partió por dentro en `run::start` +> > → `RunningModule` → `wait`/`shutdown`, y `run()` es `start` seguido de `wait`. +> > El camino ordinario ejerce el mismo código que el residente mantiene abierto, +> > así que no hay un segundo lugar donde acertar con el cgroup, la política, el +> > filtro seccomp, la raíz y el uid. +> > +> > **La pantalla ya no se bloquea.** Una línea que no es un verbo vuelve como +> > `Flow::Thinking` en vez de gastar segundos dentro de la pulsación; la pantalla +> > pregunta en un hilo, dibuja `⠋ pensando…` con el reloj corriendo cada 120 ms, y +> > cuando llega la respuesta la corre por el **mismo** dispatch, en el hilo que +> > tiene el teclado. El trabajador propone y no actúa. Y al arrancar, la sesión +> > gráfica lanza un hilo que precalienta los pesos, así que la primera frase +> > probablemente encuentra el modelo ya adentro. +> > +> > **La evidencia se cuenta en procesos, no en objetos.** Bajo cada propuesta la +> > sesión imprime `motor ▪ frío|tibio ▪ `: el mismo pid dos veces son dos +> > frases contestadas por un proceso. Y la prueba se escribió igual — +> > `the_engine_stays_alive` empaqueta el binario de la propia prueba como módulo +> > del motor y ese motor de mentira anota su pid al arrancar; lo que se afirma es +> > cuántas líneas tiene ese archivo. +> > +> > **Eso agarró un defecto que nada más habría agarrado.** +> > `if let (false, Some(stale)) = (usable, held.take())` evalúa las dos mitades de +> > la tupla antes de comparar con el patrón, así que `take()` corría siempre y el +> > residente vivo se tiraba en cada llamada. Todas las frases se contestaban bien; +> > lo único que cambiaba era el costo, que es justo lo que esta fase existía para +> > bajar. Y como `RunningModule` no tenía `Drop`, el proceso tirado seguía vivo: +> > la máquina acumulaba un motor por frase con el modelo cargado en cada uno. +> > Ahora tiene `Drop` y la regla está en [[Estrategia-de-Pruebas]]. +> > +> > De paso, dos cosas de herramienta: **`make -C image run` abre la interfaz +> > gráfica** —era `-nographic`, que fue correcto hasta el día en que la pantalla +> > se volvió la cara del sistema y siguió *funcionando* después, que es por lo que +> > nadie lo notó— y `run-serial` es la ruta vieja con un nombre que dice lo que +> > es. `CPUS ?= 4`, porque el motor escoge sus hilos con +> > `available_parallelism` y `-smp 2` era un modelo a media velocidad sin que +> > nadie lo hubiera decidido. +> > +> > **Lo que falta y lo dice `verify.sh`:** §45 y §46 en el hierro de Cesar — +> > residencia *y* confinamiento los establece la misma llamada, pero este +> > contenedor no tiene BPF LSM y lo medido aquí corrió `--unconfined`. Y los dos +> > números, frío contra tibio, con un Qwen2.5-3B real. Ver [[Motor-Residente]]. +> > +> > --- +> > +> > ## El primer arranque real en QEMU: el motor no encendía — 2026-08-28 +> > +> > El primer defecto real del motor no lo encontró ninguna prueba. Lo encontró +> > arrancar la imagen en QEMU y hablarle: +> > +> > ``` +> > the model failed: could not start module dev.thalyx.engine: +> > permission `8GiB memory` cannot be expressed as kernel policy +> > ``` +> > +> > La causa es una frontera que faltaba. `Confinement::establish` le entregaba a +> > `thalyx_permd::apply` **todos** los permisos otorgados, y `thalyx-permd` sólo +> > sabe expresar operaciones que el LSM revisa en un hook: `net/outbound` y +> > lecturas y escrituras de rutas. Un techo de memoria no es una operación —es un +> > número en `memory.max`— así que permd lo rechazaba, correctamente y de forma +> > fail-closed, y el módulo con el que la máquina se deja hablar no arrancaba. +> > +> > **La corrección no fue ablandar a permd.** Enseñarle a `bit_for` a devolver +> > `0` para `memory` habría convertido un permiso inejecutable en uno que *parece* +> > ejecutado —exactamente la promesa que su error `Inexpressible` existe para +> > negarse a hacer— y lo habría hecho para todo recurso no-LSM futuro al mismo +> > tiempo. La frontera va donde se conoce el mecanismo: en el sandbox. +> > `profile::for_kernel_policy` retira de la lista que va al kernel sólo lo que +> > **este crate ya hizo cumplir por otro medio**, y todo lo demás sigue llegando a +> > `bit_for` y sigue siendo rechazado si nada lo hace cumplir. La lista completa +> > se conserva donde `RootFs` y `Profile::for_permissions` la necesitan. +> > +> > El detalle que hace que la frontera sea la correcta y no una lista de nombres: +> > un permiso `memory` cuya acción no se lee como tamaño (`8Gib`) tampoco lo hace +> > cumplir el cgroup, así que **no** se retira: llega a la política y se rechaza. +> > Hay una prueba para cada mitad. +> > +> > Y `dev/stage-engine.sh` pedía 4 GiB fijos, así que `ENGINE_TIER=media` producía +> > un store cuyo motor moría cargando sus propios pesos y la única salida era +> > editar el archivo a mano antes de cada build. Ahora el techo se deriva de la +> > gama: `ligera→4GiB`, `media→8GiB`, `alta→16GiB`, `máxima→32GiB`. +> > +> > Lo que enseña, y ya está en `Estrategia-de-Pruebas.md` como regla 1: **el +> > defecto salió de correr el sistema.** 189 verificaciones pasaban con el motor +> > incapaz de arrancar, porque ninguna preguntaba si un módulo con el permiso que +> > el propio `stage-engine.sh` le escribe llega a existir. +> +> > ## La máquina tiene agente: una frase suya llega a un modelo y algo pasa — 2026-08-28 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Lo que faltaba desde que se decretó el agente mínimo estaba en el punto 3 del +> > bloque de abajo: **no hay agente adentro**. Todo lo demás existía —el prompt, +> > la gramática GBNF, el parser, el contrato, el router, la atribución, las +> > gamas— y ninguna de esas piezas se podía alcanzar desde la única cara que +> > tiene la máquina. Escribir algo que no fuera un verbo contestaba *«no tengo +> > modelo cargado»*, lo cual era cierto y era también el agente entero siendo +> > inalcanzable. +> > +> > Ahora la cadena está cerrada de punta a punta: +> > +> > ``` +> > pantalla → session/dispatch → thalyx-agent → prompt + gramática +> > → motor llama.cpp instalado como módulo → GGUF del store +> > → inferencia real → contrato → router/validación → verbo de Thalyx +> > ``` +> > +> > ### Las cuatro decisiones que la hacen así +> > +> > 1. **El motor es un módulo, no parte de Thalyx.** `llama-completion` de +> > llama.cpp, empaquetado en un `.thmod` firmado, instalado en el store, +> > confinado bajo `module_standard` con el uid, el cgroup, el seccomp y la +> > raíz pivotada que recibe cualquier otro módulo. La imagen sigue siendo el +> > kernel y **un** programa: `make -C image count` lo dice. +> > 2. **Un proceso por respuesta.** No hay demonio, no hay servidor, no hay API +> > HTTP. `thalyx_core::run` —el mismo que ejecuta `correr`— lanza el motor, +> > espera, y lo que el módulo escribió en su `stdout` es la respuesta. Una +> > inferencia es un módulo corriendo, con su entrada en el diario. +> > 3. **La costura es angosta a propósito.** `thalyx_agent::llama::Engine`: +> > entra un vector de argumentos, salen bytes. Arriba de esa línea no cambió +> > nada —el prompt, el marcador, la gramática, dónde termina una respuesta, +> > qué es una respuesta rota— y abajo hay dos implementaciones: +> > `ProcessEngine` (un programa en el `PATH`, que es lo que tiene una máquina +> > de desarrollo) y `ModuleEngine` (el módulo instalado, que es lo que tiene +> > la máquina). El crate del agente no puede lanzar procesos confinados y no +> > debe poder: todo lo que sabe llegó de un modelo que no es de confiar. +> > 4. **El disco arranca listo.** El motor se instala en el stage y la elección +> > de gama queda escrita, así que la máquina arranca **pudiendo ser hablada**. +> > El greeter sigue sin instalar a propósito —el paso 2 del criterio de salida +> > es una persona instalándolo— y el motor es el requisito contrario. +> > +> > ### Lo que se arregló en el camino +> > +> > - **El prompt se escribía en `/tmp`.** Un módulo sólo ve lo que su manifiesto +> > le concedió, así que un prompt fuera de esos directorios es un archivo que +> > el motor tiene orden de leer y no puede ver — y vuelve como «llama.cpp no +> > completó el prompt», culpando a llama.cpp de un error de Thalyx. El motor +> > ahora dice **dónde** se le puede escribir (`Engine::scratch_root`) y hay una +> > prueba que sólo falla si eso se rompe. +> > - **`llama.cpp` no enlazaba estático.** Tres banderas, encontradas por tres +> > enlaces fallidos, en este orden: `LLAMA_OPENSSL=OFF` (cpp-httplib encuentra +> > el OpenSSL del sistema, que sólo existe como `.so`), `GGML_OPENMP=OFF` +> > (`libgomp` igual) y `GGML_NATIVE=OFF` (la máquina que construye el store no +> > es necesariamente la que lo arranca, y `-march=native` en un módulo es una +> > instrucción ilegal en el CPU de otro). Están escritas en +> > `dev/build-engine.sh` para que nadie las vuelva a encontrar. +> > - **`agent model use` fallaba al grabar una ruta que todavía no existe.** El +> > store se construye en una máquina y se arranca en otra: la ruta que se graba +> > es la de adentro y los bytes que se miden son los del stage. `--reading`. +> > - **El modelo diminuto declaraba 128 tokens de contexto** y el prompt real +> > mide ~1800, así que rechazaba la única cosa para la que servía además de +> > `engine-needs.sh`. Ahora declara 4096, que en un modelo de dos capas no +> > cuesta nada. +> > +> > ### Qué está probado y qué no +> > +> > **Probado en el contenedor, con un llama.cpp real y un GGUF real** (dos capas, +> > hecho por `gguf-py` de llama.cpp): +> > +> > - el binario es estático, sin intérprete y sin bibliotecas compartidas; +> > - se empaqueta, se firma y se instala como módulo, con los 4 GiB que pide; +> > - `thalyx agent model check` lo corre **a través del sistema de módulos**, el +> > motor lee el prompt del directorio concedido, carga los pesos, obedece la +> > gramática y lo que imprime vuelve a Thalyx; +> > - una frase que no es un verbo, escrita en la sesión, se convierte en un verbo +> > y **se ejecuta**: `crea una carpeta llamada pruebas` → `mkdir pruebas` → la +> > carpeta existe. (Con un motor de reemplazo: es una afirmación sobre el +> > cableado, no sobre el juicio de un modelo.) +> > +> > **No probado aquí, y nombrado en vez de supuesto:** +> > +> > - **confinado.** Este contenedor no tiene BPF LSM, así que la corrida fue +> > `--unconfined` y el diario la anotó como degradada. La §45 de +> > `dev/verify.sh` lo corre confinado en la máquina de Cesar, y dice NOT PROVEN +> > si no puede; +> > - **que un Qwen2.5 real acierte la intención.** El modelo de dos capas produce +> > un objeto gramatical y vacío. Eso es una medición del modelo, no de la +> > máquina, y `thalyx agent bench` es lo que la hace. +> +> > ## La máquina se puede usar: se puede escribir en ella, y se puede terminar lo que se empieza — 2026-08-28 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Cesar arrancó la imagen y preguntó qué le falta al sistema para que él, por +> > voluntad propia, pueda pasar **un día completo adentro**. Medido contra el +> > código, el hueco más grande no estaba escrito en ningún lado. +> > +> > ### Lo que se midió, y no es lo que la bóveda decía +> > +> > Seis cosas separan el arranque de la foto de un día de uso. Dos de ellas nadie +> > las había notado: +> > +> > 1. **La pantalla que arranca es una ventana, no un taller.** Bajo +> > `thalyx-capture` el descriptor 0 es `/dev/null` —a propósito, si no la +> > máquina se cuelga con una pregunta que nadie ve— así que los ocho lugares +> > que se detienen a preguntar encuentran que no hay terminal y se niegan. +> > `instalar`, `ejecutar`, `observar`, `instalar-en` **y `editar`** se pueden +> > leer ahí y no se pueden acabar. La bóveda tenía escrita sólo «la +> > confirmación dibujada»; `editar` no lo nombraba nadie. +> > 2. **No se podía teclear español.** Un `grep` de `keymap` en todo el repo +> > salía vacío. El kernel lleva un mapa compilado adentro y es US QWERTY, y +> > `loadkeys` no cabe en la imagen. La tecla que en un teclado latinoamericano +> > dice `ñ` mandaba `;`. Un SO cuya bóveda entera está en español, en el que +> > no se podía escribir en español. +> > 3. **No hay agente adentro** — decretado el 2026-08-28. **Cerrado** el +> > mismo día: ver el bloque de arriba. +> > 4. **Una cosa a la vez** bajo la pantalla. +> > 5. **Nada entra ni sale** (`red` lee y no manda), y un store recién instalado +> > está vacío. +> > 6. **El techo de 1 GiB** por módulo, que el motor no cabe. +> > +> > ### Sus dos decisiones +> > +> > Preguntadas con opciones, contestadas el mismo día: +> > +> > - **El día es escribir y pensar con el agente, y además operar la máquina.** +> > No desarrollar Thalyx desde adentro, que abriría la Fase 2. +> > - **El techo de memoria: lo que pida el manifiesto, aprobado por él al +> > instalar.** No un número fijo más grande. +> > +> > ### Lo entregado +> > +> > **A — una pregunta, dos caras** (`crates/thalyx-cli/src/ask.rs`). Las ocho +> > confirmaciones tenían los mismos cinco renglones escritos a mano, y habían +> > derivado sobre qué es un sí: `intento abandonar` tomaba `si` y `sí`, y el +> > verbo que le quita el guardián al kernel no tomaba ninguno. Ahora hay una sola +> > comparación y las dos caras la llaman; lo que no se comparte es la negativa, +> > que es del verbo. Y el orden cambió: **decir de qué se trata va antes de +> > revisar si hay terminal**, porque el contexto *es* la confirmación y una +> > negativa emitida antes de que exista no deja nada que dibujar. +> > +> > **B — el teclado** (`crates/thalyx-term/src/keymap.rs`). Las tablas se +> > generan de `kbd` con `dev/keymap-table.py`, nunca se escriben: una +> > distribución es un dato sobre el mundo y la regla 6 aplica. `teclado` dice qué +> > hay —preguntándole al kernel, no a Thalyx— y `teclado latino|ingles` lo +> > cambia. Se carga en el arranque, con `thalyx.teclado=no` de salida de +> > emergencia, y hay prueba de que las letras de `teclado ingles` están en la +> > misma tecla en las dos distribuciones, o sea que el camino de regreso se +> > teclea desde donde haría falta. +> > +> > **C — el techo por manifiesto.** Una petición de memoria es un permiso +> > `persistent`: sale por el camino confiable que ya existe, se guarda donde ya +> > se guarda, y `for_permissions` sube el techo. El gigabyte pasa de techo a +> > piso. Con unidad siempre (`4GiB`, nunca `4294967296`), nunca `jit`, negado si +> > no cabe en la máquina, y dos concesiones dan la mayor y no su suma. Entero en +> > [[Motor-de-Inferencia-como-Modulo]]. +> > +> > **1506 pruebas verdes**, `clippy` limpio, y la compilación de musl —la regla +> > 12— corrida de verdad: este contenedor no la podía hacer y ahora sí, con +> > `apt-get install musl-tools`. +> > +> > ### Lo que le toca correr a Cesar +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli && make -C image image +> > ``` +> > +> > Y **arrancar la imagen**, que es lo único que contesta las dos mitades que +> > este contenedor no tiene: +> > +> > - **Teclear `ñ`.** Si sale `ñ`, la pieza B funciona en hierro. Si sale otra +> > cosa, `teclado ingles` regresa el mapa del kernel; si el teclado quedó +> > inservible, `thalyx.teclado=no` en la entrada de arranque. +> > - **Teclear `instalar ` o `ejecutar ` en la pantalla.** Antes +> > rechazaba; ahora debe dibujar la confirmación con el contexto arriba y +> > tomar la respuesta. **Ctrl-C cancela** — el aviso decía «Escape» y era +> > imposible: un Escape solo es el prefijo de toda flecha. +> > +> > `sudo ./dev/verify.sh` trae **tres etapas nuevas** (42, 43, 44) sobre las 189 +> > de la corrida anterior. +> > +> > ### Lo que sigue, y por qué está en ese orden +> > +> > **D — el motor adentro de la máquina.** Es lo único que queda de lo que él +> > decidió, y es lo más grande. Ahora no lo bloquea nada: el confinamiento le +> > alcanza (31 de 31 llamadas), el techo ya se puede pedir, y `ejecutar` ya se +> > puede confirmar desde la pantalla — que era el hueco por el que el motor no +> > se podía ni arrancar desde la cara con la que la máquina viene. +> > +> > Los dos huecos que quedan del día y **no** están decididos: que se pueda hacer +> > más de una cosa a la vez, y por dónde entra y sale algo de la máquina. +> +> > ## El agente no está en la máquina, y por dónde entra — 2026-08-28 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Cesar arrancó la imagen, vio la pantalla, y preguntó qué le falta al sistema +> > para ser *«algo real que pueda usar durante un buen tiempo de verdad»*. La +> > respuesta, medida contra el código, es más grande de lo que esta bóveda decía. +> > +> > ### Una máquina Thalyx arrancada no tiene agente +> > +> > Y no por un pendiente: por cómo está construido. `llama.rs` arranca +> > `llama.cpp` con `Command::new`, un binario aparte buscado en el `PATH`, y la +> > imagen lleva `/init` y `/dev/console` y nada más. Así que el motor **no existe +> > en la máquina que arranca y no puede existir ahí** mientras la imagen sea el +> > kernel y un programa. El router, la gramática y las tres gamas medidas han +> > corrido siempre en el Fedora de Cesar, nunca sobre Thalyx. +> > +> > Un sistema operativo donde la IA es ciudadana de primera arrancó sin ella, y +> > eso no estaba escrito en ningún lado como hueco. +> > +> > ### El decreto: el motor es el primer módulo real +> > +> > Decidido por Cesar el 2026-08-28. Un binario estático, firmado, en el store, +> > confinado bajo `module_standard` como cualquier módulo; el `.gguf` llega al +> > disco de store por donde `greeter` ya llega. **No contradice el decreto de la +> > imagen**: un módulo vive en el store, no en la imagen, y `make -C image count` +> > sigue diciendo uno. Lo que sí lo contradice es lo de hoy, un `PATH` del que se +> > saca un programa sin manifiesto, sin firma, sin permisos y sin confinamiento. +> > Entero en [[Motor-de-Inferencia-como-Modulo]]. +> > +> > ### Y se midió antes de construir nada, porque una pregunta podía matarlo +> > +> > Si un motor real necesitara algo que `module_standard` niega, el decreto sería +> > inconstruible. `dev/engine-needs.sh` lo preguntó —es +> > `dev/foreign-agent-needs.sh` apuntado a un motor, la misma comparación contra +> > la misma lista, una sola y no dos— con llama.cpp de verdad y un modelo escrito +> > por el propio `gguf-py` de llama.cpp, no por nosotros (regla 6). +> > +> > **31 llamadas al sistema distintas para cargar, tokenizar, correr el grafo y +> > generar. Las 31 ya están permitidas.** El confinamiento que ya existe alcanza +> > para un motor de inferencia, y eso no se sabía. De 13 rutas abiertas, 9 caen +> > dentro de lo que un módulo ve; de las otras cuatro, una es el `.gguf` —los +> > datos del módulo, como el `notes.txt` de `greeter`— y las tres restantes son +> > `/dev/tty` y dos de conteo de núcleos bajo `/sys`. +> > +> > **Lo que no contesta es el tamaño, y ahí está el hueco.** El modelo medido +> > pesa menos de un megabyte. `module_standard` topa un módulo en **1 GiB** +> > (`profile.rs`), ningún manifiesto puede pedir más, y aunque los pesos por +> > `mmap` sean caché reclamable, el KV cache y los búferes de cómputo no lo son. +> > Ese número es de Cesar: es política y cuesta su hierro. Tampoco contesta el +> > libc — se midió glibc y embarcaría musl estático, que es la regla 12. +> > +> > ### Tres defectos que su foto agarró, arreglados +> > +> > Los tres salieron de mirar la pantalla arrancada, no el código, y ninguno era +> > un error de lo que la pantalla *dice*. +> > +> > - **Un renglón se salía de su panel.** `Row::Pair` medía el valor entero y +> > luego lo alineaba a la derecha; uno más ancho que la columna quedaba con el +> > lápiz a la izquierda del panel, y `draw` no recorta. El recorte salió a +> > `Typography::fit`, porque un texto alineado a la derecha hay que medirlo **ya +> > recortado**. +> > - **`clear` mandaba un escape a una tubería.** Bajo la pantalla la salida se +> > atrapa en el descriptor, así que la pantalla dibujó `[2J[H` como texto. Ahora +> > el escape sólo sale a una terminal de verdad y hay `Flow::Emptied`. Probado +> > por tubería **y con control en una terminal hecha con `thalyx dev pty`**. +> > - **La barra decía las opciones de un tmpfs donde va el store.** Dos errores +> > independientes: el respaldo le ganaba a lo que respaldaba —un `find` pedía +> > `/var/thalyx` **o** `/`, y `/` se monta primero, así que en una máquina con +> > store el store no se consultaba nunca— y el último campo de un renglón de +> > `mountinfo` no es una etiqueta. Por eso la barra decía tmpfs mientras el panel +> > dos pulgadas a la derecha decía `btrfs`: **la pantalla se contradecía a sí +> > misma**, y ésa fue la pista. Ahora dice el disco, o «sin disco — no +> > recuerda», o `store ?` cuando no se pudo leer (regla 10). +> > +> > ### Lo que le toca correr a Cesar +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli && make -C image image +> > ``` +> > +> > Y arrancar la imagen para ver la pantalla otra vez: los tres defectos son de +> > vidrio y su Fedora no tiene framebuffer que los enseñe. `sudo ./dev/verify.sh` +> > no trae etapas nuevas todavía — la del motor llega cuando el motor exista. +> +> > ## La corrida en hierro salió limpia, y la imagen no compilaba — 2026-08-28 +> > +> > Los bloques de abajo son cómo se llegó. +> > +> > **La corrida de Cesar: `proven 185 · not proven 4 · failed 0`.** Cero fallas, y +> > el conteo cuadra: 189 comprobaciones contra las 184 de la corrida anterior +> > (`181 · 2 · 1`), y las cinco de diferencia son exactamente las cinco que agregó +> > la entrega de la pantalla. Nada dejó de correr en silencio, que es la única +> > forma de leer un marcador. +> > +> > Lo que eso deja probado en hierro: +> > +> > - **la suite ya no arma el kernel de la máquina que está midiendo** — la falla +> > de la §5 desapareció, y con ella queda comprobada en fierro la regla 11, que +> > aquí no se puede comprobar porque este contenedor no tiene guardián; +> > - **el color del camino confiable está en una confirmación y en nada más**, +> > leído por un decodificador de PNG que no es Thalyx, con su control y el +> > control del control; +> > - **`pantalla` por tubería rechaza con `not_a_terminal` y la sesión sigue +> > contestando** — una máquina sin monitor no se detiene; +> > - **la línea de comandos de la imagen deja `thalyx.pantalla` sin contestar**, +> > así que `thalyx.pantalla=no` sigue siendo la salida de una máquina negra. +> > +> > **Los cuatro `NOT PROVEN`: dos son nuevos y son el mismo hueco.** De las cinco +> > comprobaciones nuevas sólo dos tienen rama de `NOT PROVEN`, y las dos son la +> > mitad de la etapa 40 que necesita `/dev/fb0`: la comparación del `ioctl` contra +> > sysfs, y el tracer que vigila que `pantalla` por tubería no llegue a tocar el +> > framebuffer. Cesar lo confirmó a mano: **su Fedora no tiene `/dev/fb0`** ni +> > `strace` instalado. Los otros dos son los de siempre, los que ya venían del 27. +> > +> > Eso **no es un hueco de la pantalla de Thalyx**, es de la máquina que verifica: +> > la imagen lleva `CONFIG_FB`, `CONFIG_FB_EFI` y `CONFIG_FRAMEBUFFER_CONSOLE`, y +> > la consola de texto con la que arrancó en agosto ya era prueba de que ahí sí +> > hay framebuffer. La pantalla sólo se puede *ver* arrancando la imagen, no desde +> > Fedora. +> > +> > ### Y lo siguiente que tecleó no compiló +> > +> > `make -C image image` murió con cinco errores de tipo, todos de la pantalla: +> > las peticiones de `ioctl` escritas `as libc::c_ulong`. Ése es el tipo que toma +> > `libc::ioctl` **contra glibc**; contra musl toma `c_int`, y la imagen es un +> > binario estático de musl. +> > +> > **Por qué 189 comprobaciones no vieron nada.** `verify.sh` compila contra glibc +> > de principio a fin, y la etapa 11 —la que se llama «la imagen»— arma el +> > initramfs **con ese binario de glibc** para contar cuántos programas lleva +> > adentro. El único lugar del proyecto donde se compilaba lo que de verdad +> > arranca era un comando que corre Cesar, a mano, después de que todo dijo que +> > estaba bien. +> > +> > **El arreglo son cinco palabras** —`as libc::Ioctl`, el alias que ya vale en +> > los dos objetivos— y la regla ya estaba escrita en el propio crate, en el +> > comentario de `BTRFS_IOC_SUBVOL_CREATE`, desde que se construyó Btrfs. Una +> > convención que vive sólo en un comentario la obedece quien lo lee. +> > +> > **Así que lo que se entrega no es el arreglo, es la comprobación.** La etapa 2 +> > corre ahora la línea exacta del `Makefile` de la imagen: +> > +> > ``` +> > cargo build --release --target x86_64-unknown-linux-musl -p thalyx-cli +> > ``` +> > +> > Sus cuatro brazos se ejercieron uno por uno antes de entregarla, incluido el +> > que importa: con el defecto puesto de vuelta, la etapa dice `FAILED` y imprime +> > el error del compilador junto al veredicto. Si a la máquina le falta el +> > objetivo de rustup o un compilador de C para musl, dice `NOT PROVEN` nombrando +> > el remedio —un límite de la máquina no es una falla de Thalyx— y +> > `THALYX_REQUIRE_IMAGE_BUILD=1` vuelve falla esos saltos. +> > +> > La regla nueva es la 12 de `CLAUDE.md` y está entera en +> > [[Estrategia-de-Pruebas]]: **lo que se compila para verificar tiene que ser lo +> > que arranca.** Es la regla 8 apuntada al compilador: una compilación con otra +> > configuración es otro sistema. +> > +> > ### Lo que le toca correr a Cesar +> > +> > ``` +> > git pull && make -C image image +> > ``` +> > +> > Y arrancar la imagen, que es lo único que puede contestar cómo se ve la +> > pantalla: su Fedora no tiene framebuffer que enseñarla. Adentro no hay que +> > teclear nada — la pantalla es lo que sale. Si sale en negro: Ctrl-C a ciegas, +> > o `thalyx.pantalla=no` en la entrada de arranque. +> > +> > `sudo ./dev/verify.sh` completo no hace falta para esto; cuando se corra, va a +> > traer una comprobación más que las 189 de hoy. +> +> > ## La pantalla es la máquina — 2026-08-28 +> > +> > Los bloques de abajo son cómo se llegó. +> > +> > **El decreto, en sus palabras:** *«te dije que ya deberíamos tener ui, porque +> > no lo hiciste? o sea no quiero un comando para activar ui, quiero ya la ui, la +> > que se ve al iniciar, es una estupidez tener que poner un comando para ver la +> > ui definitiva»*. Y tenía razón sobre el diagnóstico también: él mismo encontró +> > que `thalyx screen` tecleado adentro de la sesión caía en el caso `_` del +> > despacho y contestaba *«I have no model loaded»*, porque `session.rs` no +> > exponía el verbo. +> > +> > **Lo que quedó.** `session::run` **entra a la pantalla antes de imprimir un +> > solo prompt**. La sesión de texto es lo que hay debajo, no la puerta. Hay un +> > verbo `pantalla`, y sirve para volver después de Ctrl-C — no para entrar. +> > +> > **Y los verbos corren ahí.** Era la entrega que estaba pendiente y la razón +> > escrita para no haberla hecho antes era buena: no apilar un segundo cambio sin +> > verificar encima del primero. Lo que la volvió barata fue notar que los brazos +> > de ese ciclo de seiscientas líneas tocan **exactamente cuatro cosas** —la +> > tienda, dónde está parada la persona, qué cara contesta, y cómo llegó a existir +> > este proceso— y nada más: ni la terminal, ni el vigilante del kernel. Salieron +> > enteros a `session::dispatch`, y las dos caras lo llaman. +> > +> > **Lo que imprimen se atrapa en el descriptor**, no pasándoles un `Write` hacia +> > abajo. `correr` y `ejecutar` arrancan **otros programas**, y la salida de un +> > módulo está en el descriptor 1 de un proceso que Thalyx no controla; cualquier +> > cosa más estrecha dibujaría una respuesta vacía justo para los dos verbos cuyo +> > sentido entero es correr algo. Vive en `crates/thalyx-capture`. +> > +> > **Y la mitad que no es sobre salida.** La entrada se manda a `/dev/null` +> > mientras corre un verbo. Varios se detienen y preguntan —`instalar`, +> > `observar`, `instalar-en`, `ejecutar`— y todos preguntan después de comprobar +> > `is_terminal`. Bajo la pantalla eso diría que sí, la pregunta se imprimiría +> > donde nadie la ve, y la máquina se quedaría ahí sin teclado con qué contestar: +> > **un cuelgue con una foto encima**. Con `/dev/null` cada uno toma el camino de +> > rechazo que ya tenía escrito y probado. Es lo que queda pendiente de la +> > pantalla, y está en [[Tareas-Pendientes]]: dibujar la confirmación. +> > +> > ### Las dos salidas, porque de esto depende que no se pierda una máquina +> > +> > ``` +> > thalyx.pantalla=no en la línea de comandos del kernel: arranca en texto +> > Ctrl-C con la línea vacía baja a la sesión de texto, y funciona a ciegas +> > ``` +> > +> > La segunda es la que importa si la pantalla sale mal: el modo gráfico y el modo +> > crudo se deshacen en `Drop`, así que devolver la consola no depende de que +> > alguien haya podido **leer** la pantalla para pedirlo. +> > +> > ### Un defecto que sólo se veía corriéndolo +> > +> > El acomodo de la conversación colocaba **un turno completo a la vez** y saltaba +> > el que no cabía — así que la respuesta de `describe`, que es todos los verbos de +> > la máquina, dibujaba **nada**. Cuarenta y tres pruebas de la pantalla en verde y +> > ninguna lo veía, porque todas usaban conversaciones que caben. Ahora se aplana a +> > renglones, se ancla abajo como una terminal, y AvPág/RePág recorren lo anterior. +> > +> > ### Y la regla 11 en un sitio nuevo +> > +> > **Los descriptores 0, 1 y 2 son del proceso.** `cargo test` corre las pruebas de +> > un binario como hilos de un mismo proceso, así que la prueba del atrapador +> > —viviendo como módulo adentro de `thalyx-cli`— atrapaba los renglones de +> > progreso de `libtest` en vez de lo suyo: sola pasaba, con `--test-threads=1` +> > pasaba, junto a las otras ciento treinta y cuatro no. Lo que no tiene dueño no +> > se aísla con una variable de entorno; se aísla con **otro proceso**, que en Rust +> > es otro crate. Está escrito en [[Estrategia-de-Pruebas]] y en `CLAUDE.md`. +> > +> > ### Lo que le toca correr a Cesar +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh +> > ``` +> > +> > Y después, lo que ninguna prueba puede contestar — **`--describe` primero**, +> > porque recorre todo el camino salvo escribir en el dispositivo y tomar la +> > consola: +> > +> > ``` +> > thalyx screen --describe dice qué es este display, SIN tocar la consola +> > ``` +> > +> > Y la de verdad, que es arrancar la imagen: **no hay que teclear nada.** La +> > pantalla es lo que sale. Adentro se teclea `ls`, `cat`, `estado`, `describe` — +> > los mismos verbos, Tab completa, las flechas repiten, AvPág recorre. Si sale en +> > negro: Ctrl-C a ciegas, o `thalyx.pantalla=no` en la entrada de arranque. +> > +> > ### Lo que falta para poder vivir adentro, medido contra el código +> > +> > | Hueco | Estado | +> > |---|---| +> > | El store de una máquina recién instalada queda vacío | Escrito en [[Tareas-Pendientes]]. Es la pregunta de Fase 2: **desde dónde** llega el software | +> > | La red ve y no usa | Decreto de Cesar del 2026-08-23, [[Red]]. Sin DHCP, sin resolutor, sin TLS | +> > | El agente no vive adentro de la máquina | Lo siguiente que eligió Cesar. Adentro de la imagen no hay llama.cpp; tendría que ser un programa ajeno en el store corrido con `ejecutar`, y **nunca se ha intentado** | +> > | La confirmación, dibujada | Los verbos que preguntan rechazan en la pantalla en vez de preguntar en ella. `Confirmation` ya existe y ya se prueba; falta cablearlo | +> +> > ## La pantalla existe — 2026-08-27 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > **Lo primero, porque cambia el encuadre.** Cesar abrió pidiendo *«empezar a +> > hacerlo realmente un SO»*, y medido contra el código **eso ya había pasado**: +> > el 2026-08-07 una PC física arrancó Thalyx de una USB, se instaló sola en otro +> > disco con `instalar-en`, y con el medio quitado arrancó de ese disco. PID 1 es +> > `thalyx`, no hay distribución debajo, y `make -C image count` dice `1`. Contra +> > el criterio que él mismo decretó, Thalyx **es** un sistema operativo desde +> > entonces. Lo que falta no es el título: es que se pueda **vivir** adentro, y +> > esa lista es corta y está abajo. +> > +> > **El decreto.** [[La-Pantalla]], tomado por él el 2026-08-27: *una sola +> > pantalla que es Thalyx*. Sin ventanas, sin escritorio, sin lanzador — no hay +> > dónde *abrir* el agente porque el agente es la pantalla. Corrige el +> > aplazamiento de la GUI del 2026-08-01, cuya razón escrita era cierta y estaba +> > **condicionada a que la Fase 1 no estuviera terminada**; cerró el 2026-08-07 y +> > el aplazamiento siguió vivo veinte días por inercia. +> > +> > **Lo construido.** `crates/thalyx-screen`, puro: estado adentro, pixeles +> > afuera. No abre un dispositivo, no hace un `ioctl`, no muestra nada — el mismo +> > patrón de `thalyx-term` y `thalyx-edit`, y por la misma razón: el contenedor +> > que construye Thalyx no tiene pantalla. **43 pruebas**, todas corriendo aquí. +> > Lo que necesita hierro vive en `thalyx-syscall`: el `ioctl` que pregunta cómo +> > empaqueta un pixel este framebuffer, el `mmap`, y quitar la consola de texto +> > de en medio. +> > +> > **Y se puede *ver* sin tener pantalla.** `thalyx dev screen ` +> > escribe un cuadro a una imagen por el mismo camino de composición que usa el +> > display, así que lo que sale es lo que se dibuja. +> > +> > **Dos cosas que construirlo enseñó**, las dos escritas como revisión en +> > [[La-Pantalla]]: +> > +> > 1. **Los pixeles no piden nada del kernel.** `FB`, `FB_EFI` y `VT` ya estaban +> > desde el 2026-08-07, y `KD_GRAPHICS` sólo impide que el kernel dibuje la +> > consola —no que la tty entregue las teclas—, así que el modo crudo que ya +> > existe sigue sirviendo. **Esta entrega no toca `thalyx.config`**, o sea que +> > no arriesga el arranque de la única máquina que verifica el proyecto. El +> > ratón sí lo pediría, y por eso queda fuera: una pantalla sin ventanas no +> > tiene qué apretar. +> > 2. **Ctrl-C, que en la sesión es la salida, aquí es la trampa.** `RawMode` +> > deja `ISIG` prendido a propósito. Con la consola en modo gráfico, ese mismo +> > `SIGINT` mata el proceso **antes** de que `Drop` devuelva la consola, y lo +> > que queda es una pantalla en negro sobre una máquina que está corriendo +> > bien. La pantalla usa `RawMode::enter_without_signals` y sale por su propio +> > pie. Una tecla que en un modo es el escape, en otro es lo que cierra la +> > puerta. +> > +> > **La etapa 40 de `verify.sh`**, en dos mitades con costos distintos. La +> > composición corre en cualquier lado y comprueba la única propiedad de la +> > pantalla que es de seguridad: que **el color del camino confiable esté en una +> > confirmación y en nada más**, con su control (la pantalla ordinaria lo usa +> > cero veces) y el control del control (el color del agente sí aparece, así que +> > el lector no está encontrando nada). Los pixeles los lee un decodificador de +> > PNG escrito en el propio guion sobre `zlib` — regla 5: un cuadro comprobado +> > por el código que lo dibujó sólo prueba que es consistente consigo mismo. La +> > otra mitad necesita `/dev/fb0` y compara lo que el `ioctl` contesta contra +> > **sysfs**, que es otro camino al mismo kernel; sin framebuffer dice +> > `NOT PROVEN` y `THALYX_REQUIRE_DISPLAY=1` lo vuelve falla. +> > +> > ### Lo que le toca correr a Cesar +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh +> > ``` +> > +> > Y después, lo que ninguna prueba puede contestar: +> > +> > ``` +> > thalyx screen --describe dice qué es este display, SIN tocar la consola +> > thalyx screen toma la pantalla; Ctrl-C la devuelve +> > ``` +> > +> > **`--describe` primero.** Recorre todo el camino salvo escribir en el +> > dispositivo y tomar la consola, así que si la respuesta es que no se puede +> > dibujar aquí, lo dice con la consola intacta. +> > +> > ### Lo que esta entrega NO hace, y por qué +> > +> > **Los verbos todavía no pasan por la pantalla.** `session::run` es un solo +> > ciclo de seiscientas líneas que imprime conforme avanza; volverlo algo que +> > devuelve una respuesta es una edición grande al código más ejercido del +> > proyecto. Hacerlo en la misma entrega que los primeros pixeles significaría +> > que, si su máquina arranca en negro, **no hay manera de saber cuál de los dos +> > cambios fue**. Es la regla de `CLAUDE.md` sobre no apilar un segundo cambio +> > sin verificar encima del primero. +> > +> > ### Lo que falta para poder vivir adentro, medido contra el código +> > +> > | Hueco | Estado | +> > |---|---| +> > | El store de una máquina recién instalada queda vacío | Escrito en [[Tareas-Pendientes]]. La imagen lleva el kernel y un programa, así que una PC recién instalada arranca sana y sin nada que instalar. Es la pregunta de Fase 2: **desde dónde** llega el software | +> > | La red ve y no usa | Decreto de Cesar del 2026-08-23, [[Red]]. Sin DHCP, sin resolutor, sin TLS | +> > | El agente no vive adentro de la máquina | Las cuatro gamas se midieron en Fedora con `llama-completion` en el `PATH`. Adentro de la imagen no hay llama.cpp; tendría que ser un programa ajeno en el store corrido con `ejecutar`, y **nunca se ha intentado** | +> > | Los verbos por la pantalla | La entrega siguiente. Ver arriba | +> > +> > **Lo siguiente que eligió Cesar** el 2026-08-27, junto con la pantalla: **el +> > agente adentro de la máquina**. Es la razón por la que este SO existe, y +> > `ejecutar` ya se construyó exactamente para correr un binario ajeno confinado. +> +> > ## La suite armaba su kernel, dos veces — 2026-08-27 +> > +> > Los bloques de abajo son cómo se llegó. +> > +> > **La segunda vuelta.** Con el arreglo puesto, la corrida trajo **181 `PROVEN`, +> > 2 `NOT PROVEN`, 1 `FAILED`**, y la falla era la medición nueva de la §5 +> > haciendo su trabajo: *«the suite moved the kernel guard from [0] to [1]»*. +> > Quedaba otro, y es el que enseña algo — `catalogue_is_true.rs`, que **no es +> > una prueba sobre el guardián**: le pregunta al binario qué verbos tiene y +> > teclea cada nombre que le contesta. En esa lista viene `negar`. Su lista de +> > exclusiones tenía cinco nombres y una sola razón detrás —*terminan la +> > corrida*—; la otra razón para no teclear algo no estaba escrita en ninguna +> > parte. +> > +> > O sea que el arreglo de la primera vuelta era **la mitad**: el peligro no es +> > una prueba que trata del interruptor, es cualquier cosa que llegue al prompt, +> > porque el prompt tiene el interruptor. La precondición se mudó a +> > `tests/machine_guard/mod.rs`, compartida, y ahora la usa también el archivo +> > que no sabía. Donde el guardián es real, los cuatro nombres se dejan fuera del +> > tecleo y se dice cuáles; donde no, se teclean como todos. +> > +> > **Y un disparador para el quinto.** Excluir por una lista de palabras es un +> > conjunto leído del lugar equivocado. Lo peligroso es que un verbo **actúe en +> > cuanto se teclea** —sin argumento no hay «cuál» que lo detenga— y eso sí se +> > lee del catálogo: `changes` verdadero y `takes` vacío. Hoy son cuatro: +> > `revertir` y `apagar`, contenidos, y los dos del guardián. Cuál de las dos +> > clases es cada uno no se puede leer de ahí, así que el conjunto quedó clavado +> > en una prueba que lo lee del binario en vivo: un quinto verbo que actúe desnudo +> > la pone en rojo y obliga a decidir. +> > +> > Cesar corrió `verify.sh` en su máquina y trajo **180 `PROVEN`, 2 `NOT PROVEN`, +> > 2 `FAILED`**. Las dos fallas eran una: la suite de la §5 **armó su kernel**. +> > +> > `the_guard_can_be_switched.rs` está escrito contra una máquina sin nada +> > cargado —sin BPF, `negar` no puede cambiar nada, y lo que se comprueba es el +> > cableado— y daba por hecho que la máquina era ésa. Cada prueba abre su +> > `THALYX_ROOT` temporal, y eso aísla **la tienda y nada más**: el guardián son +> > cuatro bytes en bpffs, de la máquina, y ninguna variable de entorno los mueve. +> > Como root y con el LSM enganchado, tres de esas pruebas hicieron lo que `negar` +> > hace. La siguiente leyó «already enforcing» y falló, y la §6 reportó que la +> > máquina llegaba armada. +> > +> > **Lo que quedó:** esas tres preguntan primero —al kernel, como lo pregunta +> > `guard::set`— y se saltan con `NOT PROVEN` si el guardián de esta máquina es +> > real; la línea base se salta con ellas, porque una línea base que sobrevive a +> > lo que sostiene dejó de serlo. Dos de las seis siguen corriendo en todas +> > partes y son las que hacen que el archivo pruebe algo en su máquina: el rechazo +> > de `observar` en cara estructurada ocurre **antes** de leer el kernel, así que +> > ahí se teclea el verbo que desarma, en una máquina que sí se puede desarmar, y +> > no se mueve nada. +> > +> > **Y dos arreglos del arnés,** porque el veredicto apuntaba a la etapa +> > equivocada: `guard_check` ahora nombra el intervalo —*«between [5. the test +> > suite…] and [6. a real module…]»*— en vez de culpar sólo a la que se dio +> > cuenta; y la §5 mide con `bpftool`, contra una línea base tomada antes, que la +> > suite dejó el guardián donde lo encontró. Era otra precondición que el guion +> > daba por hecha. +> > +> > La regla nueva es la 11 de `CLAUDE.md`, y está entera en +> > [[Estrategia-de-Pruebas]]: **una prueba que escribe algo global de la máquina +> > ya cambió la máquina que estaba midiendo.** No es «toca la máquina» —un cgroup +> > se crea, se borra y tiene dueño— sino **un interruptor global sin dueño**, +> > cuyo valor es la precondición de otra cosa. +> > +> > **Lo que sigue esperando fierro:** volver a correr `verify.sh` entero. Nada de +> > esto se puede comprobar aquí, porque el contenedor no tiene el guardián que +> > hace que el salto ocurra; lo que sí se comprueba aquí es la decisión del salto, +> > con `would_switch_this_machine` sobre las tres respuestas que puede dar el +> > kernel. +> +> > ## El sprint de lo que no necesita el fierro — 2026-08-26 +> > +> > Los bloques de abajo son cómo se llegó. +> > +> > Cesar estaba fuera de casa y pidió acumular corridas: todo lo que se pueda sin +> > comprobar en hierro. Esto es lo que salió, y casi todo son instrumentos — +> > porque el defecto de abajo llegó al fierro por huecos del arnés, no por falta +> > de código. +> > +> > **Una prueba que mide el orden en vez del efecto.** El `-EPERM` del LSM no se +> > reproduce sin LSM, pero la propiedad de la que se sigue —toda escritura de +> > Thalyx antes de entrar al cgroup— sí se mide aquí, con `strace -f -y`. La +> > ventana va del `write` a `cgroup.procs` hasta el `execve`, y adentro no hay +> > una sola apertura con `O_WRONLY` ni `O_RDWR`. Comprobada revirtiendo el +> > arreglo. +> > +> > **La columna de afuera para el pid.** El arreglo cambió *por qué* funciona el +> > ingreso al cgroup: ahora se escribe «1», y sólo sirve porque el kernel traduce +> > en el espacio de nombres de quien escribe. Si no lo hiciera, metería al init de +> > la máquina bajo la política de un módulo. Se lee `cgroup.procs` desde el +> > anfitrión, con `std::fs` y no a través de Thalyx. Comprobada con una mutación. +> > +> > **La etapa 39.** §36 era la única que armaba la máquina y sólo corre invitados; +> > `correr` bajo un kernel que niega no lo había ejecutado nunca nada. Ahora el +> > mismo módulo corre observando y negando en la misma etapa, y `Operation not +> > permitted` tiene su propio veredicto que manda a `RootFs::assemble`. +> > +> > **Y cuatro cosas que el arnés daba por hechas y ahora mide:** el modo al +> > anunciar cada etapa; que `make -C lsm enforce` de verdad armó —§36 y §39, con +> > bpftool y no con Thalyx, que es el sujeto—; en qué modo queda la máquina al +> > terminar; y el `cleanup` del demo, que se tragaba el fallo de su restauración. +> > +> > **Lo que sigue esperando fierro:** que el `-EPERM` desapareció. Etapa 36 y +> > etapa 39. +> +> > ## El LSM le negaba a Thalyx confinar — 2026-08-26 +> > +> > Los bloques de abajo son cómo se llegó. +> > +> > Segunda corrida en fierro: **169 `PROVEN`, 3 `NOT PROVEN`, 12 `FAILED`**, con +> > la anterior en 171/2/4 y ningún cambio de código entre las dos que tocara nada +> > de lo que se rompió. Doce fallas y una sola frase debajo de casi todas: +> > `I/O error at /run/thalyx/sandbox/dev/null: Operation not permitted`. +> > +> > **El lanzador entraba al cgroup antes de armar la raíz**, así que el LSM leía +> > la política del módulo y le negaba a Thalyx crear el punto de montaje de +> > `/dev/null` — una escritura que el módulo nunca pidió y que el confinamiento +> > necesita. En una máquina que niega no se podía lanzar nada, ni invitado ni +> > módulo firmado. Es el defecto del 25 de agosto otra vez, arreglado entonces +> > sólo para las lecturas. +> > +> > `RootFs` quedó partido en `assemble()` —toda la escritura— y `pivot_into()`, y +> > el cgroup se toma entre las dos. La regla completa está en +> > [[Estrategia-de-Pruebas]]. +> > +> > ### Y dos defectos del arnés que acusaban a Thalyx +> > +> > **Por qué las dos corridas no dieron lo mismo:** la segunda corrió *negando* +> > en etapas escritas para una máquina que sólo observa. `verify.sh` lo daba por +> > hecho de principio a fin y no lo medía en ninguna parte. Ahora `step()` lee el +> > modo al anunciar cada etapa, así que la próxima corrida **nombra la etapa que +> > lo dejó armado** en vez de que nadie lo sepa. Hay un sospechoso —el `cleanup` +> > del demo se traga el fallo de su restauración— y no está probado. +> > +> > **`exec-bare` y `exec-endure` nunca pasaron**, ni una vez desde que se +> > escribieron: el guion devolvía la máquina a observación *antes* de que esas +> > dos etapas lanzaran su invitado, y `ejecutar` se negaba, correctamente. El +> > reporte las contaba como fallas de G1. La restauración va ahora después del +> > último invitado. +> > +> > ### Qué falta comprobar +> > +> > Las 24 pruebas de aislamiento hacen el pivote completo en el contenedor y +> > pasan, así que el reordenamiento no rompió el lanzamiento. **Que el `-EPERM` +> > desapareció sólo lo puede decir una máquina que niegue**, y es la etapa 36. +> > Lo siguiente es correr `verify.sh` otra vez. +> +> > ## La primera corrida del sprint en fierro, y la carrera que encontró — 2026-08-26 +> > +> > Los bloques de abajo son cómo se llegó. +> > +> > Cesar corrió `sudo ./dev/verify.sh` con el sprint dentro: **171 `PROVEN`, +> > 2 `NOT PROVEN`, 4 `FAILED`.** +> > +> > ### Lo que se arregló +> > +> > De los cuatro `FAILED`, uno estaba diagnosticado por su propio mensaje: la +> > suite, en un solo test de diez, con `I/O error at /sys/fs/cgroup/thalyx: File +> > exists`. Era una **carrera** — `cgroup::parent()` preguntaba si el directorio +> > existía y después lo creaba, y diez tests en paralelo caben de sobra en la +> > ventana entre las dos líneas. El mensaje decía lo contrario de lo que pasaba: +> > la máquina estaba bien. +> > +> > Arreglado en los tres lugares que tenían la misma forma —`parent()`, +> > `Cgroup::ensure()` y, del otro lado, `Cgroup::remove()`, que reportaba +> > `No such file or directory` sobre un invitado que ya había corrido bien— y +> > con una prueba de ocho hilos contra una barrera que con el código viejo falla +> > ocho de ocho. La regla quedó en [[Estrategia-de-Pruebas]]: **no preguntes si +> > algo existe para después crearlo.** +> > +> > Este contenedor no tiene cgroup2, así que ese código nunca se ejerció aquí. +> > Lo que aquí sí corre —la suite entera, `clippy`, `fmt`— está limpio. +> > +> > ### Lo que falta, y por qué no se pudo diagnosticar todavía +> > +> > Los otros tres `FAILED` son las tres etapas de §36 que **lanzan un invitado** +> > (`exec-run`, `exec-bare`, `exec-endure`). Fallaron las tres, y el reporte no +> > traía nada más que la ruta de un log que sólo existe en la máquina de Cesar. +> > No hay diagnóstico: lo que se sabe es que el kernel cargó y que el flip a +> > enforcement tomó —si no, `exec-bare` y `exec-endure` ni siquiera habrían +> > corrido— y que la salida no contenía ninguna de las frases que `verify.sh` +> > sabe reconocer. +> > +> > Para que la próxima corrida se conteste sola, `verify.sh` ahora imprime la +> > cola del log junto al veredicto en **las 111 salidas del script que nombran +> > uno**, no sólo las de §36. +> > +> > **Lo siguiente es correr `verify.sh` otra vez y leer esas tres colas.** +> +> > ## Un sprint en vez de tres entregas, y el decreto que lo pidió — 2026-08-26 +> > +> > Los bloques de abajo son cómo se llegó. +> > +> > Cesar preguntó qué seguía. Se le contestó con tres opciones, y cortó: +> > +> > > «creo que ya habiamos dejado claro esto, me pones de opciones cosas +> > > sencillas de hacer, cuando algo es barato o no requiere de mi, hazlos todos +> > > de golpe en un sprint y deja listos los tests o herramientas para verificar +> > > que quedaron bien […] llevamos mucho tiempo haciendo sprints completos +> > > dedicados a algo super sencillo, debemos parar eso». +> > +> > Tenía razón: [[Ritmo-de-Construccion]] ya lo decía —«qué hacer con un +> > pendiente que ya está escrito» está en la columna de lo que **no** se +> > pregunta— y se preguntó igual. La revisión quedó escrita en esa nota y +> > resumida en `CLAUDE.md`, en la forma en que se rompió: **un menú donde todas +> > las opciones son baratas y ya están decididas es una pregunta prohibida**, y +> > **lo barato no se entrega de a uno**. +> > +> > ### Y antes de nada, el error que cometí al contestarle +> > +> > Se le dijo que el trabajo de G1 no estaba en `main` y que por eso nadie lo +> > había podido correr. **Era falso.** `main` ya lo tenía; lo que se leyó fue la +> > copia local de `origin/main`, sin `fetch`, con días de retraso. El merge que +> > salió de ahí no aportó nada —árbol idéntico— y quedó en `main` como un +> > commit vacío con un mensaje que dice algo que no pasó; quitarlo habría sido +> > reescribir `main` y no se hizo. +> > +> > Es la **decimocuarta** vez que el instrumento resulta ser el problema, y la +> > segunda de esta misma falta. Volvió porque la regla estaba escrita a medias: +> > ahora dice que `origin/main` **sólo es una pregunta sobre el repositorio +> > después de un `fetch`**. Ver [[Estrategia-de-Pruebas]]. +> > +> > ### Lo que trae el sprint +> > +> > Tres pendientes, todos ya decididos, todos con su forma de comprobarlos. +> > +> > **1. Thalyx enciende y apaga su propio guardia.** El hueco que abrió el +> > arreglo del 25: Thalyx leía el modo del kernel y no podía cambiarlo, porque +> > cambiarlo era `bpftool` y la imagen no lo tiene. Dentro de la máquina no +> > había forma de pasar de observar a negar, así que cada negativa cuyo remedio +> > era *«hazlo vinculante»* nombraba un comando que ahí no existe. +> > +> > Ahora son dos verbos —`negar` y `observar`— más `thalyx enforce mode` para +> > una máquina con shell. Dos y no uno con argumento porque un typo no puede +> > desarmar la máquina, y **sólo el que afloja pregunta**: `negar` aprieta, y si +> > rompe algo el algo lo dice; `observar` le quita el confinamiento a todo lo que +> > esté corriendo, invitado incluido, y una máquina que dejó de negar en silencio +> > se ve idéntica a una que niega y no tiene qué negar. La cara estructurada no +> > puede pedir `observar` — `needs_a_human`, como `ejecutar`. +> > +> > **2. `ensayo correr`.** Era el único verbo que cambia la máquina y no se podía +> > ensayar, y la razón escrita a su lado dejó de ser cierta el 25: lo que a una +> > corrida se le va a permitir es una pregunta del kernel, y Thalyx ya la sabe +> > contestar. El ensayo **es el código de la corrida**, parado un renglón antes +> > de que el programa exista, así que no puede discrepar de ella. Dice qué +> > programa correría, con qué aislamiento, **qué tiene en vigor** —no lo que pide +> > el manifiesto—, si arrancaría, y si saldría **degradada**. +> > +> > **3. `ensayo editar`.** Salió mientras se cerraba el anterior: era el último +> > que contestaba «no se puede ensayar todavía», y no lo nombraba ningún +> > pendiente porque D1 lo contaba dentro de «archivos». Barato por la misma +> > razón: `change` ya aplicaba en memoria y después guardaba, así que el ensayo +> > es ese camino **sin la línea que guarda**. +> > +> > **Con esto D1 va nueve de nueve y la lista de verbos que cambian y no se +> > pueden ensayar está vacía**, con una prueba que lo afirma. +> > +> > ### Qué está comprobado, y dónde +> > +> > Aquí: **1408 pruebas en verde**, `clippy` y `fmt` limpios. Las dos guardas +> > nuevas se rompieron a propósito y cada mutación la agarró la prueba que le +> > toca — quitar la puerta humana de `observar` tumbó dos, y un falso que +> > reporta un cambio que no hizo tumbó exactamente una. +> > +> > Cada cosa lleva su control, porque sin él ninguna dice nada: `observar` y +> > `negar` se niegan aquí por razones **distintas** (esa palabra es la decisión +> > entera); el ensayo de `correr` no deja marca en el disco **y las mismas +> > palabras con `sin-confinar` sí la dejan**; el de `editar` no toca los bytes +> > **y sin `ensayo` sí los toca**, leídos con algo que no es Thalyx. +> > +> > **En tu máquina, dos etapas nuevas.** La **37** mide el guardia con +> > `bpftool` y no con Thalyx —regla 5: preguntarle a Thalyx si sus cuatro bytes +> > llegaron pasaría en una compilación donde la lectura y la escritura están mal +> > en la misma dirección— con línea base, el acto, el control que lo mueve de +> > vuelta, el verbo de sesión y el `n` con su `y` al lado. La **38** pregunta lo +> > único que un contenedor no puede decir: qué contesta `ensayo correr` en una +> > máquina que sí puede hacer cumplir, denegando y observando, que son las dos +> > respuestas que se ven iguales si `degraded` estuviera mal. +> > +> > ### Para correrlo +> > +> > ```sh +> > git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh +> > ``` +> > +> > Y para verlo con las manos, en una sesión: +> > +> > ``` +> > estado +> > negar +> > ensayo correr +> > ``` +> > +> > ### Lo que sigue, y ninguna de las dos es barata +> > +> > Lo que queda de la vara —un agente ajeno trabajando aquí— son **G2** y **G3**, +> > y G2 empieza con una decisión tuya, no con código: **de dónde saca su runtime +> > un programa que nadie firmó**. La imagen lleva el kernel y un programa, y no +> > hay libc; el agente pide el enlazador antes que nada. Las salidas son una +> > libc en la imagen, una raíz propia que el invitado trae y Thalyx monta, o +> > sólo binarios estáticos — y la primera toca [[Filosofia-Fundacional]], así que +> > es tuya. Ver [[Superficie-para-el-LLM]]. +> +> > ## Y el segundo intento encontró el de abajo — 2026-08-25 +> > +> > Con el modo arreglado, Cesar corrió otra vez `ejecutar /usr/bin/node --version` +> > — sin `leyendo`, sin `escribiendo`. El confinamiento se armó **entero**: +> > cgroup 38600, usuario 700000, pivote, red cortada, 130 llamadas. Y murió +> > antes de `node`: +> > +> > ``` +> > thalyx: I/O error at /sys/fs/cgroup/thalyx/foreign.node-22.…/cgroup.procs: +> > Operation not permitted +> > ``` +> > +> > ### Qué pasaba +> > +> > Sin concesiones la política sale `allowed=0x0`, y el gancho `lsm/file_open` +> > **no mira rutas**: mira si es lectura o escritura y consulta el bit. Con `0x0` +> > se niega *cualquier* apertura de archivo. +> > +> > El lanzador escribe su pid en `cgroup.procs` desde **fuera** del cgroup —esa +> > pasa— y enseguida **lo vuelve a leer** para comprobar que la entrada tomó. +> > Esa lectura ya es desde dentro. Ni siquiera llegaba a `exec`, y abrir el +> > binario también habría sido una apertura de archivo. +> > +> > ### Lo que más vale la pena de esto +> > +> > **Ya estaba encontrado, y rodeado.** La cabecera de `lsm/demo-enforcement.sh` +> > dice que pone en el mapa *«filesystem allowed, network denied»*. Tenía que +> > hacerlo: con el sistema de archivos negado, el `python3` de adentro no +> > arrancaba. Esa conclusión —un proceso confinado necesita leer para existir— +> > se descubrió, se rodeó, y **se quedó dentro del script**. Nada la +> > contradecía porque nada más corría bajo enforcement: `verify.sh` va entero en +> > modo observación. +> > +> > ### Cómo quedó +> > +> > El montaje decide **qué** ve un programa confinado; la política decide **leer +> > o escribir** sobre eso. Las dos sólo componen si puede leer lo que se le +> > montó, así que la lectura de lo visible **no es una concesión**: es el piso +> > (`thalyx_permd::CONFINED_FLOOR`) que hace que el montaje signifique lo que la +> > confirmación ya prometía — *«su propia carpeta, de sólo lectura, y las rutas +> > de sistema»*. `escribiendo` sigue siendo lo único que abre la escritura. +> > +> > Se le da a la **política y nunca al perfil**: como permiso sobre `/` habría +> > hecho que `RootFs` montara el sistema de archivos entero del anfitrión dentro +> > del sandbox. Aplica a módulos igual — uno sin permiso de lectura tampoco podía +> > abrir su propio binario. `thalyx enforce apply`, que ata un cgroup a mano para +> > inspección, **no** lleva piso: tiene que escribir exactamente lo que se le +> > pidió. +> > +> > Y las dos aperturas de `cgroup.procs` ahora dan errores distintos. Decían la +> > misma frase, y esa frase era toda la evidencia de un fallo cuyas dos causas +> > candidatas necesitaban arreglos opuestos. +> > +> > ### Lo que abrió, y Cesar cerró el mismo día +> > +> > Una entrada de política tiene **una** fecha de vencimiento, y las concesiones +> > de `ejecutar` eran JIT: **treinta segundos**. Pasados, expiraba la entrada +> > entera, el piso incluido — así que `ejecutar leyendo …` no podía correr +> > más de medio minuto, y la vara es un agente que corre minutos. El comentario +> > encima de esa línea ya decía lo correcto —*«vive lo que vive el proceso»*—; el +> > tipo elegido hacía lo contrario. +> > +> > **Cesar decidió: la concesión dura la corrida.** Tipo `Session`, sin plazo, y +> > `release()` la retira al salir. Lo que se cede está dicho: los treinta +> > segundos eran también el respaldo del kernel contra un Thalyx colgado que +> > nunca llegue a `release()`; lo acota que el nombre del cgroup es determinista, +> > así que la siguiente corrida del mismo programa sobrescribe la entrada. +> > +> > Se comprueba en la etapa 36 con un invitado que **duerme 35 segundos** y +> > después lee lo concedido. Es la única forma que distingue las dos respuestas: +> > la corrida tiene que ser más larga que el plazo que ya no debe existir. +> +> > ## Lo primero que `ejecutar` dijo en su máquina encontró un hueco — 2026-08-25 +> > +> > Lo de arriba es lo que se vio en cuanto esto se arregló. +> > +> > Cesar corrió `ejecutar /usr/bin/node --version` en su Fedora, justo después de +> > `verify.sh`, y leyó: +> > +> > ``` +> > refusing to run `/usr/bin/node-22`: the kernel policy map is not loaded, so +> > none of the 0 thing(s) this was granted would be enforced. +> > Load it with `make -C lsm load`. Nobody signed this program, so there is no +> > unconfined mode to fall back to. +> > ``` +> > +> > La negativa era correcta: `verify.sh` desengancha el LSM al salir. Dos cosas +> > estaban mal de todos modos. +> > +> > ### La chica: la frase contaba cero +> > +> > «none of the 0 thing(s) this was granted» es el caso **ordinario** — +> > `ejecutar ` sin palabras después no concede nada—, así que el caso +> > ordinario era el roto. Ahora la cuenta es una cláusula que desaparece cuando +> > no hay nada que contar, y hay una prueba que falla si vuelve a aparecer un +> > cero. +> > +> > ### La grande: `make -C lsm load` no es lo que el mensaje creía +> > +> > El remedio que ese mensaje da deja la máquina en **modo observación** — +> > `make -C lsm load` aterriza ahí a propósito, para poder medir una política +> > antes de que ate. Los ganchos corren, cada negación se escribe en el anillo, y +> > **ninguna se aplica**. +> > +> > O sea: la única acción que el sistema le pedía a Cesar lo dejaba justo donde +> > `ejecutar` **sí** arrancaba al invitado y el kernel no le negaba nada. +> > +> > La causa: `is_available()` contesta *«¿se abre el mapa de políticas?»*, y todo +> > el que decidía si confinar lo leía como *«el kernel está negando»*. El modo +> > vive en otro mapa, `thalyx_enforcing`, que **nada en el lado de Rust había +> > leído nunca** — sólo el `Makefile`, con `bpftool`. `thalyx enforce status` +> > imprimía «kernel policy map: present» y se callaba. +> > +> > ### Qué se hizo +> > +> > | | módulo firmado | programa ajeno | +> > |---|---|---| +> > | mapa sin cargar | se niega, ofrece `sin-confinar` | **se niega**, no hay a qué caer | +> > | cargado, observando | **corre degradado, y el journal lo dice** | **se niega**: `make -C lsm enforce` | +> > | no se pudo leer el modo | corre degradado, y el journal lo dice | **se niega**: regla 9 | +> > | cargado, negando | corre | corre | +> > +> > La asimetría es la de [[Programas-Ajenos]] entera: a un módulo lo firmó +> > alguien y un humano leyó su manifiesto, así que un run degradado que el +> > journal nombra es auditable. Detrás de un invitado no hay nadie, y un +> > confinamiento que no niega no es un confinamiento. +> > +> > `thalyx enforce status` ahora dice el modo. La cara de máquina de `correr` +> > lleva `enforcing` al lado de `confined`, por la misma razón que `confined` +> > está ahí. El falso, `MemoryStore`, ganó los tres estados — porque el motivo de +> > que ninguna prueba agarrara esto es que **el modo de fallo no existía en el +> > falso**, y lo que no se puede nombrar no se puede probar. +> > +> > ### Qué está comprobado +> > +> > Aquí: 1384 pruebas en verde (siete nuevas), `clippy` y `fmt` limpios. Las tres +> > guardas nuevas se rompieron a propósito y cada mutación la agarró **la prueba +> > que le toca** — incluida la columna de control, que atrapó la versión que se +> > niega siempre y se vería idéntica a una que funciona. +> > +> > En su máquina, tres etapas nuevas de `verify.sh`: que `thalyx enforce status` +> > diga «observing» cuando el script lo dejó observando, que un módulo corrido +> > bajo un kernel que observa **lo diga**, y que un invitado sea rechazado ahí +> > mismo. Y la etapa 36 ahora **enciende el enforcement para su corrida real y lo +> > vuelve a dejar como estaba** — sin eso, la etapa entera reportaría una +> > negativa y la llamaría una máquina que no puede hacer cumplir nada. +> > +> > ### Lo que abrió +> > +> > Thalyx **lee** el modo sin `bpftool`. **Cambiarlo** todavía es +> > `make -C lsm enforce`, o sea `bpftool`, que la imagen no tiene: dentro de la +> > máquina no hay forma de pasar de observar a negar. Escrito en +> > [[Tareas-Pendientes]]; es una escritura de cuatro bytes en un mapa que ya se +> > abre. +> +> > ## G1: Thalyx ya puede correr un programa que nadie firmó — 2026-08-25 +> > +> > Lo de arriba corrige un hueco que esto dejó abierto. +> > +> > Cesar delegó la forma —*«lo que veas conveniente que sea coherente con nuestra +> > filosofía»*— y ésta es la forma, con la coherencia escrita en +> > [[Programas-Ajenos]] antes de escribir una línea de código. +> > +> > ### Qué se destrabó, y por qué llevaba parado desde el 23 +> > +> > `G1` de [[Superficie-para-el-LLM]] era el punto que bloqueaba la vara del +> > proyecto. La medición del 23 lo había dejado sin ambigüedad: no faltaba una +> > llamada al sistema —el filtro cubre 41 de 41— ni una ruta. Faltaba que +> > `correr` sólo lanza **módulos instalados y firmados**, y un agente ajeno no es +> > ninguna de las dos cosas. +> > +> > Lo que lo destrabó no fue código, fue **no tocar la firma**. Si Thalyx firmara +> > al vuelo lo que se le pide ejecutar, la firma dejaría de significar *alguien +> > respondió por esto* y pasaría a significar *esto pasó por aquí* — la palabra +> > sin significado para quien lea la siguiente. Así que son dos verbos: +> > +> > | | `correr ` | `ejecutar ` | +> > |---|---|---| +> > | qué lanza | un módulo firmado | un programa cualquiera | +> > | quién respondió por él | su publicador | **nadie** | +> > | canal con la API | sí, nace con él | **no, nunca** | +> > | `sin-confinar` | existe, y queda como degradado | **no existe** | +> > +> > ### Las tres decisiones que aguantan el peso +> > +> > 1. **No hay canal.** Un módulo nace sosteniendo un socket a la API de Thalyx; +> > un invitado no recibe ninguno. Eso es lo que impide que este verbo sea una +> > puerta trasera: por aquí no se instala nada, no se concede nada persistente +> > y no se pide nada, porque no hay por dónde pedirlo. +> > 2. **No hay modo degradado.** `sin-confinar` existe para módulos y se +> > justifica en que un humano leyó ese manifiesto y su publicador respondió. +> > De un programa ajeno nadie respondió nada, así que si la máquina no puede +> > hacer cumplir la política, el verbo **se niega** — y el mensaje dice que ese +> > modo no existe, en vez de ofrecerlo. +> > 3. **Ve lo que se le nombró.** Su propia carpeta de sólo lectura, las rutas de +> > sistema, y lo que diga `leyendo ` o `escribiendo ` — cada cosa +> > dibujada por Thalyx y confirmada antes de que el proceso exista. Su usuario +> > se guarda con la llave `foreign:`, así que el mismo programa +> > es el mismo usuario mañana y dos programas distintos nunca comparten uno. +> > +> > ### Qué se comprobó aquí y qué espera tu máquina +> > +> > Etapa **36** de `verify.sh`, con su columna de control: el mismo script corrido +> > **fuera** del sandbox tiene que alcanzar las dos rutas, o el «no las alcanzó» +> > de adentro no significa nada. En este contenedor da cinco `PROVEN` y un +> > `NOT PROVEN`: +> > +> > - **probado aquí** — el control de afuera; `ensayo ejecutar` resuelve el +> > programa y no corre nada; **un `n` no corre el programa** (comprobado por lo +> > que *no* apareció en el disco, no por lo que imprimió la sesión); el journal +> > lo llama `run_foreign` y nunca `run_module`; y la cara estructurada se niega +> > con `needs_a_human` / `confirm_at_a_terminal`. +> > - **espera tu máquina** — lo que un invitado ve. Aquí no hay mapa de política +> > en el kernel, así que el verbo se niega, que es el decreto funcionando. El +> > `NOT PROVEN` dice además algo cierto: esa negativa viene del núcleo, o sea +> > que el `y` sí se leyó y se aceptó. Lo que no corrió es el invitado. +> > +> > Más seis pruebas de integración en `a_program_nobody_signed_can_run.rs`. Dos +> > corren aquí —la negativa sin nada que haga cumplir, y el journal—; las otras +> > cuatro necesitan los controladores `memory` y `pids` delegados y dicen +> > `NOT PROVEN` donde no los hay, con `THALYX_REQUIRE_CONTROLLER_TESTS`. +> > +> > **En tu máquina esas cuatro corren.** `cargo test --workspace`: 1384 en verde. +> > +> > ### Lo que esto no hizo +> > +> > - **No abrió la red** (`G3`), no es `E1` —las concesiones son de una corrida, +> > no expiran porque terminan— y **no resolvió `G2`**: la imagen sigue sin +> > libc, así que `ejecutar` sirve donde hay rutas de sistema que montar, o sea +> > tu Fedora. Dentro de la imagen instalada sirve para lo que esté enlazado +> > estáticamente. +> > - No le quitó nada a `correr`. El decreto de firma sigue entero. +> > +> > ### Para correrlo +> > +> > ```sh +> > git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh +> > ``` +> > +> > Y para verlo con las manos, en una sesión: +> > +> > ``` +> > ejecutar leyendo /home/cesarmanzocode/algo /usr/bin/ls /home/cesarmanzocode/algo +> > ``` +> +> > ## Verde, y la orden de dejar de pulir — 2026-08-25 +> > +> > Cesar corrió `verify.sh` en su máquina: **`156 proven · 2 not proven · +> > 0 failed`**. Las dos fallas del día anterior están cerradas, y eran la misma +> > cosa vista dos veces —la prueba nueva y la etapa del módulo, las dos +> > preguntando con `chrt --other`, que en util-linux 2.41 sale por +> > `sched_setattr`—. El arreglo no tocó el filtro: cambió con qué se le pregunta. +> > +> > ### Y con eso, la corrección que importa más que el número +> > +> > Cesar cortó la pregunta de qué seguía, y con razón: +> > +> > > «llevamos mucho tiempo sin avanzar nada realmente, estamos siendo muy +> > > cautelosos […] le estamos dando demasiada importancia a cosas muy simples y +> > > faciles de hacer […] tenemos que empezar a ser agresivos sin ser estupidos, +> > > la perfeccion vendra despues». +> > +> > Medido contra el registro, tenía razón: del 23 al 25 se construyó un guardia +> > por argumento, dos llamadas de rango de prioridades, tres arreglos del arnés y +> > una prueba que pregunta con la herramienta correcta. Todo cierto, y ninguno de +> > esos días movió la vara de [[Filosofia-Fundacional]] —un agente ajeno +> > trabajando aquí— ni un milímetro. +> > +> > Quedó decretado en [[Ritmo-de-Construccion]], con sus palabras textuales, y +> > resumido en `CLAUDE.md` para que una sesión nueva lo lea antes de preguntar +> > nada. En una línea: **se le pregunta sólo lo que sólo él puede contestar** +> > —cambiar un decreto suyo, escribir donde se pierde algo suyo, gastar su hierro +> > o su dinero, alcance que la bóveda no cubre—. Todo lo demás se hace y se le +> > dice qué se hizo. Un pendiente ya escrito en [[Tareas-Pendientes]] ya fue +> > decidido por él; volver a preguntarlo es pedirle que decida dos veces. +> > +> > Lo que **no** baja: ninguna de las diez reglas de [[Estrategia-de-Pruebas]], +> > ningún decreto sin él, ninguna entrega a medias, y `NOT PROVEN` sigue siendo +> > `NOT PROVEN`. +> > +> > ### Lo que se hizo ese mismo día sin preguntar +> > +> > 1. **`README.md` y `docs/STATUS.md` dicen la corrida vigente.** Citaban la del +> > 23 —`134 proven`— porque la del 24 tenía fallas y no era la que correría +> > ahora. Ya no hay una que esconder. El párrafo que explicaba una caída de +> > conteo se volvió la regla que la caída enseñó: **un conteo que se mueve no +> > es una calificación**, y lo que dice qué pasó es la lista de abajo, no el +> > número. +> > 2. **El caso de aislamiento sobre un archivo, que llevaba abierto desde el +> > 2026-08-04.** Ver [[Tareas-Pendientes]]. Dos pruebas nuevas en +> > `isolation.rs`: un permiso de escritura sobre **un solo archivo**, con la +> > raíz remapeada de verdad, comprobado en el anfitrión —el contenido llegó al +> > mismo archivo y el archivo no cambió de dueño—; y su control, que afirma +> > que **el vecino de al lado no viene con él**. Las dos se rompieron a +> > propósito antes de creerles, y las dos **corren en este contenedor**: hay +> > un cgroup2 en `/sys/fs/cgroup/unified` y los montajes remapeados funcionan +> > aquí, así que esto no espera hierro. `cargo test --workspace`: 1359 en +> > verde. +> > +> > ### Las dos que quedan sin comprobar +> > +> > `verify.sh` las nombra en su propio resumen —el bloque `What this run could +> > not establish:`— y ésa es la autoridad, no lo que se escriba aquí. Si son las +> > del agente, se cierran así: +> > +> > ```sh +> > git pull && cargo install --path crates/thalyx-cli +> > sudo THALYX_AGENT_BINARY=/home/cesarmanzocode/src/llama.cpp/build/bin/llama-completion \ +> > THALYX_AGENT_WEIGHTS=/ruta/a/tu/modelo.gguf \ +> > ./dev/verify.sh +> > ``` +> > +> > ### Y lo siguiente, que sí es una decisión suya +> > +> > **G1 y G2 de [[Superficie-para-el-LLM]].** Es lo único que bloquea la vara del +> > proyecto, y lleva bloqueándola desde que se midió el 2026-08-23: +> > +> > - **G1** — hoy `correr` sólo lanza módulos **instalados y firmados**, y un +> > agente ajeno no es ninguna de las dos cosas. El sandbox ya lo aguantaría: el +> > filtro cubre 41 de 41 llamadas medidas. Lo que falta no es mecanismo, es +> > **qué se permite lanzar**, y eso es un decreto suyo. +> > - **G2** — la imagen lleva el kernel y un programa, así que no hay libc, y un +> > binario enlazado dinámicamente no arranca ahí. Ver +> > [[Que-Necesita-Un-Agente-Ajeno]]. +> > +> > Las dos son la misma pregunta vista desde dos lados, y ninguna se puede +> > construir sin que él decida primero. +> +> > ## La segunda puerta: `chrt` medía la versión de util-linux, no el filtro — 2026-08-25 +> > +> > La corrida siguiente dio `155 proven · 2 not proven · 2 failed`, y las dos +> > fallas eran la misma cosa vista dos veces: la prueba nueva y la etapa del +> > módulo, las dos preguntando con `chrt --other`. +> > +> > ### Y corrige lo que se dijo ayer +> > +> > Ayer quedó escrito que las tres fallas se habían reproducido en el contenedor. +> > **La del filtro no.** El contenedor tiene util-linux 2.39 y su máquina tiene +> > 2.41, y desde 2.41 `chrt --other` pone una política ordinaria con +> > `sched_setattr` en vez de con `sched_setscheduler`. El verde de aquí fue +> > **suerte de versión**, no una comprobación — y la prueba se había escrito el +> > mismo día en que se anotó que un programa real es mejor instrumento que una +> > llamada aislada. Lo es; falta preguntarse qué llamada hace ese programa en la +> > máquina donde va a correr. +> > +> > ### Lo que había debajo, que sí es de diseño +> > +> > `sched_setattr` es **una segunda puerta a la misma capacidad**. Pone la +> > política igual que `sched_setscheduler`, pero la recibe dentro de una +> > estructura, detrás de un puntero — y un filtro de seccomp compara registros y +> > no puede seguir un puntero. Para esa puerta no existe guardia por argumento: o +> > se permite entera, con `SCHED_FIFO` adentro, o se deniega entera. +> > +> > **Cesar decidió el 2026-08-25 denegarla.** Queda en [[Sandbox-Ejecucion]] con +> > su costo escrito: un programa que ponga política ordinaria sólo por esa puerta +> > no puede hacerlo aquí, y `chrt --other` de util-linux 2.41 es uno. Ningún +> > runtime medido depende de ella —la traza del agente ajeno lo muestra +> > arrancando con `sched_setscheduler` y con nada más—. La única cosa que podría +> > mirar detrás del puntero, un supervisor con `SECCOMP_RET_USER_NOTIF`, queda +> > anotada en [[Tareas-Pendientes]] como opción y no como pendiente. +> > +> > ### Qué cambió, y qué no +> > +> > **El filtro no cambió hoy.** Lo que cambió es con qué se le pregunta: +> > +> > - La columna ordinaria pregunta con `chrt --idle 0 true`. Ninguna versión de +> > util-linux lo manda por la puerta cerrada, y `SCHED_IDLE` es una de las tres +> > políticas que el guardia permite: sigue siendo un programa ajeno recorriendo +> > el camino entero hasta la llamada guardada, que es lo que hacía valioso a +> > `chrt`. +> > - `--other` se sigue corriendo, como **reporte y nunca como veredicto**, con +> > `strace` fuera del sandbox diciendo por cuál de las dos llamadas pasó este +> > `chrt`. Así el costo de la puerta cerrada se ve en la máquina donde se paga, +> > medido y no supuesto. +> > - El segundo `NOT PROVEN` de la corrida fue la baranda de ayer haciendo su +> > trabajo: la denegación de tiempo real se calló porque la columna ordinaria no +> > estaba en 0. Sin ella, esa línea habría dicho verde con el módulo muriendo +> > antes de nombrar ninguna política. +> > +> > ### Para cerrar las dos que quedan +> > +> > ```sh +> > git pull && cargo install --path crates/thalyx-cli +> > sudo THALYX_AGENT_BINARY=/home/cesarmanzocode/src/llama.cpp/build/bin/llama-completion \ +> > THALYX_AGENT_WEIGHTS=/ruta/a/tu/modelo.gguf \ +> > ./dev/verify.sh +> > ``` +> +> > ## Tres fallas en `verify.sh`: dos eran del arnés y una era del filtro — 2026-08-24 +> > +> > La corrida de Cesar en su máquina dio `154 proven · 1 not proven · 3 failed`. +> > Las tres fallas están diagnosticadas y arregladas, y **sólo una era de +> > Thalyx**. Ninguna de las tres necesitó su hardware para reproducirse: las tres +> > se reprodujeron en el contenedor. +> > +> > ### 1. El guardia mataba la llamada que existe para dejar pasar — era real +> > +> > `sched_ordinary=159`: el módulo confinado murió con `SIGSYS` al poner un hilo +> > suyo en una política ordinaria. El guardia por argumento de ayer estaba bien +> > escrito; **el camino hasta él no estaba permitido**. `chrt` pregunta primero +> > el rango legal de prioridades —`sched_get_priority_min` y +> > `sched_get_priority_max`— y ninguna de las dos estaba en la lista. Las dos +> > contestan una constante y no cambian nada. Ya están permitidas. +> > +> > Lo que vale más que el arreglo: **la columna de al lado estaba en verde por la +> > razón equivocada.** `chrt --fifo 1 true` moría en esa misma primera línea, sin +> > haber nombrado jamás una política de tiempo real, y eso se lee idéntico a que +> > el guardia lo haya rechazado. La denegación se estaba afirmando sin medirse. +> > Ahora `verify.sh` se calla ahí mientras la columna ordinaria no dé 0, y hay una +> > prueba en el workspace que **instala el filtro de verdad** en un proceso +> > aparte y corre `chrt` bajo él, con las dos columnas en una sola prueba para +> > que nadie las lea por separado. Falla sin el arreglo; se comprobó. +> > +> > ### 2. Las siete sondas de inyección estaban pasando por vacías — era el arnés +> > +> > `verify.sh` buscaba `A CONTRACT WAS PRODUCED` en la salida de +> > `dev agent-probe`. La sonda dejó de imprimir esa frase el mismo 24, cuando un +> > plan pasó a poder ser un verbo y no sólo un contrato. Las siete comprobaciones +> > de «ninguna forma de portarse mal produjo nada» **pasaban sin mirar nada**. +> > +> > Lo agarró el control positivo —el que exige que el mismo modelo, preguntado +> > por lo que el humano tecleó, sí produzca uno—, que es la falla que Cesar vio +> > como «the control behaved as neither a refusal nor a contract». La regla 4 +> > pagándose sola. +> > +> > ### 3. `agent grammar` sí imprimía la gramática — era el arnés +> > +> > La etapa exigía la palabra `install_module`. La gramática deletrea el verbo +> > como lo deletrea la sesión, `install`; `install_module` es como se llama la +> > operación en el **contrato** y sigue siendo alias aceptado por el analizador. +> > La etapa pedía una palabra que no está ahí. +> > +> > ### De pasada: el instrumento del agente ajeno contaba mal +> > +> > `dev/foreign-agent-needs.sh` sacaba las llamadas permitidas de todo +> > `seccomp.rs`, que también nombra 32 que un módulo tiene **prohibidas** —las de +> > las pruebas que afirman su ausencia y las que sólo agrega un permiso de red—. +> > Un agente que llamara a `socket` habría salido como cubierto. Corregido a leer +> > el cuerpo de `module_standard`, y **vuelto a correr**: la respuesta no cambió, +> > 41 de 41. +> > +> > ### El `NOT PROVEN` no es una falla, y sigue en pie +> > +> > Ningún modelo real corrió: `llama-completion` está instalado y no en el `PATH` +> > de root, y `THALYX_AGENT_WEIGHTS` no estaba puesto. Es la etapa diciendo +> > exactamente lo que no pudo comprobar. Para cerrarla: +> > +> > ```sh +> > git pull && cargo install --path crates/thalyx-cli +> > sudo THALYX_AGENT_BINARY=/home/cesarmanzocode/src/llama.cpp/build/bin/llama-completion \ +> > THALYX_AGENT_WEIGHTS=/ruta/al/modelo.gguf \ +> > ./dev/verify.sh +> > ``` +> > +> > Las asignaciones van **después** de `sudo`, porque `sudo` no lleva el entorno. +> > +> > ### Lo que falta y sólo se puede hacer en su máquina +> > +> > Volver a correr `verify.sh`. Lo que este contenedor no puede decir sigue sin +> > decirlo: el LSM, los controladores de cgroup y Btrfs. Lo que sí quedó +> > comprobado aquí es el filtro sobre un programa real, que es donde estaba el +> > defecto. +> > +> > Y con el resultado de esa corrida se actualiza el párrafo de estado de +> > `README.md` y de `docs/STATUS.md`, que todavía citan la corrida del 23 —`134 +> > proven · 2 not proven · 0 failed`—. No se cambió con los números de hoy a +> > propósito: citar el conteo de una corrida que falló, y que ya no es la que +> > correría ahora, es escribir un número que nadie midió. +> +> > ## La gramática del agente es el catálogo entero — 2026-08-24 +> > +> > Cesar decidió las dos cosas que quedaban abiertas y que no eran código sino +> > alcance. Las dos están construidas. +> > +> > ### 1. Qué puede proponer el modelo: todo el catálogo +> > +> > `Superficie-para-el-LLM.md` dejaba la pregunta abierta y le ponía dos +> > condiciones. Las dos se resolvieron construyendo, y ninguna de las dos era la +> > que parecía. +> > +> > **La abstención dejó de ser expresable, y se dio cuenta sola.** Mientras +> > `install_module` era la única operación, una lista de objetivos vacía la +> > decía: nada que instalar es nada que hacer. La mayoría de los verbos del +> > catálogo no toman argumentos, así que una lista vacía en `disks` es una +> > petición completa. Uno de los dos significados tenía que mudarse, y la +> > abstención tiene ahora palabra propia: `nothing`. Las dos siguen valiendo, +> > porque **todas las muestras capturadas de un modelo real absteniéndose usan +> > la lista vacía** y la regla 6 dice que una muestra reescrita ya no es la +> > muestra. +> > +> > **El otro condicionante era el que importaba, y no estaba escrito así.** +> > `assemble` escribía `Operation::InstallModule` en cada contrato que armaba, +> > porque mientras había una sola operación no había otra cosa que escribir. El +> > día que el modelo pudiera proponer `disks`, esa línea habría producido **un +> > contrato para instalar un disco**: un plan que se llama a sí mismo otra cosa, +> > que es la única forma de estar mal que quien lo lee no puede ver. +> > +> > Así que un plan tiene dos formas. Un contrato es lo que +> > [[Contrato-Estructurado]] le da a una operación que **cambia la máquina y +> > necesita que un humano diga que sí**. Preguntar qué discos hay no es eso, y +> > vestirlo de contrato deja la palabra sin significado para quien lea el +> > siguiente. +> > +> > **Y ahí apareció el hueco.** Un plan de verbo no tiene contrato, así que +> > nunca llegaba a `Contract::validate`, así que nunca llegaba a +> > `origins.validate()` — que es la comprobación que rechaza una operación +> > concluida mientras se leía una página hostil. La regla de procedencia habría +> > quedado con **una puerta rotulada `read`**. Se valida en los dos caminos, con +> > prueba en los dos sentidos: la lectura inyectada se rechaza, la que pidió el +> > humano no. +> > +> > La gramática son tres formas de objeto en vez de una, así que ensancharla no +> > costó nada de lo que ya compraba: `install` y `run` conservan la regla de +> > DNS inverso, todo lo demás recibe una clase de caracteres que cubre rutas y +> > nombres y **no puede cerrar la cadena JSON en la que está**, y `nothing` tiene +> > un objeto sin argumentos. +> > +> > Tres pruebas cayeron y ninguna era por este cambio: **las palabras del +> > catálogo son inglés ordinario**. `permissions` es un verbo ahora, así que una +> > prueba que buscaba la palabra en cualquier parte reportó el catálogo como una +> > fuga de procedencia; el brazo de prosa del experimento de gramática "nombraba +> > una operación" porque contiene la palabra `where`; y la sonda leía una +> > producción `root` donde ahora hay tres. Las tres son el instrumento. +> > +> > ### 2. `sched_setscheduler`: sí, pero sin tiempo real +> > +> > Cesar entendió el problema y me dejó decidir los costos. La llamada son dos +> > peticiones con un solo nombre. Un runtime acomodando sus propios hilos dentro +> > del pedazo de procesador que el cgroup ya le dio es ordinario y lo hace antes +> > que nada. Un programa pidiendo política de **tiempo real** está pidiendo +> > quedarse un procesador contra todo lo demás de la máquina, Thalyx incluido, y +> > ningún límite de cgroup se lo quita. +> > +> > El filtro aprendió a mirar un argumento. Y lo que ese cambio enseñó **sólo +> > aparece corriendo**: la primera versión del guardia permitía `SCHED_OTHER`, +> > `SCHED_BATCH` y `SCHED_IDLE`, que es lo que sugiere el manual y lo que +> > cualquiera escribiría. Node pide `0x40000000` —`SCHED_OTHER | +> > SCHED_RESET_ON_FORK`— en cada hilo. **Ese guardia habría matado al agente +> > ajeno en la llamada exacta que el guardia existe para dejar pasar, y habría +> > parecido el guardia funcionando.** +> > +> > Con eso, `dev/foreign-agent-needs.sh` dice **41 de 41**. En la capa de seccomp +> > ya no falta nada para que un agente ajeno arranque. Lo que bloquea sigue +> > siendo G1 y G2, que es lo que la medición del 2026-08-23 ya decía. +> > +> > ### Lo que queda +> > +> > - **`thalyx agent bench`** — el único `NOT PROVEN`. No es una decisión, es una +> > medición que necesita su máquina y unos minutos: +> > `sudo THALYX_AGENT_BENCH=1 ./dev/verify.sh`. +> > - **`agent do` sólo lleva a cabo instalaciones.** Poder decir una cosa no es +> > poder que se haga: todo lo demás pasa por el verbo, en una terminal, con la +> > confirmación que ese verbo ya pide. Ensancharlo es otra decisión de Cesar y +> > no se tomó. +> > - Lo de siempre que necesita hierro: `net/outbound` de punta a punta, cargar +> > `thalyx_watch` con el cargador propio, la deuda de explicación de `/home` +> > `NOEXEC`. +> +> > ## Qué necesita un agente ajeno para arrancar, medido — 2026-08-23 +> > +> > Los bloques de abajo son cómo se llegó. +> > +> > Tercera entrega del sprint, y es la que más cambia lo que creíamos. El +> > pendiente decía *«tomar Claude Code, mirar qué llama, y hacer la lista; es +> > barato y no se ha hecho, y sin ella todo lo de abajo es adivinado»*. Estaba +> > abierto desde el 2026-08-09. Se hizo con `strace`, en veinte minutos. +> > +> > **De las 41 llamadas al sistema que Claude Code hace para arrancar, +> > `module_standard` ya permite 40.** La que falta es una: `sched_setscheduler`. +> > De las 19 rutas que abre, 13 caen dentro de lo que un módulo ve. +> > +> > Eso contradice de frente la frase que estaba escrita debajo del decreto —*«hoy +> > no arrancarían, así que esto no es afinar, es construir»*—. En la capa donde +> > más caro parecía, el filtro de llamadas de este proyecto ya cubre el 97.5% de +> > lo que un agente ajeno pide para existir. **La afirmación era razonable y +> > nadie la había medido.** +> > +> > **Dónde sí es cierta**, y ahora con nombre en vez de por suposición: +> > +> > - **El enlazador.** El agente abre `/etc/ld.so.cache` y cinco objetos +> > compartidos. La imagen lleva `/init`, unos directorios y `/dev/console` — no +> > hay libc. Un binario enlazado dinámicamente no arranca ahí, y eso es +> > **exactamente la pregunta abierta del ABI de los módulos**, hecha por el +> > agente antes que ninguna otra. +> > - **`G1`, lanzar un proceso arbitrario.** No es una llamada que falte ni una +> > ruta: es que `correr` sólo lanza módulos instalados y firmados. La medición +> > lo confirma como el que bloquea en vez de contradecirlo. +> > - **`/home` montado `NOEXEC`.** Un agente que aterrice ahí no se ejecuta +> > aunque todo lo demás esté resuelto. La deuda de explicación que aplazaste el +> > 2026-08-09 ahora tiene un caso concreto detrás en lugar de ser hipotética. +> > +> > Y **seis rutas bajo `/sys`** que un módulo no ve. De las seis sólo se puede +> > afirmar algo de una: `trace_marker` dio `ENOENT` y arrancó igual, o sea que no +> > hace falta. De las otras cinco lo único cierto es que aquí no tuvo que +> > arreglárselas sin ellas — la sospecha razonable es que degrada a valores por +> > omisión, y una sospecha razonable no se apunta como medición. +> > +> > La lista entera está en [[Que-Necesita-Un-Agente-Ajeno]], **con la mitad que +> > dice qué NO contesta**: arrancar no es trabajar. No hubo red, ni terminal, ni +> > subprocesos, ni una sola escritura. +> > +> > Se reproduce con `dev/foreign-agent-needs.sh`, que es un script y no un +> > párrafo porque un procedimiento impreso para una persona es código que no +> > corre. +> +> > ## El ensayo llegó a los verbos que cambian la máquina — 2026-08-23 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Segunda entrega del sprint. El punto **D1** de [[Superficie-para-el-LLM]] +> > —ensayo en todo verbo que cambia— estaba en «hecho para los verbos de +> > archivos, los otros cinco dicen que no pueden». Ahora está en **ocho de +> > nueve**. +> > +> > Y salió casi gratis, por una razón que vale más que los cuatro verbos: +> > **cuatro de los cinco ya tenían escrita la mitad que averigua**, separada de la +> > que actúa. `revertir` tiene `plan` aparte de `apply` desde que se escribió. +> > `instalar` resuelve el candidato y lee su manifiesto antes de preguntar nada. +> > Y `instalar-en` calcula la distribución entera, encuentra el kernel y lee qué +> > hay en el disco **antes** de la confirmación — decisión del 2026-08-07, tomada +> > por otra razón completamente distinta: que un borrado ya confirmado no +> > descubriera después que no había kernel que escribir. +> > +> > Así que el ensayo no fue una segunda implementación de nada: **fue parar en la +> > línea que ya estaba dibujada.** Para el único verbo irreversible del sistema, +> > que no exista una segunda implementación que se pueda desalinear no es un +> > detalle. +> > +> > `ensayo instalar` es el que más se usa y contesta lo que una persona sólo podía +> > ver empezando la instalación y declinando: qué pide el módulo, si alguno de +> > esos permisos necesita a alguien en una terminal, y si reemplaza algo que ya +> > está. +> > +> > **Comprobado con su control, que es lo que lo hace valer**: el ensayo deja el +> > store sin journal y sin módulos, y la instalación de verdad del mismo bundle +> > deja las dos cosas. Sin esa segunda columna, un ensayo que se cayera antes de +> > hacer nada se vería igual. +> > +> > `correr` es el único que queda y se queda diciendo que no puede: qué podría +> > hacer un módulo al correr es una pregunta del lado del kernel, y contestarla +> > desde el manifiesto describiría una corrida que la máquina quizá no puede dar. +> > +> > **Y una prueba estaba tapando dos hechos con una sola palabra.** `cannot` +> > significaba a la vez *este verbo no tiene ensayo* y *aquí no hay nada que +> > deshacer*. El primero manda al que preguntó a otro lado para siempre; el +> > segundo deja de ser cierto en cuanto se instale algo. +> +> > ## Los cuarenta verbos contestan por estructura — 2026-08-23 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Cesar leyó la lista de pendientes y contestó lo que había que contestar: que +> > íbamos innecesariamente lento, que **todo eso es horizontal y ninguna pieza es +> > difícil**, y que en vez de elegir una hiciéramos un sprint para eliminar el +> > horizonte barato entero. Ésta es la primera entrega. +> > +> > Nueve verbos no tenían cara estructurada, más tres que se creían sin nada que +> > contestar. **Ninguno era difícil.** Lo que los dejó así es que el catálogo de +> > [[Superficie-para-el-LLM]] trata de superficie *nueva*, y éstos son anteriores +> > al decreto de las dos caras. +> > +> > **Lo que eso costaba, dicho bien:** `disponibles`, `instalar`, `modulos`, +> > `correr`, `permisos` y `revertir` son el ciclo completo de lo único que Thalyx +> > existe para dejar hacer. Catorce de diecinueve puntos del catálogo hechos, y el +> > ciclo entero en prosa — un programa no podía saber si lo que iba a instalar ya +> > estaba instalado, ni qué había en el repositorio, ni qué concedió la vez +> > pasada. +> > +> > Ahora el ciclo entero se corre por la cara estructurada, y quedó capturado en +> > una sola sesión por tubería: +> > +> > ``` +> > {"op":"available","ok":true,"total":1} +> > {"op":"install","ok":true,"module_id":"org.thalyx.face"} +> > {"op":"modules","ok":true,"total":1} +> > {"op":"run","ok":false,"error":"cannot_enforce","remedy":"run_unconfined"} +> > {"op":"rollback","ok":true,"undid":"undo install_module of org.thalyx.face 1.0.0"} +> > {"op":"modules","ok":true,"total":0} +> > ``` +> > +> > **El camino confiable no se debilitó, se reporta.** [[Camino-Confiable]] queda +> > intacto: sin terminal no hay confirmación y no hay instalación, y `instalar-en` +> > sigue pidiendo la ruta del disco tecleada. Lo único que cambia es que la +> > negativa vuelve como objeto en vez de una línea en `stderr`, donde un parser +> > que lee un solo flujo no la veía nunca. +> > +> > **Y salió una afirmación falsa, de correrlo y no de leerlo.** El primer campo +> > de esa negativa decía `wrote_anything: false`. El journal **sí** guarda una +> > entrada `rejected` — que es justamente el punto: una negativa del camino +> > confiable que no dejara rastro sería un camino confiable que nadie puede +> > auditar. Dice `installed: false`, que es lo cierto. La cara humana llevaba el +> > mismo exceso en prosa y también se corrigió. +> > +> > Los tres que "no tenían nada que contestar" eran el último sitio donde quedaba +> > silencio, y las tres razones son distintas: `limpiar` no limpia nada porque del +> > otro lado no hay pantalla, `salir` contesta **antes** de que el pipe se cierre +> > porque un pipe cerrado y vacío es exactamente lo que parece un cierre +> > inesperado, y `apagar` contesta antes de la llamada al sistema porque cuando +> > funciona no regresa. +> > +> > Lo que lo sostiene: la prueba del catálogo **afirma que la lista de verbos +> > sólo-prosa está vacía**, y la etapa 22 pasó de catorce verbos manejados a +> > veintiuno. Control corrido en los dos sentidos. +> > +> > **Nada de esto necesita hierro.** Sigue faltando el banco de gamas, que es una +> > medición y no una comprobación. +> +> > ## `describe` prometía prosa donde había un objeto — 2026-08-23 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Revisando qué quedó desalineado después de la corrida en verde salió **un +> > defecto real, y no estaba en la bóveda sino en el código**: `red` se construyó +> > ese mismo día con sus dos caras y quedó declarado `answers: None` en el +> > catálogo. `describe` es lo primero que lee un programa y por cada verbo dice si +> > contesta por estructura; **un verbo declarado sólo-prosa es un verbo que un +> > programa nunca llama**. La única lista de hardware de red que esta máquina +> > tiene fue invisible para eso durante el día entero, sin producir un solo error. +> > +> > Nadie lo vio porque **el catálogo y el despacho son dos archivos y cada uno +> > concuerda consigo mismo**: las pruebas de `net` ejercen la cara estructurada y +> > pasan, y la prueba del catálogo afirmaba que `modules` seguía siendo sólo-prosa +> > —un `contains` sobre un ejemplo no ve que otro se movió—. +> > +> > Tres cosas cambiaron: +> > +> > - `red` declara `answers: Some("network")`; +> > - la prueba del catálogo **fija la lista entera** de verbos sólo-prosa, así que +> > agregar una cara obliga a editar ese renglón; +> > - la **etapa 22** corre los catorce verbos que aquí se pueden correr sin +> > argumentos y compara el cable contra lo que `describe` prometió, en las dos +> > direcciones. Con el defecto devuelto a mano dice +> > `red:promised-prose-answered-network`; sin él, `ok:14`. +> > +> > La regla nueva está en [[Estrategia-de-Pruebas]]: **una afirmación que un +> > sistema hace sobre sí mismo se comprueba corriéndolo, no leyendo los dos lados +> > del código.** +> > +> > Y de paso quedaron alineados dos pendientes de [[Tareas-Pendientes]] que +> > seguían marcados abiertos y estaban cerrados desde el 2026-08-10: los tres de +> > «sólo hierro» —de los que `D2` y `B3` se construyeron y sólo queda `E1`— y los +> > tres que se podían hacer aquí —`B1`, `C2` y `F2`, los tres hechos—. +> > +> > **Nada de esto necesita hierro**, así que no interrumpe nada. +> +> > ## `proven 159 · not proven 1 · failed 0` — 2026-08-23 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > **Cero fallos, y por primera vez con un modelo de verdad.** La corrida de Cesar +> > cierra todo lo que este día abrió: +> > +> > - la terminal usable está en **9 de 9**; +> > - el kernel construye con las ocho opciones de red nuevas y `red` coincide con +> > `iproute2` en su máquina; +> > - las cuatro afirmaciones que sólo un modelo real puede contestar —que este +> > build de llama.cpp acepta las banderas que Thalyx le pasa, que una inferencia +> > real vuelve como algo que el parser acepta, y que `--grammar-file` restringe +> > en vez de ser ignorada— **quedaron probadas**, después de meses reportándose +> > como `NOT PROVEN`. +> > +> > Lo único que queda es **la única cosa que no es una comprobación sino una +> > medición**: `thalyx agent bench`, que no corre sola porque tarda minutos. +> > +> > ``` +> > sudo THALYX_AGENT_BENCH=1 \ +> > THALYX_AGENT_BINARY=/home/cesarmanzocode/src/llama.cpp/build/bin/llama-completion \ +> > THALYX_AGENT_WEIGHTS=/home/cesarmanzocode/models/qwen2.5-3b-instruct-q4_k_m.gguf \ +> > ./dev/verify.sh +> > ``` +> > +> > Eso da **la primera tabla de acierto por gama que ha existido** y es la entrada +> > a la pregunta de abstención cero de [[Gamas-de-Modelo]], que lleva meses parada +> > por no tener con qué medirla. +> > +> > **Falta que Cesar decida si se corre ahora.** Nada más está bloqueado. +> +> > ## Corregido: una interfaz abajo no tiene una sola respuesta — 2026-08-23 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > La corrida de Cesar trajo `proven 153 · not proven 1 · failed 1`, y el que +> > falló fue **una prueba mía**, no Thalyx. +> > +> > Antes de eso, lo que su corrida sí probó, y es lo que importaba: +> > +> > - **el kernel construyó con las ocho opciones nuevas.** `config-check` no tiró +> > ninguna, así que las dependencias de `NETDEVICES`, `ETHERNET` y los cuatro +> > drivers estaban bien; +> > - **la etapa 35 pasó**: `red` y `iproute2` nombran las mismas interfaces en su +> > máquina, leídas por sysfs y por netlink; +> > - **la etapa 34 pasó**: los ensayos hablan en condicional y el verbo de verdad +> > no. +> > +> > Lo que falló: la prueba afirmaba que **una interfaz abajo se niega a contestar +> > si tiene cable**. Aquí es cierto —`ifb0` da `EINVAL`—; en su Fedora un puente +> > de Docker abajo contesta `0` con toda honestidad. **Negarse o contestar es del +> > driver, no de estar abajo.** +> > +> > El módulo nunca necesitó eso. Necesita que una lectura fallida jamás se reporte +> > como cable ausente, y eso es cierto en toda máquina. La prueba ahora lee el +> > mismo archivo por su cuenta y compara el mapeo; la negativa de verdad, que no +> > toda máquina tiene, sale como `NOT PROVEN` con su propia variable. +> > +> > **Es la misma regla que la del `ENXIO` contra `EACCES` y es la segunda vez en +> > dos días.** Lo nuevo es qué la produjo: la primera fue una opción de montaje, +> > ésta fue contar dos ejemplos de la misma clase en la misma máquina y llamarlo +> > la regla. Está en [[Estrategia-de-Pruebas]]. +> > +> > **Lo único que falta para cerrar el modelo** es lo que su propia corrida ya le +> > dijo, palabra por palabra: +> > +> > ``` +> > git pull +> > sudo THALYX_AGENT_BINARY=/home/cesarmanzocode/src/llama.cpp/build/bin/llama-completion \ +> > THALYX_AGENT_WEIGHTS=/home/cesarmanzocode/models/qwen2.5-3b-instruct-q4_k_m.gguf \ +> > ./dev/verify.sh +> > ``` +> +> > ## Punto 8: la red se ve y no se usa — la terminal usable está en 9 de 9 — 2026-08-23 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Cesar lo decidió así: **verla, no usarla.** El decreto entero está en [[Red]], +> > con la razón de por qué no es lo mismo un poco más de lo otro — DHCP, DNS y TLS +> > son programas aparte en todos lados y aquí tendrían que vivir dentro de +> > `thalyx`, y lo que comprarían depende de una pregunta de Fase 2 que no está +> > contestada: de dónde saldría un módulo. +> > +> > El kernel pasó de **110 opciones a 118**. Las nuevas son dos menús y cuatro +> > drivers, cada uno con su razón al lado: `virtio_net`, `e1000`, `e1000e` y +> > `r8169`. Nada de WiFi. +> > +> > El verbo es `red`, motor en `thalyx-net`, dos caras. Y **dice en la respuesta +> > que no se puede usar** —`addressable: false` para un programa, una frase para +> > una persona— porque es la única lista del sistema cuyas cosas ningún verbo +> > puede tocar, y quien lea una lista de tarjetas va a ir a buscar el verbo que +> > las usa. +> > +> > **Lo que salió de correrlo, que ninguna prueba de fixture vio:** la primera +> > versión reportó **tres tarjetas en una máquina con una.** `ifb0` e `ifb1` dicen +> > `type 1` y traen dirección física, y son software puro. Lo que separa una +> > tarjeta es que cuelga de un bus. Regla nueva en [[Estrategia-de-Pruebas]]. +> > +> > Las otras dos, medidas y no citadas: una interfaz abajo **no dice que no tiene +> > cable, no dice nada** (`EINVAL`, no `0`), y `speed` tiene tres estados —número, +> > `-1` con el enlace arriba, y no legible—. Las dos sobreviven a lo que se +> > imprime: `cable unknown` es una columna distinta de `no cable`. +> > +> > 18 pruebas nuevas y la **etapa 35**, cuyo control es `iproute2` porque lee +> > netlink y no sysfs: pedirle a Thalyx que se compruebe contra su propia lectura +> > de `/sys` sólo probaría que es consistente. 1340 pruebas, clippy limpio. +> > +> > **Lo que falta correr, y sólo tu máquina puede:** +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli +> > make -C image kernel # los ocho CONFIG_ nuevos +> > sudo THALYX_AGENT_WEIGHTS=/home/cesarmanzocode/models/qwen2.5-3b-instruct-q4_k_m.gguf \ +> > ./dev/verify.sh +> > ``` +> > +> > `make -C image kernel` es lo primero porque **`config-check` falla la +> > construcción si `olddefconfig` tira cualquiera de las ocho opciones nuevas**, y +> > las nombra. Es la única manera de saber si acerté las dependencias: aquí no se +> > puede compilar un kernel. +> > +> > Y en tu Fedora, `red` a secas ya enseña tu tarjeta real — con su driver y su +> > velocidad negociada— sin necesidad de arrancar la imagen. +> +> > ## Un ensayo ya no dice que borró nada — 2026-08-23 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Cesar lo decidió: se arregla. `ensayo rm notas.txt` imprimía +> > `removed /ruta/notas.txt` para un archivo que seguía ahí, y lo mismo `cp`, `mv` +> > y `mkdir`. Ahora dicen `would remove`, `would copy`, `would move` y +> > `would make the directory` — y el verbo de verdad sigue diciendo `removed`, +> > que es la mitad que hace que esto signifique algo. +> > +> > **Una sola frase, dos tiempos.** El tiempo verbal viaja como un dato desde +> > quien sabe si esto es un ensayo hasta quien imprime, y `Did::would()` vive +> > pegado a `Did::word()` para que no se pueda agregar un verbo nuevo con la +> > mitad. Un segundo impresor para los ensayos sería exactamente la segunda +> > versión de los hechos que este módulo existe para no tener. +> > +> > **Por qué nadie lo vio en meses:** la cara de máquina estaba bien todo el +> > tiempo — su `op` dice `rehearse`— así que las cuatro pruebas del ensayo, que +> > leen objetos, no podían verlo. Regla nueva en [[Estrategia-de-Pruebas]]: cuando +> > un hecho se dice en dos caras, una prueba que sólo lee una de ellas prueba una +> > de ellas. +> > +> > Prueba nueva y **etapa 34**, las dos comprobadas de las dos maneras: fallan con +> > el defecto puesto de vuelta y pasan sin él. 1322 pruebas, clippy limpio. +> > +> > **Lo que sigue es el punto 8, la red**, que Cesar también decidió. Es el último +> > de los nueve de la terminal usable. +> +> > ## La imagen está construida, y el modelo sí corrió — 2026-08-23 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Cesar construyó la imagen en su máquina. La corrida pasó de `139 · 2 · 0` a +> > **`152 · 1 · 0`**: son las trece comprobaciones de la etapa 16, que arranca la +> > imagen en QEMU y le habla, y que llevaban meses fuera del reporte por no haber +> > kernel construido. **Con eso el punto 8 —la red— queda desbloqueado**, porque se +> > prueba con `make -C image run-hardware`, que necesita justamente esa imagen. +> > +> > El NOT PROVEN que quedó **no era suyo, era del instrumento.** Corrió +> > `thalyx agent model check` y el modelo contestó de verdad —una inferencia +> > parseada, 7.28 s, 4.77 GB de pico— y la etapa siguió diciendo *«no real model +> > has run: llama-completion is not installed»*. Las dos cosas ciertas a la vez: +> > el `check` lo corrió él con su `PATH`, y la etapa corre bajo `sudo`, que tira el +> > `PATH` y usa `secure_path`. Regla nueva en [[Estrategia-de-Pruebas]], la quince. +> > +> > Arreglado: la etapa busca el binario también en el `PATH` de `$SUDO_USER` y, +> > cuando lo encuentra, **dice dónde está y qué escribir** para que la corrida lo +> > vea. Las dos mitades del hueco se reportan por separado, y la de los pesos +> > distingue «no la nombraste» de «nombraste un archivo que no está» — y en el +> > primer caso dice que `sudo` no lleva el entorno. +> > +> > Comprobado corriendo los cuatro caminos, no leyéndolos. El segundo destapó un +> > defecto del arreglo mismo: una cuenta con `nologin` imprime una frase en inglés +> > y la primera versión la ofreció como si fuera la ruta del binario. +> > +> > **Lo que falta correr:** +> > +> > ``` +> > git pull +> > sudo THALYX_AGENT_WEIGHTS=/home/cesarmanzocode/models/qwen2.5-3b-instruct-q4_k_m.gguf \ +> > ./dev/verify.sh +> > ``` +> > +> > Si aun así dice NOT PROVEN, ahora la línea trae la ruta exacta que falta poner +> > en `THALYX_AGENT_BINARY`. +> +> > ## Punto 9: hay citado y no hay lenguaje — 2026-08-23 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Cesar lo decidió así: *«lo que sea más fácil de cubrir por ahora, pero en un +> > futuro sí tendremos que hacer shell completo, no ahora, pero estemos +> > preparados»*. La segunda mitad es la que mandó sobre el diseño — **nada de lo +> > que se aprenda hoy puede tener que desaprenderse el día del shell completo** — +> > y el decreto entero está en [[Palabras]]. +> > +> > Antes de preguntarle fui a ver qué faltaba de verdad, corriéndolo: **un archivo +> > con un espacio en el nombre se podía listar y nada más.** `cp mi archivo.txt x` +> > eran tres palabras y los tres verbos se negaban. Nunca se destruyó nada por +> > eso, pero no había forma de nombrar el archivo. +> > +> > Lo que hay ahora: +> > +> > - `'…'`, `"…"` y `\x` con las reglas de POSIX hasta donde POSIX llega hoy, así +> > que el día que `$` signifique algo no cambia nada de lo escrito; +> > - una comilla sin cerrar **se niega** —`unclosed_quote` / `close_the_quote`— en +> > vez de adivinarse, que es como un `rm` acaba actuando sobre algo que nadie +> > nombró. Una diagonal al final tiene su propia palabra, porque es otro error; +> > - **la expansión se queda en el verbo, y eso es decreto**. `rm "*.log"` borra el +> > archivo que se llama así y `encontrar "*.rs"` sigue siendo un patrón — las +> > dos costumbres de Unix, cada una donde estaba, igual que bash y `find`. +> > +> > Una palabra recuerda qué caracteres venían citados **carácter por carácter**, +> > porque `"a"*` es un patrón y `a"*"` es un nombre. +> > +> > **Dos cosas cambiaron de significado y hay que decirlas:** una corrida de +> > espacios ahora se colapsa (`contenido fn main` busca `fn main`; la forma de +> > pedir lo otro es `contenido "fn main"`), y el texto de `editar` es la única +> > excepción — se toma del renglón byte por byte, porque una sangría perdida en un +> > archivo de configuración no se ve hasta que algo no arranca. +> > +> > Ocho pruebas en el prompt de verdad y la **etapa 33**, comprobada de las dos +> > maneras: pasa con el cambio y falla sin él. +> > +> > **Con esto la terminal usable llega a 8 de 9.** Queda el punto 8, la red, que +> > sólo se verifica en su hierro y se cruza con la Fase 2. +> > +> > **Y una cosa encontrada de paso, que no toqué:** `ensayo rm x` imprime +> > `removed /ruta/x` sin haber borrado nada. Ya estaba en `main` desde antes —lo +> > comprobé con el binario anterior— y es de la misma familia que lo de `matar`: +> > una respuesta que dice que algo pasó cuando no pasó. La cara de máquina está +> > bien (el `op` es `rehearse`); la humana no. **Falta que Cesar decida si se +> > arregla.** +> +> > ## Comprobado en el hierro: 138 · 2 · 0 — 2026-08-23 +> > +> > Cómo se llegó. +> > +> > La corrida de Cesar cerró el lazo de verificación de los tres arreglos de este +> > día: `proven 138 · not proven 2 · failed 0`. Los dos NOT PROVEN son los de +> > siempre y no son defectos — no hay modelo instalado y no hay imagen construida +> > (`make -C image`). +> > +> > Lo que eso deja probado **en hierro**, que es lo único que cuenta para estas +> > tres cosas: +> > +> > - `matar` se niega ante un hilo del kernel y ante un proceso que ya terminó, en +> > vez de decir que los detuvo (etapa 32, con la línea base y el control); +> > - **instalar dos veces sobre el mismo disco funciona** — que era el `failed 1` +> > de la corrida anterior. Sostener las particiones abiertas sí impide el segundo +> > barrido del kernel; era lo único de ese arreglo que este contenedor no podía +> > contestar; +> > - la prueba del nodo ya no afirma un errno, así que la suite pasa en una máquina +> > que monta `/tmp` con `nodev`. +> > +> > **La terminal usable llega a 7 de 9 y los dos que faltan son decisiones suyas:** +> > el punto 8 (red) sólo se verifica en su hierro y se cruza con la Fase 2, y el +> > punto 9 (lenguaje de shell) es decreto antes que código. Nada más está +> > bloqueado. +> +> > ## Instalar dos veces sobre el mismo disco — 2026-08-23 +> > +> > Cómo se llegó. +> > +> > **Corregido el mismo día:** la prueba nueva afirmaba que abrir el nodo da +> > `ENXIO`, y en tu Fedora dio `EACCES` — porque `/tmp` está montado con `nodev` y +> > ahí no se puede abrir ningún nodo de dispositivo, haya algo detrás o no. La +> > prueba tumbó la suite entera por una opción de montaje. Ahora afirma lo que se +> > estaba probando: que el nombre resuelve y el dispositivo no. Regla en +> > [[Estrategia-de-Pruebas]], y es la catorceava vez que el instrumento miente. +> > +> > La corrida de Cesar del 2026-08-23 trajo `proven 137 · not proven 2 · failed 1`. +> > El que falló: **instalar dos veces no funcionaba**, con +> > `opening /dev/loop0p1: No such device or address`. +> > +> > `instalar-en` escribe la tabla, le pide al kernel que la relea, y espera a que +> > aparezcan las particiones antes de escribir dentro de ellas. La espera +> > preguntaba **si existía el nodo**. La primera instalación no tiene nodos, así +> > que la espera espera; la segunda los tiene de la tabla anterior, así que la +> > condición ya estaba cumplida antes de empezar y la espera terminó sin haber +> > esperado. +> > +> > Y hay algo que esperar de verdad: **cerrar el descriptor que tenía el disco +> > entero abierto para escritura hace que el kernel lo reexamine por su cuenta**, +> > en su propio tiempo. Ese segundo barrido borra cada partición y la vuelve a +> > hacer, y un nodo abierto dentro de esa ventana da `ENXIO` — *No such device or +> > address*, para un nombre que está ahí en `/dev`. +> > +> > Dos cambios, y el segundo es el que cierra la ventana en vez de esquivarla: +> > +> > 1. la espera **abre** la partición en vez de preguntar si el nombre existe; +> > 2. las particiones se devuelven **ya abiertas** y se sostienen abiertas hasta el +> > final. El kernel se niega a soltar las particiones de un disco mientras +> > alguna esté abierta, así que el segundo barrido encuentra el disco ocupado y +> > lo deja en paz. +> > +> > **Lo que está probado y lo que no.** Que `stat` y abrir contestan cosas distintas +> > quedó fijado con una prueba que hace `mknod` con un major que ningún driver +> > registró: el nombre existe y abrirlo da `ENXIO`, el mismo errno de tu corrida. +> > Que montar funciona con un descriptor abierto encima también se comprobó +> > corriéndolo. **Que sostener la partición bloquea el segundo barrido no se puede +> > comprobar en este contenedor**, que no sabe hacer particiones sobre un loop. Eso +> > lo dice tu máquina: +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli +> > sudo ./dev/verify.sh +> > ``` +> > +> > La regla que lo habría atrapado está en [[Estrategia-de-Pruebas]]: **una +> > condición de espera tiene que ser falsa al principio.** Si la puede satisfacer lo +> > que quedó de la vez pasada, no está esperando a nada — y el caso que la deja +> > pre-satisfecha es justamente el que nadie prueba, porque es el segundo. +> > +> > ## `matar` ya no dice que detuvo lo que no se puede detener — 2026-08-23 +> > +> > Lo encontró Cesar en su primera sesión con el punto 7: ensayó `matar` sobre un +> > `kworker` y Thalyx contestó *«5 would ask to stop»*. A un hilo del kernel no le +> > llega ninguna señal. Que `pidfd_send_signal` conteste `0` significa que el +> > kernel se quedó con la señal, **no que le vaya a pasar algo a alguien**. +> > +> > Son dos los sujetos que la aceptan y la tiran, y los dos quedan negados antes +> > de mandar nada: +> > +> > | sujeto | palabra | remedio | +> > |---|---|---| +> > | un hilo del kernel | `is_kernel_thread` | `cannot` | +> > | un proceso que ya terminó (zombi) | `already_ended` | `stop_the_parent`, con el número del padre | +> > +> > Es peor que un error: una respuesta que dice *«se le pidió que pare»* sobre algo +> > que nunca se movió enseña que Thalyx no es confiable, cuando Thalyx sólo era +> > crédulo — y quien la vea va a probar `forzar`, que hace exactamente lo mismo. +> > +> > `ensayo matar` se niega igual, desde la misma función: un ensayo que predice +> > algo que el verbo no hace es una respuesta equivocada que hay que desaprender +> > tecleando la de verdad. +> > +> > El hilo del kernel se reconoce por el bit `PF_KTHREAD` del campo 9 de +> > `/proc//stat`, **medido y no citado**: ese valor no está en ningún +> > encabezado que se le entregue al espacio de usuario, así que se sacó +> > comparando los 66 hilos cuyo padre es `kthreadd` contra los 6 procesos +> > ordinarios de un sistema corriendo. No se reconoce por la línea de comandos +> > vacía, que también la tiene un zombi. +> > +> > Lo que esto enseñó de las pruebas: **`matar` se había probado once veces en el +> > prompt de verdad y las once con un proceso que sí se podía detener.** La regla +> > nueva está en [[Estrategia-de-Pruebas]] — cuando el éxito de un verbo se toma +> > del valor de retorno de una llamada al sistema, hay que probarlo sobre un +> > sujeto que la llamada acepta y no obedece. Y la línea base de la etapa 32 es el +> > defecto mismo: `kill -9` al zombi, que se acepta y no hace nada. +> > +> > Falta correrlo en tu máquina: +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli +> > sudo ./dev/verify.sh +> > ``` +> > +> > Revisión completa en [[Procesos]]. +> > +> > ## Procesos: el punto 7, y una señal que no puede caer en el número equivocado — 2026-08-23 +> > +> > `procesos`, `memoria` y `matar` — todo sobre `/proc`, decreto entero en +> > [[Procesos]]. Con esto la terminal usable llega al punto 7 de 9; quedan la red +> > (punto 8, sólo hierro) y el lenguaje de shell (punto 9, decreto antes que +> > código y decisión tuya). +> > +> > ### Lo que hace a `matar` distinto de un `kill` +> > +> > **El número no es el proceso.** Entre leer `/proc/4711` y señalar 4711, ese +> > proceso puede terminar y el kernel puede darle el número a otro; toda +> > herramienta que recibe un pid tiene ese hueco y vive con él. `matar` abre un +> > `pidfd` y manda la señal por ahí, así que llega al proceso para el que se abrió +> > el descriptor o falla — no hay un tercero donde le llegue a un desconocido. +> > +> > Eso decide el orden y es el contrario del obvio: **primero el descriptor, +> > después la descripción, después la señal.** +> > +> > Por omisión `TERM`, que un programa puede atrapar para guardar lo que tenía; +> > `matar forzar` manda `KILL`, que no. Se niegan PID 1 y la propia +> > sesión, cada uno nombrando el verbo que hace ese trabajo bien —`apagar` y +> > `salir`—; no es una política sobre quién manda en la máquina, que eres tú, es +> > que una señal hace esos dos trabajos de la única forma que no deja dicho por +> > qué la máquina se detuvo. También se niegan el `0` y los negativos, que para +> > `kill(2)` son *todo lo que alcance* y un *grupo*. +> > +> > ### `ensayo matar` es el ensayo que más importa +> > +> > Un archivo se puede volver a escribir; un proceso no, y la entrada que causa el +> > error son cuatro dígitos. Así que contesta el nombre, la línea de comandos +> > entera, cuánto lleva corriendo y quién lo arrancó — y que no manda nada sólo +> > queda probado por una aserción: el proceso sigue vivo después. +> > +> > ### `libre` no es `disponible` +> > +> > `memoria` contesta las dos y nombra cuál contesta la pregunta. Un Linux sano +> > mantiene `free` cerca de cero a propósito, y quien lee sólo ese número concluye +> > que la máquina está llena y empieza a matar cosas. `en uso` se calcula como +> > `total − disponible`, nunca como `total − libre`. +> > +> > ### Dos cosas que construirlo enseñó +> > +> > - **El nombre en `/proc//stat` puede llevar paréntesis.** Se capturó un +> > renglón real de un proceso llamado `we (ird) x`; partirlo por espacios pone +> > el estado cinco campos antes y reporta un padre inventado. Regla 6. +> > - **`kill -0` contesta si el número existe, no si el proceso corre** — +> > decimotercera vez que el arnés miente. La etapa 31 dijo que `forzar` no había +> > funcionado sobre una shell que llevaba rato muerta: era un zombi, porque en +> > este contenedor **PID 1 es `process_api` y no cosecha huérfanos**. En tu +> > Fedora systemd los cosecha y las dos preguntas se ven iguales. Ambas en +> > [[Estrategia-de-Pruebas]]. +> > +> > ### Y un hueco de A2 que apareció por segunda vez +> > +> > `machine::declined` no tiene dónde poner un remedio, así que los verbos que la +> > usaban contestaban `remedy: null`. La primera vez lo parché en `search`; al +> > necesitarlo un segundo crate se volvió forma: `machine::refused(op, palabra, +> > remedio, mensaje)`. +> > +> > ### Qué correr +> > +> > Nada de esto necesita hierro. La etapa 31 corrió verde aquí. +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli +> > thalyx session +> > procesos +> > memoria +> > ensayo matar +> > ``` +> > +> > El `ensayo` primero, siempre. Son 39 verbos ahora. +> +> > ## Encontrar y contenido: el punto 6, con tres preguntas separadas — 2026-08-23 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Thalyx podía listar una carpeta y leer un archivo, y no podía contestar *dónde +> > está el archivo que se llama así* ni *qué archivos dicen esto*. Ya contesta las +> > dos, y el decreto entero está en [[Busqueda]]. +> > +> > ### Las dos decisiones fueron de Cesar +> > +> > **Dos verbos nuevos, no uno con banderas.** `encontrar ` por nombre, +> > `contenido ` por texto, y `buscar` intacto en su tercera pregunta —del +> > índice, no del disco—. Un verbo cuyo significado depende de una bandera se +> > puede pedir mal en silencio, y las tres respuestas se ven igual: una lista de +> > `ruta:línea` no dice de dónde salió. +> > +> > **Texto literal, sin expresiones regulares.** Un punto es un punto. Dos +> > razones: la imagen lleva el kernel y un programa, y un dialecto de regex aquí +> > sería decidir a escondidas un pedazo del punto 9 —si Thalyx tiene lenguaje de +> > shell—, que es decreto antes que código. Los nombres sí llevan `*` y `?`, +> > porque es el vocabulario que `rm`, `cp` y `mv` ya usan. +> > +> > ### Lo que construirlo obligó a mover +> > +> > `walk` vivía en `thalyx-graph` con dos llamadores. Ahora son cuatro, y los dos +> > nuevos son justo los que una persona compara contra el índice: un `contenido` +> > que entrara a `.git` donde `buscar` no entra contestaría sobre un archivo del +> > que el índice nunca supo, y la conclusión sería *el índice está roto*. Se movió +> > a `thalyx-files`, con el techo de 20 000 archivos, para que sigan siendo una +> > sola caminata y un solo número. +> > +> > ### Dos cosas nuevas en [[Estrategia-de-Pruebas]] +> > +> > - **Un hecho que la shell va a leer se cita** — duodécima vez que el +> > instrumento miente, y esta vez el instrumento era mío. La etapa 30 escribía +> > `names=a.rs b.rs` sin comillas, la shell asignó el primero y trató de +> > ejecutar el segundo, y la etapa acusó al verbo de no contestar cuando había +> > contestado las tres cosas bien. +> > - **Una prueba que este usuario no puede hacer fallar tampoco prueba** — quitar +> > todos los permisos no detiene a root, así que la prueba de la regla 10 dice +> > `NOT PROVEN` y `THALYX_REQUIRE_UNREADABLE_TESTS=1` la convierte en falla. Es +> > la regla 3 con una cara nueva: hasta ahora los saltos eran por lo que a la +> > máquina le falta, y éste es por quién está corriendo. +> > +> > ### Un defecto que sólo salió al correrlo +> > +> > El refuso estructurado usaba `machine::declined`, que no tiene dónde poner un +> > remedio, así que `not_a_directory` llegaba con `remedy: null` — el punto A2 de +> > [[Superficie-para-el-LLM]] perdido calladamente en dos verbos. Lo encontró una +> > prueba que pidió el remedio. Ahora usa `machine::failure`, que sí lo lleva. +> > +> > ### Qué correr +> > +> > Nada de esto necesita hierro; la etapa 30 corre entera en el contenedor y ya +> > corrió. Cuando quieras verlo: +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli +> > thalyx session +> > encontrar *.rs +> > contenido fn main +> > ``` +> > +> > Y `sudo ./dev/verify.sh 2>&1 | tee /tmp/verify.log` cuando toque la próxima +> > corrida completa — con `tee`, porque el conteo de la corrida pasada costó una +> > ida y vuelta por haber pedido nada más la cola. +> +> > ## Verde en hierro, y las diez comprobaciones que faltaban eran el arranque — 2026-08-23 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Cesar volvió a correr `verify.sh` con la precondición arreglada: +> > **134 probadas, 2 no probadas, 0 fallas.** La sexta corrida en hierro, y la +> > primera sin ninguna falla. +> > +> > ### La rama con watcher se ejerció por primera vez +> > +> > La etapa 27 imprimió *«the mutation ring was mapped and read: 4 record(s) +> > named thalyx-ringmark, and a second read had none of them left»*. Es la etapa +> > completa: el anillo mapeado, los registros leídos, y la segunda lectura vacía +> > —que es la mitad que prueba que leer consume—. Y la suite pasó, o sea que +> > `what_the_kernel_saw.rs` corrió su rama de watcher cargado sin haberse podido +> > ejecutar nunca aquí. +> > +> > ### Las diez comprobaciones que faltaban, contestadas por el reporte mismo +> > +> > El bloque anterior dejó abierto por qué esta corrida traía menos +> > comprobaciones que la del 2026-08-10, y pedía el resumen completo antes de +> > afirmar nada. Con el resumen, la respuesta está en la segunda línea de lo que +> > la corrida no pudo establecer: +> > +> > ``` +> > · no kernel or image built yet, so there is nothing to boot; run 'make -C image' +> > ``` +> > +> > **Lo que faltó es la etapa 16 entera —arrancar la máquina en QEMU—**, que en +> > esa máquina no tenía qué arrancar porque `image/build/` está vacío; +> > `image/build/` no está en el repositorio, así que se pierde con cualquier +> > limpieza y `git pull` no lo trae de vuelta. +> > +> > Y cuadra por conteo, que es la forma comprobable de decirlo: la etapa 16 tiene +> > **trece comprobaciones**, y aquí se contrajeron a **un solo NOT PROVEN**. +> > 136 − 1 + 13 = 148, menos la etapa 29 que el 2026-08-10 no existía = 147 … +> > contra las **146** que contó aquella corrida. Una de diferencia, que es el +> > tamaño de un `if`/`else` de esa etapa. La dirección no admite otra lectura: no +> > se perdió nada, no se arrancó nada. +> > +> > Para recuperarlas, `make -C image` y luego el disco de store. **No es urgente** +> > — no hay nada nuevo que sólo el arranque compruebe desde el 2026-08-07 — pero +> > mientras `image/build/` esté vacío el reporte va a seguir diciendo 134. +> > +> > ### Regla nueva +> > +> > Un conteo que baja no es una regresión hasta que se sabe *qué* dejó de +> > correr, y el reporte ya lo decía: **las líneas de NOT PROVEN son parte del +> > resultado, no una nota al pie.** Leer sólo el marcador es leer la mitad. En +> > [[Estrategia-de-Pruebas]]. +> +> > ## La corrida en hierro: el anillo funciona, y la prueba era la equivocada — 2026-08-23 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Cesar corrió `verify.sh`: **133 probadas, 2 no probadas, 1 falla.** La falla era +> > la etapa 5 —la suite— por dos pruebas de `what_the_kernel_saw.rs`. +> > +> > ### Thalyx estaba bien y la prueba estaba mal +> > +> > Las dos pruebas suponían **una máquina sin watcher**. Lo decía el encabezado del +> > archivo y lo decía el nombre de una de ellas, y ninguna de las dos lo +> > comprobaba. Era cierto durante meses porque este contenedor no puede cargar BPF, +> > así que la negativa era la única respuesta posible. +> > +> > En la máquina de Cesar el watcher **sí** estaba cargado — porque las +> > instrucciones de esa corrida le decían `make -C lsm load` antes de correr. Así +> > que `cambios` contestó correctamente y la prueba dijo que Thalyx se equivocaba. +> > +> > **Undécima instancia de la regla 5**, y la variante de «una prueba que infiere +> > su propia precondición». Arreglado: las dos preguntan si el pin existe en el +> > sistema de archivos —un hecho en el que `cambios` no participa— y **ninguna de +> > las dos ramas es un salto**, así que el archivo prueba algo dondequiera que +> > corra. Ver [[Estrategia-de-Pruebas]]. +> > +> > ### Lo bueno, y estaba en la misma salida +> > +> > La corrida imprimió esto: +> > +> > ``` +> > created by thalyx (41513), in cgroup 22518 +> > retitled by thalyx (41510), in cgroup 22518 +> > ``` +> > +> > Son **registros reales drenados de un anillo real del kernel**, con su cgroup, +> > desde una sesión. El consumidor del ringbuf funciona en hierro. Lo que la +> > etapa 27 persigue está vivo. +> > +> > ### Lo que falta saber de esa corrida +> > +> > **133 probadas contra las 143 del 2026-08-10, y se agregó una etapa.** Diez +> > comprobaciones menos no se explican con la falla de la etapa 5, y con la cola +> > del reporte no alcanza para decir por qué. Hace falta el resumen completo — +> > las dos líneas de `not proven` y el bloque final— antes de afirmar nada. +> +> > ## El editor existe, con sus dos caras — 2026-08-22 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Es el punto 5 de la terminal usable, y lo que lo justificaba desde el +> > principio: **sin un editor no se puede corregir un archivo de configuración +> > desde la máquina.** Thalyx podía hacer un archivo, copiarlo, moverlo, borrarlo +> > e imprimirlo, y no podía cambiarle un byte por dentro. +> > +> > Cesar decidió **las dos caras en una entrega**, que era la opción cara de las +> > tres. El decreto entero está en [[Editor-de-Texto]]; lo corto: +> > +> > - `editar ` abre una **pantalla** — flechas, `Ctrl-O` guarda, `Ctrl-X` +> > sale, `Ctrl-U` deshace, `Ctrl-K` corta. +> > - `editar cambiar 12 ` direcciona **renglones** y contesta un +> > objeto, porque un programa no puede manejar una pantalla que se redibuja. +> > - `crates/thalyx-edit` es **un solo motor** y las dos caras lo llaman, así que +> > no pueden acabar en desacuerdo sobre lo que el archivo dice ahora. +> > +> > Es el primer verbo donde las dos caras difieren de **forma**, y por eso valía +> > la pena decidirlo en la bóveda antes de escribirlo. +> > +> > ### Lo que se puede correr aquí, y ya corrió +> > +> > **1 231 pruebas en verde, clippy limpio.** La etapa **29** de `verify.sh` está +> > escrita y **ya pasó en este contenedor**: no necesita hierro. Ejerce las tres +> > cosas que sólo una máquina contesta, cada una con su control — +> > +> > - un renglón cambiado por dirección y **leído de vuelta con `cat`**, no con +> > Thalyx; +> > - una persona tecleando en la pantalla **a través de un pty de verdad**, con +> > pulsaciones reales, y el trabajo escrito; +> > - un archivo binario **negado sin que se moviera un byte**, que es el control: +> > un editor que negara todo pasaría las dos primeras y sería inútil. +> > +> > ### Tres defectos que salieron de correrlo, y uno que ya estaba +> > +> > Todos están escritos como reglas en [[Estrategia-de-Pruebas]]: +> > +> > 1. **`Ctrl-S` no habría funcionado nunca.** El modo crudo deja `IXON` e `ISIG` +> > encendidos a propósito, así que la disciplina de línea se come `Ctrl-C`, +> > `Ctrl-Z`, `Ctrl-S` y `Ctrl-Q` antes de que Thalyx vea un byte — y `Ctrl-S` es +> > XOFF, o sea que habría dejado la terminal aparentemente muerta. Por eso +> > guarda `Ctrl-O`. Hay una prueba que falla si alguien enlaza una de las cuatro. +> > 2. **La décima instancia de la regla 5.** Las pruebas de pantalla se colgaron: +> > un pty recién hecho no tiene tamaño de ventana, el editor se negó a dibujar +> > —correctamente— y las teclas se tecleraon en el prompt. **El arnés estaba +> > incompleto, no Thalyx.** Ahora `thalyx dev pty` le pone tamaño al pty. Lo +> > hizo barato el reloj de la prueba, que al vencerse imprime lo que se había +> > dibujado: ahí estaba la oración que decía la respuesta entera. +> > 3. **La confirmación de salida se tragaba la tecla que la contestaba.** +> > `Ctrl-X` y luego `Ctrl-O` no guardaba nada. Era una lectura anidada haciendo +> > de segundo intérprete del teclado; ahora es una bandera y toda tecla pasa por +> > el único bucle que sabe qué significan. +> > 4. **Y una que ya llevaba un día roja en `main`**: la reescritura de la portada +> > del 2026-08-21 movió la ruta de construcción a `docs/BOOT.md` y la prueba +> > que la ata seguía afirmando sobre el README. Arreglada, y ahora comprueba +> > también que la portada apunte ahí — una ruta de construcción en un archivo +> > al que nada apunta es una ruta que nadie encuentra. +> > +> > ### Lo que sigue sin correrse en hierro +> > +> > **Nada de lo del 2026-08-10 se ha vuelto a correr en tu máquina**, y hay cuatro +> > arreglos de ese día esperando: el techo de `indexar`, la etapa 14 que devuelve +> > el watcher, el `ETXTBSY` de `grammar_check` y el control del anillo. La etapa +> > 27 —el ring buffer— es la que más falta hace, porque la 14 la estaba tumbando. +> > +> > ### Qué correr +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli +> > sudo chown -R "$USER" lsm +> > make -C lsm unload && make -C lsm load +> > sudo ./dev/verify.sh +> > ``` +> +> > ## La portada del repositorio, rehecha — 2026-08-21 +> > +> > **Esto no cambió el sistema, cambió cómo se lee.** El estado técnico sigue +> > siendo el bloque de abajo. +> > +> > El README tenía 636 líneas y funcionaba como documentación profunda, no como +> > portada: quien abría el repositorio tardaba en entender qué era. Ahora son +> > ~250, con jerarquía, y el detalle se movió a dos archivos nuevos en inglés que +> > no compiten con el vault: +> > +> > - `docs/BOOT.md` — el recorrido de arranque completo, paso por paso. +> > - `docs/STATUS.md` — la contabilidad honesta: qué está probado, dónde, con qué +> > fecha, qué sigue abierto y la contradicción que el proyecto publica. +> > +> > `README.es.md` dejó de ser una traducción completa (dos copias grandes se +> > desincronizan; la que miente es la que nadie lee) y es ahora una página corta +> > de orientación que manda al vault, que ya está en español. +> > +> > ### Evidencia visual, capturada de corridas reales +> > +> > `docs/media/` lleva tres imágenes y **el texto crudo de cada una al lado**, +> > para que se puedan comprobar: el recuadro de autorización de capacidades, el +> > commit atómico muerto con `SIGABRT` entre el rename y el symlink, y el +> > autorreporte de `session --once`. No hay maquetas. El diagrama de arquitectura +> > (`docs/media/architecture.svg`) es dibujado y muestra la frontera de confianza: +> > la ruta humana completa, la del agente sólo como propuesta, y el LSM negando +> > desde el kernel. +> > +> > No se pudo capturar un arranque real en QEMU: la política de red del contenedor +> > bloquea `cdn.kernel.org`, así que el kernel no se puede descargar aquí. +> > +> > ### Dos cifras del README estaban viejas +> > +> > Decía «110 comprobaciones, 2026-08-07» — la corrida vigente es la quinta, +> > **2026-08-10: 143 probadas, 2 no probadas, 1 fallida**, con todo el lado del +> > kernel probado por primera vez. Y decía que `thalyx_watch` «nunca se ha cargado +> > sin `bpftool`», cuando lo que sigue abierto es más preciso: **nunca lo ha +> > cargado el cargador propio de Thalyx**; el ring buffer sí se leyó en hierro +> > desde un pin real del kernel. +> > +> > Los conteos que se mueven (pruebas, nombres declarados) quedaron en una forma +> > que no envejece: «más de 1 100 pruebas», «cerca de cuatro mil nombres». +> +> > ## El verbo que se colgaba era `indexar`, y el hierro quedó en verde — 2026-08-10 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > La quinta corrida en hierro dio **143 probadas, 2 no probadas, 1 falla**, y por +> > primera vez todo el lado del kernel quedó probado: la protección deniega de +> > verdad, el módulo corrió confinado, Thalyx enganchó su propio LSM sin +> > `bpftool`, y el canal del módulo sobrevivió al sandbox. `make unload` antes de +> > `make load` fue todo lo que hacía falta. +> > +> > ### El reloj sirvió: era `indexar` +> > +> > La prueba nombró el verbo en la primera corrida. `indexar` sin argumento indexa +> > el árbol donde está parada la sesión, y una sesión empieza en `/home` — que en +> > tu máquina incluye `.cargo/registry` y `.rustup`. Aquí son 329 archivos y dos +> > segundos; allá es cada fuente de cada crate que has bajado más la biblioteca +> > estándar entera. +> > +> > Dos reglas nuevas, ambas en [[Estrategia-de-Pruebas]]: +> > +> > - **No se entra a carpetas que empiecen con punto.** La lista vieja +> > —`.git`, `target`, `node_modules`— era una lista de las cosas que ya habían +> > salido mal. El árbol que se nombra explícitamente nunca se filtra. +> > - **Techo de 20 000 archivos, y arriba de eso se niega en lugar de empezar.** +> > `tree_too_large`, dentro de la transacción, así que el índice que había sigue +> > ahí. Etapa 28 de `dev/verify.sh`, con su control. +> > +> > ### Lo que sigue abierto +> > +> > La etapa 27 dijo que nada estaba anclado, y era cierto **por culpa de la etapa +> > 14**, trescientas líneas más arriba: desengancha el watcher y no lo devolvía. +> > Ya lo devuelve. El ring buffer no se ha vuelto a ejercer desde entonces. +> > +> > Y `grammar_check` corre el binario dos veces por dentro; los seis sitios que +> > pasan por ahí nunca estuvieron envueltos contra `ETXTBSY`. El comentario decía +> > que *toda prueba de este archivo* llamaba al envoltorio, y era falso. +> > +> > ## Tres de las cuatro fallas de la cuarta corrida eran el instrumento — 2026-08-10 +> > +> > La corrida en hierro dio 127 probadas, 7 no probadas y 3 fallas. De las tres, +> > dos eran de `dev/verify.sh` y la tercera se quedó sin diagnosticar a propósito. +> > +> > ### El ring buffer sí funciona +> > +> > Ésta es la noticia buena y hay que decirla primero: con el mapa renombrado a +> > `thalyx_mut_ring`, el pin apareció, `cambios` **mapeó el ring y leyó +> > registros reales del kernel**. `bpf_obj_get` sobre un pin de verdad, dos +> > `mmap` que el kernel aceptó y el protocolo corriendo sobre memoria compartida +> > — nada de eso se puede tocar desde el contenedor. +> > +> > Lo que falló fue el control, no el consumidor. Ver +> > [[Estrategia-de-Pruebas]]: *un control que pide silencio no se puede cumplir +> > en una máquina viva*. Ahora la mutación la hace un programa llamado +> > `thalyx-ringmark` y las dos columnas son sobre ese nombre. +> > +> > ### El LSM estaba enganchado y el script dijo que no +> > +> > Cesar cargó el LSM a mano antes de correr el script. `make load` se negó +> > —correctamente— y `verify.sh` leyó esa negativa como «no está enganchado», +> > perdiendo la etapa 4 y cuatro NOT PROVEN más sobre protección que estuvo +> > corriendo toda la corrida. Ahora `make unload` va antes de `make load`, +> > siempre. +> > +> > ### La suite se colgó y no se sabe en qué verbo +> > +> > `every_verb_the_catalogue_advertises_is_understood_at_the_prompt` dejó de +> > contestar. Aquí corre en 1.7 segundos, así que **no se pudo reproducir y no se +> > arregló** — adivinar cuál de los 33 verbos fue habría sido exactamente lo que +> > la regla 5 prohíbe. +> > +> > Lo que sí se hizo: la prueba lleva ahora su propio reloj de tres minutos y al +> > vencerse **nombra el verbo** en el que se quedó. Una corrida de +> > `cargo test -p thalyx-cli --test catalogue_is_true` lo dice. +> > +> > La sospecha, sin prueba: `discos` abre cada disco entero con `File::open` para +> > leer su tamaño, y abrir un lector de tarjetas vacío o una unidad óptica sin +> > medio es lo clásico que se cuelga. El tamaño se puede leer de +> > `/sys/class/block//size` sin abrir nada. **No se cambió**, porque cambiarlo +> > antes de saber habría borrado la evidencia. +> > +> > ## `intento` alcanzó `/`, y el contenedor no podía verlo — 2026-08-10 +> > +> > ### Lo primero, y hay que correrlo +> > +> > Dos snapshots de sólo lectura de tu sistema de archivos raíz quedaron en +> > `/.thalyx-snapshots/`. Los dejó la corrida de pruebas, no un `intento` que +> > hayas escrito, y **nada se destruyó** — esas pruebas nunca abandonan. Pero +> > quedaron huérfanos y anclan bloques: +> > +> > ``` +> > sudo btrfs subvolume list -o / | grep thalyx-snapshots +> > sudo btrfs subvolume delete /.thalyx-snapshots/* +> > ``` +> > +> > ### El defecto +> > +> > `subvolume_at_or_above` caminaba hacia arriba buscando el subvolumen más +> > cercano. Desde un directorio temporal bajo `/tmp` la caminata pasó por todos +> > los niveles y se detuvo en el primero que sí lo era: **`/`**. La respuesta dijo +> > que abandonar borraría 1 343 582 archivos, `/boot` entre ellos. +> > +> > El argumento para caminar hacia arriba —«un intento es sobre el árbol en el que +> > alguien está trabajando»— era exactamente al revés: caminar hacia arriba +> > **abandona en silencio** el alcance que quien llama tenía en mente, y en toda +> > instalación Btrfs ordinaria la caminata termina en la respuesta más peligrosa +> > que existe. +> > +> > Ahora: **donde estás parado, o nada.** Y `/` se niega aunque estés parado en +> > él, porque abandonarlo significa cambiar la raíz del sistema en marcha por +> > debajo de cada proceso de la máquina, incluido el que lo pidió. +> > +> > ### Por qué el contenedor no podía encontrarlo +> > +> > Aquí no hay Btrfs ni un solo subvolumen, así que *todos* los caminos se niegan +> > y la prueba no podía distinguir una negativa correcta de un accidente del +> > sistema de archivos. **Pasaba por la razón equivocada**, que es peor que +> > faltar: ocupa el lugar donde alguien buscaría la que sí sirve. +> > +> > La guarda vive ahora en una prueba unitaria contra un falso donde **todo** es +> > un subvolumen —la máquina donde la respuesta peligrosa sí aparece— y la etapa +> > 26 tiene una columna nueva que se para en `/` y exige la negativa. +> > +> > ### Y por qué la 27 no llegó a correr +> > +> > `make -C lsm load` falló con `Operation not permitted` al escribir su propio +> > `.o`. `sudo ./dev/verify.sh` compila los objetos BPF dentro del árbol de +> > fuentes **como root**, así que el siguiente `make` como tú no puede +> > sobrescribirlos. `verify.sh` ahora devuelve lo que escribió. +> > +> > ### Qué correr +> > +> > ``` +> > sudo btrfs subvolume delete /.thalyx-snapshots/* +> > git pull && cargo install --path crates/thalyx-cli +> > sudo chown -R "$USER" lsm +> > make -C lsm unload && make -C lsm load +> > sudo ./dev/verify.sh +> > ``` +> +> > ## La corrida en hierro: 26 pasó, 27 no llegó a correr, y una prueba que se peleaba consigo misma — 2026-08-10 +> > +> > Cesar corrió `verify.sh` en su máquina: **143 comprobadas, 2 no comprobadas, +> > 1 fallida**. +> > +> > ### Lo que quedó probado en hierro +> > +> > **Etapa 26 — el intento con nombre, sobre Btrfs de verdad.** Abandonar +> > devolvió un archivo a su contenido viejo, borró el que se hizo durante el +> > intento, y el control cerrado con `confirmar` no perdió ninguno de los dos. El +> > intercambio fue **atómico**. +> > +> > Y las etapas 23, 24 y 25: el listado acotado con su cursor, el índice de +> > símbolos encontrando **3 989** nombres sobre las fuentes de este repo, y la +> > historia leída desde la sesión. +> > +> > ### La prueba que fallaba: novena instancia de la regla 5 +> > +> > Dos pruebas de `llama.rs` fallaron con `Text file busy`. **Nada de Thalyx +> > estaba mal.** `ETXTBSY` es el kernel negándose a ejecutar un archivo que +> > cualquier proceso tiene abierto para escritura, y el conteo que revisa vive en +> > el inodo — así que `O_CLOEXEC` no ayuda. El mecanismo es la ventana del +> > `fork`: entre que `Command::spawn` bifurca y el hijo ejecuta, el hijo tiene +> > copia del descriptor de escritura que otro hilo estaba usando para crear ese +> > mismo archivo. +> > +> > Lo que hace que valga la pena escribirlo: **esto ya había fallado hace un +> > año**, una vez en veinticinco corridas, y el arreglo de entonces —un nombre de +> > archivo por falso— venía con un comentario que decía que era una adivinanza. +> > Sobrevivió un año porque nunca volvió a fallar donde alguien mirara. Lo que la +> > resolvió no fue pensar mejor: fue una máquina de doce núcleos. +> > +> > Arreglado con un reintento en el arnés y **no** en el código de producción: si +> > el `llama-completion` de alguien está ocupado, eso es un hecho de su máquina +> > que Thalyx debe reportar, no tapar. Y con una prueba que **reproduce la falla a +> > propósito** —sostiene el archivo abierto para escritura, comprueba que el +> > kernel de verdad se niega, y luego que el arnés lo espera. +> > +> > ### La etapa 27: el reporte no podía decir lo que pasaba +> > +> > Dijo «nada está anclado en `…/thalyx_mutations`, así que thalyx-watch no está +> > cargado». **Era falso**: la etapa 3 de la misma corrida decía que el contador +> > sí estaba anclado y la 7 leyó 36 872 mutaciones con los diez ganchos puestos. +> > +> > `BPF_OBJ_NAME_LEN` es 16 contando el terminador, así que el kernel se queda +> > con **quince** caracteres. `thalyx_mutations` (16) y `thalyx_mutation_count` +> > (21) se vuelven **los dos** `thalyx_mutation`, y el kernel tenía dos mapas del +> > mismo objeto bajo un solo nombre. Si eso fue lo que impidió el anclaje no está +> > establecido — lo que sí lo está es que dos mapas con un nombre vuelven la +> > pregunta incontestable. +> > +> > El anillo se llama ahora `thalyx_mut_ring`, quince caracteres, y hay una prueba +> > que corta cada nombre de mapa de los dos objetos BPF a quince y falla si dos +> > coinciden. Y la etapa 27 ya no dice sólo «no está»: dice qué **sí** hay en el +> > directorio y si el mapa existe en el kernel sin estar anclado. +> > +> > ### Lo que hay que correr +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli +> > make -C lsm unload && make -C lsm load +> > sudo ./dev/verify.sh +> > ``` +> > +> > El `unload`/`load` es lo que hace falta para que el anillo se ancle con su +> > nombre nuevo. Si la 27 sigue sin pasar, ahora el mensaje dice qué hay ahí. +> +> > ## Seis puntos más del catálogo, y el prompt que Cesar encontró — 2026-08-10 +> > +> > Cesar corrió lo del día anterior en su máquina, encontró un defecto de verdad, +> > y sobre los tres puntos que quedaron nombrados como negociación dijo: *«me +> > parece que ahí no hay costo real, más bien es costo de dificultad… si realmente +> > aportan un beneficio real y claro a los LLMs, entonces hazlos»*. +> > +> > **Catorce de los diecinueve puntos están hechos.** Queda **E1**, que no es +> > difícil sino que le falta el piso, y ésa es la decisión que sigue. +> > +> > ### El defecto: una pantalla en blanco que parecía colgada +> > +> > Escribió `structured on`, recibió su objeto, y se quedó frente a una pantalla +> > en blanco. Nada en ella distinguía una sesión esperando una línea de una +> > colgada, así que abrió otra ventana para escribir el siguiente comando. +> > +> > El prompt se suprimía en la cara estructurada para cumplir la promesa de un +> > objeto por renglón. **En una terminal esa promesa nunca se estaba cumpliendo**: +> > el modo crudo hace eco de cada carácter, así que el flujo de un pty nunca fue +> > un objeto por renglón — y las pruebas ya lo decían en su propio comentario. +> > Suprimir el prompt ahí no compraba nada y costaba eso. Ahora lo decide **el +> > flujo y no la cara**: por tubería no hay prompt, en terminal sí, con llaves +> > —` {/home} > `— porque cuál cara está encendida es invisible hasta que uno +> > escribe algo, y quien no ve el modo en el que está no puede salir de él. +> > +> > ### B1: ninguna respuesta larga sin límite +> > +> > La falla es la callada de los cinco costos. `ls` sobre cuarenta mil archivos no +> > falla, no avisa, y no parece un defecto: produce un agente que gastó su ventana +> > entera en nombres y olvidó su tarea. +> > +> > **Un cursor por llave y no un desplazamiento**, y ésa es la decisión entera. +> > Con `skip(200)`, borrar un archivo anterior corre todo un lugar y el renglón +> > que estaba en 200 no se le manda a nadie — sin error, sin hueco, y quien lee +> > concluye que ese archivo no existe. Lo que un cursor por llave sí no puede +> > ocultar —que la colección se movió— lo dice, en el mismo objeto que las filas. +> > +> > La cara humana **no** se acota: una ventana es un hecho sobre una ventana de +> > contexto, y una persona no tiene una. +> > +> > ### C2: símbolos, no renglones +> > +> > El decreto decía «medio construido: el parser existe», y era menos que eso — el +> > parser sabía leer importaciones, no declaraciones. Ahora `buscar ` +> > contesta dónde se declara un nombre, de qué tipo es, y en qué renglón de qué +> > archivo se usa. **Sin comentarios y sin cadenas**, que es lo que `grep` no +> > puede hacer. +> > +> > La tabla de menciones sólo registra identificadores que el árbol declara. Sin +> > eso la tabla es en su mayoría vocabulario —`println`, `let`, `self`— y un +> > índice que es vocabulario es uno que nadie puede permitirse guardar. +> > +> > La etapa 24 de `verify.sh` corre sobre las fuentes de este repositorio y +> > encuentra **3 869 nombres declarados**. Su control es una palabra que sólo +> > aparece en prosa: si volviera con usos, esto sería una búsqueda de texto +> > disfrazada. +> > +> > ### F2: `historia` +> > +> > El journal se escribía desde [[Journal-y-Snapshots]] y lo leía exactamente una +> > cosa, un subcomando. Ahora lo lee la sesión, el más nuevo primero, con la +> > advertencia que la cara humana ya imprimía convertida en campo: **esto es lo +> > que Thalyx hizo, no todo lo que pasó**. +> > +> > ### D2: `intento`, y una corrección mía +> > +> > El 2026-08-09 escribí que D2 sólo se podía construir en el hierro porque la +> > única prueba posible aquí sería contra un falso. **Eso confundió dos cosas.** +> > `thalyx-snapshot` ya tenía el corte hecho y escrito: *«la política que sólo se +> > puede ejercer en un sistema de archivos Btrfs es política que nunca se +> > ejerce»*. Cuál intento está abierto, qué hace un segundo, a qué árbol apunta un +> > abandono, qué pasa cuando el snapshot ya no está — ninguna es una pregunta de +> > Btrfs. +> > +> > Así que la política vive en `thalyx-core::attempt` con once pruebas que corren +> > aquí, y para el hierro queda sólo lo que de verdad es Btrfs: que el snapshot +> > sea atómico y que el intercambio sea un `RENAME_EXCHANGE`. **Etapa 26**, con su +> > control — la misma secuencia cerrada con `confirmar`, donde nada debe perderse. +> > +> > ### B3: `cambios`, y otra corrección mía +> > +> > Escribí que consumir el ringbuf era código BPF y que por eso tumbaría al +> > watcher. **No aplica**: el productor ya está escrito y no se toca; el +> > consumidor es código de usuario. El protocolo del anillo es una función pura +> > sobre bytes, y un arreglo de bytes lo modela exactamente — regla 8 cumplida, +> > diez pruebas aquí, incluido el registro que cruza el final del anillo. Para el +> > hierro queda **sólo el mapeo**: etapa 27. +> > +> > Dos cosas que el decreto esperaba y un anillo no puede dar, dichas en la +> > respuesta: **no es una historia** (leerlo lo vacía) y **no nombra archivos** +> > (trae cgroup, pid, tipo y programa). Para lo que sirve es para separar lo que +> > hizo el agente de lo que hizo la persona. +> > +> > ### Lo que hay que correr, en este orden +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh +> > ``` +> > +> > Tres etapas nuevas sólo tuyas: **26** (Btrfs, el intento), **27** (BPF, el +> > anillo) y las que ya estaban. Si algo se rompe, las tres son cambios distintos +> > en commits distintos, así que se sabe cuál. +> > +> > ### Lo que queda, y es una decisión tuya +> > +> > **E1 — el agente ajeno como tarea con concesión.** No es difícil. Le falta el +> > piso: G1 (ejecutar procesos) y G2 (un runtime donde un agente ajeno pueda +> > correr) no existen, así que no hay a qué darle la concesión. Construirla ahora +> > produce código que no se puede ejercer **ni siquiera en tu máquina**, que es +> > distinto de D2 y B3. Y la pregunta de fondo es tuya: hoy Thalyx sólo corre +> > módulos firmados, y un agente ajeno por definición no lo es. +> +> > ## La máquina se describe a sí misma, ensaya antes de hacer, y el índice ya es alcanzable — 2026-08-09 +> > +> > Cesar pidió cerrar [[Superficie-para-el-LLM]] hasta donde el trade-off fuera +> > claro. **Ocho de los diecinueve puntos quedaron hechos**, tres quedaron +> > nombrados como negociación —los tres necesitan hierro que este contenedor no +> > tiene— y tres más quedaron sin hacer por tiempo y no por riesgo. +> > +> > ### A1: la máquina se describe a sí misma, y era la llave +> > +> > `describe` contesta los **29 verbos**: nombres, argumentos, banderas, con qué +> > `op` responde cada uno, si puede cambiar la máquina y qué errores da. Un agente +> > que llega no necesita que nadie le pegue una lista en el prompt: pregunta. +> > Ningún sistema operativo puede hacer esto — en Linux `--help` es prosa, es por +> > herramienta, no es consistente y a veces no está. +> > +> > **Y arregló una duplicación real.** La lista de verbos vivía en **tres** sitios +> > —el `match` de la sesión, el banner y las completaciones— y ya había divergido +> > una vez. Ahora los nombres viven una vez, las completaciones se **generan**, y +> > dos pruebas atan las otras dos copias **corriendo la sesión**: +> > +> > - cada nombre del catálogo se teclea en un prompt de verdad y tiene que ser +> > entendido; +> > - cada nombre del catálogo tiene que aparecer en el banner. +> > +> > La segunda encontró algo el mismo día: **bajo un programa, `apagar` existía y +> > el banner no lo nombraba**, así que quien lo tecleaba recibía una negativa por +> > un verbo del que nunca se le habló. +> > +> > ### D1: ensayar, que es lo que cambia el comportamiento +> > +> > `ensayo rm *.log` dice qué se iría y no toca nada. Lo importante es **cómo está +> > construido**: cada `foresee_*` es *la mitad de comprobación de la operación +> > real*, y la operación real la llama. No hay camino donde el ensayo y lo +> > ensayado puedan discrepar sobre si algo se permite, porque hay **un solo +> > código** decidiendo — un ensayo que dijera «esto funcionaría» mientras la +> > operación se niega sería peor que no tener ensayo. +> > +> > Es prefijo y no modo, a propósito: un modo se queda encendido y entonces un +> > `rm` de verdad no hace nada mientras quien lo pidió cree que sí. +> > +> > Los cinco verbos que cambian la máquina y **no** tienen mitad de comprobación +> > —`instalar`, `correr`, `revertir`, `instalar-en`, `apagar`— contestan que no se +> > pueden ensayar. Un plan vacío se leería como «esto no haría nada», que es lo +> > contrario de la verdad. +> > +> > ### C1: el índice semántico, alcanzable por fin +> > +> > `indexar`, `depende `, `usan `. **La pregunta que ningún +> > recorrido de carpetas contesta** —quién se refiere a esto— por primera vez al +> > alcance de algo que vive en una sesión. Era el ejemplo que Cesar usó al pedir +> > el catálogo, y [[FS-en-Grafo]] se llama a sí mismo el ejemplo fundacional; lo +> > había sido durante meses sin que nada fuera de `thalyx graph` pudiera +> > preguntarle nada. +> > +> > Cada respuesta trae **la vigencia del índice en el mismo objeto que las filas** +> > — la regla de honestidad de [[FS-en-Grafo]], que es decreto precisamente porque +> > separar el aviso de los datos es cómo un caché empieza a confundirse con la +> > verdad. Un árbol que cambió por detrás contesta `stale` y **devuelve las filas +> > igual**: no están mal, están incompletas, y quien lee decide. +> > +> > ### Lo demás que quedó hecho +> > +> > - **A2 — el error nombra su remedio.** `remove_or_rename`, `look_first`, +> > `use_list`, y **`cannot` cuando no hay salida**: inventar un remedio +> > alentador manda a quien lee a un ciclo reintentando algo que nunca va a +> > funcionar. +> > - **A3 — `estado` en un objeto**, con los tres estados de la regla 10 sin +> > colapsar: `found`, `absent`, `unreadable`. Quien lee `absent` va a arreglar +> > algo; quien lee `unreadable` sabe que la máquina no contestó, que es otro +> > trabajo. +> > - **B2 — cada lectura trae el `sha256` del archivo entero.** «¿Sigue siendo +> > cierto lo que leí?» pasa de ser una relectura a ser una comparación. Del +> > archivo **entero** y no del extracto, porque dos archivos que comparten sus +> > primeros 64 kB darían el mismo hash y quien mira seguiría creyendo que nada +> > cambió. Hay prueba con exactamente ese par. +> > - **D3 — cada acción dice cómo se deshace.** Lo hecho se deshace borrando lo +> > que se hizo —**el destino de una copia y nunca el original**, que es un error +> > que costaría el archivo del que se copió— y un `mv` se deshace moviendo de +> > vuelta. Un borrado trae `undo: null`, porque `/home` no lo devuelve ningún +> > rollback nuestro y **eso hay que saberlo antes, no después**. +> > - **F1 — `recuerdos` estructurado**, con las tres listas separadas: lo dicho, +> > lo que sigue comprobando, y lo que ya no se puede confirmar. Juntarlas +> > entregaría lo tercero como si fuera lo segundo, que es la única cosa que la +> > memoria está construida para no hacer. +> > - **`rm` de una carpeta ya dice cuánto destruyó.** Reportaba `0`, que no le +> > dice nada a una persona sobre lo que acaba de perder ni a un agente sobre lo +> > que está en juego. Y el peso **no sigue enlaces**: seguirlos reportaría el +> > archivo de alguien más como parte de lo que va a desaparecer. +> > +> > **1075 pruebas** (1040 antes), `clippy` limpio. Etapa **22** en `verify.sh`, +> > cada comprobación con su control. +> > +> > ### Los tres que no construí, y por qué — esto es lo que hay que negociar +> > +> > Cesar dijo: *«si en alguno perdemos demasiado, entonces dime y lo +> > negociamos»*. Aquí se perdería, y los tres se pierden por la misma razón — +> > **este contenedor no tiene con qué comprobarlos**: +> > +> > | Punto | Qué necesita | Por qué no se hizo a ciegas | +> > |---|---|---| +> > | **D2** el intento con nombre | Btrfs | Es el de mayor valor que queda. Un falso de un snapshot que no falla como falla un snapshot **no es un falso, es otro sistema** — regla 8 | +> > | **B3** qué cambió desde X | BPF | Consumir el ringbuf es código BPF, y uno que falla el verificador tumba al watcher entero. Va solo, en su corrida | +> > | **E1** el agente como tarea con concesión | LSM y cgroups delegados | Lo único del catálogo que toca **seguridad**: una equivocación no cuesta una prueba roja, cuesta una concesión mal puesta | +> > +> > Los otros tres que quedan —**B1** acotar respuestas, **C2** búsqueda por +> > símbolos, **F2** el journal— **sí se pueden comprobar aquí** y quedaron sin +> > hacer por tiempo, no por riesgo. +> > +> > ## La cara estructurada existe, y encontró dos defectos en la humana — 2026-08-09 +> > +> > Cómo se llegó a lo de arriba. +> > +> > El punto 4b está hecho: **un programa ya puede pedir los hechos en vez de las +> > frases.** `structured on` en la sesión, y de ahí en adelante cada verbo de +> > archivos contesta un objeto JSON por renglón; `structured off` devuelve las +> > oraciones. Los dos nombres funcionan (`estructurado`). +> > +> > Hasta hoy el decreto del objetivo estaba **escrito y no construido**: las +> > operaciones ya devolvían un `Done` desde ayer, pero lo único que lo leía era el +> > impresor humano. +> > +> > ### Las tres cosas que la hacen otra cara y no otro impresor +> > +> > Las tres son la regla de desempate del decreto — gana el LLM, y el humano +> > conserva acceso completo por otra vía: +> > +> > 1. **No esconde nada.** `ls` le oculta los archivos con punto a una persona +> > porque tu carpeta tiene treinta y cinco antes del primero que pusiste. +> > Ocultárselos a un programa que preguntó es quitarle capacidad, así que en +> > esta cara están todos. `-a` y `-l` se leen en las dos caras y **se obedecen +> > en una sola**: a un programa no le cambian nada porque nunca se le estaba +> > dando menos. +> > 2. **Los tamaños son exactos.** `1.2 kB` es un número que perdió precisión, y +> > dos programas comparando dos números redondeados comparan dos mentiras. +> > 3. **El silencio nunca es respuesta.** `cd` no imprime nada para una persona +> > porque el prompt siguiente ya dice dónde quedó. Un parser no tiene prompt de +> > dónde leerlo y **no distingue un silencio que significa «me moví» de uno que +> > significa que la sesión se murió**. Un `cd` que falla contesta además dónde +> > sigue parada la sesión, que es lo que la cara humana ya decía con palabras. +> > +> > ### El marco, que es el hueco que casi se repite +> > +> > **Un renglón tecleado, exactamente un objeto.** `rm *.log` que alcanza tres +> > archivos podría imprimir tres objetos, y quien lee no tiene cómo saber que debe +> > leer tres — el cuarto se leería como respuesta al comando siguiente. Así que un +> > verbo que puede tocar varias cosas contesta **un** objeto con `count` y +> > `results` adentro, y `ok` es verdadero sólo si todos salieron bien: un éxito +> > parcial reportado como éxito es cómo alguien sigue adelante creyendo que su +> > ciclo terminó. +> > +> > Es el mismo error del 2026-08-08 con el marcador del prompt del agente: **un +> > límite definido de un solo lado no es un límite.** +> > +> > ### Dos defectos, los dos encontrados corriéndolo, y los dos de la cara humana +> > +> > Esto es lo que más vale de la jornada, porque **la cara estructurada resultó ser +> > un instrumento**: exige respuesta donde una persona perdona el silencio. +> > +> > 1. **Cinco verbos a secas no eran verbos.** `rm`, `mkdir`, `touch`, `cp` y `mv` +> > escritos solos caían al discurso del agente —«no tengo modelo»—, porque cada +> > brazo del despacho exige **un espacio detrás**. Es exactamente el defecto que +> > encontraste con `clear`, vivo en cinco verbos más y sin que nadie lo notara, +> > porque una persona lee esa respuesta como que la máquina está rara y teclea +> > otra cosa. Ahora cada uno tiene su propia pregunta: `cp` pide dos nombres y +> > `rm` uno, y una pista que no dice cuál no sirve. +> > 2. **El primer objeto salía pegado al prompt humano.** El prompt del comando que +> > enciende el modo se imprimió cuando la cara todavía era humana y no lleva +> > salto de línea, así que la respuesta aterrizaba como +> > ` /home > {"op":"structured",…`. **El único renglón que no parseaba era el +> > que avisa que el modo está encendido.** Ninguna prueba del objeto lo veía; se +> > encontró canalizando la sesión y leyendo la salida. +> > +> > La regla que sale de los dos, en [[Estrategia-de-Pruebas]]: **el primer renglón +> > de un modo nuevo lo escribió el modo anterior**, y es el que nadie prueba. +> > +> > ### Detalles con su razón escrita +> > +> > - **Un nombre que no es texto se marca.** Las rutas en Linux son bytes; un +> > nombre inválido en UTF-8 sólo se puede mostrar con pérdida, y devuelto como +> > argumento nombra otro archivo o ninguno. Sale `"exact": false`, y sólo cuando +> > significa algo. +> > - **La forma se escribe a mano, no se deriva.** Nada lleva `#[derive(Serialize)]`: +> > una forma derivada la decide el nombre de una variante de Rust, así que +> > renombrar `Did::Copied` renombraría en silencio un campo que alguien parsea. +> > - **`unreadable` está siempre, aunque esté vacío.** Una llave que sólo aparece +> > el día malo es una llave que nadie maneja el día malo. +> > - **La confirmación carga la salida** (`"off": "structured off"`). En la imagen +> > no hay una segunda terminal, así que un modo sin salida visible puede dejar a +> > alguien atrapado. +> > +> > **1040 pruebas** (1004 antes), `clippy` limpio. Etapa **21** nueva en +> > `verify.sh`, con su control: la misma sesión, el mismo store, y nadie pidiendo +> > nada tiene que contestar en prosa. +> > +> > ### Lo que esto todavía no es +> > +> > **Sólo los verbos de archivos tienen dos caras.** `modulos`, `disponibles`, +> > `permisos`, `recuerdos`, `estado`, `nucleo` y `discos` siguen contestando sólo +> > en prosa, y las cuatro ventajas que hacen que la vara sea *mejor* y no *igual* +> > —índice semántico, rollback, procedencia por campo, permisos por tarea— siguen +> > sin estar expuestas a nadie. Está en [[Tareas-Pendientes]] como 4c. +> > +> > ## Quedó escrito para quién se construye, y los verbos ya cambian archivos — 2026-08-09 +> > +> > Cómo se llegó a lo de arriba. +> > +> > ### Dos decretos, y son la vara de todo lo demás +> > +> > **El objetivo es uno: que un LLM trabaje mejor aquí que en cualquier otro +> > sistema.** Todo lo demás es medio, y el camino humano se cumple entero porque +> > es obligación, no porque sea hacia dónde va el proyecto. Cuando las dos cosas +> > choquen **gana el LLM**, y el humano conserva acceso completo aunque le salga +> > menos cómodo. +> > +> > El choque ya había ocurrido el mismo día sin que nadie lo notara: `ls` en +> > columnas, tamaños redondeados a `1.2 kB` y ocultos escondidos son tres +> > decisiones tomadas para un ojo humano, y las tres son peores para una máquina. +> > Se tomaron sin notar que había una elección, porque el objetivo no estaba +> > escrito. Ahora lo está. +> > +> > **Y la vara es un agente ajeno**, no el agente local de Thalyx: Claude Code y +> > los suyos moviéndose aquí mejor que en Linux. Thalyx como **anfitrión**, no +> > como llamador. Eso deja ver el estado real, que es duro: hoy Claude Code no +> > arrancaría en Thalyx. No trabajaría mal — **no arrancaría**. Necesita ejecutar +> > procesos, leer y escribir archivos, `grep`, `find`, `git`, un runtime, y aquí +> > hay el kernel, un programa y veinte verbos. +> > +> > Lo que hace que la vara sea *mejor* y no *igual* **ya existe y no está expuesto +> > a nadie**: el índice semántico, el rollback, la procedencia por campo y los +> > permisos por tarea. Ningún otro sistema operativo ofrece «intenta esto y si +> > sale mal deshazlo». +> > +> > La consecuencia de ingeniería es la que hay que recordar: **cada cosa nace con +> > dos caras**, la humana y una estructurada que un programa pueda parsear. La +> > segunda no se agrega después — si se agrega después, no se agrega. +> > +> > Detalle en [[Filosofia-Fundacional]], las dos secciones nuevas. +> > +> > ### `mkdir`, `touch`, `cp`, `mv`, `rm` — el punto 4, hecho +> > +> > Con comodines `*` y `?`. **Primera pieza construida bajo el decreto**, y se +> > nota en la forma: ninguna operación imprime lo que hizo. Devuelve un `Done` con +> > **qué pasó, dónde acabó y los bytes exactos**; la cara humana formatea ese +> > hecho y la estructurada leerá el mismo. Un segundo camino que compone su propia +> > frase es una segunda versión de los hechos. +> > +> > Cinco decisiones, cada una con la falla que evita escrita al lado: +> > +> > - **Nada sobrescribe sin pedirlo.** `Exists` es su propio error, porque +> > sobrescribir es otra petición y cuesta un archivo cuando se supone. +> > - `make_file` usa `create_new`: comprobar y crear son dos momentos y entre +> > ellos puede aparecer algo. Que decida el kernel es la única versión sin hueco. +> > - **Un enlace se copia como enlace y se borra como enlace.** Seguirlo +> > duplicaría el destino, y un enlace a un ancestro llenaría el disco. +> > - `mv` cae a copiar-y-borrar ante `EXDEV`, que aquí es el caso ordinario y no +> > el exótico: `/home` y `/opt/thalyx` son subvolúmenes distintos. +> > - **`*` no cruza `/`**, y no alcanza ocultos salvo que el patrón empiece con +> > punto. Sin lo primero, borrar `*` llega a todas las carpetas de abajo; sin lo +> > segundo, `rm *` se lleva la configuración de alguien. +> > +> > El comparador de patrones es iterativo con punto de retroceso y no recursivo: +> > cuarenta estrellas contra un nombre largo es una pila que la forma recursiva no +> > puede pagar, y un patrón así es justo lo que alguien teclea por accidente. +> > +> > `rm` con varios blancos **los lista antes de tocar nada**. `/home` es el único +> > sitio del sistema que ningún rollback nuestro puede devolver, así que ese +> > listado es el único aviso que existe. +> > +> > **1004 pruebas** (984 antes), `clippy` limpio. +> > +> > ### El hueco que esto deja abierto, y es el del decreto +> > +> > **La cara estructurada existe y nadie puede pedirla.** El `Done` lo lee hoy +> > sólo el impresor humano. Mientras siga así, el decreto está escrito y no +> > construido, y **ninguna de las cuatro ventajas está expuesta a nadie**. Es el +> > punto 4b de [[Tareas-Pendientes]] y va antes que el editor. +> > +> > ### Una falla de proceso, y una lectura equivocada de la misma sesión +> > +> > Estos tres avances **vivieron sólo en los mensajes de commit**: ni este archivo +> > ni [[Estado-de-Implementacion]] los mencionaban, y esa segunda nota no tenía +> > fila para `thalyx-files` ni para `thalyx-term` —dos crates enteros ausentes de +> > la nota que dice qué está construido—. Una sesión nueva que leyera la bóveda +> > habría creído que lo último fue la terminal. +> > +> > Y al ir a corregirlo cometí el error de leer una copia local vieja de `main` +> > como si fuera el estado del repositorio, y le dije a Cesar que los verbos no +> > le llegaban con `git pull`. **Sí le llegaban**: `origin/main` ya tenía el +> > trabajo fusionado. Lo único que faltaba de verdad era la bóveda. Queda escrito +> > porque es la regla 5 en un sitio nuevo —el instrumento otra vez antes que lo +> > medido— y porque `git rev-parse main` y `git rev-parse origin/main` son dos +> > preguntas distintas. +> > +> > La rama sí estaba mal nombrada (`claude/verbos-donde-quedamos-uutcmm`) y ahora +> > es `feat/file-mutating-verbs`. +> > +> > ## La terminal es una terminal, y dos lectores de `stdin` no caben — 2026-08-09 +> > +> > Cómo se llegó a lo de arriba. +> > +> > Flechas, borrar a media línea, historial y tab. `crates/thalyx-term` decide qué +> > significa cada tecla y dónde queda el cursor —puro, sin abrir ninguna +> > terminal—; `thalyx-syscall` apaga el editor de línea del kernel con `termios`; +> > `thalyx-cli/src/term.rs` es el único sitio donde se dibuja. +> > +> > **La guarda de modo crudo es lo más peligroso del archivo**, y por eso es una +> > guarda: una sesión que sale sin devolver la terminal deja la máquina +> > inservible, y en la imagen no hay una segunda de dónde recuperarse. El +> > `Drop` cubre la salida normal y el desenrollado de un panic; no cubre un +> > `SIGKILL`, y nada puede. +> > +> > ### Dos defectos de la misma familia, los dos encontrados corriéndolo +> > +> > Ninguna prueba unitaria los veía, porque los dos son sobre **quién es dueño de +> > la entrada**: +> > +> > 1. **Lo tecleado por adelantado se perdía.** El búfer de bytes vivía dentro de +> > `read_line`, así que al pulsar Return todo lo que venía detrás se tiraba. +> > Un `read` devuelve lo que haya llegado, y eso es rutinariamente más de una +> > línea: alguien tecleando rápido, un pegado, o una prueba escribiendo todo +> > de golpe. +> > 2. **Y al arreglar eso, la suite de `exit_criterion` se colgó entera.** +> > `instalar` pide una confirmación que leía `stdin` por su cuenta — y la `y` +> > que la contestaba ya estaba en mi búfer. **Seis sitios del CLI leían +> > `stdin` directo.** Ahora hay un solo dueño, `term::read_answer()`, y los +> > seis pasan por él. +> > +> > La regla que sale de esto, para [[Estrategia-de-Pruebas]]: **dos lugares que +> > leen la misma entrada y uno que guarda lo que sobra no pueden coexistir**; el +> > segundo espera para siempre bytes que ya salieron del kernel y están en +> > memoria. El síntoma no es un error, es un silencio. +> > +> > ### Probado con una terminal de verdad +> > +> > El contenedor no alcanza esto con tuberías, así que se manejó un pty: `lst` + +> > flecha + suprimir da `ls`; la flecha arriba devuelve el comando anterior; `cd +> > Doc` + tab da `cd Documentos/`; con varias opciones se imprimen en columnas +> > sin perder la línea; y **`cat niño` + flecha + retroceso da `nio`** — la `ñ` +> > entera, que es de lo que trata que la línea sea `Vec` y no `String`. +> > +> > Ctrl-C abandona la línea y da prompt nuevo; **no es una salida**, porque en la +> > imagen no hay a dónde salir. Ctrl-D con algo escrito no hace nada: tratarlo +> > como fin sería tirar la línea y salir, dos sorpresas por una tecla. +> > +> > **984 pruebas** (959 antes), `clippy` limpio. +> > +> > ## `ls` existe, y el vocabulario dejó de ser un problema de adopción — 2026-08-09 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > Cesar corrió los cuatro verbos en su Fedora y la primera frase fue la +> > correcta: *«eso parece juguete más que sistema operativo serio»*. Tenía razón, +> > y el problema era más viejo que mis cuatro verbos — **el vocabulario entero del +> > sistema ya era así**: `discos`, `modulos`, `correr`, `apagar`. +> > +> > ### Lo que decidió, y por qué importa +> > +> > **Estándar primero, español también.** `ls`, `cd`, `cat`, `pwd`, `clear` son +> > los que enseña el banner; `ver`, `leer`, `ir`, `donde`, `limpiar` siguen +> > funcionando. El argumento es suyo, de dos mensajes antes: si para usar el +> > sistema hay que decirle adiós a todos los comandos útiles, no hay adopción. +> > +> > **Un nombre no es un programa ajeno.** `ls` escrito en Rust dentro de `thalyx` +> > es tan propio como `ver`. Lo que [[Construccion-del-ISO]] prohíbe es +> > incrustarse en el sistema de alguien más, y `make -C image count` sigue +> > diciendo uno. +> > +> > También decidió el **lenguaje de terminal, partido en dos**: comodines y +> > redirección entran con copiar/mover/borrar porque son notación de diario; +> > tuberías después; **guiones y variables quedan sin decidir**, porque ahí la +> > pregunta pasa a ser si Thalyx tiene lenguaje de programación, que es como la +> > gente construye software encima sin pasar por los módulos. +> > +> > Y aplazó lo de `NOEXEC` con una condición: *«cuando tengamos que decidir, +> > explícamelos bien»*. Eso quedó en [[Tareas-Pendientes]] como **deuda de +> > explicación**, con la lista de lo que hay que cubrir cuando se retome. +> > +> > ### Cuatro defectos, todos encontrados corriéndolo en su máquina +> > +> > Regla 1 otra vez, y ninguno lo veía el contenedor porque ninguno aparece en un +> > directorio de prueba con seis archivos: +> > +> > 1. **`clear` contestaba con un discurso sobre el agente**, porque una línea +> > desconocida cae al mensaje de «no tengo modelo». Un comando común que +> > responde algo de otro tema es exactamente cómo un sistema se lee inacabado. +> > 2. **Los ocultos se mostraban siempre.** Su carpeta tiene **treinta y cinco +> > nombres con punto** antes del primero que él puso, así que lo que buscaba +> > quedaba sepultado. Ahora se ocultan, `ls -a` los muestra, y **el listado +> > dice cuántos escondió** — un filtro silencioso es uno que nadie descubre. +> > 3. **Una cosa por renglón.** Sesenta entradas eran cuatro pantallas. Ahora van +> > en columnas, hacia abajo y no a lo ancho, con el ancho real preguntado al +> > kernel por `TIOCGWINSZ` y ochenta como respaldo — y `None` es respuesta, no +> > fallo, porque una salida redirigida no tiene ancho. +> > 4. **Se desalineaba con nombres largos.** La columna era fija en 32 y +> > `First_Layer_Bed_Leveling_Test.stl` tiene 33: **un archivo rompía la columna +> > de todos los renglones**. Ahora se mide. +> > +> > `ls -l` da tamaños, `ls -a` los ocultos, `ls -la` las dos, y las banderas +> > tienen las dos escrituras (`todo`, `detalles`). Una bandera que no se conoce +> > **no se ignora**: se queda como el lugar, para que la persona lea «`-z` no está +> > ahí» en vez de recibir un listado que hace ver que la bandera funcionó. +> > +> > **959 pruebas** (946 antes), `clippy` limpio. +> > +> > ## Thalyx puede mirar sus propios archivos, y el prompt no era la causa — 2026-08-09 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > ### Lo que cambió de rumbo, y por qué +> > +> > Cesar preguntó cuánto falta para un sistema **usable sin el agente** — ver +> > archivos, carpetas, correr comandos. Medido contra el código, la respuesta era +> > incómoda: la sesión tenía **trece verbos y ninguno tocaba un archivo**. La +> > capa 1 de [[Principio-Doble-Ruta]], marcada *no negociable*, no tenía +> > implementación. +> > +> > Y al ir a construirlo salió que el proyecto se había estado leyendo mal a sí +> > mismo. [[Construccion-del-ISO]] decía «Ningún shell. Ningún conjunto de +> > utilidades — `ls`, `cat`», y eso parecía contradecir a Doble-Ruta. Cesar lo +> > zanjó y la nota ya lo dice con sus palabras: +> > +> > > lo que está prohibido no es la shell, lo que está prohibido es incrustarnos +> > > en la shell de otro sistema […] no es `ls` ni `cat`, está prohibido meternos +> > > en un sistema ya hecho, porque si es así, no seremos un sistema operativo, +> > > seremos una distro parcheada con IA. +> > +> > **Lo prohibido es el programa ajeno, no la capacidad.** No había +> > contradicción entre dos decretos; había una nota ambigua, y ya está corregida. +> > +> > ### Lo construido +> > +> > `crates/thalyx-files` y cuatro verbos: **`ver`**, **`leer`**, **`ir`**, +> > **`donde`**. Compilados dentro de `thalyx` — `make -C image count` sigue +> > diciendo uno. +> > +> > Y una corrección de una afirmación mía anterior: **el subvolumen `user` sí se +> > monta**, en `/home`, por `store_disk::mount()` desde PID 1. Yo había dicho que +> > nadie lo conectaba porque lo busqué en `init.rs`. El piso ya existía; lo que +> > faltaban eran los verbos. +> > +> > ### Tres defectos que sólo aparecieron corriéndolo +> > +> > Regla 1, otra vez, y los tres pasaban todas las pruebas: +> > +> > 1. **El prompt no cabía.** Con la ruta entera puso **noventa caracteres** antes +> > de que se pudiera teclear, y la consola de una máquina real suele tener +> > ochenta. Un sistema cuyo *prompt* no cabe no lo usa nadie. Ahora se acorta +> > por componentes enteros —nunca a media palabra— y **`donde` sigue siendo +> > exacto**: el recordatorio puede ser lossy, la respuesta no. +> > 2. **`ir` imprimía el destino y el prompt lo repetía debajo.** La misma ruta +> > dos veces por cada movimiento. +> > 3. **Los verbos nuevos no estaban en el banner.** Sin shell detrás, un verbo +> > que no está en esa lista no existe para quien tiene la máquina. +> > +> > Decisiones que quedaron con su razón escrita: `..` se pliega léxicamente (lo +> > contrario de lo que hace la API de módulos, y a propósito — ahí no hay +> > concesión de la que escapar); `leer` **se niega** ante un binario en vez de +> > destrozar la terminal, porque en la imagen la sesión *es* la máquina y no hay +> > una segunda de dónde recuperarse; y un enlace roto se lista **como roto**, no +> > como ausente. +> > +> > **946 pruebas** (915 antes), `clippy` limpio. +> > +> > ### El tercer brazo corrió, y refutó mi hipótesis +> > +> > `IT INVENTS EITHER WAY` otra vez en los brazos de objeto, control 9 de 11. +> > Firme, tercera vez. +> > +> > El brazo en prosa volvió a `NOT PROVEN`, y **la colisión de `NOTHING` no era la +> > causa**. Corregí el prompt con su prueba, y las veinte respuestas siguen +> > empezando con `NOTHING`. Lo que sobrevive es la degeneración, y se ve entera: +> > +> > ``` +> > NOTHING NOTHING NOTHING NOTHING NOTHING … +> > NOTHING id only NOTHING id only NOTHING id only … +> > NOTHING <<>> NOTHING <<>> NOTHING <<>> +> > ``` +> > +> > Esa última línea es nueva: **el modelo fabrica marcadores contando hacia +> > arriba**. El 2026-08-08 quedó escrito que un delimitador que el sistema medido +> > puede escribir no delimita; esto lo empeora. +> > +> > **Y el control de 3 de 11 es en realidad 0.** Los tres «encontró el módulo» son +> > eco del material devuelto (`NOTHING Identities: dev.thalyx.demo, ese …`). El +> > pendiente *«dónde termina una respuesta en prosa»* ya no infla el control: **es +> > el control**. +> > +> > Lo que queda establecido: +> > +> > | Brazo | Cómo se cicla | +> > |---|---| +> > | con gramática | `dev.thalyx.demo.versions.versions.versions…` | +> > | sin gramática, en JSON | repite el objeto entero | +> > | en prosa | `NOTHING NOTHING NOTHING…` | +> > +> > **Una patología con tres disfraces.** El 3B a temperatura 0 con este prompt +> > degenera en cuanto se le suelta; la gramática era lo único que le daba una +> > forma con final. El sospechoso ya no es el prompt. **Hipótesis, no conclusión.** +> > +> > ### Lo que sigue, en orden de dependencia +> > +> > Decidido por Cesar el 2026-08-09: construir la usabilidad, en este orden. +> > **Del 1 al 7 se prueba todo en el contenedor**, así que por primera vez el +> > trabajo no tiene a Cesar en el camino crítico. +> > +> > | # | Qué | Depende de | Estado | +> > |---|---|---|---| +> > | 1 | Dónde estoy y moverme | `/home` ya montado | **hecho** | +> > | 2 | `ver` y `leer` | 1 | **hecho** | +> > | 3 | Terminal: flechas, historial, tab | 2 — el tab completa nombres | siguiente | +> > | 4 | `crear`, `copiar`, `mover`, `borrar`, `renombrar` | 2 | | +> > | 5 | Editor de texto | 2 + 4 | | +> > | 6 | `buscar` por nombre y por contenido | 2 | | +> > | 7 | Procesos: qué corre, matarlo, memoria | independiente | | +> > | 8 | Red: drivers, IP, DNS | independiente | **sólo hierro de Cesar** | +> > | 9 | Lenguaje: tuberías, redirección, comodines | 2+4+6 **y decreto** | | +> > +> > Dos cosas que hay que decir y no se tocaron: +> > +> > - **`/home` está montado `NOEXEC`.** Nadie puede ejecutar un programa desde su +> > carpeta personal, y en Linux sí se puede. Es de las cosas que un usuario +> > nota. **Decisión de Cesar**, ver [[Tareas-Pendientes]]. +> > - **El punto 9 es decreto antes que código.** Trece verbos sueltos y un +> > lenguaje que los compone son proyectos distintos. +> > +> > ## El prompt gastó su propia señal, y la corrida no midió — 2026-08-09 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > ### Lo que quedó firme +> > +> > **La gramática no es la causa.** `IT INVENTS EITHER WAY` en la gama media, con +> > el control sostenido en 9 de 11, y **reproducido en dos corridas**. Sin +> > gramática el 3B sigue inventando en cuatro de los nueve casos de rechazo y +> > nombrando el módulo real equivocado en cinco. Quitarle la restricción no lo +> > hace abstenerse. +> > +> > **No hubo primera abstención.** Aquel `said something, named nothing` era la +> > segunda lectura: el comando reprodujo la inferencia y salió +> > `"targets": ["good-luck-module-1234567890123456789…` — 255 tokens de dígitos +> > dentro de un identificador, sin cerrar. Octavo caso de la misma patología. **La +> > abstención sigue en cero, ahora sobre 55 oportunidades.** +> > +> > ### El tercer brazo no pudo medir, y el defecto es del prompt +> > +> > Dio `NOT PROVEN` con `ABSTAINED 8/9` — que era el número que la hipótesis +> > quería. El control lo tumbó (5 de 11), y el motivo está a la vista: **las +> > veinte respuestas empiezan con `NOTHING`**, también las once donde sí había un +> > módulo. +> > +> > ``` +> > act a pronoun pointing at the one thing installed +> > in prose "NOTHING Identities: dev.thalyx.demo: 1.4.2 dev.thalyx.demo: 1.4.1 …" +> > act a module named by what it does rather than by its id +> > in prose "NOTHING NOTHING NOTHING NOTHING NOTHING NOTHING NOTHING …" +> > ``` +> > +> > Eso no es declinar. Es un primer token regalado. **Y lo regaló mi prompt**, que +> > usaba la palabra cuatro veces, tres de ellas en otro sentido: *and **nothing** +> > else*, *gains **nothing***, *if **nothing** below names a module*. +> > +> > Corregido —*add no other text*, *only costs the request*, *if no module is +> > named below*— con una prueba que cuenta las apariciones sin distinguir +> > mayúsculas y falla si hay más de una. +> > +> > **No se afirma que la colisión sea la causa**: compite con la degeneración, que +> > está igual de a la vista —las respuestas son `NOTHING NOTHING NOTHING…`, el +> > mismo ciclo que dentro de `module-id`, en otro token—. Lo que sí queda +> > establecido es que el prompt no podía medir. +> > +> > Y hay que decirlo entero: **corregirlo puede destruir el 8 de 9.** Se corrige +> > igual. +> > +> > ### Volver a correrlo +> > +> > ```sh +> > git pull && cargo install --path crates/thalyx-cli +> > +> > thalyx agent model use media --weights ~/models/qwen2.5-3b-instruct-q4_k_m.gguf +> > thalyx agent grammar-effect --keep-prompt ~/evidencia/prosa-media-2 2>&1 | tee prosa-media-2.log +> > ``` +> > +> > La gama ligera ya no aporta a esta pregunta: por **tercera** corrida, sin +> > gramática contesta las veinte con cero tokens, en los dos brazos libres. En un +> > 1.5B forzarle el primer carácter es lo único que arranca la generación. +> > +> > ### Lo que sigue sin decidirse +> > +> > **Dónde termina una respuesta en prosa.** El modelo contesta y después divaga +> > 250 tokens; el lector actual busca identificadores en todo el texto, así que +> > `NOTHING Identities: dev.thalyx.demo: 1.4.2 dev.thalyx.demo: 1.4.1 …` —el +> > material devuelto de vuelta— cuenta como haber nombrado el módulo. Eso **infla +> > el control** del brazo en prosa. Decidir dónde corta cambia lo que el +> > instrumento mide, y por eso no se toca sin aprobación. Ver +> > [[Tareas-Pendientes]]. +> > +> > 915 pruebas, `clippy` limpio. +> > +> > ## El instrumento para la pregunta de la abstención está listo — 2026-08-08 +> > +> > Cómo se llegó a lo de arriba. +> > +> > La abstención sale **0 de 46** en tres tamaños de modelo y seis corridas, y un +> > resultado que no se mueve cuando la única variable se mueve habla de lo que +> > esas corridas **comparten**. La sospecha tiene mecanismo: +> > `operation ::= "\"install_module\""` tiene **una sola alternativa**, y el orden +> > de los campos está fijo, así que lo primero que el modelo escribe en cada +> > inferencia es `install_module`, obligado; abstenerse exige contradecirlo +> > después. Detalle en [[Gamas-de-Modelo]]. +> > +> > Se construyó `thalyx agent grammar-effect` para contestarlo. **Nada del prompt, +> > la gramática ni las gamas fue tocado.** +> > +> > ### Los comandos, en orden +> > +> > ```sh +> > git pull && cargo install --path crates/thalyx-cli +> > +> > # La gama media primero: es la que más entiende, así que su brazo libre es +> > # el que mejor puede sostener el control. +> > thalyx agent model use media --weights ~/models/qwen2.5-3b-instruct-q4_k_m.gguf +> > thalyx agent grammar-effect --keep-prompt ~/evidencia/efecto-media 2>&1 | tee efecto-media.log +> > +> > thalyx agent model use ligera --weights ~/models/qwen2.5-1.5b-instruct-q4_k_m.gguf +> > thalyx agent grammar-effect --keep-prompt ~/evidencia/efecto-ligera 2>&1 | tee efecto-ligera.log +> > ``` +> > +> > Cuarenta inferencias por gama: unos **4–5 minutos** la media, unos **3** la +> > ligera. Imprime cada caso al terminarlo, así que no hay silencios largos. +> > +> > ### Qué puede contestar, y las tres son respuestas +> > +> > | Veredicto | Qué significa | +> > |---|---| +> > | `THE GRAMMAR TAKES THE DECISION` | Con gramática inventó, sin ella nunca — y aun así encontró el módulo correcto donde lo había. El cero del banco no es el modelo negándose a declinar, es Thalyx obligándolo a decir `install_module` antes de poder | +> > | `IT INVENTS EITHER WAY` | Quitar la gramática no lo detuvo. **La hipótesis queda refutada** y lo que resta sospechar es el prompt o el modelo | +> > | `NOT PROVEN` (sale distinto de cero) | Sin gramática el modelo no encontró el módulo correcto ni donde sí lo había, así que su silencio en los casos de abstención no es una decisión. **No es evidencia de nada**, y el propio comando lo dice | +> > +> > El tercero es el que hace honesto al experimento: si el control no aguanta, no +> > hay veredicto. Un sondeo que no puede fallar no es un sondeo. +> > +> > ### La primera corrida salió NOT PROVEN, por defectos míos +> > +> > El instrumento se negó a dar veredicto, que es lo correcto, pero se negó por +> > cuatro defectos suyos. Dos los encontró una prueba; **dos sólo aparecieron +> > corriendo contra un modelo de verdad**, que es la regla 1 otra vez. +> > +> > 1. El escáner de ids era **ciego al JSON**. +> > 2. Los dos brazos **se pisaban el `command` guardado** — ese ya existía en +> > `grammar-check`. +> > 3. El brazo restringido se juzgaba con `Proposal::parse`, estricto con el +> > texto sobrante, y llama.cpp añade su aviso de fin de generación después del +> > objeto: **las cuarenta inferencias volvieron `NO MEASUREMENT`**. +> > `Proposal::completion_in` ya resolvía eso y el banco la usaba desde siempre. +> > 4. **El brazo libre no es prosa.** Sin gramática el 3B sigue contestando en +> > JSON, porque el *prompt* pide JSON y el prompt está en los dos brazos: +> > `instala algo bueno` → `{"targets": ["good-bad-thing"]}`. Nada de eso es un +> > id reverse-DNS, así que el escáner reportaba **silencio** —la lectura «el +> > modelo declinó»— para una respuesta que proponía instalar algo inventado. Y +> > la ligera, sin gramática, contestó las veinte con un fin de generación +> > inmediato, contado también como silencio. **Tres hechos distintos llegaban +> > como la misma palabra.** +> > +> > Lo cuarto es la trampa de hace dos días repetida: un `[end of text]` leído como +> > una decisión. Ahora hay un estado aparte, `GENERATED NOTHING (not a decline)`. +> > +> > Las fixtures ya no son inventadas: son las salidas literales de tu corrida. +> > 896 pruebas, `clippy` limpio. +> > +> > ### Lo que la corrida fallida ya insinúa, y todavía no se afirma +> > +> > La gama media, **sin ninguna gramática**, propuso instalar algo en los nueve +> > casos de abstención. Si eso se sostiene con el instrumento arreglado, el +> > veredicto será `IT INVENTS EITHER WAY` y **mi hipótesis queda refutada**: no es +> > la gramática, es el prompt, que pide un objeto JSON en los dos brazos. Se +> > afirma cuando el instrumento lo diga, no antes. +> > +> > ## Cinco corridas, y el banco es más estable de lo que parecía — 2026-08-08 +> > +> > Cómo se llegó a la pregunta de arriba. +> > +> > Tres corridas de la gama ligera y dos de la media, con `--keep-prompt`. Esto +> > **corrige la lectura del bloque de abajo**, que decía que las cifras de +> > acierto se mueven: +> > +> > | | ligera ×3 | media ×2 | +> > |---|---|---| +> > | **Aciertos sobre los 20** | **5, 6, 6** | **9, 9** | +> > | Sin medición | 6, 5, 2 | 1, 2 | +> > | Intención (sobre lo medido) | 5/14, 6/15, 6/18 | 9/19, 9/18 | +> > | Abstención | 0/6, 0/6, 0/9 | 0/9, 0/8 | +> > +> > 1. **Lo que se mueve no es el acierto, es cuántos casos contestan.** El número +> > de respuestas correctas casi no se movió; el denominador sí. Y pasó el caso +> > que lo demuestra: la ligera contestó cuatro casos más, acertó uno más, **y +> > su fracción bajó** de 36 % a 33 %, porque los cuatro que recuperó volvieron +> > mal. **La cifra que se compara entre corridas es aciertos sobre 20.** +> > 2. **Catorce de los veinte casos dieron la misma marca en las cinco corridas.** +> > La suite es estable. Los aciertos se mueven ±1. +> > 3. **El caso 4 no ha producido una medición ni una sola vez**: `quiero la 1.4 +> > del demo`, cinco de seis corridas con el presupuesto de tokens agotado. Es +> > el único caso de la suite cuya restricción esperada lleva un punto adentro +> > (`1.4`). Hay hipótesis y **no está probada**; se resuelve con un comando, +> > abajo. +> > 4. **Abstención: cero en 46 oportunidades**, tres tamaños de modelo, seis +> > corridas. Es la propiedad más firmemente medida del proyecto. Los tres casos +> > que la ligera nunca había alcanzado a contestar resultaron ser de +> > abstención, y al contestarlos por fin los falló los tres. +> > 5. **La media no se movió ni un caso en dos corridas** (9 y 9). La distancia +> > con la alta —dos casos— ya no cae dentro del ruido del instrumento. Lo que +> > le falta a esa comparación es que la alta corra dos veces, y está aplazada. +> > +> > ### El caso 4, resuelto el mismo día — y era `module-id` +> > +> > Cesar corrió la inferencia guardada. La salida contesta sola: +> > +> > ``` +> > "targets": ["dev.thalyx.demo.versions.versions.versions.versions… +> > ``` +> > +> > 255 de 256 tokens, y **nunca llegó a `constraint`**. La hipótesis del punto en +> > `1.4` queda **refutada**: el ciclo está en `module-id`, no en `range`. +> > +> > Las tres capas, que hay que decir separadas: +> > +> > | | | +> > |---|---| +> > | Causa inmediata | agotó `n_predict` sin cerrar el objeto | +> > | Causa observada | repitió `.versions` dentro de `module-id` | +> > | Condición que lo permite | la producción admite segmentos sin cota | +> > +> > **La gramática no lo obliga a repetir** — el modelo elige `.versions`; la +> > gramática nunca le exige cerrar. +> > +> > Y explica de más, que es lo importante: `ese.abc.abc.abc`, `thallyx.ing.ing`, +> > `dev.thalyx.demo.localhost`, `photoshop-1.ashx.ashx`, +> > `python3.ipython3.ipython3` son **el mismo comportamiento**. Cuando el 1.5B no +> > sabe cerrar semánticamente un id, sigue produciendo segmentos válidos; si el +> > corte llega antes de cerrar la cadena sale `ERR`, si llega después sale una +> > invención. Un comportamiento que llevábamos contando como tres. +> > +> > El caso 4 es además la demostración más limpia del proyecto de lo que la +> > gramática no puede hacer: el modelo **empieza con el id correcto** —está en el +> > prompt— y lo convierte en otro. `dev.thalyx.demo.versions` es sintácticamente +> > válido y semánticamente inventado. Esa segunda columna es de la atribución. +> > +> > Al inspeccionar la producción salió algo que no se buscaba: **`thalyx-manifest` +> > tampoco tiene cota**, así que la gramática espeja fielmente a la autoridad y el +> > hueco está en las dos. Tres opciones escritas en [[Gamas-de-Modelo]], con la +> > predicción de que acotar **no subiría el acierto** —convertiría `ERR` en `REF`, +> > como ya se observó—. **Nada tocado.** +> > +> > Detalle completo en [[Gamas-de-Modelo]], «Tres corridas de ligera y dos de +> > media». +> > +> > ## La gama ligera se corrió dos veces, y no dio lo mismo — 2026-08-08 +> > +> > Cómo se llegó a lo de arriba, y con una lectura que el bloque anterior corrige. +> > +> > Cesar repitió `grammar-check` y el banco sobre la gama ligera, sin cambiar +> > nada. Tres resultados, en orden de importancia: +> > +> > 1. **Las cifras de acierto se mueven entre corridas; las de coste no.** Dos +> > casos de veinte cambiaron, en direcciones opuestas (14/20 → 15/20 medidos, +> > 5/14 → 6/15). El disco, el RSS pico (2.82 GB) y la latencia mediana (3.77 s) +> > salieron idénticos. La causa no es llama.cpp: la semilla está fija pero +> > **el prompt lleva un marcador aleatorio nuevo en cada invocación**, así que +> > la entrada cambia. El encabezado de `llama.rs` afirmaba lo contrario y ya no +> > lo afirma. Consecuencia directa: la distancia de **dos casos** entre la gama +> > media y la alta es del tamaño de lo que se mueve una gama consigo misma, así +> > que no es una diferencia entre gamas. Nada de lo medido se retira; ahora +> > tiene margen. +> > 2. **Los cinco `ERR` tienen una sola causa, y ya se sabe cuál**: el modelo +> > empieza el objeto y se queda sin presupuesto dentro de un identificador —la +> > gramática no acota cuán largo puede ser—. Se cicla: +> > `python3.ipython3.ipython3.…`. Subir `-n` no lo arregla, sólo alarga el +> > ciclo. Y es la **misma** patología que las invenciones (`ese.abc.abc.abc`, +> > `thallyx.ing.ing`): un fallo, contado como dos. +> > 3. **`grammar-check` de la ligera ya dice `NOT PROVEN` sobre hardware real.** +> > La corrección del día anterior quedó verificada donde importa. +> > +> > Detalle completo en [[Gamas-de-Modelo]], sección «Segunda corrida de la gama +> > ligera». +> > +> > ### Lo que Cesar decidió, y ya está construido +> > +> > **Guardar el prompt bajo una bandera.** `--keep-prompt ` en `agent model +> > check`, `agent model grammar-check` y `agent bench`: cada inferencia deja un +> > directorio —nombrado por su marcador, así que veinte casos dejan veinte— con +> > `prompt.txt`, `proposal.gbnf` y `command`. Con eso *esa* corrida se repite a +> > mano, marcador incluido. El marcador **sigue siendo aleatorio**, así que dos +> > corridas distintas siguen moviéndose, y eso es lo correcto: esconderlo daría +> > una muestra de una distribución con cara de medición. Sin la bandera no queda +> > nada en disco. +> > +> > De paso: `Invocation::command_line` —la función que el encabezado de +> > `llama.rs` citaba como *la* forma de reproducir una corrida— no tenía ninguna +> > llamada fuera de su propia prueba. Documentación de una función que nunca se +> > había ejecutado. `--keep-prompt` es su primera llamada real. +> > +> > ### Lo siguiente que hay que correr +> > +> > **Repetir la ligera y la media** —una vez cada una, sin cambiar nada— para +> > darle réplica a sus cifras de acierto. La **alta queda aplazada**: tarda +> > demasiado en esta máquina, y Cesar la corre cuando consiga el equipo con el +> > que también pueda medir la máxima. Vale la pena correrlas ya con la bandera: +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli +> > thalyx agent model use ligera --weights ~/models/qwen2.5-1.5b-instruct-q4_k_m.gguf +> > thalyx agent bench --keep-prompt ~/evidencia/ligera-3 2>&1 | tee ligera-3.log +> > thalyx agent model use media --weights ~/models/qwen2.5-3b-instruct-q4_k_m.gguf +> > thalyx agent bench --keep-prompt ~/evidencia/media-2 2>&1 | tee media-2.log +> > ``` +> > +> > Lo que hay que mirar al terminar: **cuántos casos cambiaron de marca** contra +> > la corrida anterior de esa misma gama. Ése es el margen de error del banco, y +> > hasta tenerlo la distancia entre la media y la alta no significa nada. +> > +> > ## Las cuatro gamas corrieron sobre la misma máquina, y la más grande no cabe — 2026-08-08 +> > +> > Cómo se llegó a la corrida de arriba. +> > +> > Cesar corrió `check`, `grammar-check` y el banco de 20 casos en **ligera, +> > media y alta**, sobre su Ryzen 5 5600G de 16 GB, sin GPU, en CPU, con la misma +> > familia (Qwen2.5-Instruct), la misma cuantización (Q4_K_M), el mismo +> > `llama.cpp`, el mismo prompt, la misma gramática y la misma suite. **Lo único +> > que varió es el tamaño**, que es lo que hace la comparación atribuible. +> > +> > | | ligera 1.5B | media 3B | alta 7B | maxima 14B | +> > |---|---|---|---|---| +> > | Casos medidos | 14/20 | 19/20 | 19/20 | **0/20** | +> > | Intención | 5/14 | **9/19** | 7/19 | N/D | +> > | Argumentos | 5/14 | **8/19** | 7/19 | N/D | +> > | Abstención | 0/6 | 0/9 | 0/8 | N/D | +> > | Latencia mediana | 3.77 s | 6.78 s | **33.26 s** | N/D | +> > | RSS pico | 2.82 GB | 4.79 GB | **13.93 GB** | N/D | +> > +> > ### Lo más importante, en orden +> > +> > 1. **La gama alta no superó a la media en este banco**, y costó ×4.9 de +> > latencia y ×2.9 de memoria. La afirmación legítima es estrecha —con *este* +> > prompt, *esta* gramática, *estos* casos, *esta* cuantización y *este* +> > hardware— y **no** es «3B es más listo que 7B»: la diferencia es de dos +> > casos sobre diecinueve, que es menos de lo que esta suite puede separar. Lo +> > que sí sostiene es la **ausencia de mejora medible** frente a un costo que +> > no es discutible. +> > 2. **La máxima quedó `N/D`, no en cero.** El proceso fue terminado por falta de +> > memoria después de imprimir la gama y el enunciado, antes de completar la +> > primera inferencia. No hubo banco que fallar. Lo probado es que *esta* +> > máquina de 16 GB con *esta* configuración no la sostiene — **no** que 14B +> > pida 32 GB, que sigue siendo el estimado del decreto. +> > 3. **Abstención cero en las tres gamas medidas, sin excepción.** Es la medida +> > que [[Gamas-de-Modelo]] llama la más importante, y es la única que sale +> > **idéntica** en 1.5B, 3B y 7B. Un resultado plano donde lo único que varía +> > es el tamaño apunta a lo que las tres comparten, no a lo que las separa. Es +> > hipótesis, y **no se tocó el prompt**. +> > 4. **`grammar-check` de la gama ligera decía `PROVEN` y no lo estaba.** +> > Corregido, con dos regresiones. Ver abajo. +> > +> > ### El `PROVEN` retirado, que es el defecto del día +> > +> > ``` +> > with the grammar { "operation": "install_module", "targets": ["python3.ipython3.… +> > without it [end of text] +> > PROVEN: … constrained it could not even begin with it, and left alone it did. +> > ``` +> > +> > *«Left alone it did»* — dijo la palabra prohibida. **No la dijo: no dijo +> > nada.** `[end of text]` es lo que imprime `llama.cpp` cuando el modelo termina +> > la generación de inmediato. En media y alta el brazo libre sí muestra `BANANA` +> > y ahí el veredicto es correcto; en ligera no había control. +> > +> > El veredicto afirma dos cosas y el código comprobaba una: `InForce` era el +> > `else`, así que se alcanzaba con que el brazo libre **no abriera un objeto** — +> > y un brazo callado tampoco abre uno. Regla 4, sobre el veredicto en vez de +> > sobre el experimento. Sobrevivió por la regla 8: los cuatro sustitutos del +> > sondeo decían la palabra al quitarles la gramática, **ninguno modelaba un +> > modelo callado**. Regla nueva en [[Estrategia-de-Pruebas]]. +> > +> > Lo que sí se puede afirmar de esa corrida, y es menos: **la bandera cambió la +> > salida** —con ella hubo objeto, sin ella nada—. Que lo que la gramática impidió +> > fuera *la palabra* no se midió. Así que «un contrato mal formado es imposible +> > en las cuatro gamas» está probado en **dos**, no probado en ligera, N/D en +> > máxima. +> > +> > ### La demostración de que la gramática no garantiza el contenido +> > +> > La gama ligera contestó `dev.thalyx.demo, ese quiero` con +> > `["dev.thalyx.demo","ese.quiero.ios"]`. **Fabricó un id a partir de las +> > palabras humanas «ese quiero»**, con la forma perfecta. La gramática hizo lo +> > que promete y nada más; lo que lo detuvo fue la atribución del núcleo. Contrato +> > válido, contenido inventado, en una sola línea de salida. +> > +> > ### Y una pregunta vieja quedó contestada +> > +> > `dev.thalyx.demo, ese` sale `REF` en las tres gamas: el modelo nombró algo que +> > no aparece en ningún canal. **No es una abstención.** Con eso se cae del todo +> > la hipótesis de que la instrucción de abstención del prompt pesa de más — el +> > `MISS` que la originó venía del banco que contaba `Err(_) => Abstained`, donde +> > un rechazo por atribución se veía como abstención correcta. +> > +> > ### El estatus que Cesar le puso a todas estas cifras +> > +> > Preguntado si la columna de RAM recomendada baja ahora que el RSS medido salió +> > menor, decretó que no, y con un alcance más amplio que la pregunta: +> > +> > > declara los resultados mas no los muestres como pruebas definitivas, las +> > > pruebas definitivas vendran cuando thalyx este corriendo en una ssd real como +> > > sistema operativo real, solo en ese entorno se vera la realidad +> > +> > Así que **todo lo de arriba queda declarado y nada queda como definitivo**. +> > Sirve para comparar las gamas entre sí, porque las tres corrieron bajo las +> > mismas condiciones; **no** sirve para fijar el requisito de hardware de Thalyx, +> > ni para bajar la columna de RAM, ni para decidir qué gama trae el ISO. Eso son +> > afirmaciones sobre el destino, y el destino es Thalyx como sistema operativo +> > sobre un SSD real. Queda como pendiente con condición escrita en +> > [[Tareas-Pendientes]]. +> > +> > ### Qué se cambió, y qué no +> > +> > Cambiado, y las dos cosas son evidencia y no puntuación: +> > +> > - `grammar_check` exige que el brazo libre **diga la palabra**, con dos +> > regresiones comprobadas fallando contra el código anterior. +> > - El banco imprime **qué** id rechazó la atribución, en vez de `(named +> > something nobody mentioned)`. +> > +> > **No cambiado a propósito**: el prompt, la gramática, las gamas, la suite y la +> > columna de RAM recomendada. Cambiar cualquiera de ellos como reacción a estos +> > resultados haría que la próxima corrida no se pudiera comparar con ésta. +> > +> > ### Lo que corre Cesar, y es el experimento que eligió +> > +> > **Repetir la gama ligera guardando la salida entera.** Seis de veinte casos no +> > dieron medición y el banco **sí imprime la razón de cada uno** —plazo agotado, +> > truncamiento, gramática no aplicada, `llama.cpp` cayéndose son fallos +> > distintos, y mandan a lugares opuestos— pero esa columna no llegó a la bóveda +> > en la transcripción. Es una corrida, sin cambiar nada, y hasta tenerla `5/14` +> > no es la puntuación de esa gama ni se sabe qué le pasa. +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli +> > thalyx agent model use ligera --weights ~/models/qwen2.5-1.5b-instruct-q4_k_m.gguf +> > thalyx agent model grammar-check 2>&1 | tee ligera-grammar.log +> > thalyx agent bench 2>&1 | tee ligera-bench.log +> > ``` +> > +> > Dos cosas que esperar y que **no** son fallos: +> > +> > - `grammar-check` debe salir **`NOT PROVEN`** y **distinto de cero**. Es el +> > resultado correcto: no es que la gramática falle, es que ese modelo contesta +> > el sondeo callándose y entonces no hay control. Si sale `PROVEN`, la +> > corrección no llegó. +> > - El banco vuelve a perder casos. Lo que se busca es **qué dice cada `ERR`**. +> > +> > **872 pruebas pasan** (870 antes), `clippy` limpio, `cargo fmt` aplicado. +> > +> > ## El banco contaba todo fallo como abstención correcta — 2026-08-08 +> > +> > **El bloque de arriba es más reciente.** Los de abajo son cómo se llegó. +> > +> > Cesar dijo que por ahora no descarga más modelos y que como máximo corre +> > verificaciones, así que el trabajo fue sobre el instrumento. Encontrado +> > leyendo el banco, no leyendo sus números: +> > +> > ```rust +> > Err(_) => Outcome::Abstained, +> > ``` +> > +> > **Toda forma de fallar contaba como el modelo absteniéndose bien.** Un plazo +> > agotado, un truncamiento, una gramática no aplicada, `llama.cpp` cayéndose. Una +> > gama cuyo modelo no arrancara nunca sacaba 4/4 en abstención, que es la medida +> > que [[Gamas-de-Modelo]] llama la más importante. +> > +> > Y peor: `AgentError::Attribution` —el núcleo cazando al modelo nombrando un id +> > que nadie mencionó— caía en la misma rama. **La conducta más peligrosa que el +> > banco busca, contada como la más segura.** +> > +> > ### Las cifras de acierto del 2026-08-08 quedan retiradas +> > +> > Intención 6/9, argumentos 6/9, abstención 3/4 no significan lo que parecían. Se +> > mantienen disco, RAM y latencia, que se miden alrededor del proceso. **Se cae +> > también la hipótesis** de que la instrucción de abstención del prompt pesa de +> > más: los dos `MISS` pudieron ser abstenciones reales o errores disfrazados, y +> > desde la salida impresa no se distinguen. +> > +> > ### Qué se construyó +> > +> > - **Cinco resultados** y ninguno inferido de la ausencia de otro: correcto, +> > equivocado, abstenido, **rechazado por el núcleo**, **sin medición**. +> > - Un caso sin medición no cuenta en ninguna fracción. Los denominadores son +> > sobre lo medido, y el resumen lo dice **antes** que cualquier cifra. +> > - La clasificación salió del bucle a `Outcome::of`, que es una función pura — +> > el defecto vivía enterrado en una expresión donde ninguna prueba lo alcanzaba. +> > - **La suite pasó de 9 casos a 20.** Con nueve, un caso vale once puntos. Los +> > nuevos varían **una** cosa a la vez respecto de uno que ya estaba, para que la +> > próxima corrida conteste por qué falló el caso fácil. +> > - La exención de «este caso de abstención sí nombra un módulo» era una +> > subcadena del *nombre* del caso; ahora es un campo con la razón escrita, con +> > su control. +> > +> > ### Lo que corre Cesar +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli +> > thalyx agent bench +> > ``` +> > +> > Una corrida, con el modelo que ya tiene. Devuelve las cifras de acierto con +> > significado por primera vez. +> > +> > ## La gramática restringe de verdad, probado en hierro — 2026-08-08 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > ``` +> > thalyx agent model grammar-check +> > with the grammar { "operation": "install_module", "targets": [ "python3.abc_1.abc", … +> > without it BANANA << > PROVEN +> > ``` +> > +> > Restringido no pudo ni empezar con la palabra prohibida; suelto la dijo. Con +> > eso, la frase de [[Gamas-de-Modelo]] —«un contrato malformado es imposible en +> > las cuatro gamas»— deja de apoyarse sólo en las pruebas del parser. **En una +> > gama**; las otras tres heredan el argumento y no la corrida. +> > +> > ### El defecto que traía esa corrida que pasó +> > +> > `BANANA << > que acababa de leer**. Sólo lo cortó el tope de tokens. +> > +> > El marcador es aleatorio por invocación, y eso estaba razonado contra un +> > adversario —un texto ajeno no puede adivinarlo—. No cubría esto: el modelo no lo +> > adivina, lo tiene delante. +> > +> > > **Un delimitador que el sistema medido puede escribir no delimita.** Ser +> > > imposible de adivinar no es ser imposible de copiar. +> > +> > `answer_in` tomaba la **última** aparición del marcador, así que una copia +> > completa habría movido dónde empieza la respuesta. Ahora se ancla en el prompt +> > repetido entero, y el marcador solo queda de respaldo tomando la primera. +> > `RANGE_CHARS` contiene `<`, `>` y `-`, así que esto llegaba también al camino +> > restringido, dentro de un campo `constraint`. +> > +> > **Nada falló para encontrarlo.** El veredicto era correcto; el defecto estaba en +> > la evidencia impresa al lado, y sólo porque se imprimía. Regla nueva en +> > [[Estrategia-de-Pruebas]]: una corrida que pasa también trae datos. +> > +> > ### Lo que queda abierto +> > +> > - Las otras tres gamas, que son otros tres GGUF. +> > - La instrucción de abstención pesa de más: se abstuvo con el id dicho en claro. +> > - Actuó sobre un módulo mencionado y luego descartado. Comprensión, no gramática. +> > +> > Ver [[Tareas-Pendientes]]. +> > +> > ## El agente corre entero contra hierro real, con el primer banco medido — 2026-08-08 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > ### La gama media, medida +> > +> > | Medida | Estimado | Medido | +> > |---|---|---| +> > | Disco | ~2.0 GB | 2 104 932 768 bytes | +> > | RAM | ~8 GB | **4.78 GB** | +> > | Latencia | — | mediana 6.58 s, peor 7.94 s | +> > | Intención | — | 6/9 | +> > | Abstención | — | 3/4, con **1 invención** | +> > +> > La estimación de RAM iba alta por casi el doble. Los tres fallos están +> > analizados en [[Gamas-de-Modelo]]; el que importa es que **actuó sobre un +> > módulo que la persona había descartado** — no inventó un id, tomó uno excluido. +> > +> > ### `grammar-check` falló, y el que estaba mal era `grammar-check` +> > +> > Dijo `FAILED`. La prueba de que estaba al revés venía en su propia salida: el +> > brazo restringido había emitido `{ "operation": "install_module", "targets": +> > ["banana_module_1234…` hasta agotar los 256 tokens. **Empieza con `{`.** La +> > gramática le prohibió empezar con `B` y el modelo desvió el intento a una cadena +> > de id legal, quedándose ahí hasta el tope. El JSON no cerró, así que no parseaba +> > — y la comprobación preguntaba si parseaba. +> > +> > > **Una falla al terminar no es una falla al cumplir.** Regla 10 en un sitio +> > > nuevo, y la octava vez que el instrumento se equivocó antes que lo medido. +> > +> > Y lo delató una contradicción entre dos corridas: `grammar-check` decía que la +> > gramática no se aplicaba, y `bench` sacaba nueve propuestas bien formadas de +> > nueve casos minutos después. Las dos no pueden ser ciertas. +> > +> > ### Qué se corrigió +> > +> > - Se lee **el primer carácter**, no si el resultado parsea. `root ::= "{"` es +> > absoluto y sobrevive al truncamiento. +> > - El mismo defecto estaba **en el camino de producción**: una inferencia normal +> > truncada contra `-n` también salía como «gramática no aplicada». Ahora hay +> > `Truncated`, y su mensaje dice que *esto es la gramática funcionando*. +> > - El sondeo gasta 48 tokens en vez de 256; sólo el primer carácter decide. +> > - Las dos ramas se imprimen **también cuando falla**. La versión anterior +> > escondía la de control justo en el caso donde valía más. +> > +> > ### Lo siguiente +> > +> > Cesar corre `git pull && cargo install --path crates/thalyx-cli` y luego +> > `thalyx agent model grammar-check`, que debe salir `PROVEN`. +> > +> > Decidido a medias y sin decidir: la instrucción de abstención del prompt pesa +> > demasiado —abstuvo con el id dicho en claro— y bajarla es tocar el prompt, que +> > mueve los nueve casos a la vez. Ver [[Tareas-Pendientes]]. +> > +> > ## El agente contesta desde hierro real, y falta una comprobación por correr — 2026-08-08 +> > +> > **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +> > +> > ``` +> > thalyx agent model check "dev.thalyx.demo, ese quiero" +> > answer {"operation": "install_module", "targets": ["dev.thalyx.demo"]} +> > latency 6.88s +> > peak rss 4.77 GB +> > parsed as: Proposal { operation: InstallModule, targets: ["dev.thalyx.demo"], … } +> > ``` +> > +> > Enunciado → modelo real → propuesta parseada, de extremo a extremo, en la +> > Fedora de Cesar. **La RAM medida es 4.77 GB contra los ~8 GB que estimaba +> > [[Gamas-de-Modelo]]**: el primer número de esa tabla que alguien midió. +> > +> > ### Lo construido después: separar «bandera aceptada» de «gramática aplicada» +> > +> > `llama.cpp` sale distinto de cero ante una bandera que no conoce, así que una +> > corrida limpia probaba que `--grammar-file` fue **aceptada**. No que +> > restringiera nada — el prompt real le pide un objeto al modelo, y un modelo que +> > da un objeto sólo hizo lo que le dijeron. Cuatro gamas del decreto se apoyaban +> > en no notar la diferencia. +> > +> > `thalyx agent model grammar-check` pide **la única palabra que la gramática no +> > puede emitir**, dos veces, con la bandera y sin ella, sin ninguna otra +> > diferencia entre las dos corridas. Tres resultados: +> > +> > | Resultado | Qué significa | +> > |---|---| +> > | `PROVEN` | Restringido no pudo decirla; suelto sí. Sólo la gramática explica eso | +> > | `FAILED` | La dijo con la gramática puesta. No se está aplicando | +> > | `NOT PROVEN` | Las dos ramas dieron propuesta: el sondeo no midió nada, y eso **no es pasar** | +> > +> > Etapa nueva en `verify.sh`, y regla nueva en [[Estrategia-de-Pruebas]]: probar +> > que algo restringe necesita un enunciado cuya respuesta sin la restricción sea +> > distinta. +> > +> > ### Lo siguiente que corre Cesar +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli +> > thalyx agent model grammar-check # dos inferencias +> > thalyx agent bench # las gamas, minutos +> > ``` +> > +> > `CLAUDE.md` ya dice siete veces en vez de seis, con esta causa anotada. +> > +> > ## La primera inferencia real completó, y Thalyx rechazó una respuesta correcta — 2026-08-08 +> > +> > **Éste es el estado actual.** Los dos bloques de abajo son la historia. +> > +> > Con `llama-completion`, la corrida siguiente en la Fedora de Cesar llegó hasta +> > el final: los pesos cargaron y **Qwen2.5-3B emitió exactamente el objeto que +> > describe la gramática**, con saltos de línea y sangría —que es lo que +> > `ws ::= [ \t\n]*` permite—. Thalyx lo rechazó, y con un mensaje que acusaba a la +> > herramienta de ignorar la gramática que acababa de obedecer. +> > +> > ### Qué estaba mal +> > +> > `llama.cpp` imprime ` [end of text]` **detrás** del completado cuando el modelo +> > para en un token de fin de generación (`tools/completion/completion.cpp`, sólo +> > fuera de modo interactivo). `Proposal::parse` era `serde_json::from_str`, que +> > rechaza cualquier byte después del objeto. +> > +> > El marcador aleatorio del prompt decía **dónde empieza** la respuesta. **Nada +> > decía dónde termina.** Un límite definido de un solo lado no es un límite: deja +> > el final en manos de quien imprimió el texto, y ese final cambia entre versiones. +> > +> > ### La corrección +> > +> > `Proposal::completion_in` lee **el primer valor JSON completo** después del +> > marcador. La raíz de la gramática es un objeto, así que ahí termina lo que dijo +> > el modelo y todo lo demás lo escribió la herramienta — y eso sigue siendo cierto +> > con lo que decida imprimir la versión siguiente. Recortar el literal +> > ` [end of text]` habría sido la regla 6 al revés. `Proposal::parse` sigue siendo +> > estricta: la laxitud vive en un solo sitio, el borde donde otro programa +> > imprime. +> > +> > ### Lo que esto deja probado contra hierro real, y lo que no +> > +> > | Afirmación | Estado | +> > |---|---| +> > | Las banderas que Thalyx pasa las acepta esta compilación | **Probado** | +> > | Los pesos cargan; el prompt vuelve con el marcador intacto | **Probado** | +> > | Vuelve una propuesta bien formada, dentro del plazo | **Probado** (una gama, un enunciado) | +> > | `--grammar-file` es lo que restringió esa respuesta | **No probado** — un 3B al que se le pide JSON puede darlo solo | +> > | Los números por gama del banco | **No probado**, ninguna gama medida | +> > +> > ### Reglas nuevas en [[Estrategia-de-Pruebas]] +> > +> > - **Un límite definido de un solo lado no es un límite.** +> > - **Una fixture no puede estar en desacuerdo contigo.** Las nueve de este parser +> > terminaban donde el parser esperaba que terminara una respuesta, porque las +> > escribió la misma mano. La regla 6 ya existía y aquí no se siguió; ahora hay +> > una muestra capturada literal, con su procedencia. +> > - **Una comprobación que señala a un culpable tiene que enseñar la evidencia que +> > juzgó.** Es lo único que hizo que esto se viera de una sola lectura, en vez de +> > mandar a auditar el manejo de gramáticas de `llama.cpp` durante días. +> > +> > ### Pendiente menor +> > +> > `CLAUDE.md` dice que el instrumento se equivocó **seis** veces; con ésta van +> > siete, y las dos últimas por la misma causa. Cambiarlo es decisión de Cesar. +> > +> > ## El primer `llama.cpp` de verdad: Thalyx pedía el binario que dejó de ser el correcto — 2026-08-08 +> > +> > **El bloque de abajo es el que construyó esto; éste es el que dice dónde está.** +> > Cesar lo corrió en su Fedora contra `llama.cpp b1-3653e6d` y +> > `Qwen2.5-3B-Instruct-Q4_K_M`, y falló al primer intento — que es exactamente +> > para lo que sirve correrlo. +> > +> > ### Qué pasó +> > +> > `thalyx agent model check` arrancó `llama-cli`, los pesos cargaron, y entonces +> > `llama-cli` **abrió su interfaz conversacional**: sus comandos (`/exit`, +> > `/regen`, `/clear`) y el prompt `>`, en vez de completar y terminar. +> > +> > **No era el GGUF, ni el modelo, ni su máquina.** `llama.cpp` partió sus +> > herramientas: +> > +> > | Binario | Qué es hoy | +> > |---|---| +> > | `llama-cli` | Frontend de **chat interactivo**, sobre el servidor | +> > | `llama-completion` | El completado de **una sola pasada**, con `-f`, `--grammar-file`, `-n`, `--seed` y `--temp` sin cambios | +> > +> > Con `-f`, el `llama-cli` nuevo abre una sesión sobre el archivo en vez de +> > completarlo. Carga, imprime su banner, lee fin de entrada del `stdin` cerrado y +> > **sale con cero**. La herramienta equivocada se ve igual que una que funciona y +> > dio una mala respuesta. +> > +> > ### Por qué las pruebas de aquí no lo vieron +> > +> > Había siete sustitutos y estaban bien escritos: cubrían el recorte de la +> > respuesta, el plazo, el desborde, la bandera rechazada. **Todos honraban el +> > contrato de una pasada, porque todos estaban escritos para contestar.** Modelé +> > el eje del *formato de salida* y el que importaba era el *contrato de +> > ejecución*. Regla nueva en [[Estrategia-de-Pruebas]]: la pregunta no es *«¿qué +> > puede imprimir esta herramienta?»* sino **«¿qué puede hacer que no sea +> > contestar?»**. +> > +> > ### Y el error se disfrazó justo donde había un respaldo +> > +> > `answer_in` recortaba después del marcador aleatorio y, **si el marcador no +> > estaba, devolvía toda la salida**. Ese respaldo se escribió por una causa —que +> > la herramienta no repitiera el prompt— y tenía una segunda que nadie enumeró: +> > que la herramienta **nunca leyera el prompt**. Así que el banner del chat entró +> > como respuesta, el parser falló, y el mensaje dijo *«el modelo contestó algo que +> > no parsea»*: **le echó la culpa a Qwen de una pregunta que nunca se le hizo.** +> > +> > Segunda regla del mismo hallazgo: un respaldo que cubre una causa cubre en +> > silencio todas las que producen la misma señal. +> > +> > ### Lo que se corrigió, y no es cambiar un nombre +> > +> > El binario por omisión es `llama-completion`, sí. Pero lo que arregla la clase +> > es que **el contrato se comprueba en vez de suponerse**: +> > +> > - **Se dejó de pasar `--no-display-prompt`.** El eco del prompt lleva el +> > marcador, y el marcador es la prueba positiva de que el prompt se leyó. +> > Suprimirlo borraba la única evidencia — la bandera que hacía cómoda la salida +> > era la que desarmaba la comprobación. +> > - **Marcador ausente** → `NotOneShot`, que nombra a `llama-completion` y enseña +> > los primeros 400 bytes de lo que salió en su lugar. No es una respuesta que +> > falta, es una **pregunta** que falta. +> > - **Marcador presente y respuesta que no parsea** → `GrammarNotInForce`. No es +> > heurística: un completado restringido por gramática **no puede** producir +> > prosa, así que la prosa demuestra que la gramática no se aplicó. +> > - **Y se avisa antes de la primera inferencia**: configurar `llama-cli` saca la +> > advertencia al momento de configurarlo, y `agent model show` la repite — así +> > un store configurado ayer se arregla al revisarlo y no esperando a que falle. +> > +> > Ninguna de las tres olfatea la prosa de otra herramienta, que sería la regla 6 +> > otra vez. +> > +> > ### Qué quedó probado aquí y qué no +> > +> > **Probado en este contenedor**, con un sustituto que reproduce la conducta del +> > `llama-cli` nuevo —carga, banner, comandos, sale con cero sin completar—: que +> > eso produce `NotOneShot` y no un fallo de parseo; que una salida vacía es +> > contrato roto y no un modelo callado; que un prompt leído con completado vacío +> > **sí** es un modelo callado (el control, sin el cual una comprobación que +> > rechaza todo pasaría); que la prosa produce `GrammarNotInForce`; y que la +> > bandera que desarmaba la comprobación no vuelve por omisión. +> > +> > **Sigue sin probarse, y lo tiene que cerrar su máquina**: que `llama-completion` +> > acepte estas banderas, que tome la gramática, y que la respuesta caiga después +> > del marcador. **Ninguna inferencia ha terminado nunca contra pesos reales.** +> > +> > ### Lo que falta, y es tuyo +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli +> > +> > # si no está construido, sale del mismo árbol de llama.cpp: +> > # cmake --build build --target llama-completion +> > +> > thalyx agent model use media --weights ~/models/qwen2.5-3b-instruct-q4_k_m.gguf +> > thalyx agent model check "dev.thalyx.demo, ese quiero" +> > ``` +> > +> > Ya no hace falta pasar `--binary`: el valor por omisión es el correcto. Si sale, +> > `thalyx agent bench` da la primera tabla de acierto por gama. +> > +> > **852 pruebas pasan** (847 antes), `clippy` limpio, `cargo fmt` aplicado. +> > +> > ## El agente tiene modelo: `llama.cpp` como proceso, las cuatro gamas y el banco — 2026-08-08 +> > +> > **La Fase 1 quedó cerrada al 100% y Cesar zanjó también el encuadre de lo que +> > sobró**: no es deuda de ninguna fase. Sus palabras, que son el registro: +> > +> > > no quedó nada de la fase 1, esas cosas que quedaron no pertenecen a ninguna +> > > fase real debido a que ninguna bloquea nada, son solo cosas del proyecto que +> > > se arreglarán cuando se necesiten arreglar +> > +> > Eligió seguir con **el modelo del agente**, que era el único `NOT PROVEN` de +> > una corrida verde de `verify.sh` y el decreto más grande sin construir. +> > +> > ### Lo que había, y por qué era el hueco más grande del proyecto +> > +> > `crates/thalyx-agent/src/model.rs` tenía **dos** implementaciones de `Model`: el +> > falso hostil y `UnconfiguredModel`, que contesta *«no model is configured»*. O +> > sea que en un sistema operativo donde la IA es ciudadana de primera clase, la +> > IA no existía. [[Gamas-de-Modelo]] estaba decretado desde el 2026-08-03 y nadie +> > lo había implementado. +> > +> > ### Lo que se construyó +> > +> > ``` +> > thalyx agent model show las cuatro gamas, y cuál está puesta +> > thalyx agent model use media --weights elige una, y mide el archivo +> > thalyx agent model check "" una inferencia, con lo que costó +> > thalyx agent grammar la gramática, para repetirlo a mano +> > thalyx agent bench el banco que pide el decreto +> > ``` +> > +> > `agent plan` y `agent do` usan la gama configurada; sin ninguna configurada, +> > siguen exactamente como estaban. Eso último no es cortesía: **una máquina sin +> > modelo es una máquina que se puede usar entera**, que es el +> > [[Principio-Doble-Ruta]] siendo lo que hace sobrevivible la ausencia del modelo +> > en vez de fatal. +> > +> > ### El defecto que apareció escribiendo el banco, sin correr nada +> > +> > **La gramática hacía imposible abstenerse.** Pedía al menos un id de módulo, lo +> > cual vuelve imposible un contrato mal formado —que es lo que el decreto +> > promete— y de paso volvía imposible decir *«no encontré ninguno»*. +> > +> > [[Gamas-de-Modelo]] dice que la abstención es **la medición que más importa**. +> > Con esa gramática, un enunciado ambiguo no tenía respuesta legal salvo inventar: +> > el banco habría sacado **0 de 4 en abstención en las cuatro gamas**, y la +> > lectura obvia habría sido «los modelos chicos inventan» cuando lo que pasaba es +> > que ninguna gama tenía cómo no inventar. +> > +> > Y no falla nada: todo compila, todo parsea, el banco corre y devuelve números +> > plausibles. Regla nueva en [[Estrategia-de-Pruebas]]: **una gramática que fija +> > qué se puede decir fija también qué se puede declinar**, y la pregunta que lo +> > encuentra no es *«¿acepta las respuestas correctas?»* sino *«¿qué respuestas +> > hace imposibles, y alguna era una conducta que quiero medir?»*. +> > +> > La corrección estaba a la mano y sin nombre: `AgentError::NothingToDo` ya +> > existía y ya era la respuesta correcta. Una lista vacía la alcanza. **Y el +> > prompt tiene que decirlo** — una respuesta legal que nadie menciona es una que +> > el modelo no usa, y la gama quedaría medida sobre una decisión que nunca se le +> > ofreció. +> > +> > ### La regla 6 obligaba a no parsear la salida de `llama.cpp` +> > +> > Un parser de la salida de otra herramienta necesita **una muestra real +> > capturada**, y aquí no hay ninguna ni se puede conseguir. Así que no se parsea +> > el formato: el prompt termina en un **marcador aleatorio por invocación** y la +> > respuesta es lo que sigue a su última aparición. Sirve si la herramienta repite +> > el prompt, si no lo repite, si le pone banderas o si le agrega tiempos. +> > +> > Aleatorio y no fijo **porque el texto ajeno va dentro del prompt**: un marcador +> > fijo es una cadena que un README puede contener, y un README que la contuviera +> > estaría eligiendo dónde empieza la respuesta. +> > +> > Lo que queda sin comprobar es más chico y tiene nombre: **que ese `llama.cpp` +> > acepte las banderas**. Por eso las que cambian entre versiones viven en el +> > archivo de configuración, no en el código — si una se rechaza, se arregla +> > editando una línea y `llama.cpp` sale distinto de cero diciendo cuál. +> > +> > ### Y un defecto que sólo apareció corriéndolo +> > +> > `peak rss 0.00 GB`. La unidad estaba fija en GB, así que una medición real de +> > dos megabytes se imprimía igual que *«no se pudo medir»* — las dos cosas que +> > esa función existe para mantener separadas. Encontrado corriéndolo, no +> > leyéndolo, que es la regla 1 otra vez. +> > +> > ### Lo que NO se hizo, y es lo importante de esta entrada +> > +> > **Nada de esto ha corrido contra `llama.cpp`.** El contenedor no lo tiene y no +> > alcanza los pesos. Lo que sí corrió aquí, contra procesos sustitutos: que la +> > respuesta se recorta bien del proceso, que un proceso colgado se mata, que 200 +> > kB de salida se cortan, que las banderas rechazadas salen con su texto de +> > `stderr` íntegro, y el banco entero de nueve casos. +> > +> > `verify.sh` tiene etapa nueva. Con `THALYX_AGENT_WEIGHTS` apuntando a un GGUF +> > corre lo real —incluida la inyección **con un modelo que no es falso de nada**, +> > y su control— y sin eso dice `NOT PROVEN` nombrando cuál de las dos mitades +> > falta, el binario o los pesos. +> > +> > ### Lo que falta, y es tuyo +> > +> > ``` +> > git pull && cargo install --path crates/thalyx-cli +> > +> > # baja un GGUF de Qwen2.5-3B-Instruct-Q4_K_M (~2 GB) donde quieras +> > thalyx agent model use media --weights ~/models/qwen2.5-3b-instruct-q4_k_m.gguf +> > thalyx agent model check "dev.thalyx.demo, ese quiero" +> > ``` +> > +> > Ese `check` responde de una vez las dos cosas que aquí no se pueden responder. +> > Si sale, `thalyx agent bench` da la primera tabla de acierto por gama que ha +> > existido. Y `sudo ./dev/verify.sh` con `THALYX_AGENT_WEIGHTS` puesto corre la +> > etapa entera. +> > +> > **Lo primero que puede fallar es una bandera**, y está bien: sale con su +> > mensaje, y se arregla en +> > `/state/agent-model.toml`, campo `extra_args`. +> > +> > **847 pruebas pasan** (802 antes), `clippy` limpio, `cargo fmt` aplicado. +> > +> > ## Los 40 segundos de arranque eran un puerto serie a 9600 baudios — 2026-08-07 +> > +> > **`nucleo lento` corrió en hierro y contestó al primer intento.** La memoria USB +> > no tenía nada que ver. +> > +> > ``` +> > 18.27s at 0.07s +> > after printk: legacy console [ttyS0] enabled +> > then ACPI: Core revision 20240827 +> > ``` +> > +> > **18.27 de 38.5 segundos, en el segundo 0.07**, antes de que el kernel tocara un +> > disco. La hipótesis de «la USB es lenta» queda descartada por la **posición** del +> > hueco, no por su tamaño: si fuera la memoria, el tiempo estaría al final, donde se +> > leen discos — y ahí los huecos miden 0.25 s. +> > +> > ### La causa +> > +> > `CONFIG_CMDLINE` decía `console=ttyS0`, **sin velocidad**. Sin velocidad, el +> > driver 8250 usa **9600 baudios**, y `printk` es síncrono: el kernel no avanza +> > hasta que los caracteres salieron físicamente del puerto. Los 38.5 s son ese +> > puerto, en dos mitades: +> > +> > 1. **El hueco de 18.27 s.** Una consola se registra con `CON_PRINTBUFFER`, así que +> > el kernel le vuelca **todo el log acumulado** en cuanto aparece — unas 250 +> > líneas de mapa de memoria y tablas ACPI. A 9600 baudios: `250 × ~70 × 10 ÷ 9600 +> > = 18.2 s`. Es el número que salió. +> > 2. **Los ~18 s restantes**, repartidos en 704 líneas: 25 ms por línea, que es lo +> > que cuesta cada `printk` posterior por el mismo puerto. +> > +> > ### Y es la quinta vez que el anfitrión hacía algo gratis +> > +> > El puerto serie de QEMU es un pty: **no tiene baudios**, se vacía al instante. Las +> > cuatro veces anteriores el anfitrión hacía algo que en hierro *no existía*; ésta +> > hacía algo que en hierro **existe y cuesta**, que es peor de encontrar — nada +> > falta, nada falla, la máquina nada más tarda. +> > +> > Peor todavía: `run-uefi` y `run-hardware` **no pasan `-append` a propósito**, o +> > sea que usan esa misma línea compilada. **La trampa estaba dentro del camino que +> > sí se probaba**, y era invisible porque el anfitrión la pagaba. +> > +> > ### Lo que se decidió, y lo que no +> > +> > Cesar eligió **darle velocidad en vez de quitarlo**: +> > `console=ttyS0,115200 console=tty0`. Doce veces más rápido —los ~30 s se vuelven +> > ~2.5 s— y **no se cambia nada por nada**: `run-uefi` y `run-hardware` miran por +> > `-serial mon:stdio`, y eso es lo único que hace diagnosticable un arranque que +> > muera *antes* de que suba el framebuffer. Quitarlo del todo era más rápido y +> > costaba ese diagnóstico. +> > +> > ### El segundo defecto del mismo arranque, que `config-check` no puede ver +> > +> > `CPU topo: CPU limit of 2 reached. Ignoring further CPUs`. Nadie eligió 2: +> > `allnoconfig` corre con SMP apagado, donde `NR_CPUS` es 1, y encender SMP después +> > sólo lo sube al piso de su rango. Puesto **`CONFIG_NR_CPUS=64`**, que es lo que el +> > propio kernel usa para SMP x86_64. +> > +> > **`config-check` compara lo que `thalyx.config` pide contra lo que salió, así que +> > una opción que nadie pidió no tiene línea que comparar.** Es el mismo hueco +> > estructural que dejó pasar `CONFIG_SECURITY_NETWORK` y `CONFIG_USB_STORAGE`, y no +> > se cierra con más comparaciones. Por eso las dos afirmaciones nuevas viven en +> > `init.rs`, y las dos se verificaron fallando sin el arreglo. +> > +> > ### Comprobado en hierro el mismo día, y el número salió exacto +> > +> > ``` +> > The kernel talked for 5.7s. The longest silences in it: +> > 1.53s at 0.07s +> > after printk: legacy console [ttyS0] enabled +> > then ACPI: Core revision 20240827 +> > ``` +> > +> > **38.5 s → 5.7 s.** Y lo que lo convierte en prueba y no en mejora es el hueco: +> > las mismas dos líneas, en el mismo sitio, **18.27 s → 1.53 s**. Eso es un factor +> > de **11.94**, contra el 12.0 que predice `115200 ÷ 9600`. El diagnóstico no +> > predijo «va a bajar»; predijo *cuánto*, y bajó eso. +> > +> > Con las dos medidas se despeja lo que costaba cada parte. Si `T = W + S`, donde +> > `W` es trabajo real y `S` el puerto serie, entonces `38.5 = W + S` y +> > `5.7 = W + S/12` dan **S = 35.8 s y W = 2.7 s**. O sea que el puerto se llevaba +> > **35.8 de los 38.5 segundos** —más de los ~30 que estimé— y la máquina de verdad +> > tarda **2.7 segundos** en arrancar. El resto de los 5.7 son los mismos mensajes a +> > 115200. +> > +> > ### Y de paso apareció qué máquina es +> > +> > `smpboot: CPU0: AMD Ryzen 5 5600G` — seis núcleos, doce hilos. Con `NR_CPUS=2` +> > Thalyx estaba tirando diez de los doce. El prompt ahora avisa **7 problemas** en +> > vez de 10, que es lo que se espera si la línea de `CPU topo` desapareció, pero +> > eso no está confirmado: lo confirma `nucleo`, y no se ha corrido después del +> > cambio. +> > +> > **802 pruebas pasan** (800 antes), `clippy` limpio, `cargo fmt` aplicado. +> > +> > ## La Fase 1 está cerrada: una PC se instaló Thalyx a sí misma y arrancó sin el medio — 2026-08-07 +> > +> > **El bloque de arriba es más reciente; éste es el que dice dónde está el +> > proyecto.** El acto 2b corrió en hierro y salió entero. +> > +> > ### Lo que pasó, con nombres +> > +> > Arrancado desde la memoria de 3 GiB, `discos` contestó **tres discos** —no siete— +> > y nombró cada uno: +> > +> > ``` +> > /dev/sda 447 GiB 3 btrfs `fedora` ← el sistema de Cesar +> > /dev/sdb 3 GiB 1 a Thalyx boot partition ← el medio +> > 2 a Thalyx store +> > /dev/sdc 7 GiB 1 FAT `XBOX` ← el destino +> > ``` +> > +> > `instalar-en /dev/sdc` dijo de dónde salía el kernel —`/dev/sdb1`, 10 671 104 +> > bytes— **qué había en el destino antes de preguntar** —`1 FAT \`XBOX\``— y pidió +> > teclear la ruta. Después: +> > +> > ``` +> > ok kernel taken off /dev/sdb1 +> > ok boot /dev/sdc1 ▪ the kernel, at the one path a firmware looks for +> > ok store /dev/sdc2 ▪ labelled `thalyx-store` +> > ok subvolume system / modules / user +> > +> > That disk is a Thalyx machine now. +> > ``` +> > +> > `apagar`, memoria fuera, encender. Y la máquina arrancó de ese disco: +> > +> > ``` +> > 2 disk(s): +> > /dev/sda 447 GiB 3 btrfs `fedora` +> > /dev/sdb 7 GiB 1 a Thalyx boot partition +> > 2 a Thalyx store +> > ``` +> > +> > **Un firmware real arrancó un disco físico que Thalyx particionó, formateó y +> > escribió él mismo, sin medio puesto, y la máquina encontró su store por la +> > etiqueta.** Eso es el criterio de salida. +> > +> > ### Los cuatro arreglos del día quedaron confirmados en vivo +> > +> > Ninguno se probó en una VM primero, y los cuatro se comportaron: +> > +> > - **`3 disk(s)` y no siete** — el filtro de particiones. La Fedora dejó de +> > aparecer partida en pedazos ofrecibles. +> > - **`a Thalyx boot partition`** — la máquina nombra su propio trabajo. +> > - **`it has 2 partition(s) on it now: 1 FAT \`XBOX\``** — el guardián nuevo, dicho +> > antes de la pregunta y no después. +> > - **`! 9 new kernel problems; \`nucleo\` shows them`** — el aviso del prompt, y en +> > ninguno de estos arranques el `-110` del USB volvió a pisar una línea. +> > +> > Y un detalle que confirma el escritor de GPT: **el disco instalado no produce el +> > aviso de «alternate GPT header not at the end»** que sí produce el medio. El medio +> > lo tiene porque se hizo con `dd` de una imagen más chica que la memoria; el +> > instalado no, porque Thalyx escribió la tabla contra el tamaño real del +> > dispositivo. +> > +> > ### Lo que no se ejerció, que no es lo mismo que un hueco de la Fase 1 +> > +> > **Zanjado por Cesar el 2026-08-08: la Fase 1 está cerrada al 100%, sin +> > asteriscos.** El criterio que él decretó no nombra NVMe ni disco interno; llamar +> > «huecos» a configuraciones de hardware no ejercidas les daba el peso de una +> > cláusula incumplida, y eso fue un error de encuadre. Ver +> > [[Criterio-de-Salida-Fase-1]], donde queda el razonamiento completo. +> > +> > Sigue siendo cierto y pasa a la fase de validación: +> > +> > 1. **Ningún disco interno ha recibido una instalación.** El destino fue removible. +> > El camino del instalador es idéntico —sysfs, `BLKRRPART`, los mismos +> > escritores—; lo que cambia es el bus. **No es una imposibilidad**: instalar al +> > lado de Fedora lo cerraría. Se aplaza por decisión. +> > 2. **NVMe sobre silicio real sigue sin ejercerse**, porque esa máquina no tiene +> > ninguno y no se va a comprar uno. No es un driver que falle: es hardware que no +> > existe ahí. Esto sí es una imposibilidad. +> > +> > ### Lo que abrió, y es una pregunta de Cesar +> > +> > **La máquina tarda ~40 segundos en llegar al prompt**, más que su Fedora. Los +> > tiempos del kernel dicen dónde no está el problema: `sd ... [sdb]` aparece a los +> > **38 s** y el mensaje de `struct module` a los **34 s**, y esos números son +> > **iguales desde dos memorias distintas**. Un tiempo constante entre medios +> > distintos es lo que hace un **plazo fijo**, no lo que hace una lectura lenta — así +> > que la hipótesis de «la USB es lenta» explica, como mucho, lo que tarda el +> > firmware antes de que el kernel empiece a contar. +> > +> > **Y no había con qué medirlo.** `nucleo` contesta cuatro líneas de problemas o +> > setecientas de todo, y ninguna de las dos dice a dónde se fueron los 34 segundos. +> > Construido **`nucleo lento`**: los silencios más largos entre mensajes +> > consecutivos, con la línea de antes y la de después de cada uno. El kernel ya +> > ponía la marca de tiempo en cada línea; nadie las había restado. +> > +> > **La causa no está determinada y no se va a adivinar.** Un hueco dice a dónde se +> > fue el tiempo, no qué lo tomó. +> > +> > ### Lo que falta, y es un arranque +> > +> > ``` +> > git pull && make -C image && sudo make -C image installed INSTALLEDSIZE=2G +> > ``` +> > +> > `dd` a la memoria, arrancar, y teclear **`nucleo lento`**. +> > +> > > **Contestado el mismo día — ver el bloque de arriba.** Era el puerto serie a +> > > 9600 baudios, no la memoria. Y la razón por la que la constancia entre dos +> > > memorias apuntaba a un plazo fijo era correcta en la forma y equivocada en el +> > > sitio: el tiempo constante estaba al **principio**, no al final. +> > +> > **800 pruebas pasan** (797 antes), `clippy` limpio, `cargo fmt` aplicado. +> > +> > ## `discos` corrió en hierro y ofrecía la Fedora de Cesar como destino — 2026-08-07 +> > +> > **El bloque de arriba es más reciente.** Segundo arranque en la PC real, con el arreglo +> > de la consola puesto. El error del USB **no volvió** —`nucleo` lista 10 líneas de +> > problema entre 714 registros y ninguna es del USB— y el prompt no anunció nada, +> > que es lo correcto: no hubo problemas nuevos después de que arrancó la sesión. +> > +> > **Que no volviera no es que esté arreglado, y ahora consta cuál de las dos es.** +> > Cesar confirmó que **nunca desconectó nada**: el receptor Telink estaba puesto en +> > esa corrida. O sea que **el `-110` es intermitente y sigue vivo** — no ocurrió esa +> > vez, puede ocurrir la próxima. +> > +> > Y hubo que deshacer una confusión antes de poder concluirlo: el Telink **no es el +> > WiFi**. Son dos dispositivos distintos en el mismo bus, y su `lsusb -t` los +> > separa — puerto 6 es `usbhid` (el receptor de teclado y ratón) y puerto 8 es +> > `rtl8xxxu`, que ése sí es el WiFi. Preguntar *«¿estaba conectado el Telink?»* sin +> > decir cuál de los dos era invitaba justo a esa respuesta. +> > +> > **Lo que sí quedó resuelto es el síntoma, que era el que impedía usar la máquina.** +> > Con la consola en emergencias, el `-110` ya no pisa el prompt: si vuelve, la +> > sesión dirá `! N new kernel problem(s)` y seguirá siendo usable. La causa sigue +> > abierta y **ninguna opción de kernel está justificada** — un fallo intermitente +> > que aparece en un arranque y no en el siguiente se parece mucho más a un +> > dispositivo marginal que a un hueco de `thalyx.config`. +> > +> > ### Lo que `discos` respondió, y es lo bueno primero +> > +> > ``` +> > 7 disk(s): +> > /dev/sda 3 GiB, 2 partition(s) ← la memoria USB +> > /dev/sdb 447 GiB, 3 partition(s) +> > 3 btrfs `fedora` ← su sistema +> > ``` +> > +> > - **AHCI y SATA quedaron probados en hierro.** `sd 9:0:0:0: [sda]` y un disco de +> > 447 GiB con la Fedora adentro: eso es `SATA_AHCI` + `SCSI` + `BLK_DEV_SD` +> > funcionando contra silicio real. Era una de las tres filas de la tabla de +> > riesgo. +> > - **Esa máquina no tiene NVMe.** Siete discos y ninguno es `nvme0n1`; su sistema +> > vive en un SATA. Así que **NVMe sobre silicio real es incontestable en esta +> > máquina** — no porque el driver falle, sino porque no hay hardware. Queda dicho +> > en vez de contarse como probado. +> > +> > ### Y el defecto, que es el más peligroso encontrado hasta ahora +> > +> > **Cuatro de esos «siete discos» son particiones**, incluidos los 444 GiB de +> > `/dev/sdb3` —la Fedora— listados bajo la línea *«`instalar-en ` puts +> > Thalyx on one. Everything on it is lost.»* +> > +> > El filtro existía y su comentario decía que `partitions::of` *«errors for a +> > partition»*. **No erraba.** Busca `/sys/dev/block/:`, que existe +> > igual para las dos, `read_dir` funciona sobre una partición, y como no tiene +> > hijos con archivo `partition` devolvía **`Ok([])`**. *«No tiene particiones»* y +> > *«esto no es la clase de cosa que tiene particiones»* salían por el mismo canal, +> > y el llamador sólo miraba `is_ok()`. +> > +> > Y `install` las habría aceptado: escribe la tabla en el LBA 0 de lo que reciba. +> > Una tabla dentro de una partición es **legal, invisible, y no arranca nada**, +> > mientras el sistema de archivos que había ahí ya no está — la misma forma que la +> > GPT con suma equivocada, donde el fallo no llega y el disco vuelve pareciendo +> > intacto. +> > +> > **Arreglado en los dos sitios**, que es lo que importa: `discos` deja de +> > listarlas —presentación— e **`install` se niega antes de escribir un byte**, que +> > es lo que impide perder un disco cuando alguien teclea el nombre igual. El +> > discriminador es el que el kernel ya tenía y nadie le preguntó: el archivo +> > `partition`. Tres pruebas, incluida una que le pregunta al kernel que corre las +> > pruebas si el modelo es correcto, porque un falso que modela la propiedad +> > equivocada no es un falso sino otro sistema. +> > +> > Regla nueva en [[Estrategia-de-Pruebas]], y una segunda dentro de ella: **un +> > comentario que enuncia una propiedad es una prueba que nunca corre.** Lo único +> > capaz de contradecir esa frase era una máquina con particiones, que durante +> > semanas fue ninguna máquina. +> > +> > ### Y Thalyx no reconocía su propia partición de arranque +> > +> > `discos` describía la ESP de la memoria de la que estaba corriendo como +> > *«something I do not recognise»*, teniendo un lector de FAT32 adentro. Ahora dice +> > **«a Thalyx boot partition»**, y una FAT ajena la nombra con su etiqueta. Una +> > máquina que no sabe nombrar su propio trabajo no tiene autoridad para nombrar el +> > ajeno. +> > +> > ### Dos cosas del `nucleo` que no son defectos y conviene no confundir +> > +> > - **`GPT: 4194303 != 7831551`.** La imagen es de 2 GiB y la memoria de ~3.7 GiB, +> > así que la copia de respaldo de la tabla quedó donde termina la imagen y no +> > donde termina el dispositivo. Le pasa a **toda** imagen escrita con `dd` a un +> > medio más grande. Linux avisa y sigue. +> > - **`CPU limit of 2 reached`.** `allnoconfig` deja `NR_CPUS` en 2 y esa máquina +> > tiene más. No rompe nada; explica parte de la lentitud del arranque. +> > +> > ### Lo que falta para cerrar la Fase 1, y ya no es imposible +> > +> > **Falta el acto 2b**, y el criterio de Cesar es *«ponerla en una PC sin sistema +> > operativo y que ahora tenga Thalyx como OS»*: la máquina tiene que **tener** +> > Thalyx en un disco propio, con el medio quitado. En hierro eso nunca ha pasado. +> > +> > Pero tiene **tres memorias** (4, 8 y 32 GB), y eso vuelve alcanzable hoy lo que +> > parecía imposible: **arrancar de la memoria A e `instalar-en` la memoria B**, +> > quitar A, y encender. Firmware real, instalación real escribiendo una GPT real en +> > un disco físico real, y un arranque real desde ese disco sin medio puesto. Lo +> > único que no responde es que el disco sea interno. Si eso cumple el decreto lo +> > decide Cesar, porque el decreto es suyo. +> > +> > **797 pruebas pasan** (794 antes), `clippy` limpio, `cargo fmt` aplicado. +> > +> > ## El dispositivo tiene nombre, y el prompt ya no se pisa — 2026-08-07 +> > +> > **El bloque de arriba es más reciente.** +> > +> > ### Qué es `usb 1-6` +> > +> > `lsusb -t` y `dmesg` en Fedora lo contestaron sin arrancar nada: +> > +> > ``` +> > usb 1-6: New USB device found, idVendor=248a, idProduct=16ab +> > usb 1-6: Product: Wireless Receiver +> > usb 1-6: Manufacturer: Telink +> > ``` +> > +> > Un **receptor inalámbrico Telink** de teclado y ratón, a *full speed* (12M), con +> > dos interfaces HID. En Fedora enumera a los 1.2 s; en Thalyx agota el plazo. +> > +> > **Y no es el teclado con el que Cesar escribió**: hay otro HID de dos interfaces +> > en el bus 3, puerto 1. Por eso pudo teclear `apagar`. Desconectar el receptor +> > Telink es a la vez el atajo para usar la máquina hoy **y el control** que +> > confirma que ése era el dispositivo. +> > +> > **No se agregó ninguna opción de kernel.** Todavía no está descartado que el +> > dongle sea lento o defectuoso, y una opción agregada por corazonada es lo +> > contrario de lo que hace este proyecto. Lo que faltaba era el instrumento, y +> > ahora existe: con el prompt utilizable, `nucleo` muestra el buffer entero — +> > incluidas todas las líneas de nivel informativo de la enumeración USB que la +> > consola nunca imprimió— y eso sí dice si el kernel reintentó, cuántas veces, y +> > qué estaba haciendo en los 38 segundos previos. +> > +> > ### El arreglo de la consola, que son dos mitades +> > +> > **La consola queda en emergencias (`set_console_loglevel(1)`)**, y **el prompt +> > anuncia lo que llegó**: +> > +> > ``` +> > ! 2 new kernel problem(s); `nucleo` shows them +> > > +> > ``` +> > +> > La segunda mitad es lo que impide que la primera sea esconder. Se imprime +> > **antes** del prompt y nunca a media línea, que es el defecto entero. +> > +> > Para saber *«qué ha dicho el kernel desde que miré»* hacía falta un cursor, y +> > contar registros no sirve: el buffer sobrescribe los viejos, así que la cuenta +> > puede **bajar** mientras llegan mensajes. Ahora `KernelMessage` lleva el número +> > de secuencia del kernel, que sólo sube. Comprobado contra el `/dev/kmsg` real de +> > este contenedor: 358 registros, monótono, y distinto del campo de tiempo — que es +> > el error que se habría visto igual de bien. +> > +> > Y si el buffer se dio la vuelta entre dos miradas, el aviso dice **«at least»** +> > en vez de presentar un subconteo como total. +> > +> > **Cinco pruebas nuevas**, incluida la de control: un mensaje que sólo *ocurrió* +> > no interrumpe el prompt. Sin ella, un aviso que aparece siempre es uno que nadie +> > lee, que es el mismo defecto reconstruido un nivel más arriba. +> > +> > ### Y el umbral estaba sobre el eje equivocado +> > +> > `init.rs` describía el síntoma **antes de que ocurriera** —*«a message arriving +> > mid-line steps on it — the machine looks like it stopped listening»*— y aun así +> > ocurrió. Filtrar por gravedad contesta *«¿esto importa?»*; lo que arruina una +> > interfaz no es que un mensaje importe, es que **vuelva**. Y la repetición no es +> > una propiedad del mensaje sino de la serie, así que ningún nivel la ve. +> > +> > Segundo defecto del mismo hilo: el nivel de consola suprime lo que tenga +> > prioridad **>= él**, así que el 4 tiraba las advertencias, mientras +> > `is_trouble()` las cuenta **como** problema y la línea del arranque decía +> > *«warnings and worse only»*. Un mismo juicio en dos lugares se desincronizó en +> > silencio, y hacia el lado peor: la pantalla afirmaba mostrar más de lo que +> > mostraba. Las dos reglas están en [[Estrategia-de-Pruebas]]. +> > +> > ### Lo que falta, y es tuyo +> > +> > ``` +> > git pull +> > make -C image +> > sudo make -C image installed INSTALLEDSIZE=2G +> > ``` +> > +> > `dd` a la memoria otra vez (paso 4 de [[Arranque-en-Hierro]]), arrancar, y ahora +> > sí: **`nucleo`** —que es lo que dice qué pasó con el USB— y **`discos`**, que +> > nunca se ha corrido en hierro y que diría si el NVMe y el `sda` aparecen. +> > +> > **794 pruebas pasan** (789 antes), `clippy` limpio, `cargo fmt` aplicado. +> > +> > ## Una PC de verdad arrancó Thalyx desde una USB de verdad — 2026-08-07 +> > +> > **El bloque de arriba es más reciente.** El acto 2a corrió. Firmware real, monitor +> > real por HDMI, memoria física, teclado físico. Lo que salió en la pantalla: +> > +> > ``` +> > [ 38.075277] BTRFS: device label thalyx-store devid 1 transid 2 /dev/sdb2 (8:18) scanned by init (1) +> > ok store /dev/sdb2 ▪ three subvolumes, found by the label `thalyx-store` +> > ok thalyx-lsm 2 hook(s) live, 3 map(s) pinned under /sys/fs/bpf/thalyx +> > ok enforcement 2 of 2 hook(s) live: thalyx_socket_c, thalyx_file_ope +> > ``` +> > +> > Cada línea es una afirmación distinta, y **ninguna la podía hacer una VM**: +> > +> > - **Un firmware real arrancó Thalyx de una memoria física**, por el +> > *fallback* `\EFI\BOOT\BOOTX64.EFI`, sin gestor de arranque. +> > - **La pantalla funcionó en hierro.** `FB_EFI` adoptó el framebuffer que dejó +> > *su* firmware, en un monitor por HDMI. Era la única parte de la pantalla que +> > una VM no respondía, y es el punto 3 de la lista de riesgo de +> > [[Construccion-del-ISO]] — *«arrancaría bien y no se vería nada»*. +> > - **`USB_STORAGE` funcionó en hierro**: la memoria salió como `/dev/sdb2`, +> > major 8:18, o sea la capa SCSI de verdad. La línea que faltaba esa mañana. +> > - **Encontró su store por la etiqueta**, sin `thalyx.store=`. +> > - **El LSM se enganchó**, en un arranque por firmware sobre hardware real. +> > - **Y el teclado funcionó.** Cesar tecleó `apagar` y la máquina se apagó. +> > +> > Ese último punto lo estableció **él, con el control correcto**: volvió a +> > arrancar sólo para separar *«el teclado no sirve»* de *«algo me impide +> > teclear»*, tecleó antes de que apareciera el error, y funcionó. Es la regla 4 — +> > una negativa sin línea base y sin control no dice nada — aplicada por el humano +> > sin que nadie se la pidiera. +> > +> > **Hay un `sda` además del `sdb`**, así que esa máquina tiene otro disco SCSI — +> > probablemente SATA por AHCI. `discos` lo habría dicho y no se alcanzó a correr. +> > +> > ### Y encontró un defecto real, que es para lo que sirve correr las cosas +> > +> > ``` +> > > [ 51.812474] usb 1-6: device descriptor read/64, error -110 +> > ``` +> > +> > `-110` es `ETIMEDOUT`: un dispositivo USB en el bus 1, puerto 6, cuyo descriptor +> > no se puede leer. El kernel **reintenta para siempre**, así que el mensaje +> > vuelve cada pocos segundos, encima del prompt. La sesión queda inusable aunque +> > el teclado funcione. Y los 38 segundos hasta encontrar el store son el mismo +> > síntoma: la enumeración se pasó ese tiempo agotando plazos. +> > +> > **Lo notable es que el código ya había previsto exactamente esto** y eligió el +> > umbral equivocado por uno. `init.rs:393` dice, textual: +> > +> > > *From here there is a human at a prompt, and an info-level message arriving +> > > mid-line steps on it — the machine looks like it stopped listening.* +> > +> > Y baja la consola a `4`. El razonamiento vale para un error que ocurre **una +> > vez**; no vale para uno que se repite sin parar. Un mensaje que se repite deja +> > de ser información y es ruido, y el umbral no distingue las dos cosas porque +> > mira la gravedad y no la repetición. +> > +> > **Y el mensaje del arranque miente por un nivel.** `set_console_loglevel(4)` +> > suprime todo lo que tenga prioridad `>= 4`, o sea **las advertencias se van** — +> > pero la línea dice *«warnings and worse only»* y el comentario dice *«warnings +> > and errors still come through»*. Mientras tanto `is_trouble()` cuenta la +> > prioridad 4 **como** problema, así que `nucleo` las llama problemas y la consola +> > las tira. Las dos mitades del mismo criterio no coinciden. +> > +> > ### Lo que falta, y son dos preguntas distintas que no hay que mezclar +> > +> > 1. **Qué es `usb 1-6`.** Se contesta desde Fedora, gratis y sin riesgo: +> > `lsusb -t` y `dmesg | grep -i "1-6"`. Hasta saberlo, **no se agrega ninguna +> > opción de kernel**: sería adivinar, y este proyecto tiene una regla sobre +> > creerle a un instrumento antes de descartar al que preguntó. +> > 2. **Cómo sobrevive la sesión al ruido del kernel.** Es decisión de Cesar y +> > está en [[Tareas-Pendientes]]. +> > +> > ## Los cuatro grupos de controladores corrieron, y el acto 2 se parte en dos — 2026-08-07 +> > +> > **El bloque de arriba es más reciente.** Cesar corrió `run-hardware` entero. Lo que +> > devolvió la pantalla, con nombres: +> > +> > ``` +> > hid-generic 0003:0627:0001.0001: input: USB HID v1.11 Keyboard [QEMU QEMU USB Keyboard] on usb-0000:00:03 +> > BTRFS: device label thalyx-store devid 1 transid 2 /dev/nvme0n1p2 (259:2) +> > ok store /dev/nvme0n1p2 ▪ three subvolumes, found by the label `thalyx-store` +> > ``` +> > +> > - **El teclado USB enlazó**: `xhci_hcd` enumeró el dispositivo y `hid-generic` +> > creó un dispositivo de entrada. +> > - **El NVMe enlazó y las particiones se llaman bien** — `nvme0n1p2`, major 259. +> > Era el riesgo más caro que cargaba el acto 2, el que hace que `partitions.rs` +> > lea los nombres de sysfs en vez de derivarlos. +> > - **Arrancó del NVMe sin medio puesto**: encontró **un solo** `thalyx-store`; con +> > la USB conectada habría encontrado dos y se habría negado. +> > - Y de ahí se sigue lo que la pantalla no dice: para que ese store exista, +> > `instalar-en` tuvo que leer el kernel del medio USB, así que **`USB_STORAGE` +> > también funcionó**. La línea que faltaba esa misma mañana. +> > +> > **Lo que le queda al acto 2 ya no es «¿Thalyx tiene los drivers?».** Es silicio +> > concreto. +> > +> > ### Y la restricción real, que cambia la forma del acto 2 +> > +> > Cesar tiene **una sola PC** y no va a tener otra: *«no puedo hacerla ni hoy ni +> > nunca, no tengo una pc limpia»*. Sí tiene memorias USB (4, 8 y 32 GB). Eso parte +> > el acto 2 en dos mitades de costo muy distinto: +> > +> > - **Arrancar desde la USB en su propia máquina** responde firmware real, xHCI +> > real, su teclado real, `USB_STORAGE` real y su NVMe real visto por el driver +> > real — **y no escribe un solo byte** en el disco interno. +> > - **Instalar en el disco interno destruiría Fedora.** `thalyx install` escribe una +> > GPT nueva sobre el disco entero; el módulo abre diciendo *«turning a disk with +> > no operating system on it»* y es literal. Instalar al lado **no está construido** +> > y es un decreto que Cesar no ha tomado. Se puede hacer sin romper la filosofía +> > —particiones en el espacio libre, el kernel en la ESP existente bajo +> > `\EFI\thalyx\`, y el **menú del firmware** eligiendo, que es el firmware y no un +> > segundo programa— pero lo que cuesta no es el código: es escribir en el disco +> > que sostiene la única máquina que verifica este proyecto. +> > +> > El procedimiento entero está en [[Arranque-en-Hierro]], escrito para contestarse +> > desde sí mismo: 642 MiB es el mínimo instalable, así que 2 GiB de imagen basta y +> > entra en cualquiera de las tres memorias. Lleva el aviso que importa —**`discos` +> > va a listar el NVMe con Fedora adentro y `instalar-en` no se teclea**— y el paso +> > de Secure Boot, que es lo más probable que lo detenga y no es Thalyx fallando. +> > +> > ## El acto 2 habría fallado, y se supo sin correrlo — 2026-08-07 +> > +> > **El bloque de arriba es más reciente.** Cesar preguntó si GNOME Boxes servía para el +> > acto 2, porque no tiene una segunda PC. Buscar la respuesta encontró un defecto +> > que habría aparecido con la memoria USB ya puesta en una máquina. +> > +> > ### `CONFIG_USB_STORAGE` no estaba, y su ausencia no rompe el arranque +> > +> > El acto 2 es `dd` a una USB, arrancar, `discos`, `instalar-en /dev/nvme0n1`. Sin +> > ese driver: +> > +> > - La máquina **arranca de la USB perfectamente**, porque la especificación UEFI +> > obliga al **firmware** a leer el medio con su propio controlador. Monta sus +> > siete sistemas de archivos, engancha el LSM, saca su prompt en la pantalla. +> > - Y falla **dos comandos después**: `instalar-en` busca el medio del que arrancó +> > recorriendo `/sys/block` (`partitions.rs:189`), y el kernel enumeró la USB como +> > dispositivo USB sin darle nunca un dispositivo de bloque. +> > - El mensaje sería *«no encuentro un medio de Thalyx»* en una máquina que está +> > visiblemente corriendo desde uno. **En ningún punto aparece la palabra USB.** +> > +> > Es la **cuarta vez** que algo de fuera hacía un trabajo que el diseño nunca +> > escribió —systemd con los controladores de cgroup, el initramfs externo con el +> > `switch_root`, el archivo del kernel con `/dev/console`— y la primera en que la +> > capa de abajo **no se quita**: el firmware sigue ahí haciendo su parte, y por eso +> > el arranque no se rompe y el defecto es invisible. Regla nueva en +> > [[Estrategia-de-Pruebas]], con la pregunta que sí lo encuentra: no *«¿qué hardware +> > tiene la PC?»* sino *«¿qué tiene que leer Thalyx además de lo que el firmware ya +> > leyó por él?»*. Un inventario de hardware no la contiene, que es por qué no estaba +> > en las tres filas de la tabla de riesgo. +> > +> > Ya está puesta, con **una quinta prueba** que lee `thalyx.config` y la exige, +> > comprobada en las dos direcciones —comentando la línea, falla—, porque +> > `config-check` atrapa una opción que Kconfig descartó y **no puede atrapar una que +> > nadie pidió**. +> > +> > ### Y Boxes no sirve, pero QEMU sí sirve para más de lo que decía la bóveda +> > +> > Boxes da discos virtio y teclado PS/2 — o sea lo que el acto 1 ya probó — y no +> > tiene interfaz para agregar otros controladores. Pero **QEMU emula xHCI, NVMe, +> > AHCI y un disco USB**, y el driver del kernel que habla con un controlador emulado +> > es el mismo que habla con silicio real. La bóveda decía «una VM no prueba los +> > controladores», que era cierto de la VM que se estaba usando y no de toda VM. +> > Corregido en [[Construccion-del-ISO]] con la tabla de qué responde cada cosa. +> > +> > **`make -C image run-hardware`**, con tres modos: +> > +> > ``` +> > make -C image run-hardware arranca del medio USB; adentro, `discos` +> > y `instalar-en /dev/nvme0n1` +> > make -C image run-hardware NOMEDIUM=1 la misma máquina sin la USB, que ahora +> > tiene que arrancar del NVMe que instaló +> > make -C image run-hardware NOPS2=1 sin controlador PS/2, así que una tecla +> > que llegue sólo pudo venir por USB +> > ``` +> > +> > Los dos discos en blanco se hacen una vez y se conservan: un disco que se borra +> > en cada corrida no puede mostrar que la instalación siguió ahí. +> > +> > **No es el acto 2 y no lo cierra**, y el objetivo lo dice línea por línea. Lo que +> > hace es mover el riesgo de cuatro grupos de controladores nunca ejercidos a cuatro +> > ejercidos contra controladores emulados, dejando abierto lo que de verdad pide una +> > PC: silicio real y una memoria física en un puerto físico. +> > +> > ### Lo que falta, y es tuyo +> > +> > ``` +> > git pull +> > make -C image # aquí se sabe si USB_STORAGE sobrevivió +> > sudo make -C image installed +> > make -C image run-hardware +> > ``` +> > +> > Adentro: `discos` tiene que listar `/dev/nvme0n1`, `/dev/sda` y el medio. Después +> > `instalar-en /dev/nvme0n1`, `apagar`, y `make -C image run-hardware NOMEDIUM=1`. +> > +> > **Lo primero que puede fallar sigue siendo la compilación del kernel**, y está +> > bien: `config-check` detiene el build si `olddefconfig` descartó `USB_STORAGE`. +> > Depende de `USB` y `SCSI` y las dos ya estaban, así que no debería — pero si pasa, +> > la lectura es la de `HID_SUPPORT`: buscar el `menuconfig` que la contiene antes +> > que sus dependencias. +> > +> > **789 pruebas pasan** (788 antes de este cambio), `clippy` limpio en 1.97, +> > `cargo fmt` aplicado. El bloque de abajo sigue siendo el estado del acto 1. +> > +> > ## El acto 1 está hecho: una máquina instalada arrancó sola y respondió — 2026-08-07 +> > +> > **El bloque de arriba es más reciente.** `sudo ./dev/verify.sh` cerró en +> > **`proven 135 · not proven 1 · failed 0`** —el único no probado es llama.cpp, que +> > es Fase 2— y `make -C image run-installed` arrancó la máquina instalada. +> > +> > Un firmware UEFI encontró `\EFI\BOOT\BOOTX64.EFI` en un disco escrito por Thalyx y +> > lo ejecutó: sin `-kernel`, sin `-append`, sin gestor de arranque. La máquina +> > encontró su store sin que nadie se lo nombrara, la sesión salió **por la pantalla**, +> > Cesar escribió `apagar` **dentro de la ventana** y se apagó. +> > +> > Eso último no es un detalle: el teclado entró por PS/2 emulado (`SERIO_I8042` + +> > `KEYBOARD_ATKBD` + `VT`) y la pantalla es `FB_EFI` + `FRAMEBUFFER_CONSOLE` + +> > `FONT_8x16`. Lo confirma algo que no se puede fingir: al intentar Impr Pant +> > aparecieron símbolos raros en la sesión, que es `atkbd` traduciendo scancodes de una +> > tecla que no es una letra. +> > +> > **Falta sólo el acto 2, y es hierro**: `dd` a una USB, arrancar una PC, `discos`, +> > `instalar-en /dev/nvme0n1`, `apagar`, sacar la USB, encender. Es lo único que +> > responde el teclado **USB** (xHCI + HID) y los discos **NVMe/AHCI**. De los tres +> > grupos de controladores nuevos, dos ya están probados en vivo. +> > +> > Ver [[Criterio-de-Salida-Fase-1]], que lleva el detalle de qué afirmó cada cosa. +> > +> > ## La Fase 1 está construida entera, y la primera corrida encontró dos cosas — 2026-08-07 +> > +> > **Es lo primero que hay que leer.** Cesar pidió cerrar la fase sin poder verificar +> > entre cambios, aceptando apilar comprobaciones: *«no importa que apilemos 2 +> > comprobaciones, nuestro verify.sh nos indica dónde están, no es necesario saber en +> > qué momento se introdujeron»*. Así que hubo **cuatro commits del día** y una sola +> > corrida por delante — y la apuesta salió como se dijo: el arnés nombró las dos +> > cosas rotas, sin que hiciera falta saber cuál commit las metió. +> > +> > ### Lo que devolvió esa corrida, y el segundo es serio +> > +> > **1. El kernel no compiló: faltaba `CONFIG_HID_SUPPORT`.** `config-check` nombró +> > tres opciones descartadas —`HID`, `HID_GENERIC`, `USB_HID`— y ninguna era la causa: +> > las tres viven dentro de un `menuconfig HID_SUPPORT` que es `default y`, y bajo +> > `allnoconfig` un `default y` es un `n`. Una línea. +> > +> > **2. El instalador copió el gestor de arranque de Fedora — y tenía dos causas, no +> > una.** La primera corrida en frío encontró una y la segunda encontró la otra, que +> > era la que estaba produciendo el mensaje. +> > +> > **2a. El arnés destruía la partición y no la reparaba.** La etapa 20 daña las dos +> > copias del sector de arranque de la ESP para comprobar que un vfat roto no se +> > monta —regla 4, bien aplicada— y **la dejaba dañada**. Todo lo de abajo la sigue +> > usando. Cinco fallos de una sola causa, y el primero mandaba a mirar el lector de +> > FAT, que estaba bien. Ahora el control saca los siete sectores antes, los devuelve +> > después, y **afirma que la reparación tomó** — porque una reparación que +> > silenciosamente no funciona se ve idéntica al bug original. Séptima vez que *el +> > instrumento incluye al arnés*, y la primera en que el arnés no midió mal: dejó el +> > mundo peor de como lo encontró. +> > +> > **2b. Y la búsqueda del medio.** La búsqueda del medio +> > pedía `\EFI\BOOT\BOOTX64.EFI`, que **no es un archivo de Thalyx**: es el +> > *removable media fallback* de UEFI, o sea la ruta que llevan todos los medios de +> > arranque que existen, empezando por la partición EFI de la máquina en la que uno +> > está sentado. La etapa 20 instaló un segundo disco sin `--kernel`, la búsqueda +> > encontró tu ESP, y Thalyx copió el arranque de otro sistema al disco **reportando +> > una instalación correcta**. Lo único que lo dijo fue la comparación byte a byte del +> > final. +> > +> > Ahora el medio se identifica por la **etiqueta del volumen FAT32, `THALYX`**, que +> > sí la escribe Thalyx — el mismo cambio que el store por su etiqueta, un día tarde. +> > `thalyx disk medium` contesta a qué disco iría a buscar el kernel, y la etapa 20 lo +> > usa como afirmación *y* como control: tu máquina tiene una ESP propia, así que +> > pasar quiere decir las dos cosas, que encontró el volumen de Thalyx y que no se +> > llevó el ajeno. Regla nueva en [[Estrategia-de-Pruebas]]: *un marcador que +> > identifica algo tiene que ser algo que sólo eso tenga*. +> > +> > Sin la etiqueta, aun con el medio sano, habría **dos** respuestas y la instalación +> > se habría negado — correcta, pero imposible en la máquina de cualquiera. O sea que +> > las dos correcciones hacían falta y ninguna cubría a la otra. +> > +> > Y un detalle del arnés: el `cmp` que falló decía sólo «difieren», que manda a mirar +> > el lector de FAT. Ahora imprime de qué dispositivo dijo el instalador que estaba +> > leyendo, y el tamaño de los dos archivos. +> > +> > ### Al ir a cerrar apareció que faltaba algo grande, y era un decreto sin código +> > +> > **Una máquina instalada no encontraba su store.** Decretado el 2026-08-06 —*por la +> > etiqueta del sistema de archivos*—, escrito con su razonamiento entero, marcado +> > `[x]` en [[Tareas-Pendientes]], y **nunca implementado**. `store_disk.rs` leía +> > `thalyx.store=` y, sin él, decía que nadie le había dicho cuál era el disco. +> > +> > En una máquina instalada nunca está: la línea de comandos va compilada dentro del +> > kernel, es una sola, y el disco se llama `vda` aquí y `nvme0n1p2` en una PC. +> > +> > O sea: **el instalador de esta mañana estaba terminado y el disco que produce +> > habría arrancado diciendo que no tiene store.** Nada lo habría dicho antes de que +> > encendieras la máquina. Regla nueva en [[Estrategia-de-Pruebas]]: un decreto que +> > nadie implementó se lee igual que uno implementado, y un `[x]` de *decidido* se ve +> > igual que uno de *construido*. +> > +> > Ya está construido, con los dos caminos en orden: **`thalyx.store=` gana** —lo que +> > deja `make run` y todas las etapas exactamente como estaban— y sin él se le +> > pregunta a cada disco cómo se llama. Con las dos negativas: **ninguno** se reporta +> > diciendo cuántos discos se leyeron, y **dos se niegan** en vez de elegir. Lo +> > segundo no es raro: es el caso normal justo después de instalar, con el medio +> > todavía puesto. +> > +> > `thalyx disk find` corre ese código sin ser PID 1 y sin montar nada. Existe porque +> > si no, la rama que niega dos discos iguales se ejecutaría por primera vez en tu +> > máquina, el día en que equivocarse es más caro. +> > +> > ### Y la otra mitad: la máquina se instala a sí misma +> > +> > `thalyx install` recibía el kernel por `--kernel`, y **adentro no hay ruta que +> > teclear**. Ahora Thalyx **lee** el medio del que arrancó: `medium.rs` es un lector +> > de FAT32 que busca un volumen etiquetado `THALYX` con `\EFI\BOOT\BOOTX64.EFI` +> > adentro, se niega si hay dos, y excluye el disco de destino para que reinstalar +> > siga siendo posible. +> > **No monta nada** — los bytes se leen igual que se escribieron, así que el kernel +> > no necesita `CONFIG_VFAT_FS`. +> > +> > Y dos verbos, que es lo que lo vuelve alcanzable sin shell: +> > +> > ``` +> > discos qué discos veo, y qué tiene cada uno +> > instalar-en pon esta máquina en ese disco +> > ``` +> > +> > El kernel se busca **antes** de decir nada sobre destruir el disco: una máquina que +> > preguntara, recibiera un sí, borrara el disco y sólo entonces descubriera que no +> > tenía kernel lo habría destruido para nada. +> > +> > ### El medio y la máquina instalada son el mismo archivo +> > +> > No hay un ISO aparte que construir. Un disco con una partición de arranque que un +> > firmware puede iniciar es eso mismo, esté atornillado a una máquina o enchufado en +> > un costado: +> > +> > ``` +> > sudo dd if=image/build/installed.img of=/dev/sdX bs=4M status=progress conv=fsync +> > ``` +> > +> > ### Lo que falta, y es todo tuyo +> > +> > **Acto 1, en una VM** — responde que el medio arranca solo, que la máquina +> > instalada arranca sin él, que encuentra su store sin que nadie se lo nombre, y que +> > la consola de framebuffer funciona (OVMF entrega un GOP de verdad): +> > +> > ``` +> > git pull +> > sudo ./dev/verify.sh # la etapa 20 va en diecinueve líneas +> > make -C image # aquí se sabe si las opciones nuevas del kernel sobrevivieron +> > sudo make -C image installed +> > make -C image run-installed +> > ``` +> > +> > **Acto 2, en una PC** — es lo único que responde el teclado USB y NVMe/AHCI: +> > `dd` a una USB, arrancar, `discos`, `instalar-en /dev/nvme0n1`, `apagar`, sacar la +> > USB, encender. +> > +> > **Lo primero que puede fallar sigue siendo la compilación del kernel**, y eso es +> > correcto: `config-check` detiene el build si `olddefconfig` descartó alguna opción. +> > Ya lo hizo una vez, con `HID_SUPPORT`. Si vuelve a pasar, la lectura es la que dejó +> > ese fallo: **un grupo de opciones descartadas juntas comparte una causa**, y hay que +> > buscar el `menuconfig` que las contiene antes que las dependencias de cada una. +> > +> > ### Una cosa que el criterio no pide y conviene no confundir +> > +> > Una PC recién instalada arranca con un store bueno y **vacío**: la imagen lleva el +> > kernel y un programa, así que no hay nada instalado en ella ni nada que instalar, y +> > los pasos 2 a 6 de la lista original no se pueden hacer *en ella*. Se siguen +> > haciendo en la de desarrollo y se siguen comprobando en cada cambio. No es un hueco +> > del criterio vigente —*«ponerla en una PC sin sistema operativo y que ahora tenga +> > Thalyx como OS»*, y eso se cumple— sino la pregunta de cómo llega el software a una +> > máquina que no es ésta, que es la Fase 2. +> > +> > **785 pruebas pasan, `clippy` limpio en 1.97, `cargo fmt` aplicado.** +> +> > ## Y los controladores de una PC de verdad — 2026-08-07 +> > +> > **El bloque de arriba es más reciente.** Es un commit **aparte** del instalador, a +> > propósito: se verifican por caminos distintos y ninguno de los dos tiene que +> > esconder al otro si falla. +> > +> > Son los puntos 2 y 3 de la lista de riesgo de [[Construccion-del-ISO]], y **es +> > la única parte del criterio de salida que una VM no puede responder.** +> > +> > | Qué | Qué falla sin eso | +> > |---|---| +> > | Pantalla — `FB_EFI`, `FRAMEBUFFER_CONSOLE`, `VT`, `FONT_8x16` | La ISO arranca en una PC y **no se ve nada** | +> > | Teclado — `USB_HID`, `USB_XHCI_HCD`, `USB_EHCI_HCD`, `SERIO_I8042`, `KEYBOARD_ATKBD` | Llega al prompt y no se le puede contestar | +> > | Discos — `BLK_DEV_NVME`, `SATA_AHCI`, `BLK_DEV_SD`, `PCI_MSI` | El instalador no ve el disco en el que va a instalar | +> > +> > Tres cosas que no son obvias: +> > +> > - **`FB_EFI` no es un driver de video.** Es el framebuffer que el firmware **ya +> > configuró**, y este driver lo adopta. Por eso no hay aquí un driver de ninguna +> > tarjeta: Thalyx dibuja texto, no levanta una GPU, y un driver por chip es un +> > userland entero. El costo: la resolución es la que eligió el firmware. +> > - **`PCI_MSI` no es un lujo.** NVMe arma sus colas alrededor de interrupciones +> > por mensaje y `allnoconfig` lo deja apagado. Nada más de ese archivo lo pedía. +> > - **`BLK_DEV_SD` es lo que hace que AHCI sirva.** Sin él el controlador se +> > encuentra, sus puertos se sondean, y no aparece `/dev/sda`. +> > +> > ### La consola, que fue tu decisión +> > +> > ``` +> > CONFIG_CMDLINE="console=ttyS0 console=tty0 lsm=capability,bpf panic=-1" +> > ``` +> > +> > **El kernel imprime en todas, y la ÚLTIMA es la que se vuelve `/dev/console`** — +> > el único archivo por el que habla la sesión. Arrancada por firmware no se le pega +> > nada, así que gana `tty0` y la sesión sale en la pantalla. Arrancada por QEMU, +> > `-append console=ttyS0` va **después** —comprobado en `arch/x86/kernel/setup.c`, +> > que concatena la compilada primero— así que el serie sigue ganando y la etapa 16 +> > ni se entera. +> > +> > Hay **cuatro pruebas** que leen `thalyx.config` y afirman esto, porque +> > `config-check` atrapa una opción que Kconfig descartó y **no puede atrapar una +> > que nadie pidió** — que es el error que costó `CONFIG_SECURITY_NETWORK` y un +> > arranque entero. +> > +> > ### Y `run-uefi` abre una ventana +> > +> > Con la sesión en `tty0`, `-nographic` la dejaría corriendo perfecta donde nadie +> > la ve, que es justo el fallo que el cambio evita. Ahora `run-uefi` y +> > `run-installed` abren ventana, con `-serial mon:stdio` para que los mensajes del +> > kernel sigan en la terminal. `HEADLESS=1` vuelve atrás. +> > +> > **Eso hace que la ventana sea la única forma de probar el framebuffer sin +> > hierro**: OVMF sí entrega un framebuffer GOP de verdad. El teclado y los discos +> > siguen necesitando una PC. +> > +> > ### Lo que falta, y es tuyo +> > +> > **Ninguna de estas opciones se ha compilado siquiera.** Este contenedor no +> > compila kernels. Y la regla de este proyecto dice que **ninguna comprobación de +> > construcción encuentra la siguiente opción que falta** — van tres encontradas +> > arrancando (`BPF_LSM` con BTF, `SECURITY_NETWORK`, `FUNCTION_TRACER`), y lo +> > razonable es esperar más aquí. +> > +> > Lo primero que puede fallar es la compilación: `config-check` detiene el build si +> > `olddefconfig` descartó cualquiera de estas líneas. Si pasa, **la línea que falta +> > es la que hay que mirar**, no el grupo entero — cada una tiene su párrafo al lado. +> > +> > ``` +> > make -C image # aquí es donde se sabe si sobrevivieron +> > sudo ./dev/verify.sh # el instalador, etapa 20 +> > sudo make -C image installed +> > make -C image run-installed # y aquí si una PC arranca +> > ``` +> > +> > **775 pruebas pasan, `clippy` limpio en 1.97, `cargo fmt` aplicado.** +> +> > ## El instalador existe: un disco se vuelve una máquina — 2026-08-07 +> > +> > **El bloque de arriba es más reciente y es el otro commit de hoy.** Los dos se +> > verifican por caminos distintos y están separados a propósito. +> > +> > ### Lo que se construyó +> > +> > `crates/thalyx-install` y **`thalyx install --kernel `**. Es el +> > acto que juntaba las dos piezas caras, y lo que sale es esto: +> > +> > ``` +> > LBA 0 MBR protector +> > LBA 1..34 la tabla de particiones, y su copia al otro extremo +> > 1 MiB partición 1, 512 MiB, FAT32, con \EFI\BOOT\BOOTX64.EFI adentro +> > 513 MiB.. partición 2, el resto, btrfs `thalyx-store`, tres subvolúmenes +> > ``` +> > +> > **Un archivo en la partición de arranque, y es el kernel con Thalyx adentro.** +> > Es `make -C image count` extendido al disco instalado. +> > +> > Costó **dos escritores de bytes más**, por el motivo de siempre: `sgdisk` y +> > `mkfs.vfat` son lo que usaría una persona, y la imagen lleva el kernel de Linux +> > y un programa. Van la cuarta y la quinta vez que este proyecto contesta a un +> > binario ausente con el trabajo en vez de con la herramienta — `bpftool`, `cpio`, +> > `btrfs`, `partprobe`, `mkfs.vfat` — y una cuarta llamada al kernel propia, +> > `BLKRRPART`, porque escribir una tabla en un disco que el kernel ya tiene abierto +> > no hace aparecer `/dev/sda1`. +> > +> > **FAT no es una preferencia, es del firmware.** La especificación UEFI obliga al +> > firmware a entender FAT y nada más. Es el único sistema de archivos de Thalyx que +> > existe para satisfacer algo de afuera, y conviene que quede dicho. +> > +> > ### Lo que decidió el diseño, y vale saberlo +> > +> > - **Los nombres de las particiones se le preguntan al kernel.** `/dev/sda` da +> > `/dev/sda1` y `/dev/nvme0n1` da `/dev/nvme0n1p1`; la regla que produce las dos +> > es una convención de las herramientas que las imprimen, no una promesa. Si se +> > deriva, el instalador anda en SATA y escribe el store **en la nada** en NVMe — +> > que es justo la mitad del hierro que aquí no se puede probar. Se leen de +> > `/sys/dev/block/:/`. +> > - **La ESP es de 512 MiB y la holgura es el punto.** No se agranda después sin +> > mover el store, y lo que seguro va a pasar es que una actualización de kernel +> > escriba el nuevo **al lado** del viejo. Una máquina que sobrescribe su único +> > archivo arrancable y se queda sin corriente no vuelve. +> > - **Un disco de 4 KiB por sector se rechaza en vez de escribirse.** +> > +> > ### Y hay un fallo nuevo que no se parece a ningún otro de este proyecto +> > +> > **Una GPT con una suma equivocada no se reporta como rota: se ignora.** Linux cae +> > al MBR protector, no crea ninguna partición, y el disco vuelve **igual que si +> > nadie lo hubiera tocado**. El instalador habría dicho `ok`. +> > +> > Es distinto del Btrfs de la etapa 18, donde un superbloque dañado hace que +> > `mount(2)` conteste un error. Ahí el fallo llega; aquí no llega nada. Por eso la +> > etapa 20 no comprueba «el instalador terminó» sino **«el kernel hizo dos +> > particiones»**, leídas de sysfs, con línea base de que antes no había ninguna, y +> > comparando los tamaños contra lo que `--plan` dijo. Regla nueva en +> > [[Estrategia-de-Pruebas]]. +> > +> > ### El contenedor no pudo establecerlo, y casi acusa a Thalyx +> > +> > `thalyx install` escribió la tabla aquí y el kernel no hizo particiones. La +> > lectura obvia era que la tabla estaba mal. Lo que lo resolvió fue escribir **un +> > MBR común** —cuyo parser está en todos los kernels de Linux— y ver que tampoco +> > producía nada: `/sys/block/loop0/range` vale `1`, o sea que este `loop` no admite +> > particiones de ningún tipo. **Regla 5, novena vez**, y la etapa 20 lleva ese +> > discriminador adentro en vez de una nota — cuando no aparecen particiones, +> > escribe un MBR y vuelve a mirar; si de ése tampoco salen dice `NOT PROVEN`, y si +> > salen, **entonces** el fallo es de Thalyx y lo dice como fallo. +> > +> > Lo que sí se pudo hacer aquí, y vale como red: las dos sumas de la GPT +> > recalculadas con un CRC-32 independiente, y el volumen FAT32 recorrido entero por +> > un lector escrito aparte —raíz, `EFI`, `BOOT`, la cadena de clusters— que devolvió +> > los 3 000 000 de bytes idénticos. +> > +> > ### Lo que falta, y es de Cesar +> > +> > 1. **Correr `sudo ./dev/verify.sh`.** La etapa 20 es nueva y son **once líneas** +> > que sólo tu máquina puede establecer. Espero `proven 128 · not proven 1 · +> > failed 0`. +> > 2. **Y después `make -C image run-installed`**, que es la afirmación de verdad y +> > no la ejerce ninguna etapa: +> > +> > ``` +> > make -C image # el kernel, si no está construido +> > sudo make -C image installed +> > make -C image run-installed +> > ``` +> > +> > Un firmware UEFI recibe **sólo el disco instalado** —sin ISO, sin `-kernel`, +> > sin nada— y tiene que encontrar `\EFI\BOOT\BOOTX64.EFI` y arrancarlo. Si +> > encuentra nada, se queda en el shell de UEFI o reinicia; eso es lo que hay que +> > esperar si el tipo de partición, el FAT o el lugar del archivo están mal. +> > +> > **El kernel se le pasa por `--kernel` a propósito.** Un instalador corriendo +> > *dentro* de la máquina arrancada desde la ISO tendría que sacar el bzImage del +> > medio del que arrancó, y eso pide un **lector** de FAT y saber cuál disco es el +> > medio. Es su propio cambio y no va encima de éste; está en [[Tareas-Pendientes]] +> > junto con la otra cosa que el criterio va a pedir — que el store de una máquina +> > recién instalada queda **vacío**, así que los pasos 2 a 6 no se pueden hacer *en +> > ella* hasta que exista una forma de que el software llegue a una máquina que no +> > es ésta. +> > +> > **771 pruebas pasan, `clippy` limpio en 1.97, `cargo fmt` aplicado.** El +> > contenedor se actualizó a 1.97 antes de empezar, que es la regla del desfase de +> > versión del bloque anterior aplicándose por primera vez. +> +> > ## Thalyx hace los subvolúmenes, y clippy sí era un lint — 2026-08-07 +> > +> > **El bloque de arriba es más reciente.** +> > +> > ### Lo que se construyó +> > +> > **Un sistema de archivos recién escrito ya no es lo único que sale de +> > `thalyx disk format`.** Los tres subvolúmenes decretados —`system`, `modules`, +> > `user`— los crea Thalyx por **`BTRFS_IOC_SUBVOL_CREATE`**, porque adentro de la +> > imagen no hay binario `btrfs`. Tercera vez que este proyecto contesta a un +> > binario ausente con una llamada al kernel: `bpftool`, `cpio`, y ahora `btrfs`. +> > +> > ``` +> > ok subvolume system — created +> > ok subvolume modules — created +> > ok subvolume user — created +> > +> > ok mountable subvol=system +> > ok mountable subvol=modules +> > ok mountable subvol=user +> > +> > This is a store. PID 1 can mount it. +> > ``` +> > +> > **La comprobación es montar, no mirar.** Cada uno se monta con +> > `-o subvol=`, exactamente como lo hace PID 1. Preguntar si apareció un +> > directorio con ese nombre daría *sí* para un directorio común, que es justo lo +> > único que PID 1 no puede montar. +> > +> > Y **el número del ioctl no se toma de fe.** `_IOW` es un macro de C, aquí no hay +> > C, así que la constante está escrita a mano y `tests/ioctl.rs` la recalcula desde +> > el header capturado — incluido el tamaño del argumento, que va codificado adentro +> > del número. Un tamaño equivocado no falla limpio: el kernel compara la palabra +> > entera y contesta `ENOTTY` en un sistema de archivos que soporta la llamada +> > perfectamente, lo que se lee como «este kernel es viejo». +> > +> > ### El fallo de clippy era un lint de verdad, y mi diagnóstico estaba mal +> > +> > Con el informe arreglado, tu corrida dijo qué era: **`unnecessary_sort_by`**, dos +> > veces en `format.rs`. **No era `RUSTUP_HOME`.** Era **desfase de versión**: tu +> > clippy es 1.97 y el del contenedor era 1.94, y el lint aprendió ese caso en +> > medio. Actualizado el contenedor a 1.97, apareció en el primer intento; el +> > arreglo fueron dos líneas. +> > +> > Las cuatro corridas «con el mismo `rustc`» comparaban 1.94 contra 1.94. Es la +> > regla 5 con una vuelta que valía escribir aparte: **el instrumento incluye su +> > número de versión**, y un linter es un instrumento cuyo trabajo entero es cambiar +> > de opinión entre versiones. Ahora la etapa 2 imprime la versión de clippy en las +> > dos líneas, y **el contenedor se mantiene al menos tan nuevo como tu máquina** — +> > lo contrario garantiza que cada lint nuevo se descubra en la única máquina que no +> > puede arreglarlo. +> > +> > No se fijó la cadena con un `rust-toolchain.toml`. Sería decisión tuya, te +> > obligaría a descargar una versión concreta, y además un proyecto que fija su +> > linter deja de enterarse de los lints nuevos — que es lo que se quería. +> > +> > ### Lo que falta, y es de Cesar +> > +> > **Correr `sudo ./dev/verify.sh`.** La etapa 19 es nueva y **sólo tu máquina puede +> > establecerla**: este contenedor no tiene Btrfs en el kernel, así que aquí sale +> > `NOT PROVEN` y eso es lo correcto. +> > +> > Espero `proven 117 · not proven 1 · failed 0`: tus 110, más los dos que fallaron +> > y ya no deberían, más las cinco nuevas. +> > +> > Las cinco líneas que se agregan, y cada una tiene por qué existir: +> > +> > ``` +> > PROVEN a filesystem Thalyx just wrote has no subvolumes, so there is something to do +> > PROVEN Thalyx created the three subvolumes through the kernel, with no btrfs binary +> > PROVEN all three mount the way PID 1 mounts them, read back by mount(8) and not by Thalyx +> > PROVEN a name nobody created does not mount, so subvol= is really being honoured +> > PROVEN run again on a finished store it reports them as already there and changes nothing +> > ``` +> > +> > La primera es la línea base —sin ella, un comando que no hiciera nada pasaría la +> > etapa—. La tercera lo lee con `mount(8)` y no con Thalyx, porque preguntarle al +> > programa que acaba de hacer el trabajo no prueba nada. La cuarta es el control: un +> > kernel que ignorara `subvol=` montaría los cuatro. La quinta es el camino de +> > reparación, porque un instalador que falla a la mitad no puede costar el disco. +> > +> > **Es un cambio solo, a propósito.** El instalador no va encima de esto hasta que +> > la etapa 19 haya corrido. +> > +> > **721 pruebas pasan, `clippy` limpio en 1.97, `cargo fmt` aplicado.** +> +> > ## El kernel montó el Btrfs que escribió Thalyx — 2026-08-07 +> > +> > **El bloque de arriba es más reciente.** +> > +> > ``` +> > proven 110 · not proven 1 · failed 2 +> > ``` +> > +> > Fedora 43, kernel 7.1.5, `main @ 9229268`. Lo que la etapa 18 cerró, y ninguna +> > corrida anterior podía cerrar: +> > +> > ``` +> > PROVEN Thalyx wrote a Btrfs filesystem with no mkfs.btrfs and no libbtrfs +> > PROVEN the kernel mounts a filesystem Thalyx wrote byte by byte +> > PROVEN the three decreed subvolumes can be created on it +> > PROVEN a file written to it comes back, so the allocator has somewhere to go +> > ``` +> > +> > **El kernel monta lo que Thalyx escribió**, acepta los tres subvolúmenes +> > decretados, y un archivo escrito en él vuelve. `btrfs check` ya lo aceptaba; eso +> > no era un montaje, y ahora sí lo hay. La máquina puede hacer el disco en el que +> > guarda. +> > +> > ### Y los dos fallos eran míos, ninguno del formato +> > +> > **1. El control de la etapa 18 dañaba espacio libre y acusaba al kernel.** +> > +> > ``` +> > FAILED the kernel mounted a filesystem with both copies of its root tree damaged +> > ``` +> > +> > El kernel no aceptó basura. **Btrfs es copy-on-write**: la copia que el control +> > rompe se sacaba *después* de montar, crear los subvolúmenes y escribir un +> > archivo, y a esa altura la primera transacción del kernel ya había escrito un +> > árbol raíz nuevo en otro sitio y retirado el que escribió Thalyx. Los bytes que +> > se pisaban eran espacio libre de la generación 1. +> > +> > Lo peor no es el error sino que **el informe acusaba al kernel de un defecto del +> > arnés, en una línea escrita precisamente para no dejarse engañar.** Y la prueba +> > equivalente de `cargo test` pasaba y sigue pasando, porque ahí el sistema de +> > archivos nunca se monta y el bloque sigue vivo. +> > +> > Arreglado sacando la copia antes de cualquier montaje, y con **línea base para el +> > control mismo**: se comprueba que la copia dañada difiera de la original, porque +> > un `cp` que falla o un `dd` que no escribe nada dejan una imagen intacta —que +> > monta— y eso se reportaría otra vez como el kernel aceptando basura. +> > +> > Comprobado aquí con `btrfs check`, que es lo que este contenedor puede hacer: +> > dañando la copia pristina en los mismos offsets, `checksum verify failed on +> > 5242880` en las dos copias y `cannot open file system`. El mecanismo estaba bien; +> > lo que estaba mal era cuándo se sacaba la muestra. +> > +> > **2. Clippy falló en tu máquina y no lo pude reproducir.** +> > +> > Lo intenté cuatro veces con el mismo código: `rustc` 1.94 en limpio, 1.90 en +> > limpio, y 1.90 incremental sobre el cambio. Las cuatro salieron limpias. **No +> > puedo decir que esté arreglado**, y no lo voy a decir. +> > +> > Lo que sí encontré es por qué no se puede saber: `verify.sh` construye su +> > directorio con `mktemp -d` y **lo borra al salir**. Unos treinta mensajes de +> > fallo terminan en `see $WORK/algo.log`, y los treinta apuntaban a una ruta que ya +> > no existía cuando alguien iba a leerla. El único artefacto que podía decir qué +> > lint era lo borró el script que lo escribió. +> > +> > > **Resuelto al día siguiente y no era esto.** Con el informe arreglado, la +> > > siguiente corrida dijo el lint: `unnecessary_sort_by`, y la causa era que tu +> > > clippy es 1.97 y el del contenedor era 1.94. Ver el bloque de arriba. Lo de +> > > abajo queda escrito porque los cuatro arreglos son buenos y porque el punto 4 +> > > sigue siendo un defecto real — sólo no era *este* fallo. +> > +> > Cuatro arreglos, y el cuarto parecía la causa de lo que viste: +> > +> > 1. **El directorio se conserva cuando algo falló**, y el resumen dice dónde está. +> > 2. **Los diagnósticos de clippy se imprimen** en vez de referenciarse. +> > 3. **«clippy objetó al código» y «clippy no pudo correr» dejaron de ser la misma +> > línea.** El segundo es `NOT PROVEN` y dice qué componente instalar — regla 10, +> > en el sitio donde costó un diagnóstico. +> > 4. **El arreglo del entorno de rustup bajo `sudo` estaba condicionado a que +> > `cargo` no estuviera en el `PATH` de root.** Pero que `command -v cargo` +> > encuentre el shim de rustup no dice que ese shim pueda resolver una cadena de +> > herramientas: la busca bajo `$HOME/.rustup`, y `sudo` pudo haber puesto `$HOME` +> > en `/root`. En tu corrida la etapa 1 encontró cargo, así que `RUSTUP_HOME` +> > **nunca se puso**. El fallo que eso deja es por componente: una cadena que +> > contesta `build` y `fmt` pero no `clippy` se reporta como clippy encontrando +> > problemas. Ahora se aplica siempre que se corra bajo `sudo`. +> > +> > Y la etapa 1 ahora imprime la **versión** de la cadena de herramientas, no sólo su +> > ruta: una corrida contra otra cadena se ve idéntica a una contra la esperada. +> > +> > **Si en la próxima corrida clippy vuelve a fallar, el informe va a decir qué +> > lint es.** Y así fue: dijo `unnecessary_sort_by`, que es el bloque de arriba. +> > +> > Las dos reglas nuevas están en [[Estrategia-de-Pruebas]], y la cuenta de la regla +> > 5 va en diez con la del desfase de versión. +> +> > ## Thalyx escribe su propio Btrfs — 2026-08-07 +> > +> > **El bloque de arriba es más reciente. Esto es lo que se construyó.** +> > +> > `crates/thalyx-btrfs`: ocho árboles, tres chunks y los superbloques, escritos +> > byte por byte. **Sin `mkfs.btrfs` y sin `libbtrfs`.** Es el punto 2 del orden de +> > trabajo del ISO y era el poste largo — la máquina arrancaba y no podía guardar +> > nada. +> > +> > Lo obliga [[Filosofia-Fundacional]] y no una preferencia: la imagen lleva el +> > kernel y un programa, así que `mkfs.btrfs` no puede estar ahí. Misma forma que +> > `bpftool` y que `cpio`, misma respuesta. +> > +> > ``` +> > $ thalyx disk format /tmp/store.img --yes +> > ok store /tmp/store.img — 8589934592 bytes, labelled `thalyx-store` +> > fsid a39c0565af37487b8f8fe806ff352104 +> > 2 superblock(s), 131072 bytes of metadata +> > ``` +> > +> > ### El decreto de que PID 1 nunca fabrica se conservó entero +> > +> > Estaba anotado como *pendiente de confirmar al construirlo*, y se confirmó: +> > **nada de `thalyx-btrfs` es alcanzable desde PID 1.** Lo invoca un humano con +> > `thalyx disk format`, que es el acto explícito que la tarea preveía. PID 1 sigue +> > montando y sin crear. +> > +> > La confirmación **pide teclear la ruta del dispositivo**, no una `y`. Es lo más +> > destructivo que Thalyx sabe hacer, el argumento es una palabra, `/dev/sda` y +> > `/dev/sdb` se diferencian en una tecla, y una `y` confirma una frase que el +> > humano ya dejó de leer. Antes de preguntar dice qué hay en el disco ahora, +> > leyéndolo. +> > +> > ### Lo que falta, y es de Cesar +> > +> > **Correr `sudo ./dev/verify.sh`.** La etapa 18 es nueva y **el montaje sólo lo +> > puede establecer tu máquina**: este contenedor no tiene Btrfs en el kernel ni +> > módulos que cargar. Aquí la etapa sale así, que es lo correcto: +> > +> > ``` +> > PROVEN Thalyx wrote a Btrfs filesystem with no mkfs.btrfs and no libbtrfs +> > PROVEN it identifies itself by the label an installed machine looks for +> > PROVEN a device nobody formatted is reported as no filesystem, not as no label +> > PROVEN btrfs check walks it and finds nothing wrong +> > NOT PROVEN this kernel has no Btrfs, so nothing could mount what Thalyx wrote +> > ``` +> > +> > En tu máquina esas cinco líneas se vuelven ocho, y las cuatro que se agregan son +> > las que importan: que el kernel lo monta, que acepta los tres subvolúmenes, que +> > un archivo escrito en él vuelve, y **que el mismo sistema de archivos dañado se +> > niega** — sin ese último, el montaje no demuestra nada. +> > +> > **Es un cambio solo, a propósito.** No apilé la búsqueda por etiqueta encima. +> > +> > ### Cómo se sabe que el formato es correcto sin poder montarlo +> > +> > Dos instrumentos, y ninguno es leer el formato. +> > +> > Los headers de Linux (`btrfs_tree.h` y `btrfs.h`) están capturados verbatim en +> > `crates/thalyx-btrfs/tests/`, y una prueba los parsea y comprueba **cada tamaño +> > y cada offset** que el escritor usa. Y `btrfs check` recibe lo escrito y recorre +> > los árboles, las referencias inversas y la contabilidad de cada grupo de +> > bloques: `no error found`, también en el disco más chico que se permite y +> > leyendo desde el superbloque de respaldo. +> > +> > **`btrfs check` no es un montaje**, y está dicho así en el código: lee con el +> > código de btrfs-progs, no con el del kernel, y los dos ya se han contradicho. +> > +> > btrfs-progs es dependencia **de desarrollo**, nunca de ejecución — el punto del +> > crate es que la imagen no lo tiene. Así que se salta donde falta, dice `NOT +> > PROVEN`, y hay **una variable por requisito**: `THALYX_REQUIRE_BTRFS_PROGS` para +> > el validador y `THALYX_REQUIRE_BTRFS_TESTS` para el montaje. Las dos se +> > ejercieron en las dos direcciones. +> > +> > ### Tres defectos, y los tres dan regla +> > +> > 1. **Btrfs usa el mismo CRC32C con dos convenciones y no coinciden.** La suma de +> > un bloque es CRC32C estándar; el hash del nombre de una entrada de directorio +> > es el primitivo crudo desde `~1` **sin complemento final**. La primera versión +> > aplicó la estándar a las dos y el hash de `default` salió un número estable, +> > plausible, y que hace que el kernel resuelva el subvolumen por omisión +> > encontrando nada. Leer el kernel no lo evita: la diferencia está en el +> > intermediario. Lo encontró una imagen real de `mkfs.btrfs`. +> > 2. **Un bit de versión omitido no falla al parsearse: se parsea como otro +> > formato.** Sin `MIXED_BACKREF_REV` en las banderas de cada cabecera, todo +> > parseaba perfecto y `btrfs check` reportó **once fallas de referencia**, una +> > por extent. El síntoma estaba lo más lejos posible de la causa, y lo delató +> > una línea informativa —`backref revision 0`— comparada contra la imagen de +> > referencia. +> > 3. **El arnés otra vez, y van ocho.** El parser que comprueba los offsets +> > descartaba en silencio todo campo con comentario al final de su línea, y dijo +> > que `btrfs_root_item` medía 343 cuando el escritor producía 439. **El escritor +> > tenía razón.** Ahora hay una prueba que gradúa al parser antes de medir nada +> > con él, usando los tamaños que los headers afirman en su propio texto. +> > +> > Las dos primeras están escritas como reglas nuevas en [[Estrategia-de-Pruebas]]; +> > la tercera se sumó a la cuenta de la regla 5. +> > +> > ### Lo que seguía faltando, y se hizo el mismo día +> > +> > **Un sistema de archivos recién escrito no tiene subvolúmenes**, y PID 1 monta +> > `subvol=system`. Así que un store hecho con `thalyx disk format` **todavía no era +> > un store**, y el comando lo decía al terminar en vez de dejar que pareciera que +> > sí. Ya los crea, por ioctl — el primer bloque de esta nota. +> > +> > Y `make -C image store` **sigue usando `mkfs.btrfs`** a propósito: es la red de +> > regresión de las etapas 13 y 16, y cambiarla en el mismo commit que introduce lo +> > que hay que probar dejaría la red y lo probado siendo el mismo código sin +> > ejercer. Misma razón por la que `boot` siguió pasando `-kernel` cuando apareció +> > `run-uefi`. +> > +> > **710 pruebas pasan, `clippy` limpio, `cargo fmt` aplicado.** +> +> > ## Todo lo que existe está verificado, y la persona ajena se cancela — 2026-08-06 +> > +> > **Es lo primero que hay que leer.** +> > +> > ``` +> > proven 104 · not proven 1 · failed 0 +> > ``` +> > +> > Fedora 43, kernel 7.1.5, `main @ 9e1c5f8`. Las diecisiete etapas, incluidas +> > **las tres que nunca habían corrido enteras**: el arranque frío tecleando los +> > seis pasos, el reinicio de verdad, y lo que la auditoría cerró. La única `not +> > proven` es `llama.cpp`, que **no es una comprobación que no se pudo hacer sino +> > una cosa que no existe todavía**. +> > +> > Y se corrió otra vez con `THALYX_REQUIRE_IMAGE_TESTS=1`, que convierte en +> > fallo cualquier salto de la etapa 16. Mismo resultado: la etapa corrió de +> > verdad, no se saltó en silencio. Esa segunda corrida es la que vuelve creíble +> > la primera. +> > +> > **Es la primera vez que no hay nada roto y nada pendiente de ejercer.** +> > +> > ### El ancla del kernel ya está en el repositorio +> > +> > Cesar la estableció contra la lista firmada de kernel.org y la puso con `nano` +> > en su máquina, donde no la ve nadie más. Ahora está en `image/Makefile`: +> > +> > ``` +> > KSHA256 := 0d21cd11933f49f7151b7c9dbb8cc3fddc8c8abe506434b850feecf41fc28a76 +> > ``` +> > +> > Con **quién la firmó y su huella al lado**, porque un digest a secas dice qué +> > se aceptó y no qué lo estableció, y el siguiente que lo herede no tiene cómo +> > volver a comprobarlo. `make -C image doctor` ya pasa. +> > +> > ### Y el procedimiento que la establece estaba equivocado +> > +> > Lo encontró él corriéndolo, que es la regla 1 otra vez. `pin-kernel` decía: +> > +> > ``` +> > gpg --locate-keys torvalds@kernel.org gregkh@kernel.org +> > ``` +> > +> > Son quienes firman una versión del kernel. **No son quienes firman ese +> > archivo**: `sha256sums.asc` lleva la llave automática de sumas de kernel.org, +> > así que `gpg --verify` contestó `No hay clave pública` — tres renglones debajo +> > de una frase que decía que cualquier cosa que no sea *Good signature* es +> > motivo para parar. **El procedimiento imprimía la falla que él mismo define +> > como fatal.** Cesar tuvo que encontrar `autosigner@kernel.org` por su cuenta. +> > +> > Se escribió en este contenedor, cuya red no alcanza kernel.org, así que no +> > había cómo correrlo aquí y salió sin correr. Regla nueva en +> > [[Estrategia-de-Pruebas]]: **un procedimiento impreso para una persona es +> > código sin correr.** Arreglado, con la huella impresa para comparar, el aviso +> > de gpg explicado como esperado, y una prueba que exige las dos copias. +> > +> > ### La persona ajena se cancela — decisión de Cesar +> > +> > Los seis pasos ya no los va a ejecutar alguien de fuera, por ahora. Su +> > razonamiento, entero, está en [[Criterio-de-Salida-Fase-1]]: Thalyx todavía +> > son comandos de terminal, el producto terminado será una ISO booteable, y se +> > prueba cuando haya algo que probar. Ni siquiera se pudo convencer a la persona +> > de hacerlo. +> > +> > **Lo que no cambia**: los seis pasos siguen siendo lo que el sistema tiene que +> > hacer, y se siguen comprobando solos en cada cambio y en cada corrida de +> > hardware. Lo que se cancela es quién los teclea. +> > +> > ### Y arrancó entera: el paso 1 del criterio está cerrado — 2026-08-06 +> > +> > **Un firmware arrancó Thalyx sin gestor de arranque, y la máquina hizo todo lo +> > que sabe hacer.** Con la consola puesta, el segundo intento salió así: +> > +> > ``` +> > ok root moved off the initramfs, so a module can be pivoted into a root +> > ok mounted /proc … /sys/fs/cgroup (los siete) +> > ok sandbox root the root is attached +> > ok controllers memory, pids handed down at /sys/fs/cgroup +> > no store no thalyx.store= on the kernel command line +> > ok thalyx-lsm 2 hook(s) live, 3 map(s) pinned under /sys/fs/bpf/thalyx +> > ``` +> > +> > Lo que eso demuestra, y no lo demostraba ningún arranque anterior: **todo lo +> > que Thalyx hace funciona cuando lo arranca un firmware y no `-kernel` de +> > QEMU.** El `switch_root`, los siete montajes, la delegación de controladores, +> > **el LSM enganchado**, la sesión, y el apagado limpio. No hay GRUB en ninguna +> > parte y no hace falta. +> > +> > El `no store` es **correcto y estaba previsto**: `thalyx.store=` nombra un +> > dispositivo, la línea de comandos va compilada dentro del kernel, y no hay un +> > nombre que sirva en las dos máquinas. La máquina lo dice como lo que es — +> > *«el disco no falta; nadie me dijo cuál es»*— en vez de adivinar. Es el paso 3. +> > +> > **Lo que sigue sin probarse es hierro.** Esto fue OVMF dentro de QEMU: discos +> > virtio, teclado emulado y consola serie. Una PC de verdad no tiene puerto +> > serie, y ahí es donde `console=ttyS0` deja de servir. +> > +> > ### El firmware arrancó Thalyx sin gestor de arranque — 2026-08-06 +> > +> > **El paso 1 del criterio nuevo funcionó.** OVMF encontró +> > `EFI/BOOT/BOOTX64.EFI`, lo arrancó, el kernel desempaquetó el initramfs que +> > lleva adentro y ejecutó `/init`. **No hay gestor de arranque y no hace falta +> > ninguno**: el medio lleva un archivo y ese archivo es el kernel con Thalyx +> > dentro. +> > +> > Y murió en su primera instrucción, por algo que no era ninguna de las dos +> > cosas que se estaban probando: +> > +> > ``` +> > Warning: unable to open an initial console. +> > Run /init as init process +> > traps: init[1] general protection fault ip:7fea0faff143 +> > Kernel panic - not syncing: Attempted to kill init! +> > ``` +> > +> > La instrucción que falló es `hlt`, que está al final de `abort()` de musl — la +> > que sólo se alcanza cuando `SIGABRT` **no** mató al proceso, y no lo mata +> > porque el kernel no le entrega señales fatales por defecto a PID 1. Y quien +> > llamó a `abort()` no fue código de Thalyx: fue el runtime de Rust **antes de +> > `main`**, que se niega a seguir si no puede garantizar descriptores 0, 1 y 2. +> > El archivo tenía un `/dev` vacío. +> > +> > **Ningún arranque anterior podía verlo**: `-initrd` se desempaqueta *encima* +> > del initramfs propio del kernel, que trae `/dev/console`. Meter el nuestro +> > adentro lo *reemplaza*. La consola venía de regalo de algo que nadie miró — y +> > es la **tercera vez** con esa forma exacta, después de systemd con los +> > controladores y del `switch_root`. Regla nueva en [[Estrategia-de-Pruebas]]. +> > +> > Arreglado: el archivo lleva `/dev/console` (carácter 5:1). **Sin `/dev/null` +> > al lado, a propósito** — una máquina que no alcanza su consola debe detenerse, +> > no correr perfecta hablándole a la nada, que es peor y que este proyecto ya +> > cometió una vez. +> > +> > Y el conteo tuvo que aprender una clase nueva: un nodo de dispositivo no es un +> > programa, pero `is_directory()` lo habría contado como uno y `count` habría +> > dicho **2**. Ahora hay tres clases, se imprimen los números del nodo, y una +> > prueba exige que las tres sumen el total — porque lo que no se cuenta es justo +> > por donde entraría un segundo programa sin que el número se moviera. +> > +> > `make -C image run-uefi` es lo que lo arranca. Necesita OVMF +> > (`sudo dnf install edk2-ovmf`); si falta, se niega y dice que **no se probó +> > nada** en vez de reportar que Thalyx falló. +> > +> > Y **no se tocó `make run`**: sigue pasando `-kernel` e `-initrd`, porque es la +> > red de regresión de la etapa 16 y cambiarla ahora dejaría la red y lo que se +> > prueba siendo el mismo cambio sin ejercer. Se mueve cuando la ruta del +> > firmware tenga su propia etapa. +> > +> > ### Y el criterio nuevo: una ISO independiente +> > +> > Cesar eligió el sustituto el mismo día: +> > +> > > una ISO totalmente independiente, es decir: que puedas ponerla en una PC sin +> > > sistema operativo y que ahora tenga Thalyx como OS […] el objetivo es que +> > > tengamos la ISO y nada más, y con ella sola podamos tener Thalyx corriendo. +> > +> > **Es la propiedad que la persona ajena aportaba y la lista de componentes +> > nunca tuvo: no la puede declarar nadie.** O existe un archivo que convierte +> > una máquina sin sistema operativo en una máquina Thalyx, o no existe. +> > +> > Y es **más** exigente que lo de hoy, no menos. Hoy **QEMU es el gestor de +> > arranque**: `make run` pasa `-kernel` y `-initrd`. Nada de lo construido sabe +> > arrancar solo. +> > +> > Se puede ejercer en una VM, y eso está bien **si se dice qué prueba**: una VM +> > con firmware UEFI de verdad prueba que la ISO arranca **sola**, que es la +> > mitad que importa. **No prueba los controladores** — sus discos son virtio y +> > su teclado es emulado. Esa mitad necesita hierro. +> > +> > Lo que cuesta, en [[Construccion-del-ISO]]. Los cuatro puntos, por riesgo: +> > +> > 1. **El store, que hoy nadie crea.** PID 1 monta y **tiene prohibido +> > fabricar**, con buena razón: una máquina que se inventa un store arranca +> > perfecta el día que el disco no estaba. En una PC vacía no hay store, y +> > `mkfs.btrfs` no puede ir en la imagen. Es el decreto que hay que revisar, y +> > **«es la primera vez» y «no encontré el tuyo» tienen que seguir siendo +> > distinguibles**. +> > 2. **Los controladores.** `allnoconfig` más virtio y un serie. Falta UEFI, +> > framebuffer, teclado USB y almacenamiento real. Van tres opciones de kernel +> > encontradas arrancando y hay una regla que dice que ninguna comprobación de +> > construcción encuentra la siguiente. +> > 3. **La consola es `ttyS0`.** Una PC moderna no tiene puerto serie: arrancaría +> > bien y no se vería nada. +> > 4. **El gestor de arranque, que es la pregunta de filosofía.** GRUB sería un +> > segundo programa y [[Filosofia-Fundacional]] no lo permite — la misma forma +> > que el hueco de `bpftool`. La salida que no pide excepción: un kernel con +> > `CONFIG_EFI_STUB` **es** una aplicación UEFI, así que el firmware lo carga +> > directo y el medio lleva **un archivo**. Es `count` extendido al medio de +> > arranque. **Plan, no propiedad**: no se ha construido. +> > +> > Y que nadie aceptara hacerlo **es un dato y no un contratiempo**: la primera +> > medición de [[Por-Que-Elegirian-Este-SO]] no fue una opinión sobre el sistema, +> > fue que media hora de terminal ya cuesta más de lo que hoy ofrece. +> +> > ## Los arreglos de la auditoría rompieron dos cosas, y las dos ya están — 2026-08-05 +> > +> > **Es lo primero que hay que leer.** Cesar corrió `sudo ./dev/verify.sh` con los +> > arreglos de la auditoría puestos y salió `proven 99 · not proven 2 · failed 10`. +> > Los diez fallos eran de Thalyx, no suyos, y los diez son **dos defectos**, los +> > dos del commit `3976974`. +> > +> > ### 1. El módulo perdió su `stdout`, y ese `stdout` era el instrumento +> > +> > El arreglo era correcto —el módulo compartía terminal con el camino confiable, +> > podía leer la `y` del humano y podía dibujar el marco— y la respuesta fue +> > mandarle `stdout` y `stderr` a `/dev/null`. +> > +> > **La etapa 6 le pregunta al programa confinado qué ve**, que es la regla 2: +> > su pid, su uid, su hostname, sus interfaces, su raíz, si alcanza lo concedido. +> > Las seis respuestas viajaban por `stdout`. Con el descarte, las seis +> > reportaron `nothing` — **que es también lo que reporta un sandbox que no aisló +> > nada**. La medida de contención dejó a la contención sin testigo. +> > +> > Y costaba la otra dirección: un módulo que muere con un mensaje en `stderr` no +> > dejaba mensaje. *Falló* y *falló por esto* eran el mismo evento, que es lo que +> > la regla 10 prohíbe. +> > +> > Ahora la salida es una tubería que Thalyx **drena** y reimprime marcada y +> > saneada, igual que el canal. La propiedad que el `/dev/null` compraba se +> > conserva, dicha con precisión: **un módulo no puede empezar una línea.** No que +> > sus palabras desaparezcan —desaparecer era el error— sino que todo lo que +> > escribe llega detrás del marcador de Thalyx: +> > +> > ``` +> > org.thalyx.verify wrote, at descriptors Thalyx does not mediate: +> > > uid=700000 +> > > pid=1 +> > ! and now a diagnostic +> > ``` +> > +> > Decidido por Cesar el 2026-08-05, entre tres opciones. El techo es sobre lo +> > que se **guarda** (64 KiB), nunca sobre lo que se lee: un lector que se +> > detiene en su propio techo bloquea al módulo en la siguiente escritura, y el +> > límite de memoria se vuelve un cuelgue. Hay una prueba con 200 000 líneas. +> > +> > ### 2. Lo que un módulo dice se truncaba a 72 caracteres +> > +> > Es **el mismo defecto que la auditoría ya había arreglado para los permisos**, +> > sin arreglar donde el mismo razonamiento se aplica. `sanitise` corta a 72 y +> > `sanitise_block` lo llamaba línea por línea. El `greeter` dijo: +> > +> > ``` +> > read 27 byte(s) from /tmp/tmp.BCvj7bvl02/greeter-granted/notes.txt: the… +> > ``` +> > +> > Contestó qué leyó y Thalyx tiró la respuesta. Explica las etapas 12 y 13. +> > +> > Lo peor no es el corte sino **quién decide dónde cae**: lo que gasta el +> > presupuesto es la *ruta*, así que el mismo módulo dice menos en una máquina +> > con directorios más anidados. Ahora se acota por el **medio** y con mucho más +> > aire, como los permisos. +> > +> > ### Y la etapa 17 llevaba un día sin poder probar nada +> > +> > Decía `NOT PROVEN: no installed module`, honestamente y **siempre**: la etapa +> > 6 revierte el módulo como su última comprobación. Un salto que se dispara +> > siempre no es un salto, es una comprobación que nunca se hizo. +> > +> > Le faltaba además el control que importa: afirmaba que el texto del módulo +> > **no aparece**, lo que se satisface borrándolo todo — y de hecho pasó en verde +> > la misma corrida en que la etapa 6 se quedó ciega. Ahora exige las dos cosas: +> > que aparezca, y que no empiece ninguna línea. +> > +> > **666 pruebas pasan, `clippy` limpio, `cargo fmt` aplicado.** Tres reglas +> > nuevas en [[Estrategia-de-Pruebas]]. +> > +> > ### Lo que falta, y es de Cesar +> > +> > 1. **Correr `sudo ./dev/verify.sh` otra vez.** De los diez fallos, los cuatro +> > de las etapas 12 y 13 y los de la 6 en modo `--unconfined` se ejercieron +> > aquí; **la ruta confinada no**, porque este contenedor no tiene el LSM +> > atachado y la prueba del cgroup se salta diciéndolo. El cambio del sandbox +> > —`launch::spawn` con tuberías en vez de `/dev/null`— **solo se ejerce en tu +> > máquina**. +> > 2. **Anclar el digest del kernel.** `make -C image` sigue fallando a propósito +> > hasta que lo hagas, y eso es correcto: es la tarea 2 de abajo, no un +> > defecto. `make -C image pin-kernel` imprime los cuatro comandos. +> > +> > ## Y el criterio de salida dejó de depender de que alguien corra un comando — 2026-08-04 +> > +> > **Léelo después del bloque de la auditoría, que sigue siendo lo primero.** +> > +> > Al ir a comprobar el paso 6 apareció por qué estaba "escrito y sin ejercer": +> > la etapa 15 de `verify.sh` —la que maneja el prompt e implica los pasos 2, 3, +> > 4 y 6— necesitaba `script(1)`, que Fedora trae en `util-linux-script`. En la +> > única máquina que puede verificar Thalyx, la etapa se saltaba entera. +> > +> > El salto hizo lo que debía: dijo `NOT PROVEN`. Y no alcanzó, porque nadie +> > actúa sobre un informe que ya conoce. Ver la regla nueva en +> > [[Estrategia-de-Pruebas]]. +> > +> > Dos cosas, y las dos ya están: +> > +> > 1. **Thalyx hace su propia terminal.** `thalyx dev pty` —`posix_openpt`, +> > `setsid`, `TIOCSCTTY`— en `thalyx-syscall`, donde vive el `unsafe`. La +> > misma decisión que el initramfs y el cargador de BPF: antes ochenta líneas +> > propias que una cuarta cosa que nadie eligió. `verify.sh` ya no necesita +> > nada que la máquina que corre Thalyx no tenga. +> > 2. **Los cuatro pasos que no necesitan hardware son ahora pruebas.** Corren en +> > cada cambio, contra el disco y desde fuera de la sesión que dice haber +> > hecho las cosas. En `verify.sh` queda lo que sí necesita máquina: arrancar +> > la imagen, que el kernel deniegue, un reinicio de verdad. +> > +> > **Y manejar el prompt de verdad encontró un defecto que la auditoría había +> > creado**: el saneador truncaba también las líneas de permiso, y un permiso no +> > es una etiqueta. Con un `$HOME` largo, `/home/user/projects/secrets` y +> > `/home/user/projects/public` se dibujaban igual, y el humano confirmaba una +> > frase cierta de los dos. Ahora los permisos se envuelven en varias líneas — +> > solo la primera lleva viñeta, para que una línea envuelta no parezca una +> > concesión más— y si hay que acotar se elide el **medio**, no el final: la raíz +> > y la hoja son las dos partes que dicen qué se está concediendo. +> > +> > Regla 1 otra vez: salió de correr el sistema, y hubo que escribir la terminal +> > para poder correrlo. +> > +> > **El `doctor` también aprendió el ancla del kernel.** Al hacerlo fallar sin +> > digest, el commit anterior había creado justo el fallo que el `doctor` existe +> > para evitar: decir "está todo" y que la pared llegue después de la descarga. +> > Ahora se reporta en la misma vuelta que lo demás. +> > +> > ### Lo que decidí no hacer, y por qué +> > +> > **Cargar `thalyx_watch` con el cargador propio.** Es el último punto de "lo +> > que falta comprobar" y no lo toqué: aquí no se puede compilar el objeto +> > —faltan las cabeceras de `libbpf`— así que sería una segunda cosa sin ejercer +> > apilada sobre las que ya esperan hardware, que es exactamente lo que +> > `CLAUDE.md` prohíbe. El cargador ya recorre varios programas y varios mapas, y +> > el único tipo que el watcher usa y el LSM no es `PERCPU_ARRAY`, que es un mapa +> > con clave como cualquier otro. Es probable que funcione y **probable no es +> > comprobado**. +> > +> > ## Lo último: una auditoría externa encontró nueve defectos reales — 2026-08-04 +> > +> > **Es lo primero que hay que leer y lo único pendiente de comprobar.** +> > +> > Alguien de fuera revisó el repositorio y escribió una auditoría de seguridad. +> > Se verificó afirmación por afirmación contra el código: la mayoría eran +> > ciertas, tres son críticas, y varias de las que no eran ciertas también se +> > respondieron por escrito en vez de quedar en una conversación. +> > +> > Los tres críticos, en una línea cada uno: +> > +> > 1. **El lock global decretado no existía.** [[Concurrencia]] lo decretaba +> > desde el 1 de agosto y ningún código lo tomaba. Una instalación escribe +> > cuatro archivos separados; cada `rename` es atómico y el conjunto no. +> > 2. **Una actualización interrumpida podía darle a la versión vieja los +> > permisos de la nueva.** El registro se indexaba sólo por id de módulo y la +> > comprobación preguntaba "¿está instalado?" en vez de "¿es *esta* versión?". +> > Trece pruebas de inyección de fallos cubrían esa ventana y ninguna lo vio, +> > porque todas instalaban por primera vez. +> > 3. **Un keystore corrupto se leía como uno vacío**, y uno vacío confía en +> > todo lo que le ofrezcan. Dañar un archivo degradaba a todos los +> > publicadores anclados a un primer avistamiento. +> > +> > Los seis restantes: los permisos `session` no existían (se guardaban como +> > `persistent` y nunca se revocaban); `net/outbound` quitaba el namespace de red +> > y seccomp seguía prohibiendo `socket`, así que costaba aislamiento y no daba +> > capacidad; el camino confiable dibujaba texto del publicador sin sanear y el +> > módulo heredaba la terminal donde se dibuja; el API interna comprobaba la ruta +> > y la abría después, con una carrera en medio; un módulo podía hacer crecer sin +> > límite la memoria de Thalyx mandando notificaciones; y el journal se negaba a +> > leerse entero si su última línea estaba cortada — justo lo que deja un corte. +> > +> > **Todo está corregido, con pruebas que se comprobó que fallan sin el arreglo.** +> > 657 tests pasan, `clippy` limpio, `cargo fmt` aplicado. +> > +> > ### Lo que falta, y es de Cesar +> > +> > 1. **Correr `sudo ./dev/verify.sh`.** Nada de esto se ejerció en hardware. Los +> > cambios tocan el sandbox (`stdio` del módulo, seccomp según la concesión) y +> > el arranque los usa. +> > 2. **Anclar el digest del kernel.** `image/Makefile` ahora se niega a +> > construir con `KSHA256 := UNPINNED`, porque Thalyx compila su propio kernel +> > y ese tarball se bajaba sin verificar nada más que TLS. `make -C image +> > pin-kernel` imprime los cuatro comandos; el tercero tiene que decir *Good +> > signature*. **Hasta que lo hagas, `make -C image` falla a propósito.** +> > 3. **Decidir sobre lo que quedó nombrado y no resuelto**: los módulos son +> > binarios de Linux que hablan POSIX, y el decreto dice que no. La distinción +> > que sí se sostiene está escrita en [[Sistema-de-Modulos]] —la API es la +> > única superficie *mediada*— y cerrar la brecha entera es Fase 2. Ver +> > [[Tareas-Pendientes]]. +> > +> > Un costo que se aceptó a propósito y conviene saber: el API interna ahora usa +> > `openat2` con `RESOLVE_BENEATH`, que rechaza **todo** symlink absoluto, +> > incluido uno que apunte dentro de la misma concesión. Los relativos siguen +> > andando. Hay una prueba que nombra esa pérdida. +> +> > ## La máquina arrancó — 2026-08-03 +> > +> > `make -C image run`, en la Fedora de Cesar, con kernel 6.12.101 propio y un +> > solo programa dentro. Montó sus siete filesystems, imprimió lo que es y lo que +> > no tiene, y esperó una instrucción. **Es la primera vez que Thalyx existe como +> > máquina y no como programa sobre la máquina de alguien más.** +> > +> > Se describió con tres `no`: sin Btrfs, sin enforcement, sin módulos. +> > +> > ## Y ahora tiene dónde guardar cosas — 2026-08-03, esa misma noche +> > +> > **Dos de los tres `no` están cerrados.** El store existe: `sudo make -C image +> > store` formatea un disco Btrfs con los tres subvolúmenes decretados e instala +> > el `greeter` adentro; PID 1 lo monta por el nombre que le da `thalyx.store=` y +> > **nunca lo crea**. La sesión sabe `modulos`, `correr ` y `apagar`. +> > +> > Lo que hace la máquina ahora, entero: arranca, monta su disco, lista el módulo +> > que tiene instalado, lo corre, y el módulo le pregunta a Thalyx quién es y qué +> > puede leer —porque `correr` no le pasa ningún argumento—, lee lo que le +> > concedieron y le niegan `/etc/shadow`. Todo por un socket que él no abrió, +> > dentro de la máquina, sin shell. +> > +> > **Nada de eso se ha ejercido dentro de QEMU todavía.** Ver "Lo que falta +> > comprobar" abajo. +> > +> > ## Y arrancó con su disco puesto — 2026-08-03 +> > +> > ``` +> > ok store /dev/vda — three subvolumes +> > ok filesystem btrfs +> > ok modules 1: dev.thalyx.greeter 1.0.0 +> > ``` +> > +> > **De tres `no` en el primer arranque a uno.** El que queda es el enforcement, +> > que es el hueco de arquitectura que sigue. La máquina arranca, monta su store +> > de Btrfs, y sabe qué tiene instalado. +> > +> > Cuatro cosas se rompieron entre construir el store y verlo montado, y las +> > cuatro fueron del constructor y no del sistema — están abajo, en "Los cuatro +> > fallos del camino", porque tres de ellas son la misma regla. +> > +> > ## Y ahora carga su propio enforcement — 2026-08-03, escrito y sin ejercer +> > +> > El último hueco de arquitectura. `attach_lsm` invocaba `bpftool` —un segundo +> > programa, desde una shell, en una imagen que no tiene ninguno de los dos— y +> > buscaba `/lib/thalyx/thalyx_lsm.bpf.o`, un segundo archivo. **El mensaje que +> > imprimía sugería romper el decreto que estaba reportando.** +> > +> > Ahora el objeto BPF va dentro del binario y Thalyx hace las llamadas al kernel +> > él mismo: `crates/thalyx-bpf` lee ELF, BTF, la forma de los mapas y las +> > reubicaciones CO-RE sin una línea de `unsafe`, y `thalyx-syscall` hace las +> > cuatro llamadas. Ver [[Cargador-BPF-Propio]]. +> > +> > **Nada de esto se ha ejercido.** El contenedor no tiene BPF LSM. La etapa 14 +> > de `verify.sh` es donde se comprueba, y es lo siguiente que hay que correr. +> > +> > ## Y el cargador funciona — 2026-08-03, dos fallos después +> > +> > La etapa 14 en la máquina de Cesar: **cargó, atachó, dejó los mapas donde +> > `permd` los busca y se soltó limpio.** El cargador propio es real. +> > +> > Los dos fallos que costó están en [[Cargador-BPF-Propio]]. El segundo importa +> > más que el primero porque no era del cargador: **la demo de denegación se negó +> > a correr contra enforcement que estaba vivo**, tres líneas después de que la +> > misma etapa demostrara que lo estaba. Preguntaba por un directorio que solo +> > crea `bpftool`. +> > +> > Y tirando de ahí apareció algo peor, que llevaba puesto desde antes: **la +> > sesión reportaba enforcement preguntándole a `bpftool` si había un mapa +> > fijado.** Dos errores en una línea — la imagen no tiene `bpftool`, así que +> > adentro contestaba «no» pasara lo que pasara; y un mapa fijado es un lugar +> > donde poner permisos, no algo que los lea. Una máquina con todo fijado y nada +> > atachado habría reportado enforcement. +> > +> > Ahora Thalyx le pregunta al kernel qué programas suyos corre un enlace vivo, y +> > lo hace con llamadas `bpf(2)` propias, así que **funciona dentro de la imagen**. +> > Hay una respuesta más que antes: *parte de los hooks vivos*, que se nombra +> > aparte porque es peor que ninguno. +> > +> > ## Y la etapa 14 salió verde entera — 2026-08-03 +> > +> > `proven 72 · not proven 2 · failed 0`. **Thalyx carga su propio enforcement, +> > lo atacha, y ese enforcement deniega**: una conexión negada adentro del cgroup +> > y permitida afuera, contra hooks que puso Thalyx y no `bpftool`. +> > +> > **Ese era el último hueco de arquitectura de la Fase 1.** Lo que queda sin +> > probar no es arquitectura: es que llegue el modelo de verdad. +> > +> > Las dos cosas que la máquina de Cesar no puede establecer siguen siendo las +> > mismas: `llama.cpp` no está instalado y el camino del modelo real no está +> > escrito, y `verify.sh` no arranca la imagen. +> > +> > ## Y la máquina ya puede instalar, confirmar y revertir — 2026-08-03 +> > +> > **El objetivo es cerrar la Fase 1**, y el criterio de salida no es una lista +> > de componentes: son [[Criterio-de-Salida-Fase-1|seis cosas que hace una +> > persona ajena]]. De esas seis, tres pasaban por la sesión y ninguna se podía +> > hacer: adentro no hay shell, así que lo que no es un verbo de la sesión no +> > existe para esa persona. La sesión entendía seis palabras y ninguna era +> > `instalar`. +> > +> > Ahora entiende `disponibles`, `instalar `, `permisos` y `revertir`. Nada +> > de la lógica es nueva —el repositorio local, el camino confiable y el rollback +> > ya estaban escritos— y ese era justo el problema: **estaban escritos y no +> > alcanzables.** +> > +> > Y el disco cambió de contenido: lleva el módulo **en un repositorio, sin +> > instalar**. Una máquina que arranca con él puesto vuelve el paso 2 +> > irrealizable, y el paso 3 —el camino confiable— nunca se alcanza. Hay una +> > prueba que lee `image/Makefile` para que eso no se deshaga sin que nadie lo +> > note, porque deshacerlo mejora lo que la máquina *aparenta*: arrancaría +> > listando un módulo. +> > +> > La etapa 15 maneja el prompt de verdad, con un pty, y trae el control que +> > hace falta: **responder que no no instala.** Sin eso, una sesión que +> > instalara pase lo que pase pasaría todas las demás comprobaciones. +> > +> > ## Y construir esto ya no necesita bpftool — 2026-08-03 +> > +> > Cesar decidió mandarle la máquina a una persona ajena **cuando los seis pasos +> > sean reales**, no antes. Eso convirtió cada dependencia de construcción en un +> > sitio donde esa persona se atora, y la peor era `bpftool`: en Ubuntu y +> > derivados —Linux Mint, en este caso— viene en `linux-tools-$(uname -r)`, un +> > paquete por versión de kernel cuyo nombre a menudo no coincide con el que está +> > corriendo. Y se topaba con eso **después** de compilar un kernel entero. +> > +> > `lsm/vmlinux.h` ahora está escrito a mano: nueve structs, que es lo que los dos +> > programas tocan, en vez de las cien mil líneas que generaba bpftool. Ver +> > [[Cargador-BPF-Propio]] y la regla nueva en [[Estrategia-de-Pruebas]] — porque +> > esto abrió una forma de mentir sin síntoma, y hay una prueba que la muerde. +> > +> > ## Y los seis pasos existen — 2026-08-04 +> > +> > **Se puede hacer el criterio de salida entero.** Faltaban dos pasos y los dos +> > eran lo mismo: las piezas estaban escritas y no había cómo alcanzarlas. +> > +> > **El paso 6 no tenía nada detrás.** La sesión no escribía nada en la memoria +> > persistente al instalar, así que reiniciar no perdía el contexto — no había +> > contexto. Ahora `instalar` y `revertir` escriben por el mismo +> > `recollection.rs` que usa `thalyx agent do --task`, y `recuerdos` lo lee. Todo +> > vive en `/state/`, que es el subvolumen `system`, que viene del disco: +> > hay una prueba que lo afirma contra la tabla de montajes, porque una memoria +> > en el tmpfs se ve idéntica hasta el momento de apagar, que es el único que le +> > importa al paso 6. +> > +> > Lo que sale después de instalar, reiniciar y `revertir`: +> > +> > ``` +> > About `session`, you told me: +> > · the human asked: instalar dev.thalyx.greeter +> > · the human asked: revertir +> > +> > And this I remember but can no longer confirm: +> > ? installed dev.thalyx.greeter 1.0.0 +> > ``` +> > +> > **Eso es lo que distingue una memoria de una bitácora**, y es lo que hace que +> > el paso 6 valga sin modelo: nadie le avisó que el módulo se fue. El hecho +> > quedó atestiguado contra el enlace `current`, `revertir` lo quitó, y la +> > máquina fue a ver. Lo que se le pidió sigue intacto porque ningún archivo +> > puede volver falso que alguien lo haya dicho. +> > +> > Cesar decidió el 2026-08-04 que **eso es el paso 6** y que el modelo real deja +> > de bloquear la fase. Sigue decretado en [[Gamas-de-Modelo]]. El razonamiento +> > está en [[Criterio-de-Salida-Fase-1]]. +> > +> > **El paso 1 tenía máquina y no tenía camino.** `make -C image doctor` junta +> > todas las herramientas que faltan y las contesta con una línea de `apt`, sin +> > descargar ni compilar nada, y `all` depende de él primero. Lo que detiene a la +> > persona ajena nunca es Thalyx: es un paquete, encontrado de uno en uno, cada +> > uno después de que lo anterior salió bien. El peor era `pahole` — sin él +> > Kconfig descarta `DEBUG_INFO_BTF` en silencio y la culpa cae sobre el +> > cargador. El README tiene la sección **Boot it**, que son los seis pasos y +> > nada más. +> > +> > **Un defecto encontrado al hacerlo**, y dio regla nueva: la frase que explica +> > un hecho no confirmable decía que algo había cambiado *"without going through +> > Thalyx"*, y con `revertir` esa causa dejó de ser cierta. Ninguna prueba se +> > rompió. Ver la regla del mensaje que nombra la causa en +> > [[Estrategia-de-Pruebas]]. +> > +> > **Nada de esto se ha corrido en hardware.** Es lo siguiente: `sudo +> > ./dev/verify.sh`, donde la etapa 15 creció seis comprobaciones, y después +> > `make -C image run`. +> > +> > ## La imagen arrancó con el cargador propio, y le falta un hook — 2026-08-04 +> > +> > `make -C image run` en la Fedora de Cesar. **El cargador funcionó**: llegó +> > hasta preguntarle al kernel por sus hooks y dijo exactamente cuál falta. +> > +> > ``` +> > no thalyx-lsm this kernel does not expose `bpf_lsm_socket_connect` +> > ``` +> > +> > `thalyx.config` tenía `CONFIG_SECURITY` y no `CONFIG_SECURITY_NETWORK`. Todos +> > los hooks de socket de `lsm_hook_defs.h` están adentro de ese `#ifdef`, así que +> > el símbolo **nunca se compiló**. Arreglado: la línea está en `thalyx.config` +> > con su párrafo. +> > +> > Y `config-check` pasó en verde, correctamente — compara lo pedido contra lo +> > obtenido, y nadie había pedido esa opción. **Un punto ciego con forma propia**, +> > ver la regla nueva en [[Estrategia-de-Pruebas]]. Ahora existe `hook-check`: le +> > pregunta al objeto BPF a qué símbolos se engancha (`thalyx enforce hooks`) y +> > los busca en el `System.map` del kernel recién compilado, antes de arrancar +> > nada. Probado con sus tres respuestas — falta uno, están los dos, y no hay +> > kernel construido. +> > +> > **El resto del arranque salió bien**, y es la primera vez: de tres `no` a dos, +> > con el store de Btrfs montado y `filesystem btrfs`. +> > +> > **Y la etapa 15 se saltó entera**: Fedora no trae `script` — está en +> > `util-linux-script`. Los siete controles del paso 6 no corrieron, así que ese +> > trabajo sigue sin ejercer en hardware. +> > +> > ## Y el hook existía y no se le podía enganchar nada — 2026-08-04 +> > +> > Con `CONFIG_SECURITY_NETWORK` puesto, el símbolo apareció y el arranque falló +> > un paso más adelante: +> > +> > ``` +> > no thalyx-lsm attaching `thalyx_socket_connect`: Resource busy (os error 16) +> > ``` +> > +> > Faltaba `CONFIG_FUNCTION_TRACER`. BPF se engancha a un hook LSM con un +> > trampolín, y sin ftrace dinámico el kernel parcha el texto él mismo esperando +> > el NOP de cinco bytes que esa opción pone al principio de cada función. No +> > estaba, el `memcmp` falló, y ese camino devuelve `EBUSY` — que se lee como que +> > algo más tiene el hook tomado, y no había nada. +> > +> > `CONFIG_FTRACE=y` ya estaba y es solo el menú: no emite ningún NOP. +> > +> > Dos arreglos, y el segundo importa más: +> > +> > 1. Las cuatro líneas en `thalyx.config`, con `DYNAMIC_FTRACE_WITH_DIRECT_CALLS` +> > pedida explícitamente aunque sea derivada, para que `config-check` reporte +> > si no se materializa. +> > 2. **`hook-check` pregunta por el artefacto**: `register_ftrace_direct` solo se +> > compila bajo esa opción, así que su presencia en el `System.map` *es* la +> > propiedad. Probado con sus dos respuestas. +> > +> > Y el mensaje del cargador ahora dice **las dos** causas de `EBUSY` en ese +> > camino y que no las puede distinguir. Con su control: otro errno no lleva el +> > párrafo. +> > +> > **Ya son tres opciones del kernel encontradas arrancando**, y la regla nueva en +> > [[Estrategia-de-Pruebas]] dice por qué ninguna comprobación de construcción va +> > a encontrar la cuarta. +> > +> > ## Y ahora `verify.sh` arranca la máquina — 2026-08-04 +> > +> > Decidido por Cesar después del tercer arranque a mano. **La etapa 16 arranca +> > la imagen en QEMU y teclea los seis pasos**: espera a que la máquina diga que +> > es la máquina, y escribe `recuerdos`, `disponibles`, `instalar`, la +> > confirmación, `permisos`, `correr`, `revertir`, `apagar`. Después arranca otra +> > vez y pregunta `recuerdos`. +> > +> > **Dos arranques, porque eso es lo que dice el paso 6.** Un proceso nuevo no es +> > un reinicio; lo único que cruza entre los dos es el disco. +> > +> > Y la consola serie **es** un terminal: lo que ve el invitado es `/dev/console` +> > sobre `ttyS0`, sea lo que sea el stdin de QEMU. Así que el camino confiable se +> > ejerce como lo encuentra una persona, sin `script` de por medio. +> > +> > El disco se copia primero. Arrancar lo modifica, y una etapa que cambiara el +> > disco que alguien construyó haría que la segunda corrida empezara desde otro +> > lado que la primera. +> > +> > `make -C image boot` es lo que corre, y **no construye nada**. `run` depende +> > del kernel y de la imagen, y la regla del binario depende de `toolchain`, que +> > es `.PHONY` — así que pedir `run` puede arrancar un `cargo build`, y bajo +> > `sudo` eso corre como root y deja archivos de root en `target/`. Es el mismo +> > fallo por el que `store` se partió en dos, y la misma regla: **la frontera de +> > privilegio es la frontera de target.** +> > +> > **El arnés se ejerció contra una máquina falsa** —una que se queda callada +> > hasta estar lista, para que teclear temprano se note, y una que se muere de +> > inmediato, que tiene que volver como «nunca llegó al prompt» y no como un +> > cuelgue—. La etapa en sí **nunca ha corrido contra una imagen de verdad**: el +> > contenedor no tiene QEMU ni kernel que arrancar. +> > +> > ## La imagen atachó su enforcement, y se negó a usarlo — 2026-08-04 +> > +> > **La etapa 16 corrió por primera vez y sirvió de inmediato.** Lo que salió en +> > verde, todo dentro de la máquina y sin shell: arrancó, **atachó su propio +> > enforcement** (`ok thalyx-lsm` — el tercero de los tres `no` del primer +> > arranque, cerrado), dijo que no recordaba nada, listó su repositorio, presentó +> > el camino confiable, instaló sobre su disco Btrfs, revirtió, y se apagó sola. +> > +> > Y falló en una: **`correr` se negó**, diciendo que el mapa de política no +> > estaba cargado — tres líneas después de reportar el enforcement puesto. +> > +> > `BpftoolStore::is_available()` corría `bpftool map show pinned`. **Adentro no +> > hay `bpftool`**, así que contestaba «no» pasara lo que pasara, y esa respuesta +> > es la que decide entre confinar un módulo y negarse a arrancarlo. El +> > enforcement era real y lo único que no podía verlo era el código que decidía +> > si usarlo. +> > +> > Peor: `set()` también escribía con `bpftool`, así que **ninguna política se +> > podía escribir adentro de la imagen**. La comprobación estaba equivocada y +> > además tenía razón. +> > +> > `KernelStore` lo reemplaza entero: `BPF_OBJ_GET` sobre el pin y +> > `MAP_UPDATE_ELEM` / `MAP_DELETE_ELEM` / `MAP_LOOKUP_ELEM`, con la rama de +> > `union bpf_attr` capturada verbatim del uapi y una prueba que calcula sus +> > offsets desde la captura. **`BpftoolStore` se borró**: dos implementaciones de +> > lo mismo terminan por no coincidir, y aquí el desacuerdo sería una máquina que +> > aplica permisos en un lado y no en el otro. +> > +> > Es la **cuarta** vez que algo le pregunta a `bpftool` por algo que `bpftool` +> > no hizo. La tabla completa está en [[Estrategia-de-Pruebas]]. +> > +> > **Y el arnés de la etapa 16 tenía su propio fallo**, que Cesar notó antes de +> > que terminara: se quedaba callado y colgado. `wait` no alcanzaba, porque la +> > sesión termina con EOF y **PID 1 no** — sigue cosechando huérfanos, como debe, +> > así que QEMU nunca salía. Ahora imprime cada 15 segundos qué está esperando y +> > mata QEMU por la ruta del disco, que es de esa corrida y de nada más. +> > +> > ## Y el módulo pedía un perfil que no existe — 2026-08-04 +> > +> > Con `KernelStore` puesto, la etapa 16 volvió a correr y volvió a fallar en la +> > misma línea, **por otra razón**: +> > +> > ``` +> > dev.thalyx.greeter did not run: `default` is not a sandbox profile Thalyx knows +> > ``` +> > +> > `session.rs` tenía el nombre del perfil escrito a mano —`"default"`— en lugar +> > de tomado de `thalyx_sandbox::profile::MODULE_STANDARD`, que es lo que hace +> > `main.rs` tres archivos más allá. **Ningún perfil se llama `default`.** +> > +> > Vivió ahí desde que el prompt puede correr un módulo, con los 599 tests en +> > verde, y salió **en la consola de la máquina, después de que la instalación ya +> > había salido bien** — el peor lugar posible para encontrarlo. +> > +> > Lo escondió el **orden**, no el descuido: el nombre se resolvía *después* de +> > comprobar que el mapa de política estuviera cargado. En toda máquina sin ese +> > mapa —todas menos la imagen— contestaba primero la puerta, con una respuesta +> > honesta, y el nombre no se miraba nunca. Ahora `resolve` va antes: un nombre +> > que no existe es un nombre que no existe en cualquier máquina. +> > +> > Y la razón de fondo es la regla 1 otra vez: **la etapa 15 maneja el prompt de +> > verdad y no tecleaba `correr`.** Era el único verbo sin ejercitar, y era el +> > único roto. Ahora lo teclea. +> > +> > **El mensaje de la falla apuntaba al lado equivocado**: decía que la imagen no +> > tiene `bpftool`, que era cierto la corrida anterior y ya no. La causa real +> > estaba impresa cuatro renglones debajo. Ese texto se quitó. Ambas reglas +> > quedaron en [[Estrategia-de-Pruebas]]. +> > +> > ## Y nadie le había entregado los controladores — 2026-08-04 +> > +> > Arreglado el perfil, la misma línea falló un paso más adelante: +> > +> > ``` +> > `/sys/fs/cgroup/thalyx` cannot hand down the controller(s) ["memory", "pids"] +> > It has: [] +> > ``` +> > +> > La negativa era correcta —sin esos controladores los límites no se aplican y +> > el módulo se ve acotado sin estarlo— y lo que faltaba era que **alguien los +> > delegara**. En cualquier otro Linux lo hace systemd antes de que corra nada. +> > **En la imagen no hay systemd.** No hay nada más que Thalyx. +> > +> > Que es el decreto fundacional dicho de otro modo: todo lo que otra cosa hacía +> > por nosotros es ahora trabajo de Thalyx, lo hayamos notado o no. Y no se +> > encuentra leyendo el código —el código no menciona systemd en ninguna parte— +> > sino corriendo en la única máquina donde systemd no está. +> > +> > PID 1 ahora los delega al montar, con la lista tomada del perfil bajo el que +> > corren los módulos. Y **la sesión lo reporta**: `cgroup2` decía `mounted at +> > /sys/fs/cgroup` en una máquina donde ningún módulo podía recibir un límite — +> > pantalla de arranque limpia, primer `correr` roto. Eso es el fallo sin +> > síntoma, en el único lugar construido para no tener ninguno. +> > +> > ## El módulo corrió confinado, y se cayó montando su archivo — 2026-08-04 +> > +> > **El `pivot_root` funcionó sobre el initramfs.** Era la duda que quedaba, y +> > salió bien: el módulo obtuvo su cgroup, su raíz propia, su usuario propio, +> > seccomp con 128 llamadas y sus límites de memoria y procesos, adentro de la +> > máquina. Lo que falló es el último syscall de un montaje: +> > +> > ``` +> > could not attach the remapped mount at +> > /run/thalyx/sandbox/opt/thalyx/data/greeter/notes.txt: Invalid argument +> > ``` +> > +> > El kernel exige que el punto de montaje de un archivo sea un archivo +> > (`do_move_mount`: `d_is_dir(new) != d_is_dir(old)` → `EINVAL`). `bind` lo +> > sabía; `bind_remapped` llamaba a `create_dir` sin mirar. Dos funciones que +> > obedecen la misma regla del kernel, escritas por separado. +> > +> > Sobrevivió porque **todos los permisos de todas las pruebas son directorios**. +> > El único permiso sobre un archivo suelto es el del `greeter`, y el único lugar +> > donde el `greeter` corre con usuario propio es la imagen. Un caso de prueba +> > que nunca varía no es un caso de prueba. Ver [[Estrategia-de-Pruebas]]. +> > +> > ## Y nadie había hecho el `switch_root` — 2026-08-04 +> > +> > El montaje del archivo funcionó y `pivot_root` devolvió `EINVAL`, con el +> > módulo ya en su cgroup, con su política, su usuario, sus namespaces, seccomp +> > y sus límites. Todo bien menos el último paso. +> > +> > `do_pivot_root`: `if (!mnt_has_parent(root_mnt)) goto out4;`. **La raíz de un +> > namespace de montajes no tiene padre.** En cualquier otro Linux eso no se ve, +> > porque la raíz del proceso no es la raíz del namespace — el kernel arma un +> > `rootfs` interno y el initramfs monta el sistema real encima con +> > `switch_root` antes de que arranque nada. +> > +> > **La imagen es un initramfs y nada más.** Su raíz de proceso *es* la raíz del +> > namespace, y lo sigue siendo después de `unshare`. Nadie había hecho el +> > `switch_root` porque en todas las demás máquinas ya estaba hecho — que es +> > exactamente lo que pasó con systemd y los controladores dos rondas antes. +> > +> > PID 1 lo hace ahora, con un bind en lugar de un tmpfs: comparte los mismos +> > inodos y las mismas páginas, así que no cuesta memoria. **Se comprobó +> > corriéndolo** con los mismos envoltorios de `thalyx-syscall` dentro de un +> > namespace desechable: el cambio sale bien, la raíz pasa a tener padre, y +> > `pivot_root` después funciona. +> > +> > Y la máquina lo dice de sí misma: el arranque imprime que el cambio corrió y, +> > por separado, que la raíz resultante sirve —leído del kernel, no inferido— y +> > la sesión toma la misma lectura. +> > +> > ## Lo que sigue sin verse +> > +> > **Que el módulo hable.** Lo que falta es que el `greeter` lea su archivo, pida +> > `/etc/shadow`, sea negado, y lo diga por su canal — que es la línea que la +> > etapa 16 busca. Todo lo que hay debajo ya se vio funcionando adentro de la +> > máquina. +> > +> > El procedimiento sigue en [[Primer-Arranque]]. Si Cesar pega la salida de un +> > comando, casi siempre es de ahí. +> +> ## Dónde estamos, en una frase +> +> **El 2026-08-03 se quitó la distribución.** La bóveda decretaba en tres notas +> una base Alpine y en una —marcada no negociable— que Thalyx no es una +> distribución de Linux. Se resolvió a favor de la segunda: **la imagen es el +> kernel de Linux y `thalyx`, y nada más.** Ninguna distro, nunca. Ver +> [[Construccion-del-ISO]]. +> +> Eso convirtió la **API interna de módulos** en la pieza que seguía: sin shell y +> sin utilidades, un módulo no puede ser un script y no tiene con quién hablar +> excepto Thalyx. **Diseñada y construida el 2026-08-03** en +> [[API-Interna-de-Modulos]]: protocolo, servidor, el canal por el sandbox, y +> `dev.thalyx.greeter`, el primer módulo escrito contra ella. +> +> La Fase 1 tiene **sus tres primitivas** —de las cuatro decretadas; la cuarta es +> el [[Scheduler-Predictivo]] y es de Fase 2— y su flujo canónico **construidos y +> verificados en hardware real**: 44 comprobaciones en máquina real. Desde +> entonces: **520 pruebas**, el agente mínimo que lleva un enunciado hasta un +> módulo instalado sin modelo alguno, `thalyx` como PID 1, la imagen que Thalyx +> construye para sí mismo, y el disco donde guarda lo que le instalan. +> +> **Los huecos de arquitectura de la Fase 1 están cerrados.** El último era el +> enforcement dentro de la imagen; el cargador propio salió verde en hardware el +> 2026-08-03. +> +> > Matiz del 2026-08-04, al final del día: seguía siendo cierto que no falta +> > código **del sistema**, y era falso que no faltara código para *comprobarlo*. +> > Cuatro de los seis pasos no se estaban verificando en ningún lado, porque la +> > única etapa que los cubría se saltaba sola. Ya son pruebas. +> +> **Y desde el 2026-08-06 los seis pasos están hechos por la máquina, desde un +> arranque frío, con un reinicio de verdad en medio.** En esa corrida no quedaba +> código sin ejercer: `proven 104 · not proven 1 · failed 0`. +> +> > Y desde el 2026-08-07 la máquina puede hacer el disco en el que guarda: +> > **el kernel monta el Btrfs que Thalyx escribió byte por byte.** Etapa 18, en +> > verde. Lo que queda sin ejercer no es código del sistema: son los tres +> > subvolúmenes, que todavía no están escritos. +> +> **Y ahora la máquina puede hacer el disco en el que guarda**, que es el punto 2 +> del ISO y el poste largo de los tres. Falta que ese disco se vuelva un store. +> +> **Lo que falta para cerrar la fase ya no es código y tampoco es la persona +> ajena**, que Cesar canceló ese mismo día. Es **elegir con qué se sustituye** — +> ver el punto 0 de "Lo que sigue". El modelo del agente sigue decretado en +> [[Gamas-de-Modelo]] y no bloquea la fase, por decisión de Cesar del 2026-08-04. +> +> ## Lo que falta comprobar +> +> Escrito aparte para que no se confunda con lo que sí está probado: +> +> | Qué | Estado | +> |---|---| +> | El mecanismo del store | **Probado**, etapa 13, en verde el 2026-08-03. | +> | El store arrancando en QEMU | **Probado**, arrancó con el disco montado y el módulo instalado. | +> | El cargador de BPF propio | **Probado**, etapa 14, en verde entera el 2026-08-03. | +> | El paso 6 | **Probado el 2026-08-06**, etapa 16, desde un arranque frío y con un reinicio de verdad. Thalyx hace su propia terminal, así que ya no depende de `script`. | +> | El `doctor` | **Corrido el 2026-08-06** en la máquina de Cesar: encontró el ancla del kernel ausente y nada más. | +> | La imagen con enforcement puesto | **Probado el 2026-08-06**: `ok thalyx-lsm` dentro de la máquina, con el kernel recompilado. | +> | Los arreglos de la auditoría por la ruta confinada | **Probados el 2026-08-06**, etapas 6, 12, 13 y 17 enteras. | +> | El Btrfs que Thalyx escribe | **Probado el 2026-08-07**, etapa 18: el kernel lo monta, acepta los tres subvolúmenes, y un archivo escrito en él vuelve. Con `btrfs check` en verde y los headers de uapi capturados. | +> | Los tres subvolúmenes desde dentro | No construido. Van por ioctl, y hasta entonces un store escrito por Thalyx es un filesystem y no un store. | +> | `thalyx_watch` cargado sin bpftool | No intentado. Diez hooks en vez de dos; el mismo cargador debería servir. **Es lo único de la lista que sigue abierto.** | +> +> ## Los cuatro fallos del camino, y por qué tres son el mismo +> +> Entre que el store quedó escrito y que la máquina arrancó con él montado, nada +> de lo que falló fue del sistema. Los cuatro fueron del constructor: +> +> 1. **`sudo make store` no encontraba `rustup`.** `sudo` reinicia el `PATH`. Eso +> es lo chico: de haber funcionado habría corrido toda la compilación de Rust +> como root, con los scripts de build de cada dependencia con privilegio y +> archivos de root en `target/`. **La frontera de privilegio es la frontera de +> target** — `store-stage` construye, `store` formatea y se niega a construir. +> 2. **`NOT STATIC` sobre un binario perfectamente estático.** La comprobación era +> `file | grep 'statically linked'` y Rust enlaza musl como *static-pie*, que +> `file` llama `static-pie linked`. Ahora lee el segmento `INTERP` del ELF. +> 3. **QEMU no pudo abrir el disco.** La comprobación era `test -r` y QEMU abre el +> disco para escribir. Y el disco había quedado de root: son **dos +> pertenencias distintas** —el archivo es del host, lo de adentro es de la +> máquina— y confundirlas da un store que o QEMU no abre o la máquina no posee. +> 4. **Backticks dentro de un mensaje de ayuda**, dos veces. `echo "corre \`sudo +> make store\`"` es sustitución de comandos: el mensaje que explica qué correr +> lo habría corrido. +> +> El 2, el 3 y el 4 son **la misma regla**: comprobar un sustituto de la +> propiedad en vez de la propiedad, o escribir sobre una herramienta en vez de +> preguntarle. Está escrita en [[Estrategia-de-Pruebas]]. +> +> Y hay una lección de arriba de todas: **el 2 mintió durante un rato y la máquina +> ya lo había desmentido.** La imagen había arrancado con ese mismo binario como +> `/init`; uno dinámico habría dado `No working init found`. Cuando una +> comprobación contradice algo que la máquina ya demostró, la comprobación es la +> sospechosa. Van siete. +> +> Hay pruebas para los cuatro, y tres de ellas leen el `Makefile`. +> +> ## Última corrida verificada +> +> **2026-08-07, Fedora 43, kernel 7.1.5, Btrfs, `bpf` en el orden de LSM, +> `main @ 9229268`.** > > ``` -> cargo build --release --target x86_64-unknown-linux-musl -p thalyx-cli +> proven 110 · not proven 1 · failed 2 > ``` > -> Sus cuatro brazos se ejercieron uno por uno antes de entregarla, incluido el -> que importa: con el defecto puesto de vuelta, la etapa dice `FAILED` y imprime -> el error del compilador junto al veredicto. Si a la máquina le falta el -> objetivo de rustup o un compilador de C para musl, dice `NOT PROVEN` nombrando -> el remedio —un límite de la máquina no es una falla de Thalyx— y -> `THALYX_REQUIRE_IMAGE_BUILD=1` vuelve falla esos saltos. +> **Cerró la etapa 18**: el kernel monta el Btrfs que Thalyx escribió byte por byte, +> acepta los tres subvolúmenes, y un archivo escrito en él vuelve. Los dos fallos +> eran del arnés y no del formato — el control de la etapa 18 dañaba espacio libre +> por copy-on-write, y clippy falló sin dejar rastro porque el script borra su propio +> directorio al salir. Los dos están arreglados. > -> La regla nueva es la 12 de `CLAUDE.md` y está entera en -> [[Estrategia-de-Pruebas]]: **lo que se compila para verificar tiene que ser lo -> que arranca.** Es la regla 8 apuntada al compilador: una compilación con otra -> configuración es otro sistema. +> ### Y la corrida corta que resolvió lo de clippy > -> ### Lo que le toca correr a Cesar +> **2026-08-07, la misma máquina, con el informe arreglado.** Un solo fallo, y esta +> vez con el lint impreso: `unnecessary_sort_by` en `crates/thalyx-btrfs/src/format.rs`, +> dos veces. **Era desfase de versión** —clippy 1.97 contra 1.94— y no lo que se +> había supuesto. Arreglado, y el contenedor actualizado a 1.97 para que el próximo +> lint nuevo no se descubra otra vez en la máquina que no puede arreglarlo. > -> ``` -> git pull && make -C image image -> ``` +> **La etapa 19 está sin correr.** Es la que comprueba que Thalyx crea los tres +> subvolúmenes por ioctl, y no la puede correr ningún otro sitio. > -> Y arrancar la imagen, que es lo único que puede contestar cómo se ve la -> pantalla: su Fedora no tiene framebuffer que enseñarla. Adentro no hay que -> teclear nada — la pantalla es lo que sale. Si sale en negro: Ctrl-C a ciegas, -> o `thalyx.pantalla=no` en la entrada de arranque. +> ### La anterior, y es la que sigue siendo la referencia limpia > -> `sudo ./dev/verify.sh` completo no hace falta para esto; cuando se corra, va a -> traer una comprobación más que las 189 de hoy. - -> ## La pantalla es la máquina — 2026-08-28 -> -> Los bloques de abajo son cómo se llegó. -> -> **El decreto, en sus palabras:** *«te dije que ya deberíamos tener ui, porque -> no lo hiciste? o sea no quiero un comando para activar ui, quiero ya la ui, la -> que se ve al iniciar, es una estupidez tener que poner un comando para ver la -> ui definitiva»*. Y tenía razón sobre el diagnóstico también: él mismo encontró -> que `thalyx screen` tecleado adentro de la sesión caía en el caso `_` del -> despacho y contestaba *«I have no model loaded»*, porque `session.rs` no -> exponía el verbo. -> -> **Lo que quedó.** `session::run` **entra a la pantalla antes de imprimir un -> solo prompt**. La sesión de texto es lo que hay debajo, no la puerta. Hay un -> verbo `pantalla`, y sirve para volver después de Ctrl-C — no para entrar. -> -> **Y los verbos corren ahí.** Era la entrega que estaba pendiente y la razón -> escrita para no haberla hecho antes era buena: no apilar un segundo cambio sin -> verificar encima del primero. Lo que la volvió barata fue notar que los brazos -> de ese ciclo de seiscientas líneas tocan **exactamente cuatro cosas** —la -> tienda, dónde está parada la persona, qué cara contesta, y cómo llegó a existir -> este proceso— y nada más: ni la terminal, ni el vigilante del kernel. Salieron -> enteros a `session::dispatch`, y las dos caras lo llaman. -> -> **Lo que imprimen se atrapa en el descriptor**, no pasándoles un `Write` hacia -> abajo. `correr` y `ejecutar` arrancan **otros programas**, y la salida de un -> módulo está en el descriptor 1 de un proceso que Thalyx no controla; cualquier -> cosa más estrecha dibujaría una respuesta vacía justo para los dos verbos cuyo -> sentido entero es correr algo. Vive en `crates/thalyx-capture`. -> -> **Y la mitad que no es sobre salida.** La entrada se manda a `/dev/null` -> mientras corre un verbo. Varios se detienen y preguntan —`instalar`, -> `observar`, `instalar-en`, `ejecutar`— y todos preguntan después de comprobar -> `is_terminal`. Bajo la pantalla eso diría que sí, la pregunta se imprimiría -> donde nadie la ve, y la máquina se quedaría ahí sin teclado con qué contestar: -> **un cuelgue con una foto encima**. Con `/dev/null` cada uno toma el camino de -> rechazo que ya tenía escrito y probado. Es lo que queda pendiente de la -> pantalla, y está en [[Tareas-Pendientes]]: dibujar la confirmación. -> -> ### Las dos salidas, porque de esto depende que no se pierda una máquina +> **2026-08-06, `main @ 9e1c5f8`.** > > ``` -> thalyx.pantalla=no en la línea de comandos del kernel: arranca en texto -> Ctrl-C con la línea vacía baja a la sesión de texto, y funciona a ciegas +> proven 104 · not proven 1 · failed 0 > ``` > -> La segunda es la que importa si la pantalla sale mal: el modo gráfico y el modo -> crudo se deshacen en `Drop`, así que devolver la consola no depende de que -> alguien haya podido **leer** la pantalla para pedirlo. +> **Nada falló y nada quedó sin ejercer.** Corrida dos veces: la segunda con +> `THALYX_REQUIRE_IMAGE_TESTS=1`, que convierte en fallo cualquier salto de la +> etapa 16, con idéntico resultado — así que la etapa del arranque corrió de +> verdad en vez de saltarse en silencio, que es la única forma en que un `104` +> podría estar mintiendo. > -> ### Un defecto que sólo se veía corriéndolo +> La única `not proven` es `llama.cpp`, y es de la clase que **no existe**, no de +> la que no se pudo comprobar. > -> El acomodo de la conversación colocaba **un turno completo a la vez** y saltaba -> el que no cabía — así que la respuesta de `describe`, que es todos los verbos de -> la máquina, dibujaba **nada**. Cuarenta y tres pruebas de la pantalla en verde y -> ninguna lo veía, porque todas usaban conversaciones que caben. Ahora se aplana a -> renglones, se ancla abajo como una terminal, y AvPág/RePág recorren lo anterior. +> Lo que esta corrida cerró y ninguna anterior había cerrado: > -> ### Y la regla 11 en un sitio nuevo +> - Los diez fallos del 2026-08-05, todos, por la **ruta confinada** — que era lo +> que este contenedor no puede ejercer. +> - El **paso 6** de punta a punta: un arranque frío, los seis verbos tecleados, +> el apagado, un reinicio de verdad, y la máquina diciendo sola que la +> instalación que hizo ya no le cuadra. +> - El `doctor` corrido por primera vez en la máquina de Cesar, y el ancla del +> kernel establecida contra la lista firmada de kernel.org. +> - Las 666 pruebas con los cuatro `THALYX_REQUIRE_*` que esa máquina puede +> exigir, ninguna saltada. > -> **Los descriptores 0, 1 y 2 son del proceso.** `cargo test` corre las pruebas de -> un binario como hilos de un mismo proceso, así que la prueba del atrapador -> —viviendo como módulo adentro de `thalyx-cli`— atrapaba los renglones de -> progreso de `libtest` en vez de lo suyo: sola pasaba, con `--test-threads=1` -> pasaba, junto a las otras ciento treinta y cuatro no. Lo que no tiene dueño no -> se aísla con una variable de entorno; se aísla con **otro proceso**, que en Rust -> es otro crate. Está escrito en [[Estrategia-de-Pruebas]] y en `CLAUDE.md`. +> ### La anterior, para comparar > -> ### Lo que le toca correr a Cesar +> **2026-08-05, `main @ f781ced`**: `proven 99 · not proven 2 · failed 10`. Los +> diez fallos eran de Thalyx y eran dos defectos del mismo commit — ver el bloque +> de la auditoría más arriba. Esa corrida fue la que los encontró. > -> ``` -> git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh -> ``` +> **2026-08-03, kernel 7.0.11, `main @ f1a6dd0`**: `proven 72 · not proven 2 · +> failed 0`. Cerró el cargador de BPF propio: cargó los dos programas sin +> `bpftool`, los enganchó, dejó los tres mapas donde `permd` los busca, **denegó +> una conexión adentro del cgroup y la dejó pasar afuera**, y se soltó sin dejar +> un enlace vivo. También ejerció el `EXDEV` en el que descansa el layout, con +> línea base y control — porque una afirmación que sostiene un diseño hay que +> ejercerla, no citarla. > -> Y después, lo que ninguna prueba puede contestar — **`--describe` primero**, -> porque recorre todo el camino salvo escribir en el dispositivo y tomar la -> consola: +> Reproducirla: > > ``` -> thalyx screen --describe dice qué es este display, SIN tocar la consola +> git checkout main && git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh > ``` > -> Y la de verdad, que es arrancar la imagen: **no hay que teclear nada.** La -> pantalla es lo que sale. Adentro se teclea `ls`, `cat`, `estado`, `describe` — -> los mismos verbos, Tab completa, las flechas repiten, AvPág recorre. Si sale en -> negro: Ctrl-C a ciegas, o `thalyx.pantalla=no` en la entrada de arranque. +> > **El encabezado dice qué commit se está probando.** Existe porque una corrida +> > contra código viejo se ve idéntica a una donde el arreglo no funcionó: misma +> > etapa, mismo fallo, mismo mensaje. Pasó — dos arreglos estaban en `main` y la +> > máquina seguía en la rama de la que salieron. Si la línea no dice `main` y el +> > commit que esperas, la corrida no significa nada. > -> ### Lo que falta para poder vivir adentro, medido contra el código +> ## Qué quedó construido y probado > -> | Hueco | Estado | +> | Pieza | Comprobado en hardware | > |---|---| -> | El store de una máquina recién instalada queda vacío | Escrito en [[Tareas-Pendientes]]. Es la pregunta de Fase 2: **desde dónde** llega el software | -> | La red ve y no usa | Decreto de Cesar del 2026-08-23, [[Red]]. Sin DHCP, sin resolutor, sin TLS | -> | El agente no vive adentro de la máquina | Lo siguiente que eligió Cesar. Adentro de la imagen no hay llama.cpp; tendría que ser un programa ajeno en el store corrido con `ejecutar`, y **nunca se ha intentado** | -> | La confirmación, dibujada | Los verbos que preguntan rechazan en la pantalla en vez de preguntar en ella. `Confirmation` ya existe y ya se prueba; falta cablearlo | - -> ## La pantalla existe — 2026-08-27 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> **Lo primero, porque cambia el encuadre.** Cesar abrió pidiendo *«empezar a -> hacerlo realmente un SO»*, y medido contra el código **eso ya había pasado**: -> el 2026-08-07 una PC física arrancó Thalyx de una USB, se instaló sola en otro -> disco con `instalar-en`, y con el medio quitado arrancó de ese disco. PID 1 es -> `thalyx`, no hay distribución debajo, y `make -C image count` dice `1`. Contra -> el criterio que él mismo decretó, Thalyx **es** un sistema operativo desde -> entonces. Lo que falta no es el título: es que se pueda **vivir** adentro, y -> esa lista es corta y está abajo. -> -> **El decreto.** [[La-Pantalla]], tomado por él el 2026-08-27: *una sola -> pantalla que es Thalyx*. Sin ventanas, sin escritorio, sin lanzador — no hay -> dónde *abrir* el agente porque el agente es la pantalla. Corrige el -> aplazamiento de la GUI del 2026-08-01, cuya razón escrita era cierta y estaba -> **condicionada a que la Fase 1 no estuviera terminada**; cerró el 2026-08-07 y -> el aplazamiento siguió vivo veinte días por inercia. -> -> **Lo construido.** `crates/thalyx-screen`, puro: estado adentro, pixeles -> afuera. No abre un dispositivo, no hace un `ioctl`, no muestra nada — el mismo -> patrón de `thalyx-term` y `thalyx-edit`, y por la misma razón: el contenedor -> que construye Thalyx no tiene pantalla. **43 pruebas**, todas corriendo aquí. -> Lo que necesita hierro vive en `thalyx-syscall`: el `ioctl` que pregunta cómo -> empaqueta un pixel este framebuffer, el `mmap`, y quitar la consola de texto -> de en medio. -> -> **Y se puede *ver* sin tener pantalla.** `thalyx dev screen ` -> escribe un cuadro a una imagen por el mismo camino de composición que usa el -> display, así que lo que sale es lo que se dibuja. -> -> **Dos cosas que construirlo enseñó**, las dos escritas como revisión en -> [[La-Pantalla]]: -> -> 1. **Los pixeles no piden nada del kernel.** `FB`, `FB_EFI` y `VT` ya estaban -> desde el 2026-08-07, y `KD_GRAPHICS` sólo impide que el kernel dibuje la -> consola —no que la tty entregue las teclas—, así que el modo crudo que ya -> existe sigue sirviendo. **Esta entrega no toca `thalyx.config`**, o sea que -> no arriesga el arranque de la única máquina que verifica el proyecto. El -> ratón sí lo pediría, y por eso queda fuera: una pantalla sin ventanas no -> tiene qué apretar. -> 2. **Ctrl-C, que en la sesión es la salida, aquí es la trampa.** `RawMode` -> deja `ISIG` prendido a propósito. Con la consola en modo gráfico, ese mismo -> `SIGINT` mata el proceso **antes** de que `Drop` devuelva la consola, y lo -> que queda es una pantalla en negro sobre una máquina que está corriendo -> bien. La pantalla usa `RawMode::enter_without_signals` y sale por su propio -> pie. Una tecla que en un modo es el escape, en otro es lo que cierra la -> puerta. -> -> **La etapa 40 de `verify.sh`**, en dos mitades con costos distintos. La -> composición corre en cualquier lado y comprueba la única propiedad de la -> pantalla que es de seguridad: que **el color del camino confiable esté en una -> confirmación y en nada más**, con su control (la pantalla ordinaria lo usa -> cero veces) y el control del control (el color del agente sí aparece, así que -> el lector no está encontrando nada). Los pixeles los lee un decodificador de -> PNG escrito en el propio guion sobre `zlib` — regla 5: un cuadro comprobado -> por el código que lo dibujó sólo prueba que es consistente consigo mismo. La -> otra mitad necesita `/dev/fb0` y compara lo que el `ioctl` contesta contra -> **sysfs**, que es otro camino al mismo kernel; sin framebuffer dice -> `NOT PROVEN` y `THALYX_REQUIRE_DISPLAY=1` lo vuelve falla. -> -> ### Lo que le toca correr a Cesar +> | Instalación de módulos, commit atómico, journal, permisos | Sí, incluida inyección de fallos | +> | `thalyx-lsm` (BPF LSM) | Sí — **deniega de verdad** una conexión dentro del cgroup y la permite fuera | +> | Sandbox completo: namespaces, seccomp, `pivot_root`, idmap, límites | Sí — el módulo reporta su propio pid, uid, hostname, red y raíz | +> | Un uid por módulo, nunca reutilizado | Sí | +> | Índice en grafo + parser mecánico | Sí | +> | Contador de mutaciones del kernel, 10 hooks | Sí — 5000 escrituras por descriptor abierto, todas contadas | +> | Contador acotado al árbol | Sí — 5000 dentro contadas, 5000 fuera ignoradas | +> | El atajo del índice (`graph trust`) | Sí — se gana con verificación, y un cambio real sigue saliendo obsoleto | +> | Memoria persistente (3ª primitiva) | Sí — el hecho deja de ser afirmable al editar el archivo por fuera | +> | `rollback` | Sí — quita el módulo y sus permisos; se niega la segunda vez | +> | Snapshots de Btrfs | Sí — de solo lectura, conservan el contenido viejo | +> | `restore` | Sí — restaura, destruye lo posterior, y conserva lo destruido | +> +> Detalle por crate en [[Estado-de-Implementacion]]. +> +> ## Lo que sigue, en orden +> +> ### 0. La ISO independiente — es lo que cierra la Fase 1 +> +> **Decretado por Cesar el 2026-08-06.** Una ISO que puesta en una PC sin sistema +> operativo la deje corriendo Thalyx. Se ejercerá primero en una VM con firmware +> UEFI de verdad, y eso prueba que arranca sola; los controladores necesitan +> hierro y eso se dice aparte en vez de confundirse. +> +> El diseño y lo que cuesta están en [[Construccion-del-ISO]]. El orden de trabajo, +> por riesgo descendente: +> +> 1. ~~**Arrancar sin gestor de arranque.**~~ **Hecho y probado el 2026-08-06.** +> Un firmware arrancó Thalyx entera: `switch_root`, los siete montajes, los +> controladores, **el LSM enganchado** y la sesión. Ver el bloque de arriba. +> 2. ~~**El store, que Thalyx va a escribir él mismo.**~~ **Hecho y probado el +> 2026-08-07**: el kernel monta lo que Thalyx escribió, y acepta los tres +> subvolúmenes creados sobre él — etapa 18, en verde. +> `crates/thalyx-btrfs`, invocado por `thalyx disk format`. El decreto de que PID +> 1 nunca fabrica **se conservó entero**: quien crea el store es un humano y PID +> 1 no alcanza ese código. Falta que el filesystem se vuelva un store, que son +> los tres subvolúmenes, y van por ioctl. +> 3. **El instalador**: tabla de particiones GPT, una partición EFI con el kernel, +> y el store en la otra. Cesar decidió que la máquina arranca **sin** la ISO +> después, así que hay que escribir Thalyx en el disco de la máquina. Va junto +> con el 2, porque lo que el instalador escribe es precisamente el store. +> 4. **La consola sobre el framebuffer y el teclado USB**, más almacenamiento real +> (NVMe, AHCI). Sin esto la máquina arranca en hierro y no se ve nada, que es +> el fallo que se lee como «no funciona» siendo «no puedes mirar». Va al final +> porque es lo único que **una VM no puede sustituir**, y hasta entonces todo se +> ejerce con OVMF. +> +> ### 1. El agente — su mitad determinista ya está construida +> +> Ya no bloquea la fase; ver el paso 6 en [[Criterio-de-Salida-Fase-1]]. Va +> primero de lo que queda, y el motivo es de descubrimiento, no de avance. El ISO desbloquea +> cinco de los seis pasos del [[Criterio-de-Salida-Fase-1|criterio de salida]] +> contra uno del agente, pero el ISO **integra piezas ya probadas: no puede +> enseñar nada que no se sepa ya**. El agente sí puede invalidar el diseño del +> contrato. Descubrir tarde que la procedencia por campo no sobrevive a varias +> inferencias costaría mucho más que un ISO retrasado, y la regla 1 de +> `CLAUDE.md` dice que todos los defectos reales salieron de correr el sistema. +> +> Alcance: router de reglas más un modelo con decodificación restringida por +> gramática, sobre **un solo caso de uso** —instalar un módulo—, no un agente +> general. +> +> **Construida ya la mitad que no necesita un modelo**, y probada de punta a +> punta: `thalyx agent do "install dev.thalyx.demo@^1.0" --repo ` resuelve +> contra un repositorio local de bundles firmados, pide confirmación por el camino +> confiable, y deja el módulo instalado y ejecutable. Lo que falta, en orden: +> +> 1. El `Model` real que invoca `llama.cpp` como proceso. +> 2. La gramática GBNF, que no se puede validar sin `llama.cpp`. +> 3. El banco de las cuatro gamas, para sustituir las cifras estimadas. +> +> Los tres necesitan tu máquina: aquí no hay `llama.cpp` y la política de red del +> entorno bloquea `huggingface.co`. +> +> **El decreto que lo bloqueaba ya está escrito:** [[Gamas-de-Modelo]]. No un +> modelo anclado sino **cuatro gamas de una sola familia** que el usuario elige +> según su hardware, con `llama.cpp` invocado como proceso y decodificación +> restringida por gramática. Anclar un modelo de 5 GB dejaría fuera a una máquina +> de 8 GB, y el criterio de salida exige justamente que alguien de fuera lo use. +> Con la gramática, un contrato mal formado es imposible en las cuatro gamas: lo +> que cambia entre ellas es el acierto al interpretar la intención, no la +> seguridad. Y **el modelo nunca escribe la procedencia** — la pone el +> ensamblador, porque una gramática obliga a la forma y no a la verdad. +> +> El alcance del primero está en [[Agente-Minimo]]. +> +> Lo que sí está listo para el agente cuando exista: el contrato estructurado con +> marcado de origen, el camino confiable, la memoria persistente, y el principio +> de doble ruta implementado (todo lo que el agente podrá hacer, un humano ya +> puede hacerlo por la CLI). +> +> ### 2. La imagen: las tres cosas que le faltaban están hechas +> +> **Cerrado el 2026-08-06.** El primer arranque se describió con tres `no` y los +> tres están resueltos y comprobados dentro de la máquina: +> +> ``` +> ok kernel 6.12.101 +> no filesystem rootfs — snapshots and restore need btrfs and will not work here +> ok cgroup v2 mounted at /sys/fs/cgroup +> ok lsm order capability,bpf +> no enforcement the policy map is not loaded, so no permission would be enforced +> no modules nothing installed yet +> +> 3 are not here. I will not pretend otherwise later. +> ``` +> +> Las tres, en el orden en que se resolvieron: +> +> 1. ~~**Cargar `thalyx-lsm` desde dentro de Thalyx.**~~ **Hecho, y probado dentro +> de la imagen el 2026-08-06**: `ok thalyx-lsm` al arrancar, sin `bpftool` y +> sin shell. El objeto BPF va **dentro** del binario, no junto a él, que es lo +> que [[Filosofia-Fundacional]] obliga. Ver [[Cargador-BPF-Propio]]. +> 2. ~~**El store.**~~ **Hecho el 2026-08-03.** El disco se hace al construir con +> `sudo make -C image store` —Btrfs, tres subvolúmenes, el `greeter` instalado +> adentro— porque `mkfs.btrfs` no puede estar en la imagen, que es la misma +> forma que el problema del LSM y la misma respuesta: el trabajo se mueve al +> momento de construir. PID 1 lo monta por `thalyx.store=` y nunca lo crea. Ver +> [[Construccion-del-ISO]] y la tabla de montajes en [[Journal-y-Snapshots]]. +> 3. ~~**La API interna de módulos.**~~ **Hecha el 2026-08-03.** Protocolo, +> servidor, el canal atravesando el sandbox y `dev.thalyx.greeter`. Ver +> [[API-Interna-de-Modulos]]. +> +> ### 3. Reindexado incremental +> +> Consumir el ringbuf `thalyx_mutations` para saber *qué* cambió, no solo que +> algo cambió. **Ya no hace falta para el atajo** —eso lo resolvió la atribución +> por ancestros— así que es una mejora de rendimiento, no de corrección. Ver +> [[FS-en-Grafo]]. +> +> ## Decretos abiertos +> +> Ninguno bloquea excepto el primero. +> +> - [ ] **Una frontera real que etiquete canales** — hoy `--foreign` es una bandera que un humano pasa a propósito; nada en Thalyx llama a `Segment::foreign()` por su cuenta, porque nada trae texto de terceros todavía. Toda la defensa de procedencia descansa sobre ese código, que no existe. +> - [ ] **Correr el banco de las gamas** — el decreto ya está ([[Gamas-de-Modelo]]); faltan las cifras medidas. Necesita `llama.cpp` y los pesos, que el contenedor de desarrollo no puede tener. +> - [ ] Métricas de benchmark de la Fase 2 (el umbral ya está decretado; falta el instrumento) +> - [ ] Técnicas de interpretabilidad aplicables al agente +> - [ ] Arquitectura del índice semántico a mayor escala (SQLite alcanza para Fase 1) +> - [ ] Sistema de reputación resistente a Sybil (pospuesto a propósito) +> - [ ] Dependencias entre módulos con backtracking (pospuesto hasta que un módulo real las necesite) +> - [ ] Condiciones para habilitar llamadas a modelos remotos +> +> Lista completa y viva en [[Tareas-Pendientes]]. +> +> ## Lo que sigue sin validarse, y se carga a propósito +> +> **Ningún decreto de esta bóveda ha sido contrastado con una persona ajena al +> proyecto.** Todo el razonamiento sobre por qué alguien elegiría Thalyx sigue +> siendo a priori. Eso es cierto y sigue siendo un riesgo real. +> +> **Y no se adelanta.** El [[Criterio-de-Salida-Fase-1|criterio de salida]] pone a +> esa persona *después* del ISO, arrancando la imagen: ese es su paso 1. Nadie de +> fuera toca el sistema antes. No por miedo a lo que diga —el proyecto nunca +> dependió de eso— sino porque lo que esa persona determina es **la escala, no la +> validez**, y esta fase es incompatible con la escala. > -> ``` -> git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh -> ``` -> -> Y después, lo que ninguna prueba puede contestar: +> El riesgo se lleva con los ojos abiertos hasta entonces, que no es lo mismo que +> ignorarlo. El razonamiento completo, y la deriva concreta que previene, están en +> [[Criterio-de-Salida-Fase-1]]. > -> ``` -> thalyx screen --describe dice qué es este display, SIN tocar la consola -> thalyx screen toma la pantalla; Ctrl-C la devuelve -> ``` +> Ver también [[Por-Que-Elegirian-Este-SO]] y [[Riesgo-de-Ejecucion]]. +> +> ## Cosas que hay que saber para no romper nada > -> **`--describe` primero.** Recorre todo el camino salvo escribir en el -> dispositivo y tomar la consola, así que si la respuesta es que no se puede -> dibujar aquí, lo dice con la consola intacta. +> **El watcher del LSM es todo o nada.** Diez hooks; si el kernel no expone +> alguno, declina cargarse entero en vez de cargarse pareciendo completo. Un hook +> faltante no es un número más chico, es una forma concreta de que un archivo +> cambie en silencio. `make -C lsm hooks` dice cuáles hay. > -> ### Lo que esta entrega NO hace, y por qué +> **`verify.sh` desengancha el LSM al salir.** Por eso `thalyx graph watcher` +> dice "not loaded" después de una corrida. Es correcto, no es un fallo. > -> **Los verbos todavía no pasan por la pantalla.** `session::run` es un solo -> ciclo de seiscientas líneas que imprime conforme avanza; volverlo algo que -> devuelve una respuesta es una edición grande al código más ejercido del -> proyecto. Hacerlo en la misma entrega que los primeros pixeles significaría -> que, si su máquina arranca en negro, **no hay manera de saber cuál de los dos -> cambios fue**. Es la regla de `CLAUDE.md` sobre no apilar un segundo cambio -> sin verificar encima del primero. +> **`verify.sh` compila en `dev/.verify-target`** para no dejar el `target/` del +> usuario a nombre de root. Por eso el binario que queda en el PATH es el de +> `cargo install`, y hay que reinstalarlo después de cambios en la CLI. > -> ### Lo que falta para poder vivir adentro, medido contra el código -> -> | Hueco | Estado | -> |---|---| -> | El store de una máquina recién instalada queda vacío | Escrito en [[Tareas-Pendientes]]. La imagen lleva el kernel y un programa, así que una PC recién instalada arranca sana y sin nada que instalar. Es la pregunta de Fase 2: **desde dónde** llega el software | -> | La red ve y no usa | Decreto de Cesar del 2026-08-23, [[Red]]. Sin DHCP, sin resolutor, sin TLS | -> | El agente no vive adentro de la máquina | Las cuatro gamas se midieron en Fedora con `llama-completion` en el `PATH`. Adentro de la imagen no hay llama.cpp; tendría que ser un programa ajeno en el store corrido con `ejecutar`, y **nunca se ha intentado** | -> | Los verbos por la pantalla | La entrega siguiente. Ver arriba | -> -> **Lo siguiente que eligió Cesar** el 2026-08-27, junto con la pantalla: **el -> agente adentro de la máquina**. Es la razón por la que este SO existe, y -> `ejecutar` ya se construyó exactamente para correr un binario ajeno confinado. - -> ## La suite armaba su kernel, dos veces — 2026-08-27 -> -> Los bloques de abajo son cómo se llegó. -> -> **La segunda vuelta.** Con el arreglo puesto, la corrida trajo **181 `PROVEN`, -> 2 `NOT PROVEN`, 1 `FAILED`**, y la falla era la medición nueva de la §5 -> haciendo su trabajo: *«the suite moved the kernel guard from [0] to [1]»*. -> Quedaba otro, y es el que enseña algo — `catalogue_is_true.rs`, que **no es -> una prueba sobre el guardián**: le pregunta al binario qué verbos tiene y -> teclea cada nombre que le contesta. En esa lista viene `negar`. Su lista de -> exclusiones tenía cinco nombres y una sola razón detrás —*terminan la -> corrida*—; la otra razón para no teclear algo no estaba escrita en ninguna -> parte. -> -> O sea que el arreglo de la primera vuelta era **la mitad**: el peligro no es -> una prueba que trata del interruptor, es cualquier cosa que llegue al prompt, -> porque el prompt tiene el interruptor. La precondición se mudó a -> `tests/machine_guard/mod.rs`, compartida, y ahora la usa también el archivo -> que no sabía. Donde el guardián es real, los cuatro nombres se dejan fuera del -> tecleo y se dice cuáles; donde no, se teclean como todos. -> -> **Y un disparador para el quinto.** Excluir por una lista de palabras es un -> conjunto leído del lugar equivocado. Lo peligroso es que un verbo **actúe en -> cuanto se teclea** —sin argumento no hay «cuál» que lo detenga— y eso sí se -> lee del catálogo: `changes` verdadero y `takes` vacío. Hoy son cuatro: -> `revertir` y `apagar`, contenidos, y los dos del guardián. Cuál de las dos -> clases es cada uno no se puede leer de ahí, así que el conjunto quedó clavado -> en una prueba que lo lee del binario en vivo: un quinto verbo que actúe desnudo -> la pone en rojo y obliga a decidir. -> -> Cesar corrió `verify.sh` en su máquina y trajo **180 `PROVEN`, 2 `NOT PROVEN`, -> 2 `FAILED`**. Las dos fallas eran una: la suite de la §5 **armó su kernel**. -> -> `the_guard_can_be_switched.rs` está escrito contra una máquina sin nada -> cargado —sin BPF, `negar` no puede cambiar nada, y lo que se comprueba es el -> cableado— y daba por hecho que la máquina era ésa. Cada prueba abre su -> `THALYX_ROOT` temporal, y eso aísla **la tienda y nada más**: el guardián son -> cuatro bytes en bpffs, de la máquina, y ninguna variable de entorno los mueve. -> Como root y con el LSM enganchado, tres de esas pruebas hicieron lo que `negar` -> hace. La siguiente leyó «already enforcing» y falló, y la §6 reportó que la -> máquina llegaba armada. -> -> **Lo que quedó:** esas tres preguntan primero —al kernel, como lo pregunta -> `guard::set`— y se saltan con `NOT PROVEN` si el guardián de esta máquina es -> real; la línea base se salta con ellas, porque una línea base que sobrevive a -> lo que sostiene dejó de serlo. Dos de las seis siguen corriendo en todas -> partes y son las que hacen que el archivo pruebe algo en su máquina: el rechazo -> de `observar` en cara estructurada ocurre **antes** de leer el kernel, así que -> ahí se teclea el verbo que desarma, en una máquina que sí se puede desarmar, y -> no se mueve nada. -> -> **Y dos arreglos del arnés,** porque el veredicto apuntaba a la etapa -> equivocada: `guard_check` ahora nombra el intervalo —*«between [5. the test -> suite…] and [6. a real module…]»*— en vez de culpar sólo a la que se dio -> cuenta; y la §5 mide con `bpftool`, contra una línea base tomada antes, que la -> suite dejó el guardián donde lo encontró. Era otra precondición que el guion -> daba por hecha. -> -> La regla nueva es la 11 de `CLAUDE.md`, y está entera en -> [[Estrategia-de-Pruebas]]: **una prueba que escribe algo global de la máquina -> ya cambió la máquina que estaba midiendo.** No es «toca la máquina» —un cgroup -> se crea, se borra y tiene dueño— sino **un interruptor global sin dueño**, -> cuyo valor es la precondición de otra cosa. -> -> **Lo que sigue esperando fierro:** volver a correr `verify.sh` entero. Nada de -> esto se puede comprobar aquí, porque el contenedor no tiene el guardián que -> hace que el salto ocurra; lo que sí se comprueba aquí es la decisión del salto, -> con `would_switch_this_machine` sobre las tres respuestas que puede dar el -> kernel. - -> ## El sprint de lo que no necesita el fierro — 2026-08-26 -> -> Los bloques de abajo son cómo se llegó. -> -> Cesar estaba fuera de casa y pidió acumular corridas: todo lo que se pueda sin -> comprobar en hierro. Esto es lo que salió, y casi todo son instrumentos — -> porque el defecto de abajo llegó al fierro por huecos del arnés, no por falta -> de código. -> -> **Una prueba que mide el orden en vez del efecto.** El `-EPERM` del LSM no se -> reproduce sin LSM, pero la propiedad de la que se sigue —toda escritura de -> Thalyx antes de entrar al cgroup— sí se mide aquí, con `strace -f -y`. La -> ventana va del `write` a `cgroup.procs` hasta el `execve`, y adentro no hay -> una sola apertura con `O_WRONLY` ni `O_RDWR`. Comprobada revirtiendo el -> arreglo. -> -> **La columna de afuera para el pid.** El arreglo cambió *por qué* funciona el -> ingreso al cgroup: ahora se escribe «1», y sólo sirve porque el kernel traduce -> en el espacio de nombres de quien escribe. Si no lo hiciera, metería al init de -> la máquina bajo la política de un módulo. Se lee `cgroup.procs` desde el -> anfitrión, con `std::fs` y no a través de Thalyx. Comprobada con una mutación. -> -> **La etapa 39.** §36 era la única que armaba la máquina y sólo corre invitados; -> `correr` bajo un kernel que niega no lo había ejecutado nunca nada. Ahora el -> mismo módulo corre observando y negando en la misma etapa, y `Operation not -> permitted` tiene su propio veredicto que manda a `RootFs::assemble`. -> -> **Y cuatro cosas que el arnés daba por hechas y ahora mide:** el modo al -> anunciar cada etapa; que `make -C lsm enforce` de verdad armó —§36 y §39, con -> bpftool y no con Thalyx, que es el sujeto—; en qué modo queda la máquina al -> terminar; y el `cleanup` del demo, que se tragaba el fallo de su restauración. -> -> **Lo que sigue esperando fierro:** que el `-EPERM` desapareció. Etapa 36 y -> etapa 39. - -> ## El LSM le negaba a Thalyx confinar — 2026-08-26 +> **El store por defecto es `/opt/thalyx`**, que necesita sudo. Para uso normal: +> `export THALYX_ROOT=~/.local/share/thalyx`. > -> Los bloques de abajo son cómo se llegó. +> **El atajo del índice está apagado por defecto en cada índice nuevo**, y +> `verify.sh` reconstruye el índice del repo, así que vuelve a apagarse en cada +> corrida. Para encenderlo a mano: +> `thalyx graph trust ~/thalyx/crates --counter`. > -> Segunda corrida en fierro: **169 `PROVEN`, 3 `NOT PROVEN`, 12 `FAILED`**, con -> la anterior en 171/2/4 y ningún cambio de código entre las dos que tocara nada -> de lo que se rompió. Doce fallas y una sola frase debajo de casi todas: -> `I/O error at /run/thalyx/sandbox/dev/null: Operation not permitted`. +> ## Historial de sesiones > -> **El lanzador entraba al cgroup antes de armar la raíz**, así que el LSM leía -> la política del módulo y le negaba a Thalyx crear el punto de montaje de -> `/dev/null` — una escritura que el módulo nunca pidió y que el confinamiento -> necesita. En una máquina que niega no se podía lanzar nada, ni invitado ni -> módulo firmado. Es el defecto del 25 de agosto otra vez, arreglado entonces -> sólo para las lecturas. +> ### 2026-08-06 — la primera corrida sin nada roto, y el criterio se queda sin persona +> `proven 104 · not proven 1 · failed 0`, dos veces, la segunda exigiendo que la +> etapa del arranque no se saltara. Todo lo que existe está comprobado en la única +> máquina que puede comprobarlo. La única `not proven` es algo que no existe. > -> `RootFs` quedó partido en `assemble()` —toda la escritura— y `pivot_into()`, y -> el cgroup se toma entre las dos. La regla completa está en +> **Y el único defecto del día lo encontró un humano leyendo instrucciones.** +> `pin-kernel` mandaba a verificar `sha256sums.asc` con las llaves de los +> mantenedores del kernel, que no firman ese archivo, así que gpg contestó `No hay +> clave pública` justo debajo de la frase que define esa salida como motivo de +> parar. Escrito en un contenedor sin ruta a kernel.org y publicado sin correr: +> **texto impreso para una persona es código con salida**, y va la regla nueva en > [[Estrategia-de-Pruebas]]. > -> ### Y dos defectos del arnés que acusaban a Thalyx -> -> **Por qué las dos corridas no dieron lo mismo:** la segunda corrió *negando* -> en etapas escritas para una máquina que sólo observa. `verify.sh` lo daba por -> hecho de principio a fin y no lo medía en ninguna parte. Ahora `step()` lee el -> modo al anunciar cada etapa, así que la próxima corrida **nombra la etapa que -> lo dejó armado** en vez de que nadie lo sepa. Hay un sospechoso —el `cleanup` -> del demo se traga el fallo de su restauración— y no está probado. -> -> **`exec-bare` y `exec-endure` nunca pasaron**, ni una vez desde que se -> escribieron: el guion devolvía la máquina a observación *antes* de que esas -> dos etapas lanzaran su invitado, y `ejecutar` se negaba, correctamente. El -> reporte las contaba como fallas de G1. La restauración va ahora después del -> último invitado. -> -> ### Qué falta comprobar -> -> Las 24 pruebas de aislamiento hacen el pivote completo en el contenedor y -> pasan, así que el reordenamiento no rompió el lanzamiento. **Que el `-EPERM` -> desapareció sólo lo puede decir una máquina que niegue**, y es la etapa 36. -> Lo siguiente es correr `verify.sh` otra vez. - -> ## La primera corrida del sprint en fierro, y la carrera que encontró — 2026-08-26 -> -> Los bloques de abajo son cómo se llegó. -> -> Cesar corrió `sudo ./dev/verify.sh` con el sprint dentro: **171 `PROVEN`, -> 2 `NOT PROVEN`, 4 `FAILED`.** -> -> ### Lo que se arregló -> -> De los cuatro `FAILED`, uno estaba diagnosticado por su propio mensaje: la -> suite, en un solo test de diez, con `I/O error at /sys/fs/cgroup/thalyx: File -> exists`. Era una **carrera** — `cgroup::parent()` preguntaba si el directorio -> existía y después lo creaba, y diez tests en paralelo caben de sobra en la -> ventana entre las dos líneas. El mensaje decía lo contrario de lo que pasaba: -> la máquina estaba bien. -> -> Arreglado en los tres lugares que tenían la misma forma —`parent()`, -> `Cgroup::ensure()` y, del otro lado, `Cgroup::remove()`, que reportaba -> `No such file or directory` sobre un invitado que ya había corrido bien— y -> con una prueba de ocho hilos contra una barrera que con el código viejo falla -> ocho de ocho. La regla quedó en [[Estrategia-de-Pruebas]]: **no preguntes si -> algo existe para después crearlo.** -> -> Este contenedor no tiene cgroup2, así que ese código nunca se ejerció aquí. -> Lo que aquí sí corre —la suite entera, `clippy`, `fmt`— está limpio. -> -> ### Lo que falta, y por qué no se pudo diagnosticar todavía -> -> Los otros tres `FAILED` son las tres etapas de §36 que **lanzan un invitado** -> (`exec-run`, `exec-bare`, `exec-endure`). Fallaron las tres, y el reporte no -> traía nada más que la ruta de un log que sólo existe en la máquina de Cesar. -> No hay diagnóstico: lo que se sabe es que el kernel cargó y que el flip a -> enforcement tomó —si no, `exec-bare` y `exec-endure` ni siquiera habrían -> corrido— y que la salida no contenía ninguna de las frases que `verify.sh` -> sabe reconocer. -> -> Para que la próxima corrida se conteste sola, `verify.sh` ahora imprime la -> cola del log junto al veredicto en **las 111 salidas del script que nombran -> uno**, no sólo las de §36. -> -> **Lo siguiente es correr `verify.sh` otra vez y leer esas tres colas.** - -> ## Un sprint en vez de tres entregas, y el decreto que lo pidió — 2026-08-26 -> -> Los bloques de abajo son cómo se llegó. -> -> Cesar preguntó qué seguía. Se le contestó con tres opciones, y cortó: -> -> > «creo que ya habiamos dejado claro esto, me pones de opciones cosas -> > sencillas de hacer, cuando algo es barato o no requiere de mi, hazlos todos -> > de golpe en un sprint y deja listos los tests o herramientas para verificar -> > que quedaron bien […] llevamos mucho tiempo haciendo sprints completos -> > dedicados a algo super sencillo, debemos parar eso». -> -> Tenía razón: [[Ritmo-de-Construccion]] ya lo decía —«qué hacer con un -> pendiente que ya está escrito» está en la columna de lo que **no** se -> pregunta— y se preguntó igual. La revisión quedó escrita en esa nota y -> resumida en `CLAUDE.md`, en la forma en que se rompió: **un menú donde todas -> las opciones son baratas y ya están decididas es una pregunta prohibida**, y -> **lo barato no se entrega de a uno**. -> -> ### Y antes de nada, el error que cometí al contestarle -> -> Se le dijo que el trabajo de G1 no estaba en `main` y que por eso nadie lo -> había podido correr. **Era falso.** `main` ya lo tenía; lo que se leyó fue la -> copia local de `origin/main`, sin `fetch`, con días de retraso. El merge que -> salió de ahí no aportó nada —árbol idéntico— y quedó en `main` como un -> commit vacío con un mensaje que dice algo que no pasó; quitarlo habría sido -> reescribir `main` y no se hizo. -> -> Es la **decimocuarta** vez que el instrumento resulta ser el problema, y la -> segunda de esta misma falta. Volvió porque la regla estaba escrita a medias: -> ahora dice que `origin/main` **sólo es una pregunta sobre el repositorio -> después de un `fetch`**. Ver [[Estrategia-de-Pruebas]]. -> -> ### Lo que trae el sprint -> -> Tres pendientes, todos ya decididos, todos con su forma de comprobarlos. -> -> **1. Thalyx enciende y apaga su propio guardia.** El hueco que abrió el -> arreglo del 25: Thalyx leía el modo del kernel y no podía cambiarlo, porque -> cambiarlo era `bpftool` y la imagen no lo tiene. Dentro de la máquina no -> había forma de pasar de observar a negar, así que cada negativa cuyo remedio -> era *«hazlo vinculante»* nombraba un comando que ahí no existe. -> -> Ahora son dos verbos —`negar` y `observar`— más `thalyx enforce mode` para -> una máquina con shell. Dos y no uno con argumento porque un typo no puede -> desarmar la máquina, y **sólo el que afloja pregunta**: `negar` aprieta, y si -> rompe algo el algo lo dice; `observar` le quita el confinamiento a todo lo que -> esté corriendo, invitado incluido, y una máquina que dejó de negar en silencio -> se ve idéntica a una que niega y no tiene qué negar. La cara estructurada no -> puede pedir `observar` — `needs_a_human`, como `ejecutar`. -> -> **2. `ensayo correr`.** Era el único verbo que cambia la máquina y no se podía -> ensayar, y la razón escrita a su lado dejó de ser cierta el 25: lo que a una -> corrida se le va a permitir es una pregunta del kernel, y Thalyx ya la sabe -> contestar. El ensayo **es el código de la corrida**, parado un renglón antes -> de que el programa exista, así que no puede discrepar de ella. Dice qué -> programa correría, con qué aislamiento, **qué tiene en vigor** —no lo que pide -> el manifiesto—, si arrancaría, y si saldría **degradada**. -> -> **3. `ensayo editar`.** Salió mientras se cerraba el anterior: era el último -> que contestaba «no se puede ensayar todavía», y no lo nombraba ningún -> pendiente porque D1 lo contaba dentro de «archivos». Barato por la misma -> razón: `change` ya aplicaba en memoria y después guardaba, así que el ensayo -> es ese camino **sin la línea que guarda**. -> -> **Con esto D1 va nueve de nueve y la lista de verbos que cambian y no se -> pueden ensayar está vacía**, con una prueba que lo afirma. -> -> ### Qué está comprobado, y dónde -> -> Aquí: **1408 pruebas en verde**, `clippy` y `fmt` limpios. Las dos guardas -> nuevas se rompieron a propósito y cada mutación la agarró la prueba que le -> toca — quitar la puerta humana de `observar` tumbó dos, y un falso que -> reporta un cambio que no hizo tumbó exactamente una. -> -> Cada cosa lleva su control, porque sin él ninguna dice nada: `observar` y -> `negar` se niegan aquí por razones **distintas** (esa palabra es la decisión -> entera); el ensayo de `correr` no deja marca en el disco **y las mismas -> palabras con `sin-confinar` sí la dejan**; el de `editar` no toca los bytes -> **y sin `ensayo` sí los toca**, leídos con algo que no es Thalyx. -> -> **En tu máquina, dos etapas nuevas.** La **37** mide el guardia con -> `bpftool` y no con Thalyx —regla 5: preguntarle a Thalyx si sus cuatro bytes -> llegaron pasaría en una compilación donde la lectura y la escritura están mal -> en la misma dirección— con línea base, el acto, el control que lo mueve de -> vuelta, el verbo de sesión y el `n` con su `y` al lado. La **38** pregunta lo -> único que un contenedor no puede decir: qué contesta `ensayo correr` en una -> máquina que sí puede hacer cumplir, denegando y observando, que son las dos -> respuestas que se ven iguales si `degraded` estuviera mal. -> -> ### Para correrlo -> -> ```sh -> git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh -> ``` -> -> Y para verlo con las manos, en una sesión: -> -> ``` -> estado -> negar -> ensayo correr -> ``` -> -> ### Lo que sigue, y ninguna de las dos es barata -> -> Lo que queda de la vara —un agente ajeno trabajando aquí— son **G2** y **G3**, -> y G2 empieza con una decisión tuya, no con código: **de dónde saca su runtime -> un programa que nadie firmó**. La imagen lleva el kernel y un programa, y no -> hay libc; el agente pide el enlazador antes que nada. Las salidas son una -> libc en la imagen, una raíz propia que el invitado trae y Thalyx monta, o -> sólo binarios estáticos — y la primera toca [[Filosofia-Fundacional]], así que -> es tuya. Ver [[Superficie-para-el-LLM]]. - -> ## Y el segundo intento encontró el de abajo — 2026-08-25 -> -> Con el modo arreglado, Cesar corrió otra vez `ejecutar /usr/bin/node --version` -> — sin `leyendo`, sin `escribiendo`. El confinamiento se armó **entero**: -> cgroup 38600, usuario 700000, pivote, red cortada, 130 llamadas. Y murió -> antes de `node`: -> -> ``` -> thalyx: I/O error at /sys/fs/cgroup/thalyx/foreign.node-22.…/cgroup.procs: -> Operation not permitted -> ``` -> -> ### Qué pasaba -> -> Sin concesiones la política sale `allowed=0x0`, y el gancho `lsm/file_open` -> **no mira rutas**: mira si es lectura o escritura y consulta el bit. Con `0x0` -> se niega *cualquier* apertura de archivo. -> -> El lanzador escribe su pid en `cgroup.procs` desde **fuera** del cgroup —esa -> pasa— y enseguida **lo vuelve a leer** para comprobar que la entrada tomó. -> Esa lectura ya es desde dentro. Ni siquiera llegaba a `exec`, y abrir el -> binario también habría sido una apertura de archivo. -> -> ### Lo que más vale la pena de esto -> -> **Ya estaba encontrado, y rodeado.** La cabecera de `lsm/demo-enforcement.sh` -> dice que pone en el mapa *«filesystem allowed, network denied»*. Tenía que -> hacerlo: con el sistema de archivos negado, el `python3` de adentro no -> arrancaba. Esa conclusión —un proceso confinado necesita leer para existir— -> se descubrió, se rodeó, y **se quedó dentro del script**. Nada la -> contradecía porque nada más corría bajo enforcement: `verify.sh` va entero en -> modo observación. -> -> ### Cómo quedó -> -> El montaje decide **qué** ve un programa confinado; la política decide **leer -> o escribir** sobre eso. Las dos sólo componen si puede leer lo que se le -> montó, así que la lectura de lo visible **no es una concesión**: es el piso -> (`thalyx_permd::CONFINED_FLOOR`) que hace que el montaje signifique lo que la -> confirmación ya prometía — *«su propia carpeta, de sólo lectura, y las rutas -> de sistema»*. `escribiendo` sigue siendo lo único que abre la escritura. -> -> Se le da a la **política y nunca al perfil**: como permiso sobre `/` habría -> hecho que `RootFs` montara el sistema de archivos entero del anfitrión dentro -> del sandbox. Aplica a módulos igual — uno sin permiso de lectura tampoco podía -> abrir su propio binario. `thalyx enforce apply`, que ata un cgroup a mano para -> inspección, **no** lleva piso: tiene que escribir exactamente lo que se le -> pidió. -> -> Y las dos aperturas de `cgroup.procs` ahora dan errores distintos. Decían la -> misma frase, y esa frase era toda la evidencia de un fallo cuyas dos causas -> candidatas necesitaban arreglos opuestos. -> -> ### Lo que abrió, y Cesar cerró el mismo día -> -> Una entrada de política tiene **una** fecha de vencimiento, y las concesiones -> de `ejecutar` eran JIT: **treinta segundos**. Pasados, expiraba la entrada -> entera, el piso incluido — así que `ejecutar leyendo …` no podía correr -> más de medio minuto, y la vara es un agente que corre minutos. El comentario -> encima de esa línea ya decía lo correcto —*«vive lo que vive el proceso»*—; el -> tipo elegido hacía lo contrario. -> -> **Cesar decidió: la concesión dura la corrida.** Tipo `Session`, sin plazo, y -> `release()` la retira al salir. Lo que se cede está dicho: los treinta -> segundos eran también el respaldo del kernel contra un Thalyx colgado que -> nunca llegue a `release()`; lo acota que el nombre del cgroup es determinista, -> así que la siguiente corrida del mismo programa sobrescribe la entrada. -> -> Se comprueba en la etapa 36 con un invitado que **duerme 35 segundos** y -> después lee lo concedido. Es la única forma que distingue las dos respuestas: -> la corrida tiene que ser más larga que el plazo que ya no debe existir. - -> ## Lo primero que `ejecutar` dijo en su máquina encontró un hueco — 2026-08-25 -> -> Lo de arriba es lo que se vio en cuanto esto se arregló. -> -> Cesar corrió `ejecutar /usr/bin/node --version` en su Fedora, justo después de -> `verify.sh`, y leyó: -> -> ``` -> refusing to run `/usr/bin/node-22`: the kernel policy map is not loaded, so -> none of the 0 thing(s) this was granted would be enforced. -> Load it with `make -C lsm load`. Nobody signed this program, so there is no -> unconfined mode to fall back to. -> ``` -> -> La negativa era correcta: `verify.sh` desengancha el LSM al salir. Dos cosas -> estaban mal de todos modos. -> -> ### La chica: la frase contaba cero -> -> «none of the 0 thing(s) this was granted» es el caso **ordinario** — -> `ejecutar ` sin palabras después no concede nada—, así que el caso -> ordinario era el roto. Ahora la cuenta es una cláusula que desaparece cuando -> no hay nada que contar, y hay una prueba que falla si vuelve a aparecer un -> cero. -> -> ### La grande: `make -C lsm load` no es lo que el mensaje creía -> -> El remedio que ese mensaje da deja la máquina en **modo observación** — -> `make -C lsm load` aterriza ahí a propósito, para poder medir una política -> antes de que ate. Los ganchos corren, cada negación se escribe en el anillo, y -> **ninguna se aplica**. -> -> O sea: la única acción que el sistema le pedía a Cesar lo dejaba justo donde -> `ejecutar` **sí** arrancaba al invitado y el kernel no le negaba nada. -> -> La causa: `is_available()` contesta *«¿se abre el mapa de políticas?»*, y todo -> el que decidía si confinar lo leía como *«el kernel está negando»*. El modo -> vive en otro mapa, `thalyx_enforcing`, que **nada en el lado de Rust había -> leído nunca** — sólo el `Makefile`, con `bpftool`. `thalyx enforce status` -> imprimía «kernel policy map: present» y se callaba. -> -> ### Qué se hizo -> -> | | módulo firmado | programa ajeno | -> |---|---|---| -> | mapa sin cargar | se niega, ofrece `sin-confinar` | **se niega**, no hay a qué caer | -> | cargado, observando | **corre degradado, y el journal lo dice** | **se niega**: `make -C lsm enforce` | -> | no se pudo leer el modo | corre degradado, y el journal lo dice | **se niega**: regla 9 | -> | cargado, negando | corre | corre | -> -> La asimetría es la de [[Programas-Ajenos]] entera: a un módulo lo firmó -> alguien y un humano leyó su manifiesto, así que un run degradado que el -> journal nombra es auditable. Detrás de un invitado no hay nadie, y un -> confinamiento que no niega no es un confinamiento. -> -> `thalyx enforce status` ahora dice el modo. La cara de máquina de `correr` -> lleva `enforcing` al lado de `confined`, por la misma razón que `confined` -> está ahí. El falso, `MemoryStore`, ganó los tres estados — porque el motivo de -> que ninguna prueba agarrara esto es que **el modo de fallo no existía en el -> falso**, y lo que no se puede nombrar no se puede probar. -> -> ### Qué está comprobado -> -> Aquí: 1384 pruebas en verde (siete nuevas), `clippy` y `fmt` limpios. Las tres -> guardas nuevas se rompieron a propósito y cada mutación la agarró **la prueba -> que le toca** — incluida la columna de control, que atrapó la versión que se -> niega siempre y se vería idéntica a una que funciona. -> -> En su máquina, tres etapas nuevas de `verify.sh`: que `thalyx enforce status` -> diga «observing» cuando el script lo dejó observando, que un módulo corrido -> bajo un kernel que observa **lo diga**, y que un invitado sea rechazado ahí -> mismo. Y la etapa 36 ahora **enciende el enforcement para su corrida real y lo -> vuelve a dejar como estaba** — sin eso, la etapa entera reportaría una -> negativa y la llamaría una máquina que no puede hacer cumplir nada. -> -> ### Lo que abrió -> -> Thalyx **lee** el modo sin `bpftool`. **Cambiarlo** todavía es -> `make -C lsm enforce`, o sea `bpftool`, que la imagen no tiene: dentro de la -> máquina no hay forma de pasar de observar a negar. Escrito en -> [[Tareas-Pendientes]]; es una escritura de cuatro bytes en un mapa que ya se -> abre. - -> ## G1: Thalyx ya puede correr un programa que nadie firmó — 2026-08-25 -> -> Lo de arriba corrige un hueco que esto dejó abierto. -> -> Cesar delegó la forma —*«lo que veas conveniente que sea coherente con nuestra -> filosofía»*— y ésta es la forma, con la coherencia escrita en -> [[Programas-Ajenos]] antes de escribir una línea de código. -> -> ### Qué se destrabó, y por qué llevaba parado desde el 23 -> -> `G1` de [[Superficie-para-el-LLM]] era el punto que bloqueaba la vara del -> proyecto. La medición del 23 lo había dejado sin ambigüedad: no faltaba una -> llamada al sistema —el filtro cubre 41 de 41— ni una ruta. Faltaba que -> `correr` sólo lanza **módulos instalados y firmados**, y un agente ajeno no es -> ninguna de las dos cosas. -> -> Lo que lo destrabó no fue código, fue **no tocar la firma**. Si Thalyx firmara -> al vuelo lo que se le pide ejecutar, la firma dejaría de significar *alguien -> respondió por esto* y pasaría a significar *esto pasó por aquí* — la palabra -> sin significado para quien lea la siguiente. Así que son dos verbos: -> -> | | `correr ` | `ejecutar ` | -> |---|---|---| -> | qué lanza | un módulo firmado | un programa cualquiera | -> | quién respondió por él | su publicador | **nadie** | -> | canal con la API | sí, nace con él | **no, nunca** | -> | `sin-confinar` | existe, y queda como degradado | **no existe** | -> -> ### Las tres decisiones que aguantan el peso -> -> 1. **No hay canal.** Un módulo nace sosteniendo un socket a la API de Thalyx; -> un invitado no recibe ninguno. Eso es lo que impide que este verbo sea una -> puerta trasera: por aquí no se instala nada, no se concede nada persistente -> y no se pide nada, porque no hay por dónde pedirlo. -> 2. **No hay modo degradado.** `sin-confinar` existe para módulos y se -> justifica en que un humano leyó ese manifiesto y su publicador respondió. -> De un programa ajeno nadie respondió nada, así que si la máquina no puede -> hacer cumplir la política, el verbo **se niega** — y el mensaje dice que ese -> modo no existe, en vez de ofrecerlo. -> 3. **Ve lo que se le nombró.** Su propia carpeta de sólo lectura, las rutas de -> sistema, y lo que diga `leyendo ` o `escribiendo ` — cada cosa -> dibujada por Thalyx y confirmada antes de que el proceso exista. Su usuario -> se guarda con la llave `foreign:`, así que el mismo programa -> es el mismo usuario mañana y dos programas distintos nunca comparten uno. -> -> ### Qué se comprobó aquí y qué espera tu máquina -> -> Etapa **36** de `verify.sh`, con su columna de control: el mismo script corrido -> **fuera** del sandbox tiene que alcanzar las dos rutas, o el «no las alcanzó» -> de adentro no significa nada. En este contenedor da cinco `PROVEN` y un -> `NOT PROVEN`: -> -> - **probado aquí** — el control de afuera; `ensayo ejecutar` resuelve el -> programa y no corre nada; **un `n` no corre el programa** (comprobado por lo -> que *no* apareció en el disco, no por lo que imprimió la sesión); el journal -> lo llama `run_foreign` y nunca `run_module`; y la cara estructurada se niega -> con `needs_a_human` / `confirm_at_a_terminal`. -> - **espera tu máquina** — lo que un invitado ve. Aquí no hay mapa de política -> en el kernel, así que el verbo se niega, que es el decreto funcionando. El -> `NOT PROVEN` dice además algo cierto: esa negativa viene del núcleo, o sea -> que el `y` sí se leyó y se aceptó. Lo que no corrió es el invitado. -> -> Más seis pruebas de integración en `a_program_nobody_signed_can_run.rs`. Dos -> corren aquí —la negativa sin nada que haga cumplir, y el journal—; las otras -> cuatro necesitan los controladores `memory` y `pids` delegados y dicen -> `NOT PROVEN` donde no los hay, con `THALYX_REQUIRE_CONTROLLER_TESTS`. -> -> **En tu máquina esas cuatro corren.** `cargo test --workspace`: 1384 en verde. -> -> ### Lo que esto no hizo -> -> - **No abrió la red** (`G3`), no es `E1` —las concesiones son de una corrida, -> no expiran porque terminan— y **no resolvió `G2`**: la imagen sigue sin -> libc, así que `ejecutar` sirve donde hay rutas de sistema que montar, o sea -> tu Fedora. Dentro de la imagen instalada sirve para lo que esté enlazado -> estáticamente. -> - No le quitó nada a `correr`. El decreto de firma sigue entero. -> -> ### Para correrlo -> -> ```sh -> git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh -> ``` -> -> Y para verlo con las manos, en una sesión: -> -> ``` -> ejecutar leyendo /home/cesarmanzocode/algo /usr/bin/ls /home/cesarmanzocode/algo -> ``` - -> ## Verde, y la orden de dejar de pulir — 2026-08-25 -> -> Cesar corrió `verify.sh` en su máquina: **`156 proven · 2 not proven · -> 0 failed`**. Las dos fallas del día anterior están cerradas, y eran la misma -> cosa vista dos veces —la prueba nueva y la etapa del módulo, las dos -> preguntando con `chrt --other`, que en util-linux 2.41 sale por -> `sched_setattr`—. El arreglo no tocó el filtro: cambió con qué se le pregunta. -> -> ### Y con eso, la corrección que importa más que el número -> -> Cesar cortó la pregunta de qué seguía, y con razón: -> -> > «llevamos mucho tiempo sin avanzar nada realmente, estamos siendo muy -> > cautelosos […] le estamos dando demasiada importancia a cosas muy simples y -> > faciles de hacer […] tenemos que empezar a ser agresivos sin ser estupidos, -> > la perfeccion vendra despues». -> -> Medido contra el registro, tenía razón: del 23 al 25 se construyó un guardia -> por argumento, dos llamadas de rango de prioridades, tres arreglos del arnés y -> una prueba que pregunta con la herramienta correcta. Todo cierto, y ninguno de -> esos días movió la vara de [[Filosofia-Fundacional]] —un agente ajeno -> trabajando aquí— ni un milímetro. -> -> Quedó decretado en [[Ritmo-de-Construccion]], con sus palabras textuales, y -> resumido en `CLAUDE.md` para que una sesión nueva lo lea antes de preguntar -> nada. En una línea: **se le pregunta sólo lo que sólo él puede contestar** -> —cambiar un decreto suyo, escribir donde se pierde algo suyo, gastar su hierro -> o su dinero, alcance que la bóveda no cubre—. Todo lo demás se hace y se le -> dice qué se hizo. Un pendiente ya escrito en [[Tareas-Pendientes]] ya fue -> decidido por él; volver a preguntarlo es pedirle que decida dos veces. -> -> Lo que **no** baja: ninguna de las diez reglas de [[Estrategia-de-Pruebas]], -> ningún decreto sin él, ninguna entrega a medias, y `NOT PROVEN` sigue siendo -> `NOT PROVEN`. -> -> ### Lo que se hizo ese mismo día sin preguntar -> -> 1. **`README.md` y `docs/STATUS.md` dicen la corrida vigente.** Citaban la del -> 23 —`134 proven`— porque la del 24 tenía fallas y no era la que correría -> ahora. Ya no hay una que esconder. El párrafo que explicaba una caída de -> conteo se volvió la regla que la caída enseñó: **un conteo que se mueve no -> es una calificación**, y lo que dice qué pasó es la lista de abajo, no el -> número. -> 2. **El caso de aislamiento sobre un archivo, que llevaba abierto desde el -> 2026-08-04.** Ver [[Tareas-Pendientes]]. Dos pruebas nuevas en -> `isolation.rs`: un permiso de escritura sobre **un solo archivo**, con la -> raíz remapeada de verdad, comprobado en el anfitrión —el contenido llegó al -> mismo archivo y el archivo no cambió de dueño—; y su control, que afirma -> que **el vecino de al lado no viene con él**. Las dos se rompieron a -> propósito antes de creerles, y las dos **corren en este contenedor**: hay -> un cgroup2 en `/sys/fs/cgroup/unified` y los montajes remapeados funcionan -> aquí, así que esto no espera hierro. `cargo test --workspace`: 1359 en -> verde. -> -> ### Las dos que quedan sin comprobar -> -> `verify.sh` las nombra en su propio resumen —el bloque `What this run could -> not establish:`— y ésa es la autoridad, no lo que se escriba aquí. Si son las -> del agente, se cierran así: -> -> ```sh -> git pull && cargo install --path crates/thalyx-cli -> sudo THALYX_AGENT_BINARY=/home/cesarmanzocode/src/llama.cpp/build/bin/llama-completion \ -> THALYX_AGENT_WEIGHTS=/ruta/a/tu/modelo.gguf \ -> ./dev/verify.sh -> ``` -> -> ### Y lo siguiente, que sí es una decisión suya -> -> **G1 y G2 de [[Superficie-para-el-LLM]].** Es lo único que bloquea la vara del -> proyecto, y lleva bloqueándola desde que se midió el 2026-08-23: -> -> - **G1** — hoy `correr` sólo lanza módulos **instalados y firmados**, y un -> agente ajeno no es ninguna de las dos cosas. El sandbox ya lo aguantaría: el -> filtro cubre 41 de 41 llamadas medidas. Lo que falta no es mecanismo, es -> **qué se permite lanzar**, y eso es un decreto suyo. -> - **G2** — la imagen lleva el kernel y un programa, así que no hay libc, y un -> binario enlazado dinámicamente no arranca ahí. Ver -> [[Que-Necesita-Un-Agente-Ajeno]]. -> -> Las dos son la misma pregunta vista desde dos lados, y ninguna se puede -> construir sin que él decida primero. - -> ## La segunda puerta: `chrt` medía la versión de util-linux, no el filtro — 2026-08-25 -> -> La corrida siguiente dio `155 proven · 2 not proven · 2 failed`, y las dos -> fallas eran la misma cosa vista dos veces: la prueba nueva y la etapa del -> módulo, las dos preguntando con `chrt --other`. -> -> ### Y corrige lo que se dijo ayer -> -> Ayer quedó escrito que las tres fallas se habían reproducido en el contenedor. -> **La del filtro no.** El contenedor tiene util-linux 2.39 y su máquina tiene -> 2.41, y desde 2.41 `chrt --other` pone una política ordinaria con -> `sched_setattr` en vez de con `sched_setscheduler`. El verde de aquí fue -> **suerte de versión**, no una comprobación — y la prueba se había escrito el -> mismo día en que se anotó que un programa real es mejor instrumento que una -> llamada aislada. Lo es; falta preguntarse qué llamada hace ese programa en la -> máquina donde va a correr. -> -> ### Lo que había debajo, que sí es de diseño -> -> `sched_setattr` es **una segunda puerta a la misma capacidad**. Pone la -> política igual que `sched_setscheduler`, pero la recibe dentro de una -> estructura, detrás de un puntero — y un filtro de seccomp compara registros y -> no puede seguir un puntero. Para esa puerta no existe guardia por argumento: o -> se permite entera, con `SCHED_FIFO` adentro, o se deniega entera. -> -> **Cesar decidió el 2026-08-25 denegarla.** Queda en [[Sandbox-Ejecucion]] con -> su costo escrito: un programa que ponga política ordinaria sólo por esa puerta -> no puede hacerlo aquí, y `chrt --other` de util-linux 2.41 es uno. Ningún -> runtime medido depende de ella —la traza del agente ajeno lo muestra -> arrancando con `sched_setscheduler` y con nada más—. La única cosa que podría -> mirar detrás del puntero, un supervisor con `SECCOMP_RET_USER_NOTIF`, queda -> anotada en [[Tareas-Pendientes]] como opción y no como pendiente. -> -> ### Qué cambió, y qué no -> -> **El filtro no cambió hoy.** Lo que cambió es con qué se le pregunta: -> -> - La columna ordinaria pregunta con `chrt --idle 0 true`. Ninguna versión de -> util-linux lo manda por la puerta cerrada, y `SCHED_IDLE` es una de las tres -> políticas que el guardia permite: sigue siendo un programa ajeno recorriendo -> el camino entero hasta la llamada guardada, que es lo que hacía valioso a -> `chrt`. -> - `--other` se sigue corriendo, como **reporte y nunca como veredicto**, con -> `strace` fuera del sandbox diciendo por cuál de las dos llamadas pasó este -> `chrt`. Así el costo de la puerta cerrada se ve en la máquina donde se paga, -> medido y no supuesto. -> - El segundo `NOT PROVEN` de la corrida fue la baranda de ayer haciendo su -> trabajo: la denegación de tiempo real se calló porque la columna ordinaria no -> estaba en 0. Sin ella, esa línea habría dicho verde con el módulo muriendo -> antes de nombrar ninguna política. -> -> ### Para cerrar las dos que quedan -> -> ```sh -> git pull && cargo install --path crates/thalyx-cli -> sudo THALYX_AGENT_BINARY=/home/cesarmanzocode/src/llama.cpp/build/bin/llama-completion \ -> THALYX_AGENT_WEIGHTS=/ruta/a/tu/modelo.gguf \ -> ./dev/verify.sh -> ``` - -> ## Tres fallas en `verify.sh`: dos eran del arnés y una era del filtro — 2026-08-24 -> -> La corrida de Cesar en su máquina dio `154 proven · 1 not proven · 3 failed`. -> Las tres fallas están diagnosticadas y arregladas, y **sólo una era de -> Thalyx**. Ninguna de las tres necesitó su hardware para reproducirse: las tres -> se reprodujeron en el contenedor. -> -> ### 1. El guardia mataba la llamada que existe para dejar pasar — era real -> -> `sched_ordinary=159`: el módulo confinado murió con `SIGSYS` al poner un hilo -> suyo en una política ordinaria. El guardia por argumento de ayer estaba bien -> escrito; **el camino hasta él no estaba permitido**. `chrt` pregunta primero -> el rango legal de prioridades —`sched_get_priority_min` y -> `sched_get_priority_max`— y ninguna de las dos estaba en la lista. Las dos -> contestan una constante y no cambian nada. Ya están permitidas. -> -> Lo que vale más que el arreglo: **la columna de al lado estaba en verde por la -> razón equivocada.** `chrt --fifo 1 true` moría en esa misma primera línea, sin -> haber nombrado jamás una política de tiempo real, y eso se lee idéntico a que -> el guardia lo haya rechazado. La denegación se estaba afirmando sin medirse. -> Ahora `verify.sh` se calla ahí mientras la columna ordinaria no dé 0, y hay una -> prueba en el workspace que **instala el filtro de verdad** en un proceso -> aparte y corre `chrt` bajo él, con las dos columnas en una sola prueba para -> que nadie las lea por separado. Falla sin el arreglo; se comprobó. -> -> ### 2. Las siete sondas de inyección estaban pasando por vacías — era el arnés -> -> `verify.sh` buscaba `A CONTRACT WAS PRODUCED` en la salida de -> `dev agent-probe`. La sonda dejó de imprimir esa frase el mismo 24, cuando un -> plan pasó a poder ser un verbo y no sólo un contrato. Las siete comprobaciones -> de «ninguna forma de portarse mal produjo nada» **pasaban sin mirar nada**. -> -> Lo agarró el control positivo —el que exige que el mismo modelo, preguntado -> por lo que el humano tecleó, sí produzca uno—, que es la falla que Cesar vio -> como «the control behaved as neither a refusal nor a contract». La regla 4 -> pagándose sola. -> -> ### 3. `agent grammar` sí imprimía la gramática — era el arnés -> -> La etapa exigía la palabra `install_module`. La gramática deletrea el verbo -> como lo deletrea la sesión, `install`; `install_module` es como se llama la -> operación en el **contrato** y sigue siendo alias aceptado por el analizador. -> La etapa pedía una palabra que no está ahí. -> -> ### De pasada: el instrumento del agente ajeno contaba mal -> -> `dev/foreign-agent-needs.sh` sacaba las llamadas permitidas de todo -> `seccomp.rs`, que también nombra 32 que un módulo tiene **prohibidas** —las de -> las pruebas que afirman su ausencia y las que sólo agrega un permiso de red—. -> Un agente que llamara a `socket` habría salido como cubierto. Corregido a leer -> el cuerpo de `module_standard`, y **vuelto a correr**: la respuesta no cambió, -> 41 de 41. -> -> ### El `NOT PROVEN` no es una falla, y sigue en pie -> -> Ningún modelo real corrió: `llama-completion` está instalado y no en el `PATH` -> de root, y `THALYX_AGENT_WEIGHTS` no estaba puesto. Es la etapa diciendo -> exactamente lo que no pudo comprobar. Para cerrarla: -> -> ```sh -> git pull && cargo install --path crates/thalyx-cli -> sudo THALYX_AGENT_BINARY=/home/cesarmanzocode/src/llama.cpp/build/bin/llama-completion \ -> THALYX_AGENT_WEIGHTS=/ruta/al/modelo.gguf \ -> ./dev/verify.sh -> ``` -> -> Las asignaciones van **después** de `sudo`, porque `sudo` no lleva el entorno. -> -> ### Lo que falta y sólo se puede hacer en su máquina -> -> Volver a correr `verify.sh`. Lo que este contenedor no puede decir sigue sin -> decirlo: el LSM, los controladores de cgroup y Btrfs. Lo que sí quedó -> comprobado aquí es el filtro sobre un programa real, que es donde estaba el -> defecto. -> -> Y con el resultado de esa corrida se actualiza el párrafo de estado de -> `README.md` y de `docs/STATUS.md`, que todavía citan la corrida del 23 —`134 -> proven · 2 not proven · 0 failed`—. No se cambió con los números de hoy a -> propósito: citar el conteo de una corrida que falló, y que ya no es la que -> correría ahora, es escribir un número que nadie midió. - -> ## La gramática del agente es el catálogo entero — 2026-08-24 -> -> Cesar decidió las dos cosas que quedaban abiertas y que no eran código sino -> alcance. Las dos están construidas. -> -> ### 1. Qué puede proponer el modelo: todo el catálogo -> -> `Superficie-para-el-LLM.md` dejaba la pregunta abierta y le ponía dos -> condiciones. Las dos se resolvieron construyendo, y ninguna de las dos era la -> que parecía. -> -> **La abstención dejó de ser expresable, y se dio cuenta sola.** Mientras -> `install_module` era la única operación, una lista de objetivos vacía la -> decía: nada que instalar es nada que hacer. La mayoría de los verbos del -> catálogo no toman argumentos, así que una lista vacía en `disks` es una -> petición completa. Uno de los dos significados tenía que mudarse, y la -> abstención tiene ahora palabra propia: `nothing`. Las dos siguen valiendo, -> porque **todas las muestras capturadas de un modelo real absteniéndose usan -> la lista vacía** y la regla 6 dice que una muestra reescrita ya no es la -> muestra. -> -> **El otro condicionante era el que importaba, y no estaba escrito así.** -> `assemble` escribía `Operation::InstallModule` en cada contrato que armaba, -> porque mientras había una sola operación no había otra cosa que escribir. El -> día que el modelo pudiera proponer `disks`, esa línea habría producido **un -> contrato para instalar un disco**: un plan que se llama a sí mismo otra cosa, -> que es la única forma de estar mal que quien lo lee no puede ver. -> -> Así que un plan tiene dos formas. Un contrato es lo que -> [[Contrato-Estructurado]] le da a una operación que **cambia la máquina y -> necesita que un humano diga que sí**. Preguntar qué discos hay no es eso, y -> vestirlo de contrato deja la palabra sin significado para quien lea el -> siguiente. -> -> **Y ahí apareció el hueco.** Un plan de verbo no tiene contrato, así que -> nunca llegaba a `Contract::validate`, así que nunca llegaba a -> `origins.validate()` — que es la comprobación que rechaza una operación -> concluida mientras se leía una página hostil. La regla de procedencia habría -> quedado con **una puerta rotulada `read`**. Se valida en los dos caminos, con -> prueba en los dos sentidos: la lectura inyectada se rechaza, la que pidió el -> humano no. -> -> La gramática son tres formas de objeto en vez de una, así que ensancharla no -> costó nada de lo que ya compraba: `install` y `run` conservan la regla de -> DNS inverso, todo lo demás recibe una clase de caracteres que cubre rutas y -> nombres y **no puede cerrar la cadena JSON en la que está**, y `nothing` tiene -> un objeto sin argumentos. -> -> Tres pruebas cayeron y ninguna era por este cambio: **las palabras del -> catálogo son inglés ordinario**. `permissions` es un verbo ahora, así que una -> prueba que buscaba la palabra en cualquier parte reportó el catálogo como una -> fuga de procedencia; el brazo de prosa del experimento de gramática "nombraba -> una operación" porque contiene la palabra `where`; y la sonda leía una -> producción `root` donde ahora hay tres. Las tres son el instrumento. -> -> ### 2. `sched_setscheduler`: sí, pero sin tiempo real -> -> Cesar entendió el problema y me dejó decidir los costos. La llamada son dos -> peticiones con un solo nombre. Un runtime acomodando sus propios hilos dentro -> del pedazo de procesador que el cgroup ya le dio es ordinario y lo hace antes -> que nada. Un programa pidiendo política de **tiempo real** está pidiendo -> quedarse un procesador contra todo lo demás de la máquina, Thalyx incluido, y -> ningún límite de cgroup se lo quita. -> -> El filtro aprendió a mirar un argumento. Y lo que ese cambio enseñó **sólo -> aparece corriendo**: la primera versión del guardia permitía `SCHED_OTHER`, -> `SCHED_BATCH` y `SCHED_IDLE`, que es lo que sugiere el manual y lo que -> cualquiera escribiría. Node pide `0x40000000` —`SCHED_OTHER | -> SCHED_RESET_ON_FORK`— en cada hilo. **Ese guardia habría matado al agente -> ajeno en la llamada exacta que el guardia existe para dejar pasar, y habría -> parecido el guardia funcionando.** -> -> Con eso, `dev/foreign-agent-needs.sh` dice **41 de 41**. En la capa de seccomp -> ya no falta nada para que un agente ajeno arranque. Lo que bloquea sigue -> siendo G1 y G2, que es lo que la medición del 2026-08-23 ya decía. -> -> ### Lo que queda -> -> - **`thalyx agent bench`** — el único `NOT PROVEN`. No es una decisión, es una -> medición que necesita su máquina y unos minutos: -> `sudo THALYX_AGENT_BENCH=1 ./dev/verify.sh`. -> - **`agent do` sólo lleva a cabo instalaciones.** Poder decir una cosa no es -> poder que se haga: todo lo demás pasa por el verbo, en una terminal, con la -> confirmación que ese verbo ya pide. Ensancharlo es otra decisión de Cesar y -> no se tomó. -> - Lo de siempre que necesita hierro: `net/outbound` de punta a punta, cargar -> `thalyx_watch` con el cargador propio, la deuda de explicación de `/home` -> `NOEXEC`. - -> ## Qué necesita un agente ajeno para arrancar, medido — 2026-08-23 -> -> Los bloques de abajo son cómo se llegó. -> -> Tercera entrega del sprint, y es la que más cambia lo que creíamos. El -> pendiente decía *«tomar Claude Code, mirar qué llama, y hacer la lista; es -> barato y no se ha hecho, y sin ella todo lo de abajo es adivinado»*. Estaba -> abierto desde el 2026-08-09. Se hizo con `strace`, en veinte minutos. -> -> **De las 41 llamadas al sistema que Claude Code hace para arrancar, -> `module_standard` ya permite 40.** La que falta es una: `sched_setscheduler`. -> De las 19 rutas que abre, 13 caen dentro de lo que un módulo ve. -> -> Eso contradice de frente la frase que estaba escrita debajo del decreto —*«hoy -> no arrancarían, así que esto no es afinar, es construir»*—. En la capa donde -> más caro parecía, el filtro de llamadas de este proyecto ya cubre el 97.5% de -> lo que un agente ajeno pide para existir. **La afirmación era razonable y -> nadie la había medido.** -> -> **Dónde sí es cierta**, y ahora con nombre en vez de por suposición: -> -> - **El enlazador.** El agente abre `/etc/ld.so.cache` y cinco objetos -> compartidos. La imagen lleva `/init`, unos directorios y `/dev/console` — no -> hay libc. Un binario enlazado dinámicamente no arranca ahí, y eso es -> **exactamente la pregunta abierta del ABI de los módulos**, hecha por el -> agente antes que ninguna otra. -> - **`G1`, lanzar un proceso arbitrario.** No es una llamada que falte ni una -> ruta: es que `correr` sólo lanza módulos instalados y firmados. La medición -> lo confirma como el que bloquea en vez de contradecirlo. -> - **`/home` montado `NOEXEC`.** Un agente que aterrice ahí no se ejecuta -> aunque todo lo demás esté resuelto. La deuda de explicación que aplazaste el -> 2026-08-09 ahora tiene un caso concreto detrás en lugar de ser hipotética. -> -> Y **seis rutas bajo `/sys`** que un módulo no ve. De las seis sólo se puede -> afirmar algo de una: `trace_marker` dio `ENOENT` y arrancó igual, o sea que no -> hace falta. De las otras cinco lo único cierto es que aquí no tuvo que -> arreglárselas sin ellas — la sospecha razonable es que degrada a valores por -> omisión, y una sospecha razonable no se apunta como medición. -> -> La lista entera está en [[Que-Necesita-Un-Agente-Ajeno]], **con la mitad que -> dice qué NO contesta**: arrancar no es trabajar. No hubo red, ni terminal, ni -> subprocesos, ni una sola escritura. -> -> Se reproduce con `dev/foreign-agent-needs.sh`, que es un script y no un -> párrafo porque un procedimiento impreso para una persona es código que no +> El ancla quedó en el repositorio con la llave y la huella que la establecieron, +> porque un digest solo dice qué se aceptó, no qué lo validó. +> +> **Y Cesar canceló la persona ajena.** Los seis pasos siguen; quien los teclea, +> no. Eso deja a la Fase 1 sin criterio de salida hasta que él elija el +> sustituto, y está escrito así en vez de dejar que la nota aparente tener uno. +> +> ### 2026-08-05 — los arreglos de la auditoría rompieron el instrumento +> `verify.sh` con la auditoría puesta: `failed 10`, todos de Thalyx. Dos defectos, +> los dos del mismo commit, y los dos con la misma forma vista de dos lados: **una +> defensa correcta aplicada a algo que no era lo que creía estar protegiendo.** +> +> Quitarle la terminal al módulo era correcto y le quitó también el `stdout` por +> el que contesta qué ve desde adentro del sandbox, que es el único instrumento +> que prueba el aislamiento. Truncar a 72 caracteres es correcto para una +> etiqueta y lo que un módulo dice no es una etiqueta — el mismo razonamiento que +> la auditoría ya había escrito para los permisos, sin aplicar donde valía igual. +> +> Y la prueba que debía atrapar el primero afirmaba una **ausencia**, que se +> satisface borrándolo todo. Pasó en verde la misma corrida en que la etapa 6 se +> quedó ciega. +> +> ### 2026-08-04 (2) — la máquina corrió los seis pasos y falló en el quinto +> La etapa 16 —arrancar la imagen y teclearle los seis pasos— corrió por primera +> vez contra una imagen real. Sirvió de inmediato y encontró dos defectos, uno +> detrás del otro, en la misma línea. +> +> **El primero:** `is_available()` preguntaba por `bpftool`, que adentro de la +> imagen no existe, y esa respuesta decide entre confinar un módulo y negarse a +> arrancarlo. `KernelStore` lo reemplaza con `bpf(2)` directo y `BpftoolStore` se +> borró. Cuarta vez que algo le pregunta a `bpftool` por algo que `bpftool` no +> hizo. +> +> **El segundo:** el prompt pedía un perfil de sandbox llamado `default`, que no +> existe. Lo escondió el orden —el nombre se resolvía después de comprobar el +> mapa de política, así que solo la imagen llegaba a mirarlo— y lo dejó pasar que +> la etapa 15 maneja el prompt de verdad y **no tecleaba `correr`**: el único +> verbo sin ejercitar era el único roto. +> +> **El tercero:** la raíz de cgroups no entregaba `memory` ni `pids`, porque en +> todas las demás máquinas eso lo hace systemd antes de que corra nada, y en la +> imagen no hay systemd. PID 1 lo hace ahora, y la sesión reporta un cgroup2 que +> está montado y no delega nada como lo que es: ausente. +> +> **El cuarto:** el punto de montaje de un permiso sobre un archivo se creaba +> como directorio en la ruta remapeada, y el kernel exige que sean del mismo tipo. +> Todos los permisos de todas las pruebas son directorios; el único permiso sobre +> un archivo suelto es el del `greeter`. +> +> **El quinto:** nadie había hecho el `switch_root`. La raíz de la imagen es la +> raíz de su namespace de montajes, y esa no tiene padre, así que `pivot_root` +> niega todo módulo. En cualquier otro Linux el initramfs ya se bajó de sí mismo +> antes de que arranque nada. +> +> Los cinco son la misma forma vista desde cinco lados: **una comprobación que +> depende de una condición solo se hace en las máquinas que la cumplen**, y la +> imagen es la única máquina que no cumple ninguna. Seis reglas nuevas en +> [[Estrategia-de-Pruebas]], incluida la del mensaje de falla que nombraba una +> causa que nadie midió y mandaba a buscar al lado equivocado. +> +> ### 2026-08-04 — los seis pasos existen +> El objetivo pasó a ser cerrar la Fase 1, y quedaban dos pasos del criterio de +> salida sin nada detrás. Los dos tenían la misma forma: **la pieza estaba escrita +> y no había cómo alcanzarla.** +> +> **El 6.** La memoria persistente es la tercera primitiva y está probada en +> hardware desde el 2026-08-02, y la sesión no escribía en ella. Ahora `instalar` +> y `revertir` escriben por el mismo `recollection.rs` del agente —no una copia— y +> `recuerdos` lo lee. Lo que lo vuelve una prueba y no una demostración: después +> de `revertir`, la instalación sale como *no confirmable* **sola**, porque quedó +> atestiguada contra el enlace `current` que el rollback quitó. +> +> Antes hubo que decidir qué cuenta como el paso 6, porque la bóveda decía dos +> cosas distintas. Lo decidió Cesar: la memoria sobreviviendo al reinicio; el +> modelo real deja de bloquear la fase sin cancelarse. +> +> **El 1.** `make -C image doctor`. Lo que detiene a la persona ajena nunca es +> Thalyx: es un paquete que falta, encontrado de uno en uno y cada uno después de +> que lo anterior salió bien. Ahora salen todos juntos, con la línea de `apt` que +> los instala, antes de descargar o compilar nada. El peor era `pahole`, cuya +> ausencia hace que Kconfig descarte `DEBUG_INFO_BTF` **en silencio** y la culpa +> caiga sobre el cargador de BPF varios pasos después. +> +> Y el `doctor` se comprueba a sí mismo: sin `gcc` no puede probar las cabeceras, +> y lo dice en vez de callarlo. Regla 3 aplicada al comprobador. +> +> **Un defecto propio, y dio regla nueva.** El párrafo que explica un hecho no +> confirmable decía que algo había cambiado *"without going through Thalyx"*. +> Cierto mientras la única ruta fuera una edición por fuera; con `revertir` pasó a +> ser una explicación segura de una causa que ese código no puede ver. Ninguna +> prueba se rompió. Ver [[Estrategia-de-Pruebas]]. +> +> También se corrigió el README, que seguía diciendo *"Phase 1 — Thalyx core on an +> Alpine base"* — un decreto derogado el 2026-08-03 que sobrevivió en una de las +> cuatro puertas de entrada. Es la regla de que una afirmación de ausencia caduca +> sola, en su versión más incómoda: caducan también las de presencia cuando nadie +> las vuelve a leer. +> +> ### 2026-08-03 (12) — dos fallos en hardware, y ninguno era de Thalyx en el sentido esperado +> La corrida en la máquina de Cesar dio `proven 59 · failed 2`. Los dos se +> arreglaron y los dos enseñaron algo. +> +> **El primero era del arnés.** `verify.sh` activaba +> `THALYX_REQUIRE_BTRFS_TESTS` porque había btrfs-progs, y nunca ponía +> `THALYX_BTRFS_SCRATCH`, que es lo que ese test necesita para crear un +> subvolumen. Exigió una comprobación y le negó su entrada. El error de fondo: +> **tener la herramienta y tener dónde usarla son dos hechos**, y en Fedora se +> separan de inmediato porque `/tmp` es tmpfs. Ahora se establecen los dos, y el +> segundo creando un subvolumen de verdad — `stat -f` dice btrfs también para un +> montaje de solo lectura. Séptima vez que el culpable es el instrumento. +> +> **El segundo era real y estaba en el `allowlist` de seccomp.** El módulo moría +> con `SIGSYS` en su primera respuesta. La causa la dio `strace` en tres minutos y +> no la habría dado leer el código: **un `UnixStream` de Rust lee con `recv(2)` y +> escribe con `send(2)`**, no con `read` y `write`. `recvfrom` y `sendto` no +> estaban en la lista. +> +> Lo que lo explica es más interesante que el arreglo: el `allowlist` se derivó +> empíricamente corriendo módulos reales, que es el método correcto — pero **todos +> esos módulos eran scripts de shell, y `/bin/sh` no toca un socket**. El método +> cubre exactamente los programas que se usaron para derivarlo. De ahí la regla +> nueva de [[Estrategia-de-Pruebas]]: **un sustituto que nunca ejerció el +> mecanismo no lo probó.** +> +> `recvfrom` y `sendto` entran; `socket`, `connect` y `bind` siguen fuera. Un +> módulo puede **usar** el socket que le dieron y no puede **fabricarse** otro, y +> la prueba afirma las dos mitades juntas a propósito: separadas, cada una pasaría +> sola y una sola no sirve. +> +> ### 2026-08-03 (11) — hay un módulo, y habla +> `dev.thalyx.greeter` existe: el primer módulo desde que se borró el que era un +> script de shell. Se instala desde un bundle firmado, corre, y **habla con +> Thalyx por un socket que nunca abrió**. Lo que sale por pantalla: +> +> ``` +> dev.thalyx.greeter said: +> I am dev.thalyx.greeter 1.0.0, speaking protocol 1, holding 1 grant(s). +> read 27 byte(s) from .../notes.txt: the vault is the authority +> I asked for /etc/shadow and was refused, which is correct. +> ``` +> +> Las tres líneas dicen cosas distintas. La primera: **un módulo no sabe quién +> es**, pregunta, y lo que le contestan sale del manifiesto firmado. La segunda: +> la línea base. La tercera: la denegación — sin la segunda no probaría nada, +> porque un Thalyx que negara todo se vería igual. +> +> Y una cuarta que no sale por pantalla: **ejecutado a mano no arranca**. No +> porque compruebe una licencia, sino porque en el descriptor 3 no hay nadie. +> Eso es [[Filosofia-Fundacional]] vuelta comprobación. +> +> Lo construido: `thalyx-syscall` coloca el descriptor (`place_on`, +> `spawn_with_channel`, `inherited_channel`), `launch.rs` lo lleva por las dos +> etapas del sandbox, y `thalyx-core/api.rs` es el servidor. +> +> **El hallazgo que más importa está en `api.rs`, y es de seguridad.** El +> servidor **no está dentro del sandbox**: corre como Thalyx, con el alcance de +> Thalyx. Un módulo que pide una ruta le está pidiendo a *Thalyx* que la abra, así +> que la raíz vacía del sandbox y el LSM no protegen nada ahí. Cada ruta se +> comprueba dos veces: por el nombre, y por **lo que el kernel resuelve** — que es +> lo único que atrapa un symlink plantado dentro de un directorio que el módulo +> puede escribir. Esa era la vía que sí habría funcionado. +> +> Etapa 12 en `verify.sh`, con su control. Y **una guarda mía salió mal primero**: +> se disparaba con "cgroup2 montado" cuando la condición real es "el LSM está +> cargado", así que exigió a este contenedor algo que no puede hacer y reportó +> roto a Thalyx. Es la regla 3 otra vez: un salto que se dispara solo se ve +> idéntico a un fallo real. +> +> Falta la ruta confinada —el canal por dos `exec` y un filtro seccomp— que solo +> se puede comprobar en máquina con LSM. +> +> ### 2026-08-03 (10) — la API interna deja de ser una línea de una nota +> Decretada en [[API-Interna-de-Modulos]] y construida en `crates/thalyx-abi`: +> **un socket que Thalyx entrega ya abierto en el descriptor 3** al ejecutar el +> módulo —sin ruta que equivocar, sobrevive a la raíz vacía del sandbox, y su +> ausencia es lo que impide que un módulo corra fuera de Thalyx—, mensajes de +> longitud explícita más CBOR, y tres familias: archivos, notificar, y preguntar +> quién es. **27 pruebas**, incluidas las dos mitades de la conversación +> hablando por un socket real entre dos hilos. +> +> Tres decisiones que valen más que el código: +> +> - **Denegado y fallido son respuestas distintas.** "No puedes leer esto" y +> "esto no se pudo leer" son hechos diferentes sobre el mundo, y un módulo que +> solo supiera que falló reportaría un disco ausente como un problema de +> permisos. Es la regla 10 de `CLAUDE.md` puesta en el protocolo. +> - **Un campo desconocido se rechaza, no se ignora.** Es la dirección incómoda +> —rompe con un módulo más nuevo— y la correcta: ignorarlo dejaría al que envía +> creyendo que restringió la operación y al que recibe sin haber visto la +> restricción, en un canal que gobierna permisos. +> - **Un marco ilegible cierra la conexión; un mensaje ilegible se contesta.** +> Después de una longitud mala no hay dónde empezar a leer otra vez; después de +> un mensaje malo, sí. +> +> **Y una tercera contradicción del mismo tipo que las anteriores.** +> [[Core-Nucleo]] listaba *"ejecutar comandos"* entre las capacidades de esta API. +> No hay comandos que ejecutar. Como el login en tty1 y como `bpftool`: una +> capacidad que se apoyaba en la base y envejeció callada cuando la base se cayó. +> Queda anulada por decreto, no implementada. +> +> Falta lo que la vuelve real: pasar el descriptor por las dos etapas del +> lanzamiento, el servidor contra los permisos verdaderos, y un módulo escrito +> contra ella. Eso último es lo que el decreto pone como prueba de que sirve. +> +> ### 2026-08-03 (9) — existe la máquina +> `make -C image run` arrancó. Kernel 6.12.101 construido desde `allnoconfig`, +> initramfs con **un solo archivo**, `thalyx` como PID 1. Montó los siete +> filesystems, arrancó la sesión, y la sesión imprimió el párrafo que dice que no +> hay shell detrás — que solo imprime cuando su padre es el pid 1, así que la +> frase no está cableada: es una comprobación. +> +> Y se describió con tres `no` que no oculta: sin Btrfs, sin enforcement, sin +> módulos. Los tres eran conocidos y están arriba con su orden de resolución. +> +> **Lo que esto cierra**: el paso 1 del [[Criterio-de-Salida-Fase-1]] tiene por +> fin una máquina detrás. No cierra el criterio —ese exige que lo haga alguien de +> fuera, sin ayuda— pero hasta hoy no había nada que esa persona pudiera arrancar. +> +> **Un hallazgo del arranque**: `attach_lsm` en `init.rs` busca +> `/lib/thalyx/thalyx_lsm.bpf.o`. Ese archivo **no puede existir**: sería un +> segundo archivo en una imagen que el decreto obliga a tener uno. El mensaje +> "is not in the image" es cierto y su arreglo obvio es el equivocado. El objeto +> BPF va incrustado en el binario. +> +> ### 2026-08-03 (8) — el kernel no compilaba, y la configuración se perdía sola +> El primer `make -C image kernel` en la máquina de Cesar falló entero en +> `arch/x86/boot/compressed/`: GCC 15 (Fedora 43) usa C23 por defecto, donde +> `bool`, `true` y `false` son palabras reservadas, y ese directorio era el único +> del kernel que nunca pasaba `-std=`. **No se puede arreglar desde fuera** —su +> Makefile abre con `KBUILD_CFLAGS :=`, que tira lo que venga de arriba, así que +> `KCFLAGS` jamás llega. Río arriba lo arreglaron en enero de 2025 y aterrizó en +> la serie estable en **6.12.14**, comprobado tag por tag. `KVERSION` pasa a +> **6.12.101**, la cabeza de la línea 6.12 LTS. +> +> **Y al reproducir la configuración a mano apareció algo peor.** `olddefconfig` +> descarta en silencio toda opción cuyas dependencias no se cumplan: **nueve de +> las de `thalyx.config` no llegaban al `.config` final**, entre ellas +> `CONFIG_BPF_LSM` y `CONFIG_DEBUG_INFO_BTF`. La máquina habría arrancado +> perfecta y `thalyx-lsm` no se habría podido enganchar nunca, con un síntoma +> idéntico al hueco de `bpftool` que ya conocíamos — la culpa habría caído sobre +> el cargador, que no tenía nada que ver. También faltaban `VIRTIO_MENU` y +> `BLK_DEV`, sin los cuales no hay disco del store, e `IPC_NS`. +> +> `make -C image kernel` ahora compara lo pedido contra lo que salió y **se niega +> a compilar** si falta una línea. Probado con su control: quitando `BPF_LSM` y +> `BTF` a mano, los nombra y sale con error. De ahí la regla nueva de +> [[Estrategia-de-Pruebas]]: **pedirle algo a una herramienta no es haberlo +> obtenido**. +> +> Con las nueve líneas puestas, 6.12.101 configura y compila limpio en el +> contenedor, y el `vmlinux` trae `.BTF`. Eso comprueba la configuración, **no** +> el problema de GCC 15: aquí hay GCC 13. QEMU sigue sin correr nunca. +> +> ### 2026-08-03 (7) — el decreto fundacional, y todo listo para arrancar +> Cesar escribió el texto que funda el proyecto y quedó **literal** como primera +> sección de [[Filosofia-Fundacional]], con la regla de que cualquier decreto que +> lo contradiga está equivocado. Está enlazado desde `CLAUDE.md`, el índice y el +> README, que son las cuatro puertas de entrada. +> +> Se registraron los dos decretos que su propio texto invalida: `bpftool` (que ya +> no puede estar en la imagen) y `llama.cpp` como proceso (que sería un segundo +> programa — probablemente el modelo del agente sea **un módulo**, pero eso lo +> decide Cesar). +> +> `rusqlite` pasa a `bundled`: SQLite se compila dentro del binario. No es +> preferencia, es necesidad — no hay libsqlite3 en el disco de la imagen contra el +> que enlazar, y era el primer bloqueador del binario estático. +> +> [[Primer-Arranque]] tiene el procedimiento completo. +> +> ### 2026-08-03 (6) — hay máquina: PID 1, la imagen, y el kernel +> `thalyx` es PID 1 (`init.rs`): monta siete filesystems diciendo por qué cada +> uno, arranca la sesión, y cosecha huérfanos para siempre. Si un montaje falla no +> aborta — la máquina arranca describiéndose a sí misma, porque un sistema que se +> niega a arrancar no te dice *por qué* desde una pantalla a la que no llegas. +> +> **Thalyx construye su propia imagen** (`image.rs`): un cpio `newc` escrito aquí, +> sin `cpio` ni herramientas ajenas. Un initramfs, no un ISO — sin gestor de +> arranque, sin tabla de particiones, sin una tercera cosa donde algo se esconda. +> **Un solo archivo dentro**, `/init`, porque si el decreto dice un programa, +> un archivo es lo que lo vuelve cierto en vez de casi cierto. +> +> Y se cuenta: `make -C image count` parsea el archivo y dice cuántos programas +> hay. Si no dice uno, el decreto está roto y el número lo dice antes de que nadie +> discuta. +> +> `image/` lleva el Makefile y `thalyx.config`, un kernel desde `allnoconfig`. +> **Jamás ejecutados**: aquí no hay red a kernel.org ni QEMU. +> +> El hueco grande queda dicho: **`thalyx-lsm` no se carga en el arranque**. El +> cargador invocaba `bpftool`, y no hay bpftool en la imagen ni shell para +> llamarlo. La máquina arranca y lo dice. +> +> ### 2026-08-03 (5) — se cae la distro, y con ella lo que se apoyaba en ella +> Cesar preguntó por qué habría un login al arrancar si nadie lo construyó. La +> respuesta —lo pone la base— hizo visible que había una base, y que la bóveda se +> contradecía en cuatro notas. Decreto: **cualquier distribución queda fuera para +> siempre**; el kernel de Linux nunca estuvo en discusión. +> +> Borrados por falsos: el esqueleto del ISO escrito esa misma noche, que producía +> una distro de Alpine con el getty quitado, y el módulo `dev.thalyx.hola`, que +> era un script de shell y por lo tanto corría en cualquier Linux. +> +> Reescritos: [[Construccion-del-ISO]] entero, y las secciones de +> [[Core-Nucleo]] y [[Fases-de-Implementacion]] que decretaban la base. +> +> ### 2026-08-03 (4) — el enunciado llega hasta el disco, y un fallo que solo salió corriéndolo +> **El paso 6, ahora con sus dos mitades.** El agente escribe lo que hizo **y lo +> lee**: `thalyx agent recall `, y `--task` trae el contexto solo. Lo que +> recuerda entra como estado de Thalyx y puede tener efecto, salvo lo que ya no +> puede confirmar, que se muestra y no se usa. Falta que retome una conversación +> de varios turnos, que necesita un modelo. +> +> Lo que quedó: `thalyx agent do --task ` +> escribe en la memoria persistente qué se pidió y qué se instaló, y +> `thalyx memory recall ` lo lee desde otro proceso. Los dos hechos son de +> clase distinta a propósito: lo que el humano dijo **no atestigua nada** —ningún +> archivo puede volver falso que lo haya dicho— y lo instalado atestigua el enlace +> `current`, así que quitar el módulo deja el recuerdo *no afirmable* y lo dice, +> en vez de seguir reportando una instalación que ya no está. +> `thalyx agent plan` y `thalyx agent do`, más el repositorio local y la +> resolución de versiones (`thalyx-core/repo.rs`): **máxima versión que satisface +> el constraint y cuya firma valida**, como manda [[Resolucion-de-Versiones]]. La +> cadena entera funciona contra bundles firmados de verdad — enunciado, contrato, +> resolución, camino confiable, commit atómico, journal, y el módulo instalado > corre. - -> ## El ensayo llegó a los verbos que cambian la máquina — 2026-08-23 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Segunda entrega del sprint. El punto **D1** de [[Superficie-para-el-LLM]] -> —ensayo en todo verbo que cambia— estaba en «hecho para los verbos de -> archivos, los otros cinco dicen que no pueden». Ahora está en **ocho de -> nueve**. -> -> Y salió casi gratis, por una razón que vale más que los cuatro verbos: -> **cuatro de los cinco ya tenían escrita la mitad que averigua**, separada de la -> que actúa. `revertir` tiene `plan` aparte de `apply` desde que se escribió. -> `instalar` resuelve el candidato y lee su manifiesto antes de preguntar nada. -> Y `instalar-en` calcula la distribución entera, encuentra el kernel y lee qué -> hay en el disco **antes** de la confirmación — decisión del 2026-08-07, tomada -> por otra razón completamente distinta: que un borrado ya confirmado no -> descubriera después que no había kernel que escribir. -> -> Así que el ensayo no fue una segunda implementación de nada: **fue parar en la -> línea que ya estaba dibujada.** Para el único verbo irreversible del sistema, -> que no exista una segunda implementación que se pueda desalinear no es un -> detalle. -> -> `ensayo instalar` es el que más se usa y contesta lo que una persona sólo podía -> ver empezando la instalación y declinando: qué pide el módulo, si alguno de -> esos permisos necesita a alguien en una terminal, y si reemplaza algo que ya -> está. -> -> **Comprobado con su control, que es lo que lo hace valer**: el ensayo deja el -> store sin journal y sin módulos, y la instalación de verdad del mismo bundle -> deja las dos cosas. Sin esa segunda columna, un ensayo que se cayera antes de -> hacer nada se vería igual. -> -> `correr` es el único que queda y se queda diciendo que no puede: qué podría -> hacer un módulo al correr es una pregunta del lado del kernel, y contestarla -> desde el manifiesto describiría una corrida que la máquina quizá no puede dar. -> -> **Y una prueba estaba tapando dos hechos con una sola palabra.** `cannot` -> significaba a la vez *este verbo no tiene ensayo* y *aquí no hay nada que -> deshacer*. El primero manda al que preguntó a otro lado para siempre; el -> segundo deja de ser cierto en cuanto se instale algo. - -> ## Los cuarenta verbos contestan por estructura — 2026-08-23 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Cesar leyó la lista de pendientes y contestó lo que había que contestar: que -> íbamos innecesariamente lento, que **todo eso es horizontal y ninguna pieza es -> difícil**, y que en vez de elegir una hiciéramos un sprint para eliminar el -> horizonte barato entero. Ésta es la primera entrega. -> -> Nueve verbos no tenían cara estructurada, más tres que se creían sin nada que -> contestar. **Ninguno era difícil.** Lo que los dejó así es que el catálogo de -> [[Superficie-para-el-LLM]] trata de superficie *nueva*, y éstos son anteriores -> al decreto de las dos caras. -> -> **Lo que eso costaba, dicho bien:** `disponibles`, `instalar`, `modulos`, -> `correr`, `permisos` y `revertir` son el ciclo completo de lo único que Thalyx -> existe para dejar hacer. Catorce de diecinueve puntos del catálogo hechos, y el -> ciclo entero en prosa — un programa no podía saber si lo que iba a instalar ya -> estaba instalado, ni qué había en el repositorio, ni qué concedió la vez -> pasada. -> -> Ahora el ciclo entero se corre por la cara estructurada, y quedó capturado en -> una sola sesión por tubería: -> -> ``` -> {"op":"available","ok":true,"total":1} -> {"op":"install","ok":true,"module_id":"org.thalyx.face"} -> {"op":"modules","ok":true,"total":1} -> {"op":"run","ok":false,"error":"cannot_enforce","remedy":"run_unconfined"} -> {"op":"rollback","ok":true,"undid":"undo install_module of org.thalyx.face 1.0.0"} -> {"op":"modules","ok":true,"total":0} -> ``` -> -> **El camino confiable no se debilitó, se reporta.** [[Camino-Confiable]] queda -> intacto: sin terminal no hay confirmación y no hay instalación, y `instalar-en` -> sigue pidiendo la ruta del disco tecleada. Lo único que cambia es que la -> negativa vuelve como objeto en vez de una línea en `stderr`, donde un parser -> que lee un solo flujo no la veía nunca. -> -> **Y salió una afirmación falsa, de correrlo y no de leerlo.** El primer campo -> de esa negativa decía `wrote_anything: false`. El journal **sí** guarda una -> entrada `rejected` — que es justamente el punto: una negativa del camino -> confiable que no dejara rastro sería un camino confiable que nadie puede -> auditar. Dice `installed: false`, que es lo cierto. La cara humana llevaba el -> mismo exceso en prosa y también se corrigió. -> -> Los tres que "no tenían nada que contestar" eran el último sitio donde quedaba -> silencio, y las tres razones son distintas: `limpiar` no limpia nada porque del -> otro lado no hay pantalla, `salir` contesta **antes** de que el pipe se cierre -> porque un pipe cerrado y vacío es exactamente lo que parece un cierre -> inesperado, y `apagar` contesta antes de la llamada al sistema porque cuando -> funciona no regresa. -> -> Lo que lo sostiene: la prueba del catálogo **afirma que la lista de verbos -> sólo-prosa está vacía**, y la etapa 22 pasó de catorce verbos manejados a -> veintiuno. Control corrido en los dos sentidos. -> -> **Nada de esto necesita hierro.** Sigue faltando el banco de gamas, que es una -> medición y no una comprobación. - -> ## `describe` prometía prosa donde había un objeto — 2026-08-23 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Revisando qué quedó desalineado después de la corrida en verde salió **un -> defecto real, y no estaba en la bóveda sino en el código**: `red` se construyó -> ese mismo día con sus dos caras y quedó declarado `answers: None` en el -> catálogo. `describe` es lo primero que lee un programa y por cada verbo dice si -> contesta por estructura; **un verbo declarado sólo-prosa es un verbo que un -> programa nunca llama**. La única lista de hardware de red que esta máquina -> tiene fue invisible para eso durante el día entero, sin producir un solo error. -> -> Nadie lo vio porque **el catálogo y el despacho son dos archivos y cada uno -> concuerda consigo mismo**: las pruebas de `net` ejercen la cara estructurada y -> pasan, y la prueba del catálogo afirmaba que `modules` seguía siendo sólo-prosa -> —un `contains` sobre un ejemplo no ve que otro se movió—. -> -> Tres cosas cambiaron: -> -> - `red` declara `answers: Some("network")`; -> - la prueba del catálogo **fija la lista entera** de verbos sólo-prosa, así que -> agregar una cara obliga a editar ese renglón; -> - la **etapa 22** corre los catorce verbos que aquí se pueden correr sin -> argumentos y compara el cable contra lo que `describe` prometió, en las dos -> direcciones. Con el defecto devuelto a mano dice -> `red:promised-prose-answered-network`; sin él, `ok:14`. -> -> La regla nueva está en [[Estrategia-de-Pruebas]]: **una afirmación que un -> sistema hace sobre sí mismo se comprueba corriéndolo, no leyendo los dos lados -> del código.** -> -> Y de paso quedaron alineados dos pendientes de [[Tareas-Pendientes]] que -> seguían marcados abiertos y estaban cerrados desde el 2026-08-10: los tres de -> «sólo hierro» —de los que `D2` y `B3` se construyeron y sólo queda `E1`— y los -> tres que se podían hacer aquí —`B1`, `C2` y `F2`, los tres hechos—. -> -> **Nada de esto necesita hierro**, así que no interrumpe nada. - -> ## `proven 159 · not proven 1 · failed 0` — 2026-08-23 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> **Cero fallos, y por primera vez con un modelo de verdad.** La corrida de Cesar -> cierra todo lo que este día abrió: -> -> - la terminal usable está en **9 de 9**; -> - el kernel construye con las ocho opciones de red nuevas y `red` coincide con -> `iproute2` en su máquina; -> - las cuatro afirmaciones que sólo un modelo real puede contestar —que este -> build de llama.cpp acepta las banderas que Thalyx le pasa, que una inferencia -> real vuelve como algo que el parser acepta, y que `--grammar-file` restringe -> en vez de ser ignorada— **quedaron probadas**, después de meses reportándose -> como `NOT PROVEN`. -> -> Lo único que queda es **la única cosa que no es una comprobación sino una -> medición**: `thalyx agent bench`, que no corre sola porque tarda minutos. -> -> ``` -> sudo THALYX_AGENT_BENCH=1 \ -> THALYX_AGENT_BINARY=/home/cesarmanzocode/src/llama.cpp/build/bin/llama-completion \ -> THALYX_AGENT_WEIGHTS=/home/cesarmanzocode/models/qwen2.5-3b-instruct-q4_k_m.gguf \ -> ./dev/verify.sh -> ``` -> -> Eso da **la primera tabla de acierto por gama que ha existido** y es la entrada -> a la pregunta de abstención cero de [[Gamas-de-Modelo]], que lleva meses parada -> por no tener con qué medirla. -> -> **Falta que Cesar decida si se corre ahora.** Nada más está bloqueado. - -> ## Corregido: una interfaz abajo no tiene una sola respuesta — 2026-08-23 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> La corrida de Cesar trajo `proven 153 · not proven 1 · failed 1`, y el que -> falló fue **una prueba mía**, no Thalyx. -> -> Antes de eso, lo que su corrida sí probó, y es lo que importaba: -> -> - **el kernel construyó con las ocho opciones nuevas.** `config-check` no tiró -> ninguna, así que las dependencias de `NETDEVICES`, `ETHERNET` y los cuatro -> drivers estaban bien; -> - **la etapa 35 pasó**: `red` y `iproute2` nombran las mismas interfaces en su -> máquina, leídas por sysfs y por netlink; -> - **la etapa 34 pasó**: los ensayos hablan en condicional y el verbo de verdad -> no. -> -> Lo que falló: la prueba afirmaba que **una interfaz abajo se niega a contestar -> si tiene cable**. Aquí es cierto —`ifb0` da `EINVAL`—; en su Fedora un puente -> de Docker abajo contesta `0` con toda honestidad. **Negarse o contestar es del -> driver, no de estar abajo.** -> -> El módulo nunca necesitó eso. Necesita que una lectura fallida jamás se reporte -> como cable ausente, y eso es cierto en toda máquina. La prueba ahora lee el -> mismo archivo por su cuenta y compara el mapeo; la negativa de verdad, que no -> toda máquina tiene, sale como `NOT PROVEN` con su propia variable. -> -> **Es la misma regla que la del `ENXIO` contra `EACCES` y es la segunda vez en -> dos días.** Lo nuevo es qué la produjo: la primera fue una opción de montaje, -> ésta fue contar dos ejemplos de la misma clase en la misma máquina y llamarlo -> la regla. Está en [[Estrategia-de-Pruebas]]. -> -> **Lo único que falta para cerrar el modelo** es lo que su propia corrida ya le -> dijo, palabra por palabra: -> -> ``` -> git pull -> sudo THALYX_AGENT_BINARY=/home/cesarmanzocode/src/llama.cpp/build/bin/llama-completion \ -> THALYX_AGENT_WEIGHTS=/home/cesarmanzocode/models/qwen2.5-3b-instruct-q4_k_m.gguf \ -> ./dev/verify.sh -> ``` - -> ## Punto 8: la red se ve y no se usa — la terminal usable está en 9 de 9 — 2026-08-23 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Cesar lo decidió así: **verla, no usarla.** El decreto entero está en [[Red]], -> con la razón de por qué no es lo mismo un poco más de lo otro — DHCP, DNS y TLS -> son programas aparte en todos lados y aquí tendrían que vivir dentro de -> `thalyx`, y lo que comprarían depende de una pregunta de Fase 2 que no está -> contestada: de dónde saldría un módulo. -> -> El kernel pasó de **110 opciones a 118**. Las nuevas son dos menús y cuatro -> drivers, cada uno con su razón al lado: `virtio_net`, `e1000`, `e1000e` y -> `r8169`. Nada de WiFi. -> -> El verbo es `red`, motor en `thalyx-net`, dos caras. Y **dice en la respuesta -> que no se puede usar** —`addressable: false` para un programa, una frase para -> una persona— porque es la única lista del sistema cuyas cosas ningún verbo -> puede tocar, y quien lea una lista de tarjetas va a ir a buscar el verbo que -> las usa. -> -> **Lo que salió de correrlo, que ninguna prueba de fixture vio:** la primera -> versión reportó **tres tarjetas en una máquina con una.** `ifb0` e `ifb1` dicen -> `type 1` y traen dirección física, y son software puro. Lo que separa una -> tarjeta es que cuelga de un bus. Regla nueva en [[Estrategia-de-Pruebas]]. -> -> Las otras dos, medidas y no citadas: una interfaz abajo **no dice que no tiene -> cable, no dice nada** (`EINVAL`, no `0`), y `speed` tiene tres estados —número, -> `-1` con el enlace arriba, y no legible—. Las dos sobreviven a lo que se -> imprime: `cable unknown` es una columna distinta de `no cable`. > -> 18 pruebas nuevas y la **etapa 35**, cuyo control es `iproute2` porque lee -> netlink y no sysfs: pedirle a Thalyx que se compruebe contra su propia lectura -> de `/sys` sólo probaría que es consistente. 1340 pruebas, clippy limpio. -> -> **Lo que falta correr, y sólo tu máquina puede:** -> -> ``` -> git pull && cargo install --path crates/thalyx-cli -> make -C image kernel # los ocho CONFIG_ nuevos -> sudo THALYX_AGENT_WEIGHTS=/home/cesarmanzocode/models/qwen2.5-3b-instruct-q4_k_m.gguf \ -> ./dev/verify.sh -> ``` -> -> `make -C image kernel` es lo primero porque **`config-check` falla la -> construcción si `olddefconfig` tira cualquiera de las ocho opciones nuevas**, y -> las nombra. Es la única manera de saber si acerté las dependencias: aquí no se -> puede compilar un kernel. -> -> Y en tu Fedora, `red` a secas ya enseña tu tarjeta real — con su driver y su -> velocidad negociada— sin necesidad de arrancar la imagen. - -> ## Un ensayo ya no dice que borró nada — 2026-08-23 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Cesar lo decidió: se arregla. `ensayo rm notas.txt` imprimía -> `removed /ruta/notas.txt` para un archivo que seguía ahí, y lo mismo `cp`, `mv` -> y `mkdir`. Ahora dicen `would remove`, `would copy`, `would move` y -> `would make the directory` — y el verbo de verdad sigue diciendo `removed`, -> que es la mitad que hace que esto signifique algo. -> -> **Una sola frase, dos tiempos.** El tiempo verbal viaja como un dato desde -> quien sabe si esto es un ensayo hasta quien imprime, y `Did::would()` vive -> pegado a `Did::word()` para que no se pueda agregar un verbo nuevo con la -> mitad. Un segundo impresor para los ensayos sería exactamente la segunda -> versión de los hechos que este módulo existe para no tener. -> -> **Por qué nadie lo vio en meses:** la cara de máquina estaba bien todo el -> tiempo — su `op` dice `rehearse`— así que las cuatro pruebas del ensayo, que -> leen objetos, no podían verlo. Regla nueva en [[Estrategia-de-Pruebas]]: cuando -> un hecho se dice en dos caras, una prueba que sólo lee una de ellas prueba una -> de ellas. -> -> Prueba nueva y **etapa 34**, las dos comprobadas de las dos maneras: fallan con -> el defecto puesto de vuelta y pasan sin él. 1322 pruebas, clippy limpio. -> -> **Lo que sigue es el punto 8, la red**, que Cesar también decidió. Es el último -> de los nueve de la terminal usable. - -> ## La imagen está construida, y el modelo sí corrió — 2026-08-23 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Cesar construyó la imagen en su máquina. La corrida pasó de `139 · 2 · 0` a -> **`152 · 1 · 0`**: son las trece comprobaciones de la etapa 16, que arranca la -> imagen en QEMU y le habla, y que llevaban meses fuera del reporte por no haber -> kernel construido. **Con eso el punto 8 —la red— queda desbloqueado**, porque se -> prueba con `make -C image run-hardware`, que necesita justamente esa imagen. -> -> El NOT PROVEN que quedó **no era suyo, era del instrumento.** Corrió -> `thalyx agent model check` y el modelo contestó de verdad —una inferencia -> parseada, 7.28 s, 4.77 GB de pico— y la etapa siguió diciendo *«no real model -> has run: llama-completion is not installed»*. Las dos cosas ciertas a la vez: -> el `check` lo corrió él con su `PATH`, y la etapa corre bajo `sudo`, que tira el -> `PATH` y usa `secure_path`. Regla nueva en [[Estrategia-de-Pruebas]], la quince. -> -> Arreglado: la etapa busca el binario también en el `PATH` de `$SUDO_USER` y, -> cuando lo encuentra, **dice dónde está y qué escribir** para que la corrida lo -> vea. Las dos mitades del hueco se reportan por separado, y la de los pesos -> distingue «no la nombraste» de «nombraste un archivo que no está» — y en el -> primer caso dice que `sudo` no lleva el entorno. -> -> Comprobado corriendo los cuatro caminos, no leyéndolos. El segundo destapó un -> defecto del arreglo mismo: una cuenta con `nologin` imprime una frase en inglés -> y la primera versión la ofreció como si fuera la ruta del binario. -> -> **Lo que falta correr:** -> -> ``` -> git pull -> sudo THALYX_AGENT_WEIGHTS=/home/cesarmanzocode/models/qwen2.5-3b-instruct-q4_k_m.gguf \ -> ./dev/verify.sh -> ``` -> -> Si aun así dice NOT PROVEN, ahora la línea trae la ruta exacta que falta poner -> en `THALYX_AGENT_BINARY`. - -> ## Punto 9: hay citado y no hay lenguaje — 2026-08-23 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Cesar lo decidió así: *«lo que sea más fácil de cubrir por ahora, pero en un -> futuro sí tendremos que hacer shell completo, no ahora, pero estemos -> preparados»*. La segunda mitad es la que mandó sobre el diseño — **nada de lo -> que se aprenda hoy puede tener que desaprenderse el día del shell completo** — -> y el decreto entero está en [[Palabras]]. -> -> Antes de preguntarle fui a ver qué faltaba de verdad, corriéndolo: **un archivo -> con un espacio en el nombre se podía listar y nada más.** `cp mi archivo.txt x` -> eran tres palabras y los tres verbos se negaban. Nunca se destruyó nada por -> eso, pero no había forma de nombrar el archivo. -> -> Lo que hay ahora: -> -> - `'…'`, `"…"` y `\x` con las reglas de POSIX hasta donde POSIX llega hoy, así -> que el día que `$` signifique algo no cambia nada de lo escrito; -> - una comilla sin cerrar **se niega** —`unclosed_quote` / `close_the_quote`— en -> vez de adivinarse, que es como un `rm` acaba actuando sobre algo que nadie -> nombró. Una diagonal al final tiene su propia palabra, porque es otro error; -> - **la expansión se queda en el verbo, y eso es decreto**. `rm "*.log"` borra el -> archivo que se llama así y `encontrar "*.rs"` sigue siendo un patrón — las -> dos costumbres de Unix, cada una donde estaba, igual que bash y `find`. -> -> Una palabra recuerda qué caracteres venían citados **carácter por carácter**, -> porque `"a"*` es un patrón y `a"*"` es un nombre. -> -> **Dos cosas cambiaron de significado y hay que decirlas:** una corrida de -> espacios ahora se colapsa (`contenido fn main` busca `fn main`; la forma de -> pedir lo otro es `contenido "fn main"`), y el texto de `editar` es la única -> excepción — se toma del renglón byte por byte, porque una sangría perdida en un -> archivo de configuración no se ve hasta que algo no arranca. -> -> Ocho pruebas en el prompt de verdad y la **etapa 33**, comprobada de las dos -> maneras: pasa con el cambio y falla sin él. -> -> **Con esto la terminal usable llega a 8 de 9.** Queda el punto 8, la red, que -> sólo se verifica en su hierro y se cruza con la Fase 2. -> -> **Y una cosa encontrada de paso, que no toqué:** `ensayo rm x` imprime -> `removed /ruta/x` sin haber borrado nada. Ya estaba en `main` desde antes —lo -> comprobé con el binario anterior— y es de la misma familia que lo de `matar`: -> una respuesta que dice que algo pasó cuando no pasó. La cara de máquina está -> bien (el `op` es `rehearse`); la humana no. **Falta que Cesar decida si se -> arregla.** - -> ## Comprobado en el hierro: 138 · 2 · 0 — 2026-08-23 -> -> Cómo se llegó. -> -> La corrida de Cesar cerró el lazo de verificación de los tres arreglos de este -> día: `proven 138 · not proven 2 · failed 0`. Los dos NOT PROVEN son los de -> siempre y no son defectos — no hay modelo instalado y no hay imagen construida -> (`make -C image`). -> -> Lo que eso deja probado **en hierro**, que es lo único que cuenta para estas -> tres cosas: -> -> - `matar` se niega ante un hilo del kernel y ante un proceso que ya terminó, en -> vez de decir que los detuvo (etapa 32, con la línea base y el control); -> - **instalar dos veces sobre el mismo disco funciona** — que era el `failed 1` -> de la corrida anterior. Sostener las particiones abiertas sí impide el segundo -> barrido del kernel; era lo único de ese arreglo que este contenedor no podía -> contestar; -> - la prueba del nodo ya no afirma un errno, así que la suite pasa en una máquina -> que monta `/tmp` con `nodev`. -> -> **La terminal usable llega a 7 de 9 y los dos que faltan son decisiones suyas:** -> el punto 8 (red) sólo se verifica en su hierro y se cruza con la Fase 2, y el -> punto 9 (lenguaje de shell) es decreto antes que código. Nada más está -> bloqueado. - -> ## Instalar dos veces sobre el mismo disco — 2026-08-23 -> -> Cómo se llegó. -> -> **Corregido el mismo día:** la prueba nueva afirmaba que abrir el nodo da -> `ENXIO`, y en tu Fedora dio `EACCES` — porque `/tmp` está montado con `nodev` y -> ahí no se puede abrir ningún nodo de dispositivo, haya algo detrás o no. La -> prueba tumbó la suite entera por una opción de montaje. Ahora afirma lo que se -> estaba probando: que el nombre resuelve y el dispositivo no. Regla en -> [[Estrategia-de-Pruebas]], y es la catorceava vez que el instrumento miente. -> -> La corrida de Cesar del 2026-08-23 trajo `proven 137 · not proven 2 · failed 1`. -> El que falló: **instalar dos veces no funcionaba**, con -> `opening /dev/loop0p1: No such device or address`. -> -> `instalar-en` escribe la tabla, le pide al kernel que la relea, y espera a que -> aparezcan las particiones antes de escribir dentro de ellas. La espera -> preguntaba **si existía el nodo**. La primera instalación no tiene nodos, así -> que la espera espera; la segunda los tiene de la tabla anterior, así que la -> condición ya estaba cumplida antes de empezar y la espera terminó sin haber -> esperado. -> -> Y hay algo que esperar de verdad: **cerrar el descriptor que tenía el disco -> entero abierto para escritura hace que el kernel lo reexamine por su cuenta**, -> en su propio tiempo. Ese segundo barrido borra cada partición y la vuelve a -> hacer, y un nodo abierto dentro de esa ventana da `ENXIO` — *No such device or -> address*, para un nombre que está ahí en `/dev`. -> -> Dos cambios, y el segundo es el que cierra la ventana en vez de esquivarla: -> -> 1. la espera **abre** la partición en vez de preguntar si el nombre existe; -> 2. las particiones se devuelven **ya abiertas** y se sostienen abiertas hasta el -> final. El kernel se niega a soltar las particiones de un disco mientras -> alguna esté abierta, así que el segundo barrido encuentra el disco ocupado y -> lo deja en paz. -> -> **Lo que está probado y lo que no.** Que `stat` y abrir contestan cosas distintas -> quedó fijado con una prueba que hace `mknod` con un major que ningún driver -> registró: el nombre existe y abrirlo da `ENXIO`, el mismo errno de tu corrida. -> Que montar funciona con un descriptor abierto encima también se comprobó -> corriéndolo. **Que sostener la partición bloquea el segundo barrido no se puede -> comprobar en este contenedor**, que no sabe hacer particiones sobre un loop. Eso -> lo dice tu máquina: -> -> ``` -> git pull && cargo install --path crates/thalyx-cli -> sudo ./dev/verify.sh -> ``` -> -> La regla que lo habría atrapado está en [[Estrategia-de-Pruebas]]: **una -> condición de espera tiene que ser falsa al principio.** Si la puede satisfacer lo -> que quedó de la vez pasada, no está esperando a nada — y el caso que la deja -> pre-satisfecha es justamente el que nadie prueba, porque es el segundo. -> -> ## `matar` ya no dice que detuvo lo que no se puede detener — 2026-08-23 -> -> Lo encontró Cesar en su primera sesión con el punto 7: ensayó `matar` sobre un -> `kworker` y Thalyx contestó *«5 would ask to stop»*. A un hilo del kernel no le -> llega ninguna señal. Que `pidfd_send_signal` conteste `0` significa que el -> kernel se quedó con la señal, **no que le vaya a pasar algo a alguien**. -> -> Son dos los sujetos que la aceptan y la tiran, y los dos quedan negados antes -> de mandar nada: -> -> | sujeto | palabra | remedio | -> |---|---|---| -> | un hilo del kernel | `is_kernel_thread` | `cannot` | -> | un proceso que ya terminó (zombi) | `already_ended` | `stop_the_parent`, con el número del padre | -> -> Es peor que un error: una respuesta que dice *«se le pidió que pare»* sobre algo -> que nunca se movió enseña que Thalyx no es confiable, cuando Thalyx sólo era -> crédulo — y quien la vea va a probar `forzar`, que hace exactamente lo mismo. -> -> `ensayo matar` se niega igual, desde la misma función: un ensayo que predice -> algo que el verbo no hace es una respuesta equivocada que hay que desaprender -> tecleando la de verdad. -> -> El hilo del kernel se reconoce por el bit `PF_KTHREAD` del campo 9 de -> `/proc//stat`, **medido y no citado**: ese valor no está en ningún -> encabezado que se le entregue al espacio de usuario, así que se sacó -> comparando los 66 hilos cuyo padre es `kthreadd` contra los 6 procesos -> ordinarios de un sistema corriendo. No se reconoce por la línea de comandos -> vacía, que también la tiene un zombi. -> -> Lo que esto enseñó de las pruebas: **`matar` se había probado once veces en el -> prompt de verdad y las once con un proceso que sí se podía detener.** La regla -> nueva está en [[Estrategia-de-Pruebas]] — cuando el éxito de un verbo se toma -> del valor de retorno de una llamada al sistema, hay que probarlo sobre un -> sujeto que la llamada acepta y no obedece. Y la línea base de la etapa 32 es el -> defecto mismo: `kill -9` al zombi, que se acepta y no hace nada. -> -> Falta correrlo en tu máquina: -> -> ``` -> git pull && cargo install --path crates/thalyx-cli -> sudo ./dev/verify.sh -> ``` -> -> Revisión completa en [[Procesos]]. -> -> ## Procesos: el punto 7, y una señal que no puede caer en el número equivocado — 2026-08-23 -> -> `procesos`, `memoria` y `matar` — todo sobre `/proc`, decreto entero en -> [[Procesos]]. Con esto la terminal usable llega al punto 7 de 9; quedan la red -> (punto 8, sólo hierro) y el lenguaje de shell (punto 9, decreto antes que -> código y decisión tuya). -> -> ### Lo que hace a `matar` distinto de un `kill` -> -> **El número no es el proceso.** Entre leer `/proc/4711` y señalar 4711, ese -> proceso puede terminar y el kernel puede darle el número a otro; toda -> herramienta que recibe un pid tiene ese hueco y vive con él. `matar` abre un -> `pidfd` y manda la señal por ahí, así que llega al proceso para el que se abrió -> el descriptor o falla — no hay un tercero donde le llegue a un desconocido. -> -> Eso decide el orden y es el contrario del obvio: **primero el descriptor, -> después la descripción, después la señal.** -> -> Por omisión `TERM`, que un programa puede atrapar para guardar lo que tenía; -> `matar forzar` manda `KILL`, que no. Se niegan PID 1 y la propia -> sesión, cada uno nombrando el verbo que hace ese trabajo bien —`apagar` y -> `salir`—; no es una política sobre quién manda en la máquina, que eres tú, es -> que una señal hace esos dos trabajos de la única forma que no deja dicho por -> qué la máquina se detuvo. También se niegan el `0` y los negativos, que para -> `kill(2)` son *todo lo que alcance* y un *grupo*. -> -> ### `ensayo matar` es el ensayo que más importa -> -> Un archivo se puede volver a escribir; un proceso no, y la entrada que causa el -> error son cuatro dígitos. Así que contesta el nombre, la línea de comandos -> entera, cuánto lleva corriendo y quién lo arrancó — y que no manda nada sólo -> queda probado por una aserción: el proceso sigue vivo después. -> -> ### `libre` no es `disponible` -> -> `memoria` contesta las dos y nombra cuál contesta la pregunta. Un Linux sano -> mantiene `free` cerca de cero a propósito, y quien lee sólo ese número concluye -> que la máquina está llena y empieza a matar cosas. `en uso` se calcula como -> `total − disponible`, nunca como `total − libre`. -> -> ### Dos cosas que construirlo enseñó -> -> - **El nombre en `/proc//stat` puede llevar paréntesis.** Se capturó un -> renglón real de un proceso llamado `we (ird) x`; partirlo por espacios pone -> el estado cinco campos antes y reporta un padre inventado. Regla 6. -> - **`kill -0` contesta si el número existe, no si el proceso corre** — -> decimotercera vez que el arnés miente. La etapa 31 dijo que `forzar` no había -> funcionado sobre una shell que llevaba rato muerta: era un zombi, porque en -> este contenedor **PID 1 es `process_api` y no cosecha huérfanos**. En tu -> Fedora systemd los cosecha y las dos preguntas se ven iguales. Ambas en -> [[Estrategia-de-Pruebas]]. -> -> ### Y un hueco de A2 que apareció por segunda vez -> -> `machine::declined` no tiene dónde poner un remedio, así que los verbos que la -> usaban contestaban `remedy: null`. La primera vez lo parché en `search`; al -> necesitarlo un segundo crate se volvió forma: `machine::refused(op, palabra, -> remedio, mensaje)`. -> -> ### Qué correr -> -> Nada de esto necesita hierro. La etapa 31 corrió verde aquí. -> -> ``` -> git pull && cargo install --path crates/thalyx-cli -> thalyx session -> procesos -> memoria -> ensayo matar -> ``` -> -> El `ensayo` primero, siempre. Son 39 verbos ahora. - -> ## Encontrar y contenido: el punto 6, con tres preguntas separadas — 2026-08-23 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Thalyx podía listar una carpeta y leer un archivo, y no podía contestar *dónde -> está el archivo que se llama así* ni *qué archivos dicen esto*. Ya contesta las -> dos, y el decreto entero está en [[Busqueda]]. -> -> ### Las dos decisiones fueron de Cesar -> -> **Dos verbos nuevos, no uno con banderas.** `encontrar ` por nombre, -> `contenido ` por texto, y `buscar` intacto en su tercera pregunta —del -> índice, no del disco—. Un verbo cuyo significado depende de una bandera se -> puede pedir mal en silencio, y las tres respuestas se ven igual: una lista de -> `ruta:línea` no dice de dónde salió. -> -> **Texto literal, sin expresiones regulares.** Un punto es un punto. Dos -> razones: la imagen lleva el kernel y un programa, y un dialecto de regex aquí -> sería decidir a escondidas un pedazo del punto 9 —si Thalyx tiene lenguaje de -> shell—, que es decreto antes que código. Los nombres sí llevan `*` y `?`, -> porque es el vocabulario que `rm`, `cp` y `mv` ya usan. -> -> ### Lo que construirlo obligó a mover -> -> `walk` vivía en `thalyx-graph` con dos llamadores. Ahora son cuatro, y los dos -> nuevos son justo los que una persona compara contra el índice: un `contenido` -> que entrara a `.git` donde `buscar` no entra contestaría sobre un archivo del -> que el índice nunca supo, y la conclusión sería *el índice está roto*. Se movió -> a `thalyx-files`, con el techo de 20 000 archivos, para que sigan siendo una -> sola caminata y un solo número. -> -> ### Dos cosas nuevas en [[Estrategia-de-Pruebas]] -> -> - **Un hecho que la shell va a leer se cita** — duodécima vez que el -> instrumento miente, y esta vez el instrumento era mío. La etapa 30 escribía -> `names=a.rs b.rs` sin comillas, la shell asignó el primero y trató de -> ejecutar el segundo, y la etapa acusó al verbo de no contestar cuando había -> contestado las tres cosas bien. -> - **Una prueba que este usuario no puede hacer fallar tampoco prueba** — quitar -> todos los permisos no detiene a root, así que la prueba de la regla 10 dice -> `NOT PROVEN` y `THALYX_REQUIRE_UNREADABLE_TESTS=1` la convierte en falla. Es -> la regla 3 con una cara nueva: hasta ahora los saltos eran por lo que a la -> máquina le falta, y éste es por quién está corriendo. -> -> ### Un defecto que sólo salió al correrlo -> -> El refuso estructurado usaba `machine::declined`, que no tiene dónde poner un -> remedio, así que `not_a_directory` llegaba con `remedy: null` — el punto A2 de -> [[Superficie-para-el-LLM]] perdido calladamente en dos verbos. Lo encontró una -> prueba que pidió el remedio. Ahora usa `machine::failure`, que sí lo lleva. -> -> ### Qué correr -> -> Nada de esto necesita hierro; la etapa 30 corre entera en el contenedor y ya -> corrió. Cuando quieras verlo: -> -> ``` -> git pull && cargo install --path crates/thalyx-cli -> thalyx session -> encontrar *.rs -> contenido fn main -> ``` -> -> Y `sudo ./dev/verify.sh 2>&1 | tee /tmp/verify.log` cuando toque la próxima -> corrida completa — con `tee`, porque el conteo de la corrida pasada costó una -> ida y vuelta por haber pedido nada más la cola. - -> ## Verde en hierro, y las diez comprobaciones que faltaban eran el arranque — 2026-08-23 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Cesar volvió a correr `verify.sh` con la precondición arreglada: -> **134 probadas, 2 no probadas, 0 fallas.** La sexta corrida en hierro, y la -> primera sin ninguna falla. -> -> ### La rama con watcher se ejerció por primera vez -> -> La etapa 27 imprimió *«the mutation ring was mapped and read: 4 record(s) -> named thalyx-ringmark, and a second read had none of them left»*. Es la etapa -> completa: el anillo mapeado, los registros leídos, y la segunda lectura vacía -> —que es la mitad que prueba que leer consume—. Y la suite pasó, o sea que -> `what_the_kernel_saw.rs` corrió su rama de watcher cargado sin haberse podido -> ejecutar nunca aquí. -> -> ### Las diez comprobaciones que faltaban, contestadas por el reporte mismo -> -> El bloque anterior dejó abierto por qué esta corrida traía menos -> comprobaciones que la del 2026-08-10, y pedía el resumen completo antes de -> afirmar nada. Con el resumen, la respuesta está en la segunda línea de lo que -> la corrida no pudo establecer: -> -> ``` -> · no kernel or image built yet, so there is nothing to boot; run 'make -C image' -> ``` -> -> **Lo que faltó es la etapa 16 entera —arrancar la máquina en QEMU—**, que en -> esa máquina no tenía qué arrancar porque `image/build/` está vacío; -> `image/build/` no está en el repositorio, así que se pierde con cualquier -> limpieza y `git pull` no lo trae de vuelta. -> -> Y cuadra por conteo, que es la forma comprobable de decirlo: la etapa 16 tiene -> **trece comprobaciones**, y aquí se contrajeron a **un solo NOT PROVEN**. -> 136 − 1 + 13 = 148, menos la etapa 29 que el 2026-08-10 no existía = 147 … -> contra las **146** que contó aquella corrida. Una de diferencia, que es el -> tamaño de un `if`/`else` de esa etapa. La dirección no admite otra lectura: no -> se perdió nada, no se arrancó nada. -> -> Para recuperarlas, `make -C image` y luego el disco de store. **No es urgente** -> — no hay nada nuevo que sólo el arranque compruebe desde el 2026-08-07 — pero -> mientras `image/build/` esté vacío el reporte va a seguir diciendo 134. -> -> ### Regla nueva -> -> Un conteo que baja no es una regresión hasta que se sabe *qué* dejó de -> correr, y el reporte ya lo decía: **las líneas de NOT PROVEN son parte del -> resultado, no una nota al pie.** Leer sólo el marcador es leer la mitad. En -> [[Estrategia-de-Pruebas]]. - -> ## La corrida en hierro: el anillo funciona, y la prueba era la equivocada — 2026-08-23 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Cesar corrió `verify.sh`: **133 probadas, 2 no probadas, 1 falla.** La falla era -> la etapa 5 —la suite— por dos pruebas de `what_the_kernel_saw.rs`. -> -> ### Thalyx estaba bien y la prueba estaba mal -> -> Las dos pruebas suponían **una máquina sin watcher**. Lo decía el encabezado del -> archivo y lo decía el nombre de una de ellas, y ninguna de las dos lo -> comprobaba. Era cierto durante meses porque este contenedor no puede cargar BPF, -> así que la negativa era la única respuesta posible. -> -> En la máquina de Cesar el watcher **sí** estaba cargado — porque las -> instrucciones de esa corrida le decían `make -C lsm load` antes de correr. Así -> que `cambios` contestó correctamente y la prueba dijo que Thalyx se equivocaba. -> -> **Undécima instancia de la regla 5**, y la variante de «una prueba que infiere -> su propia precondición». Arreglado: las dos preguntan si el pin existe en el -> sistema de archivos —un hecho en el que `cambios` no participa— y **ninguna de -> las dos ramas es un salto**, así que el archivo prueba algo dondequiera que -> corra. Ver [[Estrategia-de-Pruebas]]. -> -> ### Lo bueno, y estaba en la misma salida -> -> La corrida imprimió esto: -> -> ``` -> created by thalyx (41513), in cgroup 22518 -> retitled by thalyx (41510), in cgroup 22518 -> ``` -> -> Son **registros reales drenados de un anillo real del kernel**, con su cgroup, -> desde una sesión. El consumidor del ringbuf funciona en hierro. Lo que la -> etapa 27 persigue está vivo. -> -> ### Lo que falta saber de esa corrida -> -> **133 probadas contra las 143 del 2026-08-10, y se agregó una etapa.** Diez -> comprobaciones menos no se explican con la falla de la etapa 5, y con la cola -> del reporte no alcanza para decir por qué. Hace falta el resumen completo — -> las dos líneas de `not proven` y el bloque final— antes de afirmar nada. - -> ## El editor existe, con sus dos caras — 2026-08-22 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Es el punto 5 de la terminal usable, y lo que lo justificaba desde el -> principio: **sin un editor no se puede corregir un archivo de configuración -> desde la máquina.** Thalyx podía hacer un archivo, copiarlo, moverlo, borrarlo -> e imprimirlo, y no podía cambiarle un byte por dentro. -> -> Cesar decidió **las dos caras en una entrega**, que era la opción cara de las -> tres. El decreto entero está en [[Editor-de-Texto]]; lo corto: -> -> - `editar ` abre una **pantalla** — flechas, `Ctrl-O` guarda, `Ctrl-X` -> sale, `Ctrl-U` deshace, `Ctrl-K` corta. -> - `editar cambiar 12 ` direcciona **renglones** y contesta un -> objeto, porque un programa no puede manejar una pantalla que se redibuja. -> - `crates/thalyx-edit` es **un solo motor** y las dos caras lo llaman, así que -> no pueden acabar en desacuerdo sobre lo que el archivo dice ahora. -> -> Es el primer verbo donde las dos caras difieren de **forma**, y por eso valía -> la pena decidirlo en la bóveda antes de escribirlo. -> -> ### Lo que se puede correr aquí, y ya corrió -> -> **1 231 pruebas en verde, clippy limpio.** La etapa **29** de `verify.sh` está -> escrita y **ya pasó en este contenedor**: no necesita hierro. Ejerce las tres -> cosas que sólo una máquina contesta, cada una con su control — -> -> - un renglón cambiado por dirección y **leído de vuelta con `cat`**, no con -> Thalyx; -> - una persona tecleando en la pantalla **a través de un pty de verdad**, con -> pulsaciones reales, y el trabajo escrito; -> - un archivo binario **negado sin que se moviera un byte**, que es el control: -> un editor que negara todo pasaría las dos primeras y sería inútil. -> -> ### Tres defectos que salieron de correrlo, y uno que ya estaba -> -> Todos están escritos como reglas en [[Estrategia-de-Pruebas]]: -> -> 1. **`Ctrl-S` no habría funcionado nunca.** El modo crudo deja `IXON` e `ISIG` -> encendidos a propósito, así que la disciplina de línea se come `Ctrl-C`, -> `Ctrl-Z`, `Ctrl-S` y `Ctrl-Q` antes de que Thalyx vea un byte — y `Ctrl-S` es -> XOFF, o sea que habría dejado la terminal aparentemente muerta. Por eso -> guarda `Ctrl-O`. Hay una prueba que falla si alguien enlaza una de las cuatro. -> 2. **La décima instancia de la regla 5.** Las pruebas de pantalla se colgaron: -> un pty recién hecho no tiene tamaño de ventana, el editor se negó a dibujar -> —correctamente— y las teclas se tecleraon en el prompt. **El arnés estaba -> incompleto, no Thalyx.** Ahora `thalyx dev pty` le pone tamaño al pty. Lo -> hizo barato el reloj de la prueba, que al vencerse imprime lo que se había -> dibujado: ahí estaba la oración que decía la respuesta entera. -> 3. **La confirmación de salida se tragaba la tecla que la contestaba.** -> `Ctrl-X` y luego `Ctrl-O` no guardaba nada. Era una lectura anidada haciendo -> de segundo intérprete del teclado; ahora es una bandera y toda tecla pasa por -> el único bucle que sabe qué significan. -> 4. **Y una que ya llevaba un día roja en `main`**: la reescritura de la portada -> del 2026-08-21 movió la ruta de construcción a `docs/BOOT.md` y la prueba -> que la ata seguía afirmando sobre el README. Arreglada, y ahora comprueba -> también que la portada apunte ahí — una ruta de construcción en un archivo -> al que nada apunta es una ruta que nadie encuentra. -> -> ### Lo que sigue sin correrse en hierro -> -> **Nada de lo del 2026-08-10 se ha vuelto a correr en tu máquina**, y hay cuatro -> arreglos de ese día esperando: el techo de `indexar`, la etapa 14 que devuelve -> el watcher, el `ETXTBSY` de `grammar_check` y el control del anillo. La etapa -> 27 —el ring buffer— es la que más falta hace, porque la 14 la estaba tumbando. -> -> ### Qué correr -> -> ``` -> git pull && cargo install --path crates/thalyx-cli -> sudo chown -R "$USER" lsm -> make -C lsm unload && make -C lsm load -> sudo ./dev/verify.sh -> ``` - -> ## La portada del repositorio, rehecha — 2026-08-21 -> -> **Esto no cambió el sistema, cambió cómo se lee.** El estado técnico sigue -> siendo el bloque de abajo. -> -> El README tenía 636 líneas y funcionaba como documentación profunda, no como -> portada: quien abría el repositorio tardaba en entender qué era. Ahora son -> ~250, con jerarquía, y el detalle se movió a dos archivos nuevos en inglés que -> no compiten con el vault: -> -> - `docs/BOOT.md` — el recorrido de arranque completo, paso por paso. -> - `docs/STATUS.md` — la contabilidad honesta: qué está probado, dónde, con qué -> fecha, qué sigue abierto y la contradicción que el proyecto publica. -> -> `README.es.md` dejó de ser una traducción completa (dos copias grandes se -> desincronizan; la que miente es la que nadie lee) y es ahora una página corta -> de orientación que manda al vault, que ya está en español. -> -> ### Evidencia visual, capturada de corridas reales -> -> `docs/media/` lleva tres imágenes y **el texto crudo de cada una al lado**, -> para que se puedan comprobar: el recuadro de autorización de capacidades, el -> commit atómico muerto con `SIGABRT` entre el rename y el symlink, y el -> autorreporte de `session --once`. No hay maquetas. El diagrama de arquitectura -> (`docs/media/architecture.svg`) es dibujado y muestra la frontera de confianza: -> la ruta humana completa, la del agente sólo como propuesta, y el LSM negando -> desde el kernel. -> -> No se pudo capturar un arranque real en QEMU: la política de red del contenedor -> bloquea `cdn.kernel.org`, así que el kernel no se puede descargar aquí. -> -> ### Dos cifras del README estaban viejas -> -> Decía «110 comprobaciones, 2026-08-07» — la corrida vigente es la quinta, -> **2026-08-10: 143 probadas, 2 no probadas, 1 fallida**, con todo el lado del -> kernel probado por primera vez. Y decía que `thalyx_watch` «nunca se ha cargado -> sin `bpftool`», cuando lo que sigue abierto es más preciso: **nunca lo ha -> cargado el cargador propio de Thalyx**; el ring buffer sí se leyó en hierro -> desde un pin real del kernel. -> -> Los conteos que se mueven (pruebas, nombres declarados) quedaron en una forma -> que no envejece: «más de 1 100 pruebas», «cerca de cuatro mil nombres». - -> ## El verbo que se colgaba era `indexar`, y el hierro quedó en verde — 2026-08-10 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> La quinta corrida en hierro dio **143 probadas, 2 no probadas, 1 falla**, y por -> primera vez todo el lado del kernel quedó probado: la protección deniega de -> verdad, el módulo corrió confinado, Thalyx enganchó su propio LSM sin -> `bpftool`, y el canal del módulo sobrevivió al sandbox. `make unload` antes de -> `make load` fue todo lo que hacía falta. -> -> ### El reloj sirvió: era `indexar` -> -> La prueba nombró el verbo en la primera corrida. `indexar` sin argumento indexa -> el árbol donde está parada la sesión, y una sesión empieza en `/home` — que en -> tu máquina incluye `.cargo/registry` y `.rustup`. Aquí son 329 archivos y dos -> segundos; allá es cada fuente de cada crate que has bajado más la biblioteca -> estándar entera. -> -> Dos reglas nuevas, ambas en [[Estrategia-de-Pruebas]]: -> -> - **No se entra a carpetas que empiecen con punto.** La lista vieja -> —`.git`, `target`, `node_modules`— era una lista de las cosas que ya habían -> salido mal. El árbol que se nombra explícitamente nunca se filtra. -> - **Techo de 20 000 archivos, y arriba de eso se niega en lugar de empezar.** -> `tree_too_large`, dentro de la transacción, así que el índice que había sigue -> ahí. Etapa 28 de `dev/verify.sh`, con su control. -> -> ### Lo que sigue abierto -> -> La etapa 27 dijo que nada estaba anclado, y era cierto **por culpa de la etapa -> 14**, trescientas líneas más arriba: desengancha el watcher y no lo devolvía. -> Ya lo devuelve. El ring buffer no se ha vuelto a ejercer desde entonces. -> -> Y `grammar_check` corre el binario dos veces por dentro; los seis sitios que -> pasan por ahí nunca estuvieron envueltos contra `ETXTBSY`. El comentario decía -> que *toda prueba de este archivo* llamaba al envoltorio, y era falso. -> -> ## Tres de las cuatro fallas de la cuarta corrida eran el instrumento — 2026-08-10 -> -> La corrida en hierro dio 127 probadas, 7 no probadas y 3 fallas. De las tres, -> dos eran de `dev/verify.sh` y la tercera se quedó sin diagnosticar a propósito. -> -> ### El ring buffer sí funciona -> -> Ésta es la noticia buena y hay que decirla primero: con el mapa renombrado a -> `thalyx_mut_ring`, el pin apareció, `cambios` **mapeó el ring y leyó -> registros reales del kernel**. `bpf_obj_get` sobre un pin de verdad, dos -> `mmap` que el kernel aceptó y el protocolo corriendo sobre memoria compartida -> — nada de eso se puede tocar desde el contenedor. -> -> Lo que falló fue el control, no el consumidor. Ver -> [[Estrategia-de-Pruebas]]: *un control que pide silencio no se puede cumplir -> en una máquina viva*. Ahora la mutación la hace un programa llamado -> `thalyx-ringmark` y las dos columnas son sobre ese nombre. -> -> ### El LSM estaba enganchado y el script dijo que no -> -> Cesar cargó el LSM a mano antes de correr el script. `make load` se negó -> —correctamente— y `verify.sh` leyó esa negativa como «no está enganchado», -> perdiendo la etapa 4 y cuatro NOT PROVEN más sobre protección que estuvo -> corriendo toda la corrida. Ahora `make unload` va antes de `make load`, -> siempre. -> -> ### La suite se colgó y no se sabe en qué verbo -> -> `every_verb_the_catalogue_advertises_is_understood_at_the_prompt` dejó de -> contestar. Aquí corre en 1.7 segundos, así que **no se pudo reproducir y no se -> arregló** — adivinar cuál de los 33 verbos fue habría sido exactamente lo que -> la regla 5 prohíbe. -> -> Lo que sí se hizo: la prueba lleva ahora su propio reloj de tres minutos y al -> vencerse **nombra el verbo** en el que se quedó. Una corrida de -> `cargo test -p thalyx-cli --test catalogue_is_true` lo dice. -> -> La sospecha, sin prueba: `discos` abre cada disco entero con `File::open` para -> leer su tamaño, y abrir un lector de tarjetas vacío o una unidad óptica sin -> medio es lo clásico que se cuelga. El tamaño se puede leer de -> `/sys/class/block//size` sin abrir nada. **No se cambió**, porque cambiarlo -> antes de saber habría borrado la evidencia. -> -> ## `intento` alcanzó `/`, y el contenedor no podía verlo — 2026-08-10 -> -> ### Lo primero, y hay que correrlo -> -> Dos snapshots de sólo lectura de tu sistema de archivos raíz quedaron en -> `/.thalyx-snapshots/`. Los dejó la corrida de pruebas, no un `intento` que -> hayas escrito, y **nada se destruyó** — esas pruebas nunca abandonan. Pero -> quedaron huérfanos y anclan bloques: -> -> ``` -> sudo btrfs subvolume list -o / | grep thalyx-snapshots -> sudo btrfs subvolume delete /.thalyx-snapshots/* -> ``` -> -> ### El defecto -> -> `subvolume_at_or_above` caminaba hacia arriba buscando el subvolumen más -> cercano. Desde un directorio temporal bajo `/tmp` la caminata pasó por todos -> los niveles y se detuvo en el primero que sí lo era: **`/`**. La respuesta dijo -> que abandonar borraría 1 343 582 archivos, `/boot` entre ellos. -> -> El argumento para caminar hacia arriba —«un intento es sobre el árbol en el que -> alguien está trabajando»— era exactamente al revés: caminar hacia arriba -> **abandona en silencio** el alcance que quien llama tenía en mente, y en toda -> instalación Btrfs ordinaria la caminata termina en la respuesta más peligrosa -> que existe. -> -> Ahora: **donde estás parado, o nada.** Y `/` se niega aunque estés parado en -> él, porque abandonarlo significa cambiar la raíz del sistema en marcha por -> debajo de cada proceso de la máquina, incluido el que lo pidió. -> -> ### Por qué el contenedor no podía encontrarlo -> -> Aquí no hay Btrfs ni un solo subvolumen, así que *todos* los caminos se niegan -> y la prueba no podía distinguir una negativa correcta de un accidente del -> sistema de archivos. **Pasaba por la razón equivocada**, que es peor que -> faltar: ocupa el lugar donde alguien buscaría la que sí sirve. -> -> La guarda vive ahora en una prueba unitaria contra un falso donde **todo** es -> un subvolumen —la máquina donde la respuesta peligrosa sí aparece— y la etapa -> 26 tiene una columna nueva que se para en `/` y exige la negativa. -> -> ### Y por qué la 27 no llegó a correr -> -> `make -C lsm load` falló con `Operation not permitted` al escribir su propio -> `.o`. `sudo ./dev/verify.sh` compila los objetos BPF dentro del árbol de -> fuentes **como root**, así que el siguiente `make` como tú no puede -> sobrescribirlos. `verify.sh` ahora devuelve lo que escribió. -> -> ### Qué correr -> -> ``` -> sudo btrfs subvolume delete /.thalyx-snapshots/* -> git pull && cargo install --path crates/thalyx-cli -> sudo chown -R "$USER" lsm -> make -C lsm unload && make -C lsm load -> sudo ./dev/verify.sh -> ``` - -> ## La corrida en hierro: 26 pasó, 27 no llegó a correr, y una prueba que se peleaba consigo misma — 2026-08-10 -> -> Cesar corrió `verify.sh` en su máquina: **143 comprobadas, 2 no comprobadas, -> 1 fallida**. -> -> ### Lo que quedó probado en hierro -> -> **Etapa 26 — el intento con nombre, sobre Btrfs de verdad.** Abandonar -> devolvió un archivo a su contenido viejo, borró el que se hizo durante el -> intento, y el control cerrado con `confirmar` no perdió ninguno de los dos. El -> intercambio fue **atómico**. -> -> Y las etapas 23, 24 y 25: el listado acotado con su cursor, el índice de -> símbolos encontrando **3 989** nombres sobre las fuentes de este repo, y la -> historia leída desde la sesión. -> -> ### La prueba que fallaba: novena instancia de la regla 5 -> -> Dos pruebas de `llama.rs` fallaron con `Text file busy`. **Nada de Thalyx -> estaba mal.** `ETXTBSY` es el kernel negándose a ejecutar un archivo que -> cualquier proceso tiene abierto para escritura, y el conteo que revisa vive en -> el inodo — así que `O_CLOEXEC` no ayuda. El mecanismo es la ventana del -> `fork`: entre que `Command::spawn` bifurca y el hijo ejecuta, el hijo tiene -> copia del descriptor de escritura que otro hilo estaba usando para crear ese -> mismo archivo. -> -> Lo que hace que valga la pena escribirlo: **esto ya había fallado hace un -> año**, una vez en veinticinco corridas, y el arreglo de entonces —un nombre de -> archivo por falso— venía con un comentario que decía que era una adivinanza. -> Sobrevivió un año porque nunca volvió a fallar donde alguien mirara. Lo que la -> resolvió no fue pensar mejor: fue una máquina de doce núcleos. -> -> Arreglado con un reintento en el arnés y **no** en el código de producción: si -> el `llama-completion` de alguien está ocupado, eso es un hecho de su máquina -> que Thalyx debe reportar, no tapar. Y con una prueba que **reproduce la falla a -> propósito** —sostiene el archivo abierto para escritura, comprueba que el -> kernel de verdad se niega, y luego que el arnés lo espera. -> -> ### La etapa 27: el reporte no podía decir lo que pasaba -> -> Dijo «nada está anclado en `…/thalyx_mutations`, así que thalyx-watch no está -> cargado». **Era falso**: la etapa 3 de la misma corrida decía que el contador -> sí estaba anclado y la 7 leyó 36 872 mutaciones con los diez ganchos puestos. -> -> `BPF_OBJ_NAME_LEN` es 16 contando el terminador, así que el kernel se queda -> con **quince** caracteres. `thalyx_mutations` (16) y `thalyx_mutation_count` -> (21) se vuelven **los dos** `thalyx_mutation`, y el kernel tenía dos mapas del -> mismo objeto bajo un solo nombre. Si eso fue lo que impidió el anclaje no está -> establecido — lo que sí lo está es que dos mapas con un nombre vuelven la -> pregunta incontestable. -> -> El anillo se llama ahora `thalyx_mut_ring`, quince caracteres, y hay una prueba -> que corta cada nombre de mapa de los dos objetos BPF a quince y falla si dos -> coinciden. Y la etapa 27 ya no dice sólo «no está»: dice qué **sí** hay en el -> directorio y si el mapa existe en el kernel sin estar anclado. -> -> ### Lo que hay que correr -> -> ``` -> git pull && cargo install --path crates/thalyx-cli -> make -C lsm unload && make -C lsm load -> sudo ./dev/verify.sh -> ``` -> -> El `unload`/`load` es lo que hace falta para que el anillo se ancle con su -> nombre nuevo. Si la 27 sigue sin pasar, ahora el mensaje dice qué hay ahí. - -> ## Seis puntos más del catálogo, y el prompt que Cesar encontró — 2026-08-10 -> -> Cesar corrió lo del día anterior en su máquina, encontró un defecto de verdad, -> y sobre los tres puntos que quedaron nombrados como negociación dijo: *«me -> parece que ahí no hay costo real, más bien es costo de dificultad… si realmente -> aportan un beneficio real y claro a los LLMs, entonces hazlos»*. -> -> **Catorce de los diecinueve puntos están hechos.** Queda **E1**, que no es -> difícil sino que le falta el piso, y ésa es la decisión que sigue. -> -> ### El defecto: una pantalla en blanco que parecía colgada -> -> Escribió `structured on`, recibió su objeto, y se quedó frente a una pantalla -> en blanco. Nada en ella distinguía una sesión esperando una línea de una -> colgada, así que abrió otra ventana para escribir el siguiente comando. -> -> El prompt se suprimía en la cara estructurada para cumplir la promesa de un -> objeto por renglón. **En una terminal esa promesa nunca se estaba cumpliendo**: -> el modo crudo hace eco de cada carácter, así que el flujo de un pty nunca fue -> un objeto por renglón — y las pruebas ya lo decían en su propio comentario. -> Suprimir el prompt ahí no compraba nada y costaba eso. Ahora lo decide **el -> flujo y no la cara**: por tubería no hay prompt, en terminal sí, con llaves -> —` {/home} > `— porque cuál cara está encendida es invisible hasta que uno -> escribe algo, y quien no ve el modo en el que está no puede salir de él. -> -> ### B1: ninguna respuesta larga sin límite -> -> La falla es la callada de los cinco costos. `ls` sobre cuarenta mil archivos no -> falla, no avisa, y no parece un defecto: produce un agente que gastó su ventana -> entera en nombres y olvidó su tarea. -> -> **Un cursor por llave y no un desplazamiento**, y ésa es la decisión entera. -> Con `skip(200)`, borrar un archivo anterior corre todo un lugar y el renglón -> que estaba en 200 no se le manda a nadie — sin error, sin hueco, y quien lee -> concluye que ese archivo no existe. Lo que un cursor por llave sí no puede -> ocultar —que la colección se movió— lo dice, en el mismo objeto que las filas. -> -> La cara humana **no** se acota: una ventana es un hecho sobre una ventana de -> contexto, y una persona no tiene una. -> -> ### C2: símbolos, no renglones -> -> El decreto decía «medio construido: el parser existe», y era menos que eso — el -> parser sabía leer importaciones, no declaraciones. Ahora `buscar ` -> contesta dónde se declara un nombre, de qué tipo es, y en qué renglón de qué -> archivo se usa. **Sin comentarios y sin cadenas**, que es lo que `grep` no -> puede hacer. -> -> La tabla de menciones sólo registra identificadores que el árbol declara. Sin -> eso la tabla es en su mayoría vocabulario —`println`, `let`, `self`— y un -> índice que es vocabulario es uno que nadie puede permitirse guardar. -> -> La etapa 24 de `verify.sh` corre sobre las fuentes de este repositorio y -> encuentra **3 869 nombres declarados**. Su control es una palabra que sólo -> aparece en prosa: si volviera con usos, esto sería una búsqueda de texto -> disfrazada. -> -> ### F2: `historia` -> -> El journal se escribía desde [[Journal-y-Snapshots]] y lo leía exactamente una -> cosa, un subcomando. Ahora lo lee la sesión, el más nuevo primero, con la -> advertencia que la cara humana ya imprimía convertida en campo: **esto es lo -> que Thalyx hizo, no todo lo que pasó**. -> -> ### D2: `intento`, y una corrección mía -> -> El 2026-08-09 escribí que D2 sólo se podía construir en el hierro porque la -> única prueba posible aquí sería contra un falso. **Eso confundió dos cosas.** -> `thalyx-snapshot` ya tenía el corte hecho y escrito: *«la política que sólo se -> puede ejercer en un sistema de archivos Btrfs es política que nunca se -> ejerce»*. Cuál intento está abierto, qué hace un segundo, a qué árbol apunta un -> abandono, qué pasa cuando el snapshot ya no está — ninguna es una pregunta de -> Btrfs. -> -> Así que la política vive en `thalyx-core::attempt` con once pruebas que corren -> aquí, y para el hierro queda sólo lo que de verdad es Btrfs: que el snapshot -> sea atómico y que el intercambio sea un `RENAME_EXCHANGE`. **Etapa 26**, con su -> control — la misma secuencia cerrada con `confirmar`, donde nada debe perderse. -> -> ### B3: `cambios`, y otra corrección mía -> -> Escribí que consumir el ringbuf era código BPF y que por eso tumbaría al -> watcher. **No aplica**: el productor ya está escrito y no se toca; el -> consumidor es código de usuario. El protocolo del anillo es una función pura -> sobre bytes, y un arreglo de bytes lo modela exactamente — regla 8 cumplida, -> diez pruebas aquí, incluido el registro que cruza el final del anillo. Para el -> hierro queda **sólo el mapeo**: etapa 27. -> -> Dos cosas que el decreto esperaba y un anillo no puede dar, dichas en la -> respuesta: **no es una historia** (leerlo lo vacía) y **no nombra archivos** -> (trae cgroup, pid, tipo y programa). Para lo que sirve es para separar lo que -> hizo el agente de lo que hizo la persona. -> -> ### Lo que hay que correr, en este orden -> -> ``` -> git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh -> ``` -> -> Tres etapas nuevas sólo tuyas: **26** (Btrfs, el intento), **27** (BPF, el -> anillo) y las que ya estaban. Si algo se rompe, las tres son cambios distintos -> en commits distintos, así que se sabe cuál. -> -> ### Lo que queda, y es una decisión tuya -> -> **E1 — el agente ajeno como tarea con concesión.** No es difícil. Le falta el -> piso: G1 (ejecutar procesos) y G2 (un runtime donde un agente ajeno pueda -> correr) no existen, así que no hay a qué darle la concesión. Construirla ahora -> produce código que no se puede ejercer **ni siquiera en tu máquina**, que es -> distinto de D2 y B3. Y la pregunta de fondo es tuya: hoy Thalyx sólo corre -> módulos firmados, y un agente ajeno por definición no lo es. - -> ## La máquina se describe a sí misma, ensaya antes de hacer, y el índice ya es alcanzable — 2026-08-09 -> -> Cesar pidió cerrar [[Superficie-para-el-LLM]] hasta donde el trade-off fuera -> claro. **Ocho de los diecinueve puntos quedaron hechos**, tres quedaron -> nombrados como negociación —los tres necesitan hierro que este contenedor no -> tiene— y tres más quedaron sin hacer por tiempo y no por riesgo. -> -> ### A1: la máquina se describe a sí misma, y era la llave -> -> `describe` contesta los **29 verbos**: nombres, argumentos, banderas, con qué -> `op` responde cada uno, si puede cambiar la máquina y qué errores da. Un agente -> que llega no necesita que nadie le pegue una lista en el prompt: pregunta. -> Ningún sistema operativo puede hacer esto — en Linux `--help` es prosa, es por -> herramienta, no es consistente y a veces no está. -> -> **Y arregló una duplicación real.** La lista de verbos vivía en **tres** sitios -> —el `match` de la sesión, el banner y las completaciones— y ya había divergido -> una vez. Ahora los nombres viven una vez, las completaciones se **generan**, y -> dos pruebas atan las otras dos copias **corriendo la sesión**: -> -> - cada nombre del catálogo se teclea en un prompt de verdad y tiene que ser -> entendido; -> - cada nombre del catálogo tiene que aparecer en el banner. -> -> La segunda encontró algo el mismo día: **bajo un programa, `apagar` existía y -> el banner no lo nombraba**, así que quien lo tecleaba recibía una negativa por -> un verbo del que nunca se le habló. -> -> ### D1: ensayar, que es lo que cambia el comportamiento -> -> `ensayo rm *.log` dice qué se iría y no toca nada. Lo importante es **cómo está -> construido**: cada `foresee_*` es *la mitad de comprobación de la operación -> real*, y la operación real la llama. No hay camino donde el ensayo y lo -> ensayado puedan discrepar sobre si algo se permite, porque hay **un solo -> código** decidiendo — un ensayo que dijera «esto funcionaría» mientras la -> operación se niega sería peor que no tener ensayo. -> -> Es prefijo y no modo, a propósito: un modo se queda encendido y entonces un -> `rm` de verdad no hace nada mientras quien lo pidió cree que sí. -> -> Los cinco verbos que cambian la máquina y **no** tienen mitad de comprobación -> —`instalar`, `correr`, `revertir`, `instalar-en`, `apagar`— contestan que no se -> pueden ensayar. Un plan vacío se leería como «esto no haría nada», que es lo -> contrario de la verdad. -> -> ### C1: el índice semántico, alcanzable por fin -> -> `indexar`, `depende `, `usan `. **La pregunta que ningún -> recorrido de carpetas contesta** —quién se refiere a esto— por primera vez al -> alcance de algo que vive en una sesión. Era el ejemplo que Cesar usó al pedir -> el catálogo, y [[FS-en-Grafo]] se llama a sí mismo el ejemplo fundacional; lo -> había sido durante meses sin que nada fuera de `thalyx graph` pudiera -> preguntarle nada. -> -> Cada respuesta trae **la vigencia del índice en el mismo objeto que las filas** -> — la regla de honestidad de [[FS-en-Grafo]], que es decreto precisamente porque -> separar el aviso de los datos es cómo un caché empieza a confundirse con la -> verdad. Un árbol que cambió por detrás contesta `stale` y **devuelve las filas -> igual**: no están mal, están incompletas, y quien lee decide. -> -> ### Lo demás que quedó hecho -> -> - **A2 — el error nombra su remedio.** `remove_or_rename`, `look_first`, -> `use_list`, y **`cannot` cuando no hay salida**: inventar un remedio -> alentador manda a quien lee a un ciclo reintentando algo que nunca va a -> funcionar. -> - **A3 — `estado` en un objeto**, con los tres estados de la regla 10 sin -> colapsar: `found`, `absent`, `unreadable`. Quien lee `absent` va a arreglar -> algo; quien lee `unreadable` sabe que la máquina no contestó, que es otro -> trabajo. -> - **B2 — cada lectura trae el `sha256` del archivo entero.** «¿Sigue siendo -> cierto lo que leí?» pasa de ser una relectura a ser una comparación. Del -> archivo **entero** y no del extracto, porque dos archivos que comparten sus -> primeros 64 kB darían el mismo hash y quien mira seguiría creyendo que nada -> cambió. Hay prueba con exactamente ese par. -> - **D3 — cada acción dice cómo se deshace.** Lo hecho se deshace borrando lo -> que se hizo —**el destino de una copia y nunca el original**, que es un error -> que costaría el archivo del que se copió— y un `mv` se deshace moviendo de -> vuelta. Un borrado trae `undo: null`, porque `/home` no lo devuelve ningún -> rollback nuestro y **eso hay que saberlo antes, no después**. -> - **F1 — `recuerdos` estructurado**, con las tres listas separadas: lo dicho, -> lo que sigue comprobando, y lo que ya no se puede confirmar. Juntarlas -> entregaría lo tercero como si fuera lo segundo, que es la única cosa que la -> memoria está construida para no hacer. -> - **`rm` de una carpeta ya dice cuánto destruyó.** Reportaba `0`, que no le -> dice nada a una persona sobre lo que acaba de perder ni a un agente sobre lo -> que está en juego. Y el peso **no sigue enlaces**: seguirlos reportaría el -> archivo de alguien más como parte de lo que va a desaparecer. -> -> **1075 pruebas** (1040 antes), `clippy` limpio. Etapa **22** en `verify.sh`, -> cada comprobación con su control. -> -> ### Los tres que no construí, y por qué — esto es lo que hay que negociar -> -> Cesar dijo: *«si en alguno perdemos demasiado, entonces dime y lo -> negociamos»*. Aquí se perdería, y los tres se pierden por la misma razón — -> **este contenedor no tiene con qué comprobarlos**: -> -> | Punto | Qué necesita | Por qué no se hizo a ciegas | -> |---|---|---| -> | **D2** el intento con nombre | Btrfs | Es el de mayor valor que queda. Un falso de un snapshot que no falla como falla un snapshot **no es un falso, es otro sistema** — regla 8 | -> | **B3** qué cambió desde X | BPF | Consumir el ringbuf es código BPF, y uno que falla el verificador tumba al watcher entero. Va solo, en su corrida | -> | **E1** el agente como tarea con concesión | LSM y cgroups delegados | Lo único del catálogo que toca **seguridad**: una equivocación no cuesta una prueba roja, cuesta una concesión mal puesta | -> -> Los otros tres que quedan —**B1** acotar respuestas, **C2** búsqueda por -> símbolos, **F2** el journal— **sí se pueden comprobar aquí** y quedaron sin -> hacer por tiempo, no por riesgo. -> -> ## La cara estructurada existe, y encontró dos defectos en la humana — 2026-08-09 -> -> Cómo se llegó a lo de arriba. -> -> El punto 4b está hecho: **un programa ya puede pedir los hechos en vez de las -> frases.** `structured on` en la sesión, y de ahí en adelante cada verbo de -> archivos contesta un objeto JSON por renglón; `structured off` devuelve las -> oraciones. Los dos nombres funcionan (`estructurado`). -> -> Hasta hoy el decreto del objetivo estaba **escrito y no construido**: las -> operaciones ya devolvían un `Done` desde ayer, pero lo único que lo leía era el -> impresor humano. -> -> ### Las tres cosas que la hacen otra cara y no otro impresor -> -> Las tres son la regla de desempate del decreto — gana el LLM, y el humano -> conserva acceso completo por otra vía: -> -> 1. **No esconde nada.** `ls` le oculta los archivos con punto a una persona -> porque tu carpeta tiene treinta y cinco antes del primero que pusiste. -> Ocultárselos a un programa que preguntó es quitarle capacidad, así que en -> esta cara están todos. `-a` y `-l` se leen en las dos caras y **se obedecen -> en una sola**: a un programa no le cambian nada porque nunca se le estaba -> dando menos. -> 2. **Los tamaños son exactos.** `1.2 kB` es un número que perdió precisión, y -> dos programas comparando dos números redondeados comparan dos mentiras. -> 3. **El silencio nunca es respuesta.** `cd` no imprime nada para una persona -> porque el prompt siguiente ya dice dónde quedó. Un parser no tiene prompt de -> dónde leerlo y **no distingue un silencio que significa «me moví» de uno que -> significa que la sesión se murió**. Un `cd` que falla contesta además dónde -> sigue parada la sesión, que es lo que la cara humana ya decía con palabras. -> -> ### El marco, que es el hueco que casi se repite -> -> **Un renglón tecleado, exactamente un objeto.** `rm *.log` que alcanza tres -> archivos podría imprimir tres objetos, y quien lee no tiene cómo saber que debe -> leer tres — el cuarto se leería como respuesta al comando siguiente. Así que un -> verbo que puede tocar varias cosas contesta **un** objeto con `count` y -> `results` adentro, y `ok` es verdadero sólo si todos salieron bien: un éxito -> parcial reportado como éxito es cómo alguien sigue adelante creyendo que su -> ciclo terminó. -> -> Es el mismo error del 2026-08-08 con el marcador del prompt del agente: **un -> límite definido de un solo lado no es un límite.** -> -> ### Dos defectos, los dos encontrados corriéndolo, y los dos de la cara humana -> -> Esto es lo que más vale de la jornada, porque **la cara estructurada resultó ser -> un instrumento**: exige respuesta donde una persona perdona el silencio. -> -> 1. **Cinco verbos a secas no eran verbos.** `rm`, `mkdir`, `touch`, `cp` y `mv` -> escritos solos caían al discurso del agente —«no tengo modelo»—, porque cada -> brazo del despacho exige **un espacio detrás**. Es exactamente el defecto que -> encontraste con `clear`, vivo en cinco verbos más y sin que nadie lo notara, -> porque una persona lee esa respuesta como que la máquina está rara y teclea -> otra cosa. Ahora cada uno tiene su propia pregunta: `cp` pide dos nombres y -> `rm` uno, y una pista que no dice cuál no sirve. -> 2. **El primer objeto salía pegado al prompt humano.** El prompt del comando que -> enciende el modo se imprimió cuando la cara todavía era humana y no lleva -> salto de línea, así que la respuesta aterrizaba como -> ` /home > {"op":"structured",…`. **El único renglón que no parseaba era el -> que avisa que el modo está encendido.** Ninguna prueba del objeto lo veía; se -> encontró canalizando la sesión y leyendo la salida. -> -> La regla que sale de los dos, en [[Estrategia-de-Pruebas]]: **el primer renglón -> de un modo nuevo lo escribió el modo anterior**, y es el que nadie prueba. -> -> ### Detalles con su razón escrita -> -> - **Un nombre que no es texto se marca.** Las rutas en Linux son bytes; un -> nombre inválido en UTF-8 sólo se puede mostrar con pérdida, y devuelto como -> argumento nombra otro archivo o ninguno. Sale `"exact": false`, y sólo cuando -> significa algo. -> - **La forma se escribe a mano, no se deriva.** Nada lleva `#[derive(Serialize)]`: -> una forma derivada la decide el nombre de una variante de Rust, así que -> renombrar `Did::Copied` renombraría en silencio un campo que alguien parsea. -> - **`unreadable` está siempre, aunque esté vacío.** Una llave que sólo aparece -> el día malo es una llave que nadie maneja el día malo. -> - **La confirmación carga la salida** (`"off": "structured off"`). En la imagen -> no hay una segunda terminal, así que un modo sin salida visible puede dejar a -> alguien atrapado. -> -> **1040 pruebas** (1004 antes), `clippy` limpio. Etapa **21** nueva en -> `verify.sh`, con su control: la misma sesión, el mismo store, y nadie pidiendo -> nada tiene que contestar en prosa. -> -> ### Lo que esto todavía no es -> -> **Sólo los verbos de archivos tienen dos caras.** `modulos`, `disponibles`, -> `permisos`, `recuerdos`, `estado`, `nucleo` y `discos` siguen contestando sólo -> en prosa, y las cuatro ventajas que hacen que la vara sea *mejor* y no *igual* -> —índice semántico, rollback, procedencia por campo, permisos por tarea— siguen -> sin estar expuestas a nadie. Está en [[Tareas-Pendientes]] como 4c. -> -> ## Quedó escrito para quién se construye, y los verbos ya cambian archivos — 2026-08-09 -> -> Cómo se llegó a lo de arriba. -> -> ### Dos decretos, y son la vara de todo lo demás -> -> **El objetivo es uno: que un LLM trabaje mejor aquí que en cualquier otro -> sistema.** Todo lo demás es medio, y el camino humano se cumple entero porque -> es obligación, no porque sea hacia dónde va el proyecto. Cuando las dos cosas -> choquen **gana el LLM**, y el humano conserva acceso completo aunque le salga -> menos cómodo. -> -> El choque ya había ocurrido el mismo día sin que nadie lo notara: `ls` en -> columnas, tamaños redondeados a `1.2 kB` y ocultos escondidos son tres -> decisiones tomadas para un ojo humano, y las tres son peores para una máquina. -> Se tomaron sin notar que había una elección, porque el objetivo no estaba -> escrito. Ahora lo está. -> -> **Y la vara es un agente ajeno**, no el agente local de Thalyx: Claude Code y -> los suyos moviéndose aquí mejor que en Linux. Thalyx como **anfitrión**, no -> como llamador. Eso deja ver el estado real, que es duro: hoy Claude Code no -> arrancaría en Thalyx. No trabajaría mal — **no arrancaría**. Necesita ejecutar -> procesos, leer y escribir archivos, `grep`, `find`, `git`, un runtime, y aquí -> hay el kernel, un programa y veinte verbos. -> -> Lo que hace que la vara sea *mejor* y no *igual* **ya existe y no está expuesto -> a nadie**: el índice semántico, el rollback, la procedencia por campo y los -> permisos por tarea. Ningún otro sistema operativo ofrece «intenta esto y si -> sale mal deshazlo». -> -> La consecuencia de ingeniería es la que hay que recordar: **cada cosa nace con -> dos caras**, la humana y una estructurada que un programa pueda parsear. La -> segunda no se agrega después — si se agrega después, no se agrega. -> -> Detalle en [[Filosofia-Fundacional]], las dos secciones nuevas. -> -> ### `mkdir`, `touch`, `cp`, `mv`, `rm` — el punto 4, hecho -> -> Con comodines `*` y `?`. **Primera pieza construida bajo el decreto**, y se -> nota en la forma: ninguna operación imprime lo que hizo. Devuelve un `Done` con -> **qué pasó, dónde acabó y los bytes exactos**; la cara humana formatea ese -> hecho y la estructurada leerá el mismo. Un segundo camino que compone su propia -> frase es una segunda versión de los hechos. -> -> Cinco decisiones, cada una con la falla que evita escrita al lado: -> -> - **Nada sobrescribe sin pedirlo.** `Exists` es su propio error, porque -> sobrescribir es otra petición y cuesta un archivo cuando se supone. -> - `make_file` usa `create_new`: comprobar y crear son dos momentos y entre -> ellos puede aparecer algo. Que decida el kernel es la única versión sin hueco. -> - **Un enlace se copia como enlace y se borra como enlace.** Seguirlo -> duplicaría el destino, y un enlace a un ancestro llenaría el disco. -> - `mv` cae a copiar-y-borrar ante `EXDEV`, que aquí es el caso ordinario y no -> el exótico: `/home` y `/opt/thalyx` son subvolúmenes distintos. -> - **`*` no cruza `/`**, y no alcanza ocultos salvo que el patrón empiece con -> punto. Sin lo primero, borrar `*` llega a todas las carpetas de abajo; sin lo -> segundo, `rm *` se lleva la configuración de alguien. -> -> El comparador de patrones es iterativo con punto de retroceso y no recursivo: -> cuarenta estrellas contra un nombre largo es una pila que la forma recursiva no -> puede pagar, y un patrón así es justo lo que alguien teclea por accidente. -> -> `rm` con varios blancos **los lista antes de tocar nada**. `/home` es el único -> sitio del sistema que ningún rollback nuestro puede devolver, así que ese -> listado es el único aviso que existe. -> -> **1004 pruebas** (984 antes), `clippy` limpio. -> -> ### El hueco que esto deja abierto, y es el del decreto -> -> **La cara estructurada existe y nadie puede pedirla.** El `Done` lo lee hoy -> sólo el impresor humano. Mientras siga así, el decreto está escrito y no -> construido, y **ninguna de las cuatro ventajas está expuesta a nadie**. Es el -> punto 4b de [[Tareas-Pendientes]] y va antes que el editor. -> -> ### Una falla de proceso, y una lectura equivocada de la misma sesión -> -> Estos tres avances **vivieron sólo en los mensajes de commit**: ni este archivo -> ni [[Estado-de-Implementacion]] los mencionaban, y esa segunda nota no tenía -> fila para `thalyx-files` ni para `thalyx-term` —dos crates enteros ausentes de -> la nota que dice qué está construido—. Una sesión nueva que leyera la bóveda -> habría creído que lo último fue la terminal. -> -> Y al ir a corregirlo cometí el error de leer una copia local vieja de `main` -> como si fuera el estado del repositorio, y le dije a Cesar que los verbos no -> le llegaban con `git pull`. **Sí le llegaban**: `origin/main` ya tenía el -> trabajo fusionado. Lo único que faltaba de verdad era la bóveda. Queda escrito -> porque es la regla 5 en un sitio nuevo —el instrumento otra vez antes que lo -> medido— y porque `git rev-parse main` y `git rev-parse origin/main` son dos -> preguntas distintas. -> -> La rama sí estaba mal nombrada (`claude/verbos-donde-quedamos-uutcmm`) y ahora -> es `feat/file-mutating-verbs`. -> -> ## La terminal es una terminal, y dos lectores de `stdin` no caben — 2026-08-09 -> -> Cómo se llegó a lo de arriba. -> -> Flechas, borrar a media línea, historial y tab. `crates/thalyx-term` decide qué -> significa cada tecla y dónde queda el cursor —puro, sin abrir ninguna -> terminal—; `thalyx-syscall` apaga el editor de línea del kernel con `termios`; -> `thalyx-cli/src/term.rs` es el único sitio donde se dibuja. -> -> **La guarda de modo crudo es lo más peligroso del archivo**, y por eso es una -> guarda: una sesión que sale sin devolver la terminal deja la máquina -> inservible, y en la imagen no hay una segunda de dónde recuperarse. El -> `Drop` cubre la salida normal y el desenrollado de un panic; no cubre un -> `SIGKILL`, y nada puede. -> -> ### Dos defectos de la misma familia, los dos encontrados corriéndolo -> -> Ninguna prueba unitaria los veía, porque los dos son sobre **quién es dueño de -> la entrada**: -> -> 1. **Lo tecleado por adelantado se perdía.** El búfer de bytes vivía dentro de -> `read_line`, así que al pulsar Return todo lo que venía detrás se tiraba. -> Un `read` devuelve lo que haya llegado, y eso es rutinariamente más de una -> línea: alguien tecleando rápido, un pegado, o una prueba escribiendo todo -> de golpe. -> 2. **Y al arreglar eso, la suite de `exit_criterion` se colgó entera.** -> `instalar` pide una confirmación que leía `stdin` por su cuenta — y la `y` -> que la contestaba ya estaba en mi búfer. **Seis sitios del CLI leían -> `stdin` directo.** Ahora hay un solo dueño, `term::read_answer()`, y los -> seis pasan por él. -> -> La regla que sale de esto, para [[Estrategia-de-Pruebas]]: **dos lugares que -> leen la misma entrada y uno que guarda lo que sobra no pueden coexistir**; el -> segundo espera para siempre bytes que ya salieron del kernel y están en -> memoria. El síntoma no es un error, es un silencio. -> -> ### Probado con una terminal de verdad -> -> El contenedor no alcanza esto con tuberías, así que se manejó un pty: `lst` + -> flecha + suprimir da `ls`; la flecha arriba devuelve el comando anterior; `cd -> Doc` + tab da `cd Documentos/`; con varias opciones se imprimen en columnas -> sin perder la línea; y **`cat niño` + flecha + retroceso da `nio`** — la `ñ` -> entera, que es de lo que trata que la línea sea `Vec` y no `String`. -> -> Ctrl-C abandona la línea y da prompt nuevo; **no es una salida**, porque en la -> imagen no hay a dónde salir. Ctrl-D con algo escrito no hace nada: tratarlo -> como fin sería tirar la línea y salir, dos sorpresas por una tecla. -> -> **984 pruebas** (959 antes), `clippy` limpio. -> -> ## `ls` existe, y el vocabulario dejó de ser un problema de adopción — 2026-08-09 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> Cesar corrió los cuatro verbos en su Fedora y la primera frase fue la -> correcta: *«eso parece juguete más que sistema operativo serio»*. Tenía razón, -> y el problema era más viejo que mis cuatro verbos — **el vocabulario entero del -> sistema ya era así**: `discos`, `modulos`, `correr`, `apagar`. -> -> ### Lo que decidió, y por qué importa -> -> **Estándar primero, español también.** `ls`, `cd`, `cat`, `pwd`, `clear` son -> los que enseña el banner; `ver`, `leer`, `ir`, `donde`, `limpiar` siguen -> funcionando. El argumento es suyo, de dos mensajes antes: si para usar el -> sistema hay que decirle adiós a todos los comandos útiles, no hay adopción. -> -> **Un nombre no es un programa ajeno.** `ls` escrito en Rust dentro de `thalyx` -> es tan propio como `ver`. Lo que [[Construccion-del-ISO]] prohíbe es -> incrustarse en el sistema de alguien más, y `make -C image count` sigue -> diciendo uno. -> -> También decidió el **lenguaje de terminal, partido en dos**: comodines y -> redirección entran con copiar/mover/borrar porque son notación de diario; -> tuberías después; **guiones y variables quedan sin decidir**, porque ahí la -> pregunta pasa a ser si Thalyx tiene lenguaje de programación, que es como la -> gente construye software encima sin pasar por los módulos. -> -> Y aplazó lo de `NOEXEC` con una condición: *«cuando tengamos que decidir, -> explícamelos bien»*. Eso quedó en [[Tareas-Pendientes]] como **deuda de -> explicación**, con la lista de lo que hay que cubrir cuando se retome. -> -> ### Cuatro defectos, todos encontrados corriéndolo en su máquina -> -> Regla 1 otra vez, y ninguno lo veía el contenedor porque ninguno aparece en un -> directorio de prueba con seis archivos: -> -> 1. **`clear` contestaba con un discurso sobre el agente**, porque una línea -> desconocida cae al mensaje de «no tengo modelo». Un comando común que -> responde algo de otro tema es exactamente cómo un sistema se lee inacabado. -> 2. **Los ocultos se mostraban siempre.** Su carpeta tiene **treinta y cinco -> nombres con punto** antes del primero que él puso, así que lo que buscaba -> quedaba sepultado. Ahora se ocultan, `ls -a` los muestra, y **el listado -> dice cuántos escondió** — un filtro silencioso es uno que nadie descubre. -> 3. **Una cosa por renglón.** Sesenta entradas eran cuatro pantallas. Ahora van -> en columnas, hacia abajo y no a lo ancho, con el ancho real preguntado al -> kernel por `TIOCGWINSZ` y ochenta como respaldo — y `None` es respuesta, no -> fallo, porque una salida redirigida no tiene ancho. -> 4. **Se desalineaba con nombres largos.** La columna era fija en 32 y -> `First_Layer_Bed_Leveling_Test.stl` tiene 33: **un archivo rompía la columna -> de todos los renglones**. Ahora se mide. -> -> `ls -l` da tamaños, `ls -a` los ocultos, `ls -la` las dos, y las banderas -> tienen las dos escrituras (`todo`, `detalles`). Una bandera que no se conoce -> **no se ignora**: se queda como el lugar, para que la persona lea «`-z` no está -> ahí» en vez de recibir un listado que hace ver que la bandera funcionó. -> -> **959 pruebas** (946 antes), `clippy` limpio. -> -> ## Thalyx puede mirar sus propios archivos, y el prompt no era la causa — 2026-08-09 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> ### Lo que cambió de rumbo, y por qué -> -> Cesar preguntó cuánto falta para un sistema **usable sin el agente** — ver -> archivos, carpetas, correr comandos. Medido contra el código, la respuesta era -> incómoda: la sesión tenía **trece verbos y ninguno tocaba un archivo**. La -> capa 1 de [[Principio-Doble-Ruta]], marcada *no negociable*, no tenía -> implementación. -> -> Y al ir a construirlo salió que el proyecto se había estado leyendo mal a sí -> mismo. [[Construccion-del-ISO]] decía «Ningún shell. Ningún conjunto de -> utilidades — `ls`, `cat`», y eso parecía contradecir a Doble-Ruta. Cesar lo -> zanjó y la nota ya lo dice con sus palabras: -> -> > lo que está prohibido no es la shell, lo que está prohibido es incrustarnos -> > en la shell de otro sistema […] no es `ls` ni `cat`, está prohibido meternos -> > en un sistema ya hecho, porque si es así, no seremos un sistema operativo, -> > seremos una distro parcheada con IA. -> -> **Lo prohibido es el programa ajeno, no la capacidad.** No había -> contradicción entre dos decretos; había una nota ambigua, y ya está corregida. -> -> ### Lo construido -> -> `crates/thalyx-files` y cuatro verbos: **`ver`**, **`leer`**, **`ir`**, -> **`donde`**. Compilados dentro de `thalyx` — `make -C image count` sigue -> diciendo uno. -> -> Y una corrección de una afirmación mía anterior: **el subvolumen `user` sí se -> monta**, en `/home`, por `store_disk::mount()` desde PID 1. Yo había dicho que -> nadie lo conectaba porque lo busqué en `init.rs`. El piso ya existía; lo que -> faltaban eran los verbos. -> -> ### Tres defectos que sólo aparecieron corriéndolo -> -> Regla 1, otra vez, y los tres pasaban todas las pruebas: -> -> 1. **El prompt no cabía.** Con la ruta entera puso **noventa caracteres** antes -> de que se pudiera teclear, y la consola de una máquina real suele tener -> ochenta. Un sistema cuyo *prompt* no cabe no lo usa nadie. Ahora se acorta -> por componentes enteros —nunca a media palabra— y **`donde` sigue siendo -> exacto**: el recordatorio puede ser lossy, la respuesta no. -> 2. **`ir` imprimía el destino y el prompt lo repetía debajo.** La misma ruta -> dos veces por cada movimiento. -> 3. **Los verbos nuevos no estaban en el banner.** Sin shell detrás, un verbo -> que no está en esa lista no existe para quien tiene la máquina. -> -> Decisiones que quedaron con su razón escrita: `..` se pliega léxicamente (lo -> contrario de lo que hace la API de módulos, y a propósito — ahí no hay -> concesión de la que escapar); `leer` **se niega** ante un binario en vez de -> destrozar la terminal, porque en la imagen la sesión *es* la máquina y no hay -> una segunda de dónde recuperarse; y un enlace roto se lista **como roto**, no -> como ausente. -> -> **946 pruebas** (915 antes), `clippy` limpio. -> -> ### El tercer brazo corrió, y refutó mi hipótesis -> -> `IT INVENTS EITHER WAY` otra vez en los brazos de objeto, control 9 de 11. -> Firme, tercera vez. -> -> El brazo en prosa volvió a `NOT PROVEN`, y **la colisión de `NOTHING` no era la -> causa**. Corregí el prompt con su prueba, y las veinte respuestas siguen -> empezando con `NOTHING`. Lo que sobrevive es la degeneración, y se ve entera: -> -> ``` -> NOTHING NOTHING NOTHING NOTHING NOTHING … -> NOTHING id only NOTHING id only NOTHING id only … -> NOTHING <<>> NOTHING <<>> NOTHING <<>> -> ``` -> -> Esa última línea es nueva: **el modelo fabrica marcadores contando hacia -> arriba**. El 2026-08-08 quedó escrito que un delimitador que el sistema medido -> puede escribir no delimita; esto lo empeora. -> -> **Y el control de 3 de 11 es en realidad 0.** Los tres «encontró el módulo» son -> eco del material devuelto (`NOTHING Identities: dev.thalyx.demo, ese …`). El -> pendiente *«dónde termina una respuesta en prosa»* ya no infla el control: **es -> el control**. -> -> Lo que queda establecido: -> -> | Brazo | Cómo se cicla | -> |---|---| -> | con gramática | `dev.thalyx.demo.versions.versions.versions…` | -> | sin gramática, en JSON | repite el objeto entero | -> | en prosa | `NOTHING NOTHING NOTHING…` | -> -> **Una patología con tres disfraces.** El 3B a temperatura 0 con este prompt -> degenera en cuanto se le suelta; la gramática era lo único que le daba una -> forma con final. El sospechoso ya no es el prompt. **Hipótesis, no conclusión.** -> -> ### Lo que sigue, en orden de dependencia -> -> Decidido por Cesar el 2026-08-09: construir la usabilidad, en este orden. -> **Del 1 al 7 se prueba todo en el contenedor**, así que por primera vez el -> trabajo no tiene a Cesar en el camino crítico. -> -> | # | Qué | Depende de | Estado | -> |---|---|---|---| -> | 1 | Dónde estoy y moverme | `/home` ya montado | **hecho** | -> | 2 | `ver` y `leer` | 1 | **hecho** | -> | 3 | Terminal: flechas, historial, tab | 2 — el tab completa nombres | siguiente | -> | 4 | `crear`, `copiar`, `mover`, `borrar`, `renombrar` | 2 | | -> | 5 | Editor de texto | 2 + 4 | | -> | 6 | `buscar` por nombre y por contenido | 2 | | -> | 7 | Procesos: qué corre, matarlo, memoria | independiente | | -> | 8 | Red: drivers, IP, DNS | independiente | **sólo hierro de Cesar** | -> | 9 | Lenguaje: tuberías, redirección, comodines | 2+4+6 **y decreto** | | -> -> Dos cosas que hay que decir y no se tocaron: -> -> - **`/home` está montado `NOEXEC`.** Nadie puede ejecutar un programa desde su -> carpeta personal, y en Linux sí se puede. Es de las cosas que un usuario -> nota. **Decisión de Cesar**, ver [[Tareas-Pendientes]]. -> - **El punto 9 es decreto antes que código.** Trece verbos sueltos y un -> lenguaje que los compone son proyectos distintos. -> -> ## El prompt gastó su propia señal, y la corrida no midió — 2026-08-09 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> ### Lo que quedó firme -> -> **La gramática no es la causa.** `IT INVENTS EITHER WAY` en la gama media, con -> el control sostenido en 9 de 11, y **reproducido en dos corridas**. Sin -> gramática el 3B sigue inventando en cuatro de los nueve casos de rechazo y -> nombrando el módulo real equivocado en cinco. Quitarle la restricción no lo -> hace abstenerse. -> -> **No hubo primera abstención.** Aquel `said something, named nothing` era la -> segunda lectura: el comando reprodujo la inferencia y salió -> `"targets": ["good-luck-module-1234567890123456789…` — 255 tokens de dígitos -> dentro de un identificador, sin cerrar. Octavo caso de la misma patología. **La -> abstención sigue en cero, ahora sobre 55 oportunidades.** -> -> ### El tercer brazo no pudo medir, y el defecto es del prompt -> -> Dio `NOT PROVEN` con `ABSTAINED 8/9` — que era el número que la hipótesis -> quería. El control lo tumbó (5 de 11), y el motivo está a la vista: **las -> veinte respuestas empiezan con `NOTHING`**, también las once donde sí había un -> módulo. -> -> ``` -> act a pronoun pointing at the one thing installed -> in prose "NOTHING Identities: dev.thalyx.demo: 1.4.2 dev.thalyx.demo: 1.4.1 …" -> act a module named by what it does rather than by its id -> in prose "NOTHING NOTHING NOTHING NOTHING NOTHING NOTHING NOTHING …" -> ``` -> -> Eso no es declinar. Es un primer token regalado. **Y lo regaló mi prompt**, que -> usaba la palabra cuatro veces, tres de ellas en otro sentido: *and **nothing** -> else*, *gains **nothing***, *if **nothing** below names a module*. -> -> Corregido —*add no other text*, *only costs the request*, *if no module is -> named below*— con una prueba que cuenta las apariciones sin distinguir -> mayúsculas y falla si hay más de una. -> -> **No se afirma que la colisión sea la causa**: compite con la degeneración, que -> está igual de a la vista —las respuestas son `NOTHING NOTHING NOTHING…`, el -> mismo ciclo que dentro de `module-id`, en otro token—. Lo que sí queda -> establecido es que el prompt no podía medir. -> -> Y hay que decirlo entero: **corregirlo puede destruir el 8 de 9.** Se corrige -> igual. -> -> ### Volver a correrlo -> -> ```sh -> git pull && cargo install --path crates/thalyx-cli -> -> thalyx agent model use media --weights ~/models/qwen2.5-3b-instruct-q4_k_m.gguf -> thalyx agent grammar-effect --keep-prompt ~/evidencia/prosa-media-2 2>&1 | tee prosa-media-2.log -> ``` -> -> La gama ligera ya no aporta a esta pregunta: por **tercera** corrida, sin -> gramática contesta las veinte con cero tokens, en los dos brazos libres. En un -> 1.5B forzarle el primer carácter es lo único que arranca la generación. -> -> ### Lo que sigue sin decidirse -> -> **Dónde termina una respuesta en prosa.** El modelo contesta y después divaga -> 250 tokens; el lector actual busca identificadores en todo el texto, así que -> `NOTHING Identities: dev.thalyx.demo: 1.4.2 dev.thalyx.demo: 1.4.1 …` —el -> material devuelto de vuelta— cuenta como haber nombrado el módulo. Eso **infla -> el control** del brazo en prosa. Decidir dónde corta cambia lo que el -> instrumento mide, y por eso no se toca sin aprobación. Ver -> [[Tareas-Pendientes]]. -> -> 915 pruebas, `clippy` limpio. -> -> ## El instrumento para la pregunta de la abstención está listo — 2026-08-08 -> -> Cómo se llegó a lo de arriba. -> -> La abstención sale **0 de 46** en tres tamaños de modelo y seis corridas, y un -> resultado que no se mueve cuando la única variable se mueve habla de lo que -> esas corridas **comparten**. La sospecha tiene mecanismo: -> `operation ::= "\"install_module\""` tiene **una sola alternativa**, y el orden -> de los campos está fijo, así que lo primero que el modelo escribe en cada -> inferencia es `install_module`, obligado; abstenerse exige contradecirlo -> después. Detalle en [[Gamas-de-Modelo]]. -> -> Se construyó `thalyx agent grammar-effect` para contestarlo. **Nada del prompt, -> la gramática ni las gamas fue tocado.** -> -> ### Los comandos, en orden -> -> ```sh -> git pull && cargo install --path crates/thalyx-cli -> -> # La gama media primero: es la que más entiende, así que su brazo libre es -> # el que mejor puede sostener el control. -> thalyx agent model use media --weights ~/models/qwen2.5-3b-instruct-q4_k_m.gguf -> thalyx agent grammar-effect --keep-prompt ~/evidencia/efecto-media 2>&1 | tee efecto-media.log -> -> thalyx agent model use ligera --weights ~/models/qwen2.5-1.5b-instruct-q4_k_m.gguf -> thalyx agent grammar-effect --keep-prompt ~/evidencia/efecto-ligera 2>&1 | tee efecto-ligera.log -> ``` -> -> Cuarenta inferencias por gama: unos **4–5 minutos** la media, unos **3** la -> ligera. Imprime cada caso al terminarlo, así que no hay silencios largos. -> -> ### Qué puede contestar, y las tres son respuestas -> -> | Veredicto | Qué significa | -> |---|---| -> | `THE GRAMMAR TAKES THE DECISION` | Con gramática inventó, sin ella nunca — y aun así encontró el módulo correcto donde lo había. El cero del banco no es el modelo negándose a declinar, es Thalyx obligándolo a decir `install_module` antes de poder | -> | `IT INVENTS EITHER WAY` | Quitar la gramática no lo detuvo. **La hipótesis queda refutada** y lo que resta sospechar es el prompt o el modelo | -> | `NOT PROVEN` (sale distinto de cero) | Sin gramática el modelo no encontró el módulo correcto ni donde sí lo había, así que su silencio en los casos de abstención no es una decisión. **No es evidencia de nada**, y el propio comando lo dice | -> -> El tercero es el que hace honesto al experimento: si el control no aguanta, no -> hay veredicto. Un sondeo que no puede fallar no es un sondeo. -> -> ### La primera corrida salió NOT PROVEN, por defectos míos -> -> El instrumento se negó a dar veredicto, que es lo correcto, pero se negó por -> cuatro defectos suyos. Dos los encontró una prueba; **dos sólo aparecieron -> corriendo contra un modelo de verdad**, que es la regla 1 otra vez. -> -> 1. El escáner de ids era **ciego al JSON**. -> 2. Los dos brazos **se pisaban el `command` guardado** — ese ya existía en -> `grammar-check`. -> 3. El brazo restringido se juzgaba con `Proposal::parse`, estricto con el -> texto sobrante, y llama.cpp añade su aviso de fin de generación después del -> objeto: **las cuarenta inferencias volvieron `NO MEASUREMENT`**. -> `Proposal::completion_in` ya resolvía eso y el banco la usaba desde siempre. -> 4. **El brazo libre no es prosa.** Sin gramática el 3B sigue contestando en -> JSON, porque el *prompt* pide JSON y el prompt está en los dos brazos: -> `instala algo bueno` → `{"targets": ["good-bad-thing"]}`. Nada de eso es un -> id reverse-DNS, así que el escáner reportaba **silencio** —la lectura «el -> modelo declinó»— para una respuesta que proponía instalar algo inventado. Y -> la ligera, sin gramática, contestó las veinte con un fin de generación -> inmediato, contado también como silencio. **Tres hechos distintos llegaban -> como la misma palabra.** -> -> Lo cuarto es la trampa de hace dos días repetida: un `[end of text]` leído como -> una decisión. Ahora hay un estado aparte, `GENERATED NOTHING (not a decline)`. -> -> Las fixtures ya no son inventadas: son las salidas literales de tu corrida. -> 896 pruebas, `clippy` limpio. -> -> ### Lo que la corrida fallida ya insinúa, y todavía no se afirma -> -> La gama media, **sin ninguna gramática**, propuso instalar algo en los nueve -> casos de abstención. Si eso se sostiene con el instrumento arreglado, el -> veredicto será `IT INVENTS EITHER WAY` y **mi hipótesis queda refutada**: no es -> la gramática, es el prompt, que pide un objeto JSON en los dos brazos. Se -> afirma cuando el instrumento lo diga, no antes. -> -> ## Cinco corridas, y el banco es más estable de lo que parecía — 2026-08-08 -> -> Cómo se llegó a la pregunta de arriba. -> -> Tres corridas de la gama ligera y dos de la media, con `--keep-prompt`. Esto -> **corrige la lectura del bloque de abajo**, que decía que las cifras de -> acierto se mueven: -> -> | | ligera ×3 | media ×2 | -> |---|---|---| -> | **Aciertos sobre los 20** | **5, 6, 6** | **9, 9** | -> | Sin medición | 6, 5, 2 | 1, 2 | -> | Intención (sobre lo medido) | 5/14, 6/15, 6/18 | 9/19, 9/18 | -> | Abstención | 0/6, 0/6, 0/9 | 0/9, 0/8 | -> -> 1. **Lo que se mueve no es el acierto, es cuántos casos contestan.** El número -> de respuestas correctas casi no se movió; el denominador sí. Y pasó el caso -> que lo demuestra: la ligera contestó cuatro casos más, acertó uno más, **y -> su fracción bajó** de 36 % a 33 %, porque los cuatro que recuperó volvieron -> mal. **La cifra que se compara entre corridas es aciertos sobre 20.** -> 2. **Catorce de los veinte casos dieron la misma marca en las cinco corridas.** -> La suite es estable. Los aciertos se mueven ±1. -> 3. **El caso 4 no ha producido una medición ni una sola vez**: `quiero la 1.4 -> del demo`, cinco de seis corridas con el presupuesto de tokens agotado. Es -> el único caso de la suite cuya restricción esperada lleva un punto adentro -> (`1.4`). Hay hipótesis y **no está probada**; se resuelve con un comando, -> abajo. -> 4. **Abstención: cero en 46 oportunidades**, tres tamaños de modelo, seis -> corridas. Es la propiedad más firmemente medida del proyecto. Los tres casos -> que la ligera nunca había alcanzado a contestar resultaron ser de -> abstención, y al contestarlos por fin los falló los tres. -> 5. **La media no se movió ni un caso en dos corridas** (9 y 9). La distancia -> con la alta —dos casos— ya no cae dentro del ruido del instrumento. Lo que -> le falta a esa comparación es que la alta corra dos veces, y está aplazada. -> -> ### El caso 4, resuelto el mismo día — y era `module-id` -> -> Cesar corrió la inferencia guardada. La salida contesta sola: -> -> ``` -> "targets": ["dev.thalyx.demo.versions.versions.versions.versions… -> ``` -> -> 255 de 256 tokens, y **nunca llegó a `constraint`**. La hipótesis del punto en -> `1.4` queda **refutada**: el ciclo está en `module-id`, no en `range`. -> -> Las tres capas, que hay que decir separadas: -> -> | | | -> |---|---| -> | Causa inmediata | agotó `n_predict` sin cerrar el objeto | -> | Causa observada | repitió `.versions` dentro de `module-id` | -> | Condición que lo permite | la producción admite segmentos sin cota | -> -> **La gramática no lo obliga a repetir** — el modelo elige `.versions`; la -> gramática nunca le exige cerrar. -> -> Y explica de más, que es lo importante: `ese.abc.abc.abc`, `thallyx.ing.ing`, -> `dev.thalyx.demo.localhost`, `photoshop-1.ashx.ashx`, -> `python3.ipython3.ipython3` son **el mismo comportamiento**. Cuando el 1.5B no -> sabe cerrar semánticamente un id, sigue produciendo segmentos válidos; si el -> corte llega antes de cerrar la cadena sale `ERR`, si llega después sale una -> invención. Un comportamiento que llevábamos contando como tres. -> -> El caso 4 es además la demostración más limpia del proyecto de lo que la -> gramática no puede hacer: el modelo **empieza con el id correcto** —está en el -> prompt— y lo convierte en otro. `dev.thalyx.demo.versions` es sintácticamente -> válido y semánticamente inventado. Esa segunda columna es de la atribución. -> -> Al inspeccionar la producción salió algo que no se buscaba: **`thalyx-manifest` -> tampoco tiene cota**, así que la gramática espeja fielmente a la autoridad y el -> hueco está en las dos. Tres opciones escritas en [[Gamas-de-Modelo]], con la -> predicción de que acotar **no subiría el acierto** —convertiría `ERR` en `REF`, -> como ya se observó—. **Nada tocado.** -> -> Detalle completo en [[Gamas-de-Modelo]], «Tres corridas de ligera y dos de -> media». -> -> ## La gama ligera se corrió dos veces, y no dio lo mismo — 2026-08-08 -> -> Cómo se llegó a lo de arriba, y con una lectura que el bloque anterior corrige. -> -> Cesar repitió `grammar-check` y el banco sobre la gama ligera, sin cambiar -> nada. Tres resultados, en orden de importancia: -> -> 1. **Las cifras de acierto se mueven entre corridas; las de coste no.** Dos -> casos de veinte cambiaron, en direcciones opuestas (14/20 → 15/20 medidos, -> 5/14 → 6/15). El disco, el RSS pico (2.82 GB) y la latencia mediana (3.77 s) -> salieron idénticos. La causa no es llama.cpp: la semilla está fija pero -> **el prompt lleva un marcador aleatorio nuevo en cada invocación**, así que -> la entrada cambia. El encabezado de `llama.rs` afirmaba lo contrario y ya no -> lo afirma. Consecuencia directa: la distancia de **dos casos** entre la gama -> media y la alta es del tamaño de lo que se mueve una gama consigo misma, así -> que no es una diferencia entre gamas. Nada de lo medido se retira; ahora -> tiene margen. -> 2. **Los cinco `ERR` tienen una sola causa, y ya se sabe cuál**: el modelo -> empieza el objeto y se queda sin presupuesto dentro de un identificador —la -> gramática no acota cuán largo puede ser—. Se cicla: -> `python3.ipython3.ipython3.…`. Subir `-n` no lo arregla, sólo alarga el -> ciclo. Y es la **misma** patología que las invenciones (`ese.abc.abc.abc`, -> `thallyx.ing.ing`): un fallo, contado como dos. -> 3. **`grammar-check` de la ligera ya dice `NOT PROVEN` sobre hardware real.** -> La corrección del día anterior quedó verificada donde importa. -> -> Detalle completo en [[Gamas-de-Modelo]], sección «Segunda corrida de la gama -> ligera». -> -> ### Lo que Cesar decidió, y ya está construido -> -> **Guardar el prompt bajo una bandera.** `--keep-prompt ` en `agent model -> check`, `agent model grammar-check` y `agent bench`: cada inferencia deja un -> directorio —nombrado por su marcador, así que veinte casos dejan veinte— con -> `prompt.txt`, `proposal.gbnf` y `command`. Con eso *esa* corrida se repite a -> mano, marcador incluido. El marcador **sigue siendo aleatorio**, así que dos -> corridas distintas siguen moviéndose, y eso es lo correcto: esconderlo daría -> una muestra de una distribución con cara de medición. Sin la bandera no queda -> nada en disco. -> -> De paso: `Invocation::command_line` —la función que el encabezado de -> `llama.rs` citaba como *la* forma de reproducir una corrida— no tenía ninguna -> llamada fuera de su propia prueba. Documentación de una función que nunca se -> había ejecutado. `--keep-prompt` es su primera llamada real. -> -> ### Lo siguiente que hay que correr -> -> **Repetir la ligera y la media** —una vez cada una, sin cambiar nada— para -> darle réplica a sus cifras de acierto. La **alta queda aplazada**: tarda -> demasiado en esta máquina, y Cesar la corre cuando consiga el equipo con el -> que también pueda medir la máxima. Vale la pena correrlas ya con la bandera: -> -> ``` -> git pull && cargo install --path crates/thalyx-cli -> thalyx agent model use ligera --weights ~/models/qwen2.5-1.5b-instruct-q4_k_m.gguf -> thalyx agent bench --keep-prompt ~/evidencia/ligera-3 2>&1 | tee ligera-3.log -> thalyx agent model use media --weights ~/models/qwen2.5-3b-instruct-q4_k_m.gguf -> thalyx agent bench --keep-prompt ~/evidencia/media-2 2>&1 | tee media-2.log -> ``` -> -> Lo que hay que mirar al terminar: **cuántos casos cambiaron de marca** contra -> la corrida anterior de esa misma gama. Ése es el margen de error del banco, y -> hasta tenerlo la distancia entre la media y la alta no significa nada. -> -> ## Las cuatro gamas corrieron sobre la misma máquina, y la más grande no cabe — 2026-08-08 -> -> Cómo se llegó a la corrida de arriba. -> -> Cesar corrió `check`, `grammar-check` y el banco de 20 casos en **ligera, -> media y alta**, sobre su Ryzen 5 5600G de 16 GB, sin GPU, en CPU, con la misma -> familia (Qwen2.5-Instruct), la misma cuantización (Q4_K_M), el mismo -> `llama.cpp`, el mismo prompt, la misma gramática y la misma suite. **Lo único -> que varió es el tamaño**, que es lo que hace la comparación atribuible. -> -> | | ligera 1.5B | media 3B | alta 7B | maxima 14B | -> |---|---|---|---|---| -> | Casos medidos | 14/20 | 19/20 | 19/20 | **0/20** | -> | Intención | 5/14 | **9/19** | 7/19 | N/D | -> | Argumentos | 5/14 | **8/19** | 7/19 | N/D | -> | Abstención | 0/6 | 0/9 | 0/8 | N/D | -> | Latencia mediana | 3.77 s | 6.78 s | **33.26 s** | N/D | -> | RSS pico | 2.82 GB | 4.79 GB | **13.93 GB** | N/D | -> -> ### Lo más importante, en orden -> -> 1. **La gama alta no superó a la media en este banco**, y costó ×4.9 de -> latencia y ×2.9 de memoria. La afirmación legítima es estrecha —con *este* -> prompt, *esta* gramática, *estos* casos, *esta* cuantización y *este* -> hardware— y **no** es «3B es más listo que 7B»: la diferencia es de dos -> casos sobre diecinueve, que es menos de lo que esta suite puede separar. Lo -> que sí sostiene es la **ausencia de mejora medible** frente a un costo que -> no es discutible. -> 2. **La máxima quedó `N/D`, no en cero.** El proceso fue terminado por falta de -> memoria después de imprimir la gama y el enunciado, antes de completar la -> primera inferencia. No hubo banco que fallar. Lo probado es que *esta* -> máquina de 16 GB con *esta* configuración no la sostiene — **no** que 14B -> pida 32 GB, que sigue siendo el estimado del decreto. -> 3. **Abstención cero en las tres gamas medidas, sin excepción.** Es la medida -> que [[Gamas-de-Modelo]] llama la más importante, y es la única que sale -> **idéntica** en 1.5B, 3B y 7B. Un resultado plano donde lo único que varía -> es el tamaño apunta a lo que las tres comparten, no a lo que las separa. Es -> hipótesis, y **no se tocó el prompt**. -> 4. **`grammar-check` de la gama ligera decía `PROVEN` y no lo estaba.** -> Corregido, con dos regresiones. Ver abajo. -> -> ### El `PROVEN` retirado, que es el defecto del día -> -> ``` -> with the grammar { "operation": "install_module", "targets": ["python3.ipython3.… -> without it [end of text] -> PROVEN: … constrained it could not even begin with it, and left alone it did. -> ``` -> -> *«Left alone it did»* — dijo la palabra prohibida. **No la dijo: no dijo -> nada.** `[end of text]` es lo que imprime `llama.cpp` cuando el modelo termina -> la generación de inmediato. En media y alta el brazo libre sí muestra `BANANA` -> y ahí el veredicto es correcto; en ligera no había control. -> -> El veredicto afirma dos cosas y el código comprobaba una: `InForce` era el -> `else`, así que se alcanzaba con que el brazo libre **no abriera un objeto** — -> y un brazo callado tampoco abre uno. Regla 4, sobre el veredicto en vez de -> sobre el experimento. Sobrevivió por la regla 8: los cuatro sustitutos del -> sondeo decían la palabra al quitarles la gramática, **ninguno modelaba un -> modelo callado**. Regla nueva en [[Estrategia-de-Pruebas]]. -> -> Lo que sí se puede afirmar de esa corrida, y es menos: **la bandera cambió la -> salida** —con ella hubo objeto, sin ella nada—. Que lo que la gramática impidió -> fuera *la palabra* no se midió. Así que «un contrato mal formado es imposible -> en las cuatro gamas» está probado en **dos**, no probado en ligera, N/D en -> máxima. -> -> ### La demostración de que la gramática no garantiza el contenido -> -> La gama ligera contestó `dev.thalyx.demo, ese quiero` con -> `["dev.thalyx.demo","ese.quiero.ios"]`. **Fabricó un id a partir de las -> palabras humanas «ese quiero»**, con la forma perfecta. La gramática hizo lo -> que promete y nada más; lo que lo detuvo fue la atribución del núcleo. Contrato -> válido, contenido inventado, en una sola línea de salida. -> -> ### Y una pregunta vieja quedó contestada -> -> `dev.thalyx.demo, ese` sale `REF` en las tres gamas: el modelo nombró algo que -> no aparece en ningún canal. **No es una abstención.** Con eso se cae del todo -> la hipótesis de que la instrucción de abstención del prompt pesa de más — el -> `MISS` que la originó venía del banco que contaba `Err(_) => Abstained`, donde -> un rechazo por atribución se veía como abstención correcta. -> -> ### El estatus que Cesar le puso a todas estas cifras -> -> Preguntado si la columna de RAM recomendada baja ahora que el RSS medido salió -> menor, decretó que no, y con un alcance más amplio que la pregunta: -> -> > declara los resultados mas no los muestres como pruebas definitivas, las -> > pruebas definitivas vendran cuando thalyx este corriendo en una ssd real como -> > sistema operativo real, solo en ese entorno se vera la realidad -> -> Así que **todo lo de arriba queda declarado y nada queda como definitivo**. -> Sirve para comparar las gamas entre sí, porque las tres corrieron bajo las -> mismas condiciones; **no** sirve para fijar el requisito de hardware de Thalyx, -> ni para bajar la columna de RAM, ni para decidir qué gama trae el ISO. Eso son -> afirmaciones sobre el destino, y el destino es Thalyx como sistema operativo -> sobre un SSD real. Queda como pendiente con condición escrita en -> [[Tareas-Pendientes]]. -> -> ### Qué se cambió, y qué no -> -> Cambiado, y las dos cosas son evidencia y no puntuación: -> -> - `grammar_check` exige que el brazo libre **diga la palabra**, con dos -> regresiones comprobadas fallando contra el código anterior. -> - El banco imprime **qué** id rechazó la atribución, en vez de `(named -> something nobody mentioned)`. -> -> **No cambiado a propósito**: el prompt, la gramática, las gamas, la suite y la -> columna de RAM recomendada. Cambiar cualquiera de ellos como reacción a estos -> resultados haría que la próxima corrida no se pudiera comparar con ésta. -> -> ### Lo que corre Cesar, y es el experimento que eligió -> -> **Repetir la gama ligera guardando la salida entera.** Seis de veinte casos no -> dieron medición y el banco **sí imprime la razón de cada uno** —plazo agotado, -> truncamiento, gramática no aplicada, `llama.cpp` cayéndose son fallos -> distintos, y mandan a lugares opuestos— pero esa columna no llegó a la bóveda -> en la transcripción. Es una corrida, sin cambiar nada, y hasta tenerla `5/14` -> no es la puntuación de esa gama ni se sabe qué le pasa. -> -> ``` -> git pull && cargo install --path crates/thalyx-cli -> thalyx agent model use ligera --weights ~/models/qwen2.5-1.5b-instruct-q4_k_m.gguf -> thalyx agent model grammar-check 2>&1 | tee ligera-grammar.log -> thalyx agent bench 2>&1 | tee ligera-bench.log -> ``` -> -> Dos cosas que esperar y que **no** son fallos: -> -> - `grammar-check` debe salir **`NOT PROVEN`** y **distinto de cero**. Es el -> resultado correcto: no es que la gramática falle, es que ese modelo contesta -> el sondeo callándose y entonces no hay control. Si sale `PROVEN`, la -> corrección no llegó. -> - El banco vuelve a perder casos. Lo que se busca es **qué dice cada `ERR`**. -> -> **872 pruebas pasan** (870 antes), `clippy` limpio, `cargo fmt` aplicado. -> -> ## El banco contaba todo fallo como abstención correcta — 2026-08-08 -> -> **El bloque de arriba es más reciente.** Los de abajo son cómo se llegó. -> -> Cesar dijo que por ahora no descarga más modelos y que como máximo corre -> verificaciones, así que el trabajo fue sobre el instrumento. Encontrado -> leyendo el banco, no leyendo sus números: -> -> ```rust -> Err(_) => Outcome::Abstained, -> ``` -> -> **Toda forma de fallar contaba como el modelo absteniéndose bien.** Un plazo -> agotado, un truncamiento, una gramática no aplicada, `llama.cpp` cayéndose. Una -> gama cuyo modelo no arrancara nunca sacaba 4/4 en abstención, que es la medida -> que [[Gamas-de-Modelo]] llama la más importante. -> -> Y peor: `AgentError::Attribution` —el núcleo cazando al modelo nombrando un id -> que nadie mencionó— caía en la misma rama. **La conducta más peligrosa que el -> banco busca, contada como la más segura.** -> -> ### Las cifras de acierto del 2026-08-08 quedan retiradas -> -> Intención 6/9, argumentos 6/9, abstención 3/4 no significan lo que parecían. Se -> mantienen disco, RAM y latencia, que se miden alrededor del proceso. **Se cae -> también la hipótesis** de que la instrucción de abstención del prompt pesa de -> más: los dos `MISS` pudieron ser abstenciones reales o errores disfrazados, y -> desde la salida impresa no se distinguen. -> -> ### Qué se construyó -> -> - **Cinco resultados** y ninguno inferido de la ausencia de otro: correcto, -> equivocado, abstenido, **rechazado por el núcleo**, **sin medición**. -> - Un caso sin medición no cuenta en ninguna fracción. Los denominadores son -> sobre lo medido, y el resumen lo dice **antes** que cualquier cifra. -> - La clasificación salió del bucle a `Outcome::of`, que es una función pura — -> el defecto vivía enterrado en una expresión donde ninguna prueba lo alcanzaba. -> - **La suite pasó de 9 casos a 20.** Con nueve, un caso vale once puntos. Los -> nuevos varían **una** cosa a la vez respecto de uno que ya estaba, para que la -> próxima corrida conteste por qué falló el caso fácil. -> - La exención de «este caso de abstención sí nombra un módulo» era una -> subcadena del *nombre* del caso; ahora es un campo con la razón escrita, con -> su control. -> -> ### Lo que corre Cesar -> -> ``` -> git pull && cargo install --path crates/thalyx-cli -> thalyx agent bench -> ``` -> -> Una corrida, con el modelo que ya tiene. Devuelve las cifras de acierto con -> significado por primera vez. -> -> ## La gramática restringe de verdad, probado en hierro — 2026-08-08 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> ``` -> thalyx agent model grammar-check -> with the grammar { "operation": "install_module", "targets": [ "python3.abc_1.abc", … -> without it BANANA << PROVEN -> ``` -> -> Restringido no pudo ni empezar con la palabra prohibida; suelto la dijo. Con -> eso, la frase de [[Gamas-de-Modelo]] —«un contrato malformado es imposible en -> las cuatro gamas»— deja de apoyarse sólo en las pruebas del parser. **En una -> gama**; las otras tres heredan el argumento y no la corrida. -> -> ### El defecto que traía esa corrida que pasó -> -> `BANANA << que acababa de leer**. Sólo lo cortó el tope de tokens. -> -> El marcador es aleatorio por invocación, y eso estaba razonado contra un -> adversario —un texto ajeno no puede adivinarlo—. No cubría esto: el modelo no lo -> adivina, lo tiene delante. -> -> > **Un delimitador que el sistema medido puede escribir no delimita.** Ser -> > imposible de adivinar no es ser imposible de copiar. -> -> `answer_in` tomaba la **última** aparición del marcador, así que una copia -> completa habría movido dónde empieza la respuesta. Ahora se ancla en el prompt -> repetido entero, y el marcador solo queda de respaldo tomando la primera. -> `RANGE_CHARS` contiene `<`, `>` y `-`, así que esto llegaba también al camino -> restringido, dentro de un campo `constraint`. -> -> **Nada falló para encontrarlo.** El veredicto era correcto; el defecto estaba en -> la evidencia impresa al lado, y sólo porque se imprimía. Regla nueva en -> [[Estrategia-de-Pruebas]]: una corrida que pasa también trae datos. -> -> ### Lo que queda abierto -> -> - Las otras tres gamas, que son otros tres GGUF. -> - La instrucción de abstención pesa de más: se abstuvo con el id dicho en claro. -> - Actuó sobre un módulo mencionado y luego descartado. Comprensión, no gramática. -> -> Ver [[Tareas-Pendientes]]. -> -> ## El agente corre entero contra hierro real, con el primer banco medido — 2026-08-08 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> ### La gama media, medida -> -> | Medida | Estimado | Medido | -> |---|---|---| -> | Disco | ~2.0 GB | 2 104 932 768 bytes | -> | RAM | ~8 GB | **4.78 GB** | -> | Latencia | — | mediana 6.58 s, peor 7.94 s | -> | Intención | — | 6/9 | -> | Abstención | — | 3/4, con **1 invención** | -> -> La estimación de RAM iba alta por casi el doble. Los tres fallos están -> analizados en [[Gamas-de-Modelo]]; el que importa es que **actuó sobre un -> módulo que la persona había descartado** — no inventó un id, tomó uno excluido. -> -> ### `grammar-check` falló, y el que estaba mal era `grammar-check` -> -> Dijo `FAILED`. La prueba de que estaba al revés venía en su propia salida: el -> brazo restringido había emitido `{ "operation": "install_module", "targets": -> ["banana_module_1234…` hasta agotar los 256 tokens. **Empieza con `{`.** La -> gramática le prohibió empezar con `B` y el modelo desvió el intento a una cadena -> de id legal, quedándose ahí hasta el tope. El JSON no cerró, así que no parseaba -> — y la comprobación preguntaba si parseaba. -> -> > **Una falla al terminar no es una falla al cumplir.** Regla 10 en un sitio -> > nuevo, y la octava vez que el instrumento se equivocó antes que lo medido. -> -> Y lo delató una contradicción entre dos corridas: `grammar-check` decía que la -> gramática no se aplicaba, y `bench` sacaba nueve propuestas bien formadas de -> nueve casos minutos después. Las dos no pueden ser ciertas. -> -> ### Qué se corrigió -> -> - Se lee **el primer carácter**, no si el resultado parsea. `root ::= "{"` es -> absoluto y sobrevive al truncamiento. -> - El mismo defecto estaba **en el camino de producción**: una inferencia normal -> truncada contra `-n` también salía como «gramática no aplicada». Ahora hay -> `Truncated`, y su mensaje dice que *esto es la gramática funcionando*. -> - El sondeo gasta 48 tokens en vez de 256; sólo el primer carácter decide. -> - Las dos ramas se imprimen **también cuando falla**. La versión anterior -> escondía la de control justo en el caso donde valía más. -> -> ### Lo siguiente -> -> Cesar corre `git pull && cargo install --path crates/thalyx-cli` y luego -> `thalyx agent model grammar-check`, que debe salir `PROVEN`. -> -> Decidido a medias y sin decidir: la instrucción de abstención del prompt pesa -> demasiado —abstuvo con el id dicho en claro— y bajarla es tocar el prompt, que -> mueve los nueve casos a la vez. Ver [[Tareas-Pendientes]]. -> -> ## El agente contesta desde hierro real, y falta una comprobación por correr — 2026-08-08 -> -> **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. -> -> ``` -> thalyx agent model check "dev.thalyx.demo, ese quiero" -> answer {"operation": "install_module", "targets": ["dev.thalyx.demo"]} -> latency 6.88s -> peak rss 4.77 GB -> parsed as: Proposal { operation: InstallModule, targets: ["dev.thalyx.demo"], … } -> ``` -> -> Enunciado → modelo real → propuesta parseada, de extremo a extremo, en la -> Fedora de Cesar. **La RAM medida es 4.77 GB contra los ~8 GB que estimaba -> [[Gamas-de-Modelo]]**: el primer número de esa tabla que alguien midió. -> -> ### Lo construido después: separar «bandera aceptada» de «gramática aplicada» -> -> `llama.cpp` sale distinto de cero ante una bandera que no conoce, así que una -> corrida limpia probaba que `--grammar-file` fue **aceptada**. No que -> restringiera nada — el prompt real le pide un objeto al modelo, y un modelo que -> da un objeto sólo hizo lo que le dijeron. Cuatro gamas del decreto se apoyaban -> en no notar la diferencia. -> -> `thalyx agent model grammar-check` pide **la única palabra que la gramática no -> puede emitir**, dos veces, con la bandera y sin ella, sin ninguna otra -> diferencia entre las dos corridas. Tres resultados: -> -> | Resultado | Qué significa | -> |---|---| -> | `PROVEN` | Restringido no pudo decirla; suelto sí. Sólo la gramática explica eso | -> | `FAILED` | La dijo con la gramática puesta. No se está aplicando | -> | `NOT PROVEN` | Las dos ramas dieron propuesta: el sondeo no midió nada, y eso **no es pasar** | -> -> Etapa nueva en `verify.sh`, y regla nueva en [[Estrategia-de-Pruebas]]: probar -> que algo restringe necesita un enunciado cuya respuesta sin la restricción sea -> distinta. -> -> ### Lo siguiente que corre Cesar -> -> ``` -> git pull && cargo install --path crates/thalyx-cli -> thalyx agent model grammar-check # dos inferencias -> thalyx agent bench # las gamas, minutos -> ``` -> -> `CLAUDE.md` ya dice siete veces en vez de seis, con esta causa anotada. -> -> ## La primera inferencia real completó, y Thalyx rechazó una respuesta correcta — 2026-08-08 -> -> **Éste es el estado actual.** Los dos bloques de abajo son la historia. -> -> Con `llama-completion`, la corrida siguiente en la Fedora de Cesar llegó hasta -> el final: los pesos cargaron y **Qwen2.5-3B emitió exactamente el objeto que -> describe la gramática**, con saltos de línea y sangría —que es lo que -> `ws ::= [ \t\n]*` permite—. Thalyx lo rechazó, y con un mensaje que acusaba a la -> herramienta de ignorar la gramática que acababa de obedecer. -> -> ### Qué estaba mal -> -> `llama.cpp` imprime ` [end of text]` **detrás** del completado cuando el modelo -> para en un token de fin de generación (`tools/completion/completion.cpp`, sólo -> fuera de modo interactivo). `Proposal::parse` era `serde_json::from_str`, que -> rechaza cualquier byte después del objeto. -> -> El marcador aleatorio del prompt decía **dónde empieza** la respuesta. **Nada -> decía dónde termina.** Un límite definido de un solo lado no es un límite: deja -> el final en manos de quien imprimió el texto, y ese final cambia entre versiones. -> -> ### La corrección -> -> `Proposal::completion_in` lee **el primer valor JSON completo** después del -> marcador. La raíz de la gramática es un objeto, así que ahí termina lo que dijo -> el modelo y todo lo demás lo escribió la herramienta — y eso sigue siendo cierto -> con lo que decida imprimir la versión siguiente. Recortar el literal -> ` [end of text]` habría sido la regla 6 al revés. `Proposal::parse` sigue siendo -> estricta: la laxitud vive en un solo sitio, el borde donde otro programa -> imprime. -> -> ### Lo que esto deja probado contra hierro real, y lo que no -> -> | Afirmación | Estado | -> |---|---| -> | Las banderas que Thalyx pasa las acepta esta compilación | **Probado** | -> | Los pesos cargan; el prompt vuelve con el marcador intacto | **Probado** | -> | Vuelve una propuesta bien formada, dentro del plazo | **Probado** (una gama, un enunciado) | -> | `--grammar-file` es lo que restringió esa respuesta | **No probado** — un 3B al que se le pide JSON puede darlo solo | -> | Los números por gama del banco | **No probado**, ninguna gama medida | -> -> ### Reglas nuevas en [[Estrategia-de-Pruebas]] -> -> - **Un límite definido de un solo lado no es un límite.** -> - **Una fixture no puede estar en desacuerdo contigo.** Las nueve de este parser -> terminaban donde el parser esperaba que terminara una respuesta, porque las -> escribió la misma mano. La regla 6 ya existía y aquí no se siguió; ahora hay -> una muestra capturada literal, con su procedencia. -> - **Una comprobación que señala a un culpable tiene que enseñar la evidencia que -> juzgó.** Es lo único que hizo que esto se viera de una sola lectura, en vez de -> mandar a auditar el manejo de gramáticas de `llama.cpp` durante días. -> -> ### Pendiente menor -> -> `CLAUDE.md` dice que el instrumento se equivocó **seis** veces; con ésta van -> siete, y las dos últimas por la misma causa. Cambiarlo es decisión de Cesar. -> -> ## El primer `llama.cpp` de verdad: Thalyx pedía el binario que dejó de ser el correcto — 2026-08-08 -> -> **El bloque de abajo es el que construyó esto; éste es el que dice dónde está.** -> Cesar lo corrió en su Fedora contra `llama.cpp b1-3653e6d` y -> `Qwen2.5-3B-Instruct-Q4_K_M`, y falló al primer intento — que es exactamente -> para lo que sirve correrlo. -> -> ### Qué pasó -> -> `thalyx agent model check` arrancó `llama-cli`, los pesos cargaron, y entonces -> `llama-cli` **abrió su interfaz conversacional**: sus comandos (`/exit`, -> `/regen`, `/clear`) y el prompt `>`, en vez de completar y terminar. -> -> **No era el GGUF, ni el modelo, ni su máquina.** `llama.cpp` partió sus -> herramientas: -> -> | Binario | Qué es hoy | -> |---|---| -> | `llama-cli` | Frontend de **chat interactivo**, sobre el servidor | -> | `llama-completion` | El completado de **una sola pasada**, con `-f`, `--grammar-file`, `-n`, `--seed` y `--temp` sin cambios | -> -> Con `-f`, el `llama-cli` nuevo abre una sesión sobre el archivo en vez de -> completarlo. Carga, imprime su banner, lee fin de entrada del `stdin` cerrado y -> **sale con cero**. La herramienta equivocada se ve igual que una que funciona y -> dio una mala respuesta. -> -> ### Por qué las pruebas de aquí no lo vieron -> -> Había siete sustitutos y estaban bien escritos: cubrían el recorte de la -> respuesta, el plazo, el desborde, la bandera rechazada. **Todos honraban el -> contrato de una pasada, porque todos estaban escritos para contestar.** Modelé -> el eje del *formato de salida* y el que importaba era el *contrato de -> ejecución*. Regla nueva en [[Estrategia-de-Pruebas]]: la pregunta no es *«¿qué -> puede imprimir esta herramienta?»* sino **«¿qué puede hacer que no sea -> contestar?»**. -> -> ### Y el error se disfrazó justo donde había un respaldo -> -> `answer_in` recortaba después del marcador aleatorio y, **si el marcador no -> estaba, devolvía toda la salida**. Ese respaldo se escribió por una causa —que -> la herramienta no repitiera el prompt— y tenía una segunda que nadie enumeró: -> que la herramienta **nunca leyera el prompt**. Así que el banner del chat entró -> como respuesta, el parser falló, y el mensaje dijo *«el modelo contestó algo que -> no parsea»*: **le echó la culpa a Qwen de una pregunta que nunca se le hizo.** -> -> Segunda regla del mismo hallazgo: un respaldo que cubre una causa cubre en -> silencio todas las que producen la misma señal. -> -> ### Lo que se corrigió, y no es cambiar un nombre -> -> El binario por omisión es `llama-completion`, sí. Pero lo que arregla la clase -> es que **el contrato se comprueba en vez de suponerse**: -> -> - **Se dejó de pasar `--no-display-prompt`.** El eco del prompt lleva el -> marcador, y el marcador es la prueba positiva de que el prompt se leyó. -> Suprimirlo borraba la única evidencia — la bandera que hacía cómoda la salida -> era la que desarmaba la comprobación. -> - **Marcador ausente** → `NotOneShot`, que nombra a `llama-completion` y enseña -> los primeros 400 bytes de lo que salió en su lugar. No es una respuesta que -> falta, es una **pregunta** que falta. -> - **Marcador presente y respuesta que no parsea** → `GrammarNotInForce`. No es -> heurística: un completado restringido por gramática **no puede** producir -> prosa, así que la prosa demuestra que la gramática no se aplicó. -> - **Y se avisa antes de la primera inferencia**: configurar `llama-cli` saca la -> advertencia al momento de configurarlo, y `agent model show` la repite — así -> un store configurado ayer se arregla al revisarlo y no esperando a que falle. -> -> Ninguna de las tres olfatea la prosa de otra herramienta, que sería la regla 6 -> otra vez. -> -> ### Qué quedó probado aquí y qué no -> -> **Probado en este contenedor**, con un sustituto que reproduce la conducta del -> `llama-cli` nuevo —carga, banner, comandos, sale con cero sin completar—: que -> eso produce `NotOneShot` y no un fallo de parseo; que una salida vacía es -> contrato roto y no un modelo callado; que un prompt leído con completado vacío -> **sí** es un modelo callado (el control, sin el cual una comprobación que -> rechaza todo pasaría); que la prosa produce `GrammarNotInForce`; y que la -> bandera que desarmaba la comprobación no vuelve por omisión. -> -> **Sigue sin probarse, y lo tiene que cerrar su máquina**: que `llama-completion` -> acepte estas banderas, que tome la gramática, y que la respuesta caiga después -> del marcador. **Ninguna inferencia ha terminado nunca contra pesos reales.** -> -> ### Lo que falta, y es tuyo -> -> ``` -> git pull && cargo install --path crates/thalyx-cli -> -> # si no está construido, sale del mismo árbol de llama.cpp: -> # cmake --build build --target llama-completion -> -> thalyx agent model use media --weights ~/models/qwen2.5-3b-instruct-q4_k_m.gguf -> thalyx agent model check "dev.thalyx.demo, ese quiero" -> ``` -> -> Ya no hace falta pasar `--binary`: el valor por omisión es el correcto. Si sale, -> `thalyx agent bench` da la primera tabla de acierto por gama. -> -> **852 pruebas pasan** (847 antes), `clippy` limpio, `cargo fmt` aplicado. -> -> ## El agente tiene modelo: `llama.cpp` como proceso, las cuatro gamas y el banco — 2026-08-08 -> -> **La Fase 1 quedó cerrada al 100% y Cesar zanjó también el encuadre de lo que -> sobró**: no es deuda de ninguna fase. Sus palabras, que son el registro: -> -> > no quedó nada de la fase 1, esas cosas que quedaron no pertenecen a ninguna -> > fase real debido a que ninguna bloquea nada, son solo cosas del proyecto que -> > se arreglarán cuando se necesiten arreglar -> -> Eligió seguir con **el modelo del agente**, que era el único `NOT PROVEN` de -> una corrida verde de `verify.sh` y el decreto más grande sin construir. -> -> ### Lo que había, y por qué era el hueco más grande del proyecto -> -> `crates/thalyx-agent/src/model.rs` tenía **dos** implementaciones de `Model`: el -> falso hostil y `UnconfiguredModel`, que contesta *«no model is configured»*. O -> sea que en un sistema operativo donde la IA es ciudadana de primera clase, la -> IA no existía. [[Gamas-de-Modelo]] estaba decretado desde el 2026-08-03 y nadie -> lo había implementado. -> -> ### Lo que se construyó -> -> ``` -> thalyx agent model show las cuatro gamas, y cuál está puesta -> thalyx agent model use media --weights elige una, y mide el archivo -> thalyx agent model check "" una inferencia, con lo que costó -> thalyx agent grammar la gramática, para repetirlo a mano -> thalyx agent bench el banco que pide el decreto -> ``` -> -> `agent plan` y `agent do` usan la gama configurada; sin ninguna configurada, -> siguen exactamente como estaban. Eso último no es cortesía: **una máquina sin -> modelo es una máquina que se puede usar entera**, que es el -> [[Principio-Doble-Ruta]] siendo lo que hace sobrevivible la ausencia del modelo -> en vez de fatal. -> -> ### El defecto que apareció escribiendo el banco, sin correr nada -> -> **La gramática hacía imposible abstenerse.** Pedía al menos un id de módulo, lo -> cual vuelve imposible un contrato mal formado —que es lo que el decreto -> promete— y de paso volvía imposible decir *«no encontré ninguno»*. -> -> [[Gamas-de-Modelo]] dice que la abstención es **la medición que más importa**. -> Con esa gramática, un enunciado ambiguo no tenía respuesta legal salvo inventar: -> el banco habría sacado **0 de 4 en abstención en las cuatro gamas**, y la -> lectura obvia habría sido «los modelos chicos inventan» cuando lo que pasaba es -> que ninguna gama tenía cómo no inventar. -> -> Y no falla nada: todo compila, todo parsea, el banco corre y devuelve números -> plausibles. Regla nueva en [[Estrategia-de-Pruebas]]: **una gramática que fija -> qué se puede decir fija también qué se puede declinar**, y la pregunta que lo -> encuentra no es *«¿acepta las respuestas correctas?»* sino *«¿qué respuestas -> hace imposibles, y alguna era una conducta que quiero medir?»*. -> -> La corrección estaba a la mano y sin nombre: `AgentError::NothingToDo` ya -> existía y ya era la respuesta correcta. Una lista vacía la alcanza. **Y el -> prompt tiene que decirlo** — una respuesta legal que nadie menciona es una que -> el modelo no usa, y la gama quedaría medida sobre una decisión que nunca se le -> ofreció. -> -> ### La regla 6 obligaba a no parsear la salida de `llama.cpp` -> -> Un parser de la salida de otra herramienta necesita **una muestra real -> capturada**, y aquí no hay ninguna ni se puede conseguir. Así que no se parsea -> el formato: el prompt termina en un **marcador aleatorio por invocación** y la -> respuesta es lo que sigue a su última aparición. Sirve si la herramienta repite -> el prompt, si no lo repite, si le pone banderas o si le agrega tiempos. -> -> Aleatorio y no fijo **porque el texto ajeno va dentro del prompt**: un marcador -> fijo es una cadena que un README puede contener, y un README que la contuviera -> estaría eligiendo dónde empieza la respuesta. -> -> Lo que queda sin comprobar es más chico y tiene nombre: **que ese `llama.cpp` -> acepte las banderas**. Por eso las que cambian entre versiones viven en el -> archivo de configuración, no en el código — si una se rechaza, se arregla -> editando una línea y `llama.cpp` sale distinto de cero diciendo cuál. -> -> ### Y un defecto que sólo apareció corriéndolo -> -> `peak rss 0.00 GB`. La unidad estaba fija en GB, así que una medición real de -> dos megabytes se imprimía igual que *«no se pudo medir»* — las dos cosas que -> esa función existe para mantener separadas. Encontrado corriéndolo, no -> leyéndolo, que es la regla 1 otra vez. -> -> ### Lo que NO se hizo, y es lo importante de esta entrada -> -> **Nada de esto ha corrido contra `llama.cpp`.** El contenedor no lo tiene y no -> alcanza los pesos. Lo que sí corrió aquí, contra procesos sustitutos: que la -> respuesta se recorta bien del proceso, que un proceso colgado se mata, que 200 -> kB de salida se cortan, que las banderas rechazadas salen con su texto de -> `stderr` íntegro, y el banco entero de nueve casos. -> -> `verify.sh` tiene etapa nueva. Con `THALYX_AGENT_WEIGHTS` apuntando a un GGUF -> corre lo real —incluida la inyección **con un modelo que no es falso de nada**, -> y su control— y sin eso dice `NOT PROVEN` nombrando cuál de las dos mitades -> falta, el binario o los pesos. -> -> ### Lo que falta, y es tuyo -> -> ``` -> git pull && cargo install --path crates/thalyx-cli -> -> # baja un GGUF de Qwen2.5-3B-Instruct-Q4_K_M (~2 GB) donde quieras -> thalyx agent model use media --weights ~/models/qwen2.5-3b-instruct-q4_k_m.gguf -> thalyx agent model check "dev.thalyx.demo, ese quiero" -> ``` -> -> Ese `check` responde de una vez las dos cosas que aquí no se pueden responder. -> Si sale, `thalyx agent bench` da la primera tabla de acierto por gama que ha -> existido. Y `sudo ./dev/verify.sh` con `THALYX_AGENT_WEIGHTS` puesto corre la -> etapa entera. -> -> **Lo primero que puede fallar es una bandera**, y está bien: sale con su -> mensaje, y se arregla en -> `/state/agent-model.toml`, campo `extra_args`. -> -> **847 pruebas pasan** (802 antes), `clippy` limpio, `cargo fmt` aplicado. -> -> ## Los 40 segundos de arranque eran un puerto serie a 9600 baudios — 2026-08-07 -> -> **`nucleo lento` corrió en hierro y contestó al primer intento.** La memoria USB -> no tenía nada que ver. -> -> ``` -> 18.27s at 0.07s -> after printk: legacy console [ttyS0] enabled -> then ACPI: Core revision 20240827 -> ``` -> -> **18.27 de 38.5 segundos, en el segundo 0.07**, antes de que el kernel tocara un -> disco. La hipótesis de «la USB es lenta» queda descartada por la **posición** del -> hueco, no por su tamaño: si fuera la memoria, el tiempo estaría al final, donde se -> leen discos — y ahí los huecos miden 0.25 s. -> -> ### La causa -> -> `CONFIG_CMDLINE` decía `console=ttyS0`, **sin velocidad**. Sin velocidad, el -> driver 8250 usa **9600 baudios**, y `printk` es síncrono: el kernel no avanza -> hasta que los caracteres salieron físicamente del puerto. Los 38.5 s son ese -> puerto, en dos mitades: -> -> 1. **El hueco de 18.27 s.** Una consola se registra con `CON_PRINTBUFFER`, así que -> el kernel le vuelca **todo el log acumulado** en cuanto aparece — unas 250 -> líneas de mapa de memoria y tablas ACPI. A 9600 baudios: `250 × ~70 × 10 ÷ 9600 -> = 18.2 s`. Es el número que salió. -> 2. **Los ~18 s restantes**, repartidos en 704 líneas: 25 ms por línea, que es lo -> que cuesta cada `printk` posterior por el mismo puerto. -> -> ### Y es la quinta vez que el anfitrión hacía algo gratis -> -> El puerto serie de QEMU es un pty: **no tiene baudios**, se vacía al instante. Las -> cuatro veces anteriores el anfitrión hacía algo que en hierro *no existía*; ésta -> hacía algo que en hierro **existe y cuesta**, que es peor de encontrar — nada -> falta, nada falla, la máquina nada más tarda. -> -> Peor todavía: `run-uefi` y `run-hardware` **no pasan `-append` a propósito**, o -> sea que usan esa misma línea compilada. **La trampa estaba dentro del camino que -> sí se probaba**, y era invisible porque el anfitrión la pagaba. -> -> ### Lo que se decidió, y lo que no -> -> Cesar eligió **darle velocidad en vez de quitarlo**: -> `console=ttyS0,115200 console=tty0`. Doce veces más rápido —los ~30 s se vuelven -> ~2.5 s— y **no se cambia nada por nada**: `run-uefi` y `run-hardware` miran por -> `-serial mon:stdio`, y eso es lo único que hace diagnosticable un arranque que -> muera *antes* de que suba el framebuffer. Quitarlo del todo era más rápido y -> costaba ese diagnóstico. -> -> ### El segundo defecto del mismo arranque, que `config-check` no puede ver -> -> `CPU topo: CPU limit of 2 reached. Ignoring further CPUs`. Nadie eligió 2: -> `allnoconfig` corre con SMP apagado, donde `NR_CPUS` es 1, y encender SMP después -> sólo lo sube al piso de su rango. Puesto **`CONFIG_NR_CPUS=64`**, que es lo que el -> propio kernel usa para SMP x86_64. -> -> **`config-check` compara lo que `thalyx.config` pide contra lo que salió, así que -> una opción que nadie pidió no tiene línea que comparar.** Es el mismo hueco -> estructural que dejó pasar `CONFIG_SECURITY_NETWORK` y `CONFIG_USB_STORAGE`, y no -> se cierra con más comparaciones. Por eso las dos afirmaciones nuevas viven en -> `init.rs`, y las dos se verificaron fallando sin el arreglo. -> -> ### Comprobado en hierro el mismo día, y el número salió exacto -> -> ``` -> The kernel talked for 5.7s. The longest silences in it: -> 1.53s at 0.07s -> after printk: legacy console [ttyS0] enabled -> then ACPI: Core revision 20240827 -> ``` -> -> **38.5 s → 5.7 s.** Y lo que lo convierte en prueba y no en mejora es el hueco: -> las mismas dos líneas, en el mismo sitio, **18.27 s → 1.53 s**. Eso es un factor -> de **11.94**, contra el 12.0 que predice `115200 ÷ 9600`. El diagnóstico no -> predijo «va a bajar»; predijo *cuánto*, y bajó eso. -> -> Con las dos medidas se despeja lo que costaba cada parte. Si `T = W + S`, donde -> `W` es trabajo real y `S` el puerto serie, entonces `38.5 = W + S` y -> `5.7 = W + S/12` dan **S = 35.8 s y W = 2.7 s**. O sea que el puerto se llevaba -> **35.8 de los 38.5 segundos** —más de los ~30 que estimé— y la máquina de verdad -> tarda **2.7 segundos** en arrancar. El resto de los 5.7 son los mismos mensajes a -> 115200. -> -> ### Y de paso apareció qué máquina es -> -> `smpboot: CPU0: AMD Ryzen 5 5600G` — seis núcleos, doce hilos. Con `NR_CPUS=2` -> Thalyx estaba tirando diez de los doce. El prompt ahora avisa **7 problemas** en -> vez de 10, que es lo que se espera si la línea de `CPU topo` desapareció, pero -> eso no está confirmado: lo confirma `nucleo`, y no se ha corrido después del -> cambio. -> -> **802 pruebas pasan** (800 antes), `clippy` limpio, `cargo fmt` aplicado. -> -> ## La Fase 1 está cerrada: una PC se instaló Thalyx a sí misma y arrancó sin el medio — 2026-08-07 -> -> **El bloque de arriba es más reciente; éste es el que dice dónde está el -> proyecto.** El acto 2b corrió en hierro y salió entero. -> -> ### Lo que pasó, con nombres -> -> Arrancado desde la memoria de 3 GiB, `discos` contestó **tres discos** —no siete— -> y nombró cada uno: -> -> ``` -> /dev/sda 447 GiB 3 btrfs `fedora` ← el sistema de Cesar -> /dev/sdb 3 GiB 1 a Thalyx boot partition ← el medio -> 2 a Thalyx store -> /dev/sdc 7 GiB 1 FAT `XBOX` ← el destino -> ``` -> -> `instalar-en /dev/sdc` dijo de dónde salía el kernel —`/dev/sdb1`, 10 671 104 -> bytes— **qué había en el destino antes de preguntar** —`1 FAT \`XBOX\``— y pidió -> teclear la ruta. Después: -> -> ``` -> ok kernel taken off /dev/sdb1 -> ok boot /dev/sdc1 ▪ the kernel, at the one path a firmware looks for -> ok store /dev/sdc2 ▪ labelled `thalyx-store` -> ok subvolume system / modules / user -> -> That disk is a Thalyx machine now. -> ``` -> -> `apagar`, memoria fuera, encender. Y la máquina arrancó de ese disco: -> -> ``` -> 2 disk(s): -> /dev/sda 447 GiB 3 btrfs `fedora` -> /dev/sdb 7 GiB 1 a Thalyx boot partition -> 2 a Thalyx store -> ``` -> -> **Un firmware real arrancó un disco físico que Thalyx particionó, formateó y -> escribió él mismo, sin medio puesto, y la máquina encontró su store por la -> etiqueta.** Eso es el criterio de salida. -> -> ### Los cuatro arreglos del día quedaron confirmados en vivo -> -> Ninguno se probó en una VM primero, y los cuatro se comportaron: -> -> - **`3 disk(s)` y no siete** — el filtro de particiones. La Fedora dejó de -> aparecer partida en pedazos ofrecibles. -> - **`a Thalyx boot partition`** — la máquina nombra su propio trabajo. -> - **`it has 2 partition(s) on it now: 1 FAT \`XBOX\``** — el guardián nuevo, dicho -> antes de la pregunta y no después. -> - **`! 9 new kernel problems; \`nucleo\` shows them`** — el aviso del prompt, y en -> ninguno de estos arranques el `-110` del USB volvió a pisar una línea. -> -> Y un detalle que confirma el escritor de GPT: **el disco instalado no produce el -> aviso de «alternate GPT header not at the end»** que sí produce el medio. El medio -> lo tiene porque se hizo con `dd` de una imagen más chica que la memoria; el -> instalado no, porque Thalyx escribió la tabla contra el tamaño real del -> dispositivo. -> -> ### Lo que no se ejerció, que no es lo mismo que un hueco de la Fase 1 -> -> **Zanjado por Cesar el 2026-08-08: la Fase 1 está cerrada al 100%, sin -> asteriscos.** El criterio que él decretó no nombra NVMe ni disco interno; llamar -> «huecos» a configuraciones de hardware no ejercidas les daba el peso de una -> cláusula incumplida, y eso fue un error de encuadre. Ver -> [[Criterio-de-Salida-Fase-1]], donde queda el razonamiento completo. -> -> Sigue siendo cierto y pasa a la fase de validación: -> -> 1. **Ningún disco interno ha recibido una instalación.** El destino fue removible. -> El camino del instalador es idéntico —sysfs, `BLKRRPART`, los mismos -> escritores—; lo que cambia es el bus. **No es una imposibilidad**: instalar al -> lado de Fedora lo cerraría. Se aplaza por decisión. -> 2. **NVMe sobre silicio real sigue sin ejercerse**, porque esa máquina no tiene -> ninguno y no se va a comprar uno. No es un driver que falle: es hardware que no -> existe ahí. Esto sí es una imposibilidad. -> -> ### Lo que abrió, y es una pregunta de Cesar -> -> **La máquina tarda ~40 segundos en llegar al prompt**, más que su Fedora. Los -> tiempos del kernel dicen dónde no está el problema: `sd ... [sdb]` aparece a los -> **38 s** y el mensaje de `struct module` a los **34 s**, y esos números son -> **iguales desde dos memorias distintas**. Un tiempo constante entre medios -> distintos es lo que hace un **plazo fijo**, no lo que hace una lectura lenta — así -> que la hipótesis de «la USB es lenta» explica, como mucho, lo que tarda el -> firmware antes de que el kernel empiece a contar. -> -> **Y no había con qué medirlo.** `nucleo` contesta cuatro líneas de problemas o -> setecientas de todo, y ninguna de las dos dice a dónde se fueron los 34 segundos. -> Construido **`nucleo lento`**: los silencios más largos entre mensajes -> consecutivos, con la línea de antes y la de después de cada uno. El kernel ya -> ponía la marca de tiempo en cada línea; nadie las había restado. -> -> **La causa no está determinada y no se va a adivinar.** Un hueco dice a dónde se -> fue el tiempo, no qué lo tomó. -> -> ### Lo que falta, y es un arranque -> -> ``` -> git pull && make -C image && sudo make -C image installed INSTALLEDSIZE=2G -> ``` -> -> `dd` a la memoria, arrancar, y teclear **`nucleo lento`**. -> -> > **Contestado el mismo día — ver el bloque de arriba.** Era el puerto serie a -> > 9600 baudios, no la memoria. Y la razón por la que la constancia entre dos -> > memorias apuntaba a un plazo fijo era correcta en la forma y equivocada en el -> > sitio: el tiempo constante estaba al **principio**, no al final. -> -> **800 pruebas pasan** (797 antes), `clippy` limpio, `cargo fmt` aplicado. -> -> ## `discos` corrió en hierro y ofrecía la Fedora de Cesar como destino — 2026-08-07 -> -> **El bloque de arriba es más reciente.** Segundo arranque en la PC real, con el arreglo -> de la consola puesto. El error del USB **no volvió** —`nucleo` lista 10 líneas de -> problema entre 714 registros y ninguna es del USB— y el prompt no anunció nada, -> que es lo correcto: no hubo problemas nuevos después de que arrancó la sesión. -> -> **Que no volviera no es que esté arreglado, y ahora consta cuál de las dos es.** -> Cesar confirmó que **nunca desconectó nada**: el receptor Telink estaba puesto en -> esa corrida. O sea que **el `-110` es intermitente y sigue vivo** — no ocurrió esa -> vez, puede ocurrir la próxima. -> -> Y hubo que deshacer una confusión antes de poder concluirlo: el Telink **no es el -> WiFi**. Son dos dispositivos distintos en el mismo bus, y su `lsusb -t` los -> separa — puerto 6 es `usbhid` (el receptor de teclado y ratón) y puerto 8 es -> `rtl8xxxu`, que ése sí es el WiFi. Preguntar *«¿estaba conectado el Telink?»* sin -> decir cuál de los dos era invitaba justo a esa respuesta. -> -> **Lo que sí quedó resuelto es el síntoma, que era el que impedía usar la máquina.** -> Con la consola en emergencias, el `-110` ya no pisa el prompt: si vuelve, la -> sesión dirá `! N new kernel problem(s)` y seguirá siendo usable. La causa sigue -> abierta y **ninguna opción de kernel está justificada** — un fallo intermitente -> que aparece en un arranque y no en el siguiente se parece mucho más a un -> dispositivo marginal que a un hueco de `thalyx.config`. -> -> ### Lo que `discos` respondió, y es lo bueno primero -> -> ``` -> 7 disk(s): -> /dev/sda 3 GiB, 2 partition(s) ← la memoria USB -> /dev/sdb 447 GiB, 3 partition(s) -> 3 btrfs `fedora` ← su sistema -> ``` -> -> - **AHCI y SATA quedaron probados en hierro.** `sd 9:0:0:0: [sda]` y un disco de -> 447 GiB con la Fedora adentro: eso es `SATA_AHCI` + `SCSI` + `BLK_DEV_SD` -> funcionando contra silicio real. Era una de las tres filas de la tabla de -> riesgo. -> - **Esa máquina no tiene NVMe.** Siete discos y ninguno es `nvme0n1`; su sistema -> vive en un SATA. Así que **NVMe sobre silicio real es incontestable en esta -> máquina** — no porque el driver falle, sino porque no hay hardware. Queda dicho -> en vez de contarse como probado. -> -> ### Y el defecto, que es el más peligroso encontrado hasta ahora -> -> **Cuatro de esos «siete discos» son particiones**, incluidos los 444 GiB de -> `/dev/sdb3` —la Fedora— listados bajo la línea *«`instalar-en ` puts -> Thalyx on one. Everything on it is lost.»* -> -> El filtro existía y su comentario decía que `partitions::of` *«errors for a -> partition»*. **No erraba.** Busca `/sys/dev/block/:`, que existe -> igual para las dos, `read_dir` funciona sobre una partición, y como no tiene -> hijos con archivo `partition` devolvía **`Ok([])`**. *«No tiene particiones»* y -> *«esto no es la clase de cosa que tiene particiones»* salían por el mismo canal, -> y el llamador sólo miraba `is_ok()`. -> -> Y `install` las habría aceptado: escribe la tabla en el LBA 0 de lo que reciba. -> Una tabla dentro de una partición es **legal, invisible, y no arranca nada**, -> mientras el sistema de archivos que había ahí ya no está — la misma forma que la -> GPT con suma equivocada, donde el fallo no llega y el disco vuelve pareciendo -> intacto. -> -> **Arreglado en los dos sitios**, que es lo que importa: `discos` deja de -> listarlas —presentación— e **`install` se niega antes de escribir un byte**, que -> es lo que impide perder un disco cuando alguien teclea el nombre igual. El -> discriminador es el que el kernel ya tenía y nadie le preguntó: el archivo -> `partition`. Tres pruebas, incluida una que le pregunta al kernel que corre las -> pruebas si el modelo es correcto, porque un falso que modela la propiedad -> equivocada no es un falso sino otro sistema. -> -> Regla nueva en [[Estrategia-de-Pruebas]], y una segunda dentro de ella: **un -> comentario que enuncia una propiedad es una prueba que nunca corre.** Lo único -> capaz de contradecir esa frase era una máquina con particiones, que durante -> semanas fue ninguna máquina. -> -> ### Y Thalyx no reconocía su propia partición de arranque -> -> `discos` describía la ESP de la memoria de la que estaba corriendo como -> *«something I do not recognise»*, teniendo un lector de FAT32 adentro. Ahora dice -> **«a Thalyx boot partition»**, y una FAT ajena la nombra con su etiqueta. Una -> máquina que no sabe nombrar su propio trabajo no tiene autoridad para nombrar el -> ajeno. -> -> ### Dos cosas del `nucleo` que no son defectos y conviene no confundir -> -> - **`GPT: 4194303 != 7831551`.** La imagen es de 2 GiB y la memoria de ~3.7 GiB, -> así que la copia de respaldo de la tabla quedó donde termina la imagen y no -> donde termina el dispositivo. Le pasa a **toda** imagen escrita con `dd` a un -> medio más grande. Linux avisa y sigue. -> - **`CPU limit of 2 reached`.** `allnoconfig` deja `NR_CPUS` en 2 y esa máquina -> tiene más. No rompe nada; explica parte de la lentitud del arranque. -> -> ### Lo que falta para cerrar la Fase 1, y ya no es imposible -> -> **Falta el acto 2b**, y el criterio de Cesar es *«ponerla en una PC sin sistema -> operativo y que ahora tenga Thalyx como OS»*: la máquina tiene que **tener** -> Thalyx en un disco propio, con el medio quitado. En hierro eso nunca ha pasado. -> -> Pero tiene **tres memorias** (4, 8 y 32 GB), y eso vuelve alcanzable hoy lo que -> parecía imposible: **arrancar de la memoria A e `instalar-en` la memoria B**, -> quitar A, y encender. Firmware real, instalación real escribiendo una GPT real en -> un disco físico real, y un arranque real desde ese disco sin medio puesto. Lo -> único que no responde es que el disco sea interno. Si eso cumple el decreto lo -> decide Cesar, porque el decreto es suyo. -> -> **797 pruebas pasan** (794 antes), `clippy` limpio, `cargo fmt` aplicado. -> -> ## El dispositivo tiene nombre, y el prompt ya no se pisa — 2026-08-07 -> -> **El bloque de arriba es más reciente.** -> -> ### Qué es `usb 1-6` -> -> `lsusb -t` y `dmesg` en Fedora lo contestaron sin arrancar nada: -> -> ``` -> usb 1-6: New USB device found, idVendor=248a, idProduct=16ab -> usb 1-6: Product: Wireless Receiver -> usb 1-6: Manufacturer: Telink -> ``` -> -> Un **receptor inalámbrico Telink** de teclado y ratón, a *full speed* (12M), con -> dos interfaces HID. En Fedora enumera a los 1.2 s; en Thalyx agota el plazo. -> -> **Y no es el teclado con el que Cesar escribió**: hay otro HID de dos interfaces -> en el bus 3, puerto 1. Por eso pudo teclear `apagar`. Desconectar el receptor -> Telink es a la vez el atajo para usar la máquina hoy **y el control** que -> confirma que ése era el dispositivo. -> -> **No se agregó ninguna opción de kernel.** Todavía no está descartado que el -> dongle sea lento o defectuoso, y una opción agregada por corazonada es lo -> contrario de lo que hace este proyecto. Lo que faltaba era el instrumento, y -> ahora existe: con el prompt utilizable, `nucleo` muestra el buffer entero — -> incluidas todas las líneas de nivel informativo de la enumeración USB que la -> consola nunca imprimió— y eso sí dice si el kernel reintentó, cuántas veces, y -> qué estaba haciendo en los 38 segundos previos. -> -> ### El arreglo de la consola, que son dos mitades -> -> **La consola queda en emergencias (`set_console_loglevel(1)`)**, y **el prompt -> anuncia lo que llegó**: -> -> ``` -> ! 2 new kernel problem(s); `nucleo` shows them -> > -> ``` -> -> La segunda mitad es lo que impide que la primera sea esconder. Se imprime -> **antes** del prompt y nunca a media línea, que es el defecto entero. -> -> Para saber *«qué ha dicho el kernel desde que miré»* hacía falta un cursor, y -> contar registros no sirve: el buffer sobrescribe los viejos, así que la cuenta -> puede **bajar** mientras llegan mensajes. Ahora `KernelMessage` lleva el número -> de secuencia del kernel, que sólo sube. Comprobado contra el `/dev/kmsg` real de -> este contenedor: 358 registros, monótono, y distinto del campo de tiempo — que es -> el error que se habría visto igual de bien. -> -> Y si el buffer se dio la vuelta entre dos miradas, el aviso dice **«at least»** -> en vez de presentar un subconteo como total. -> -> **Cinco pruebas nuevas**, incluida la de control: un mensaje que sólo *ocurrió* -> no interrumpe el prompt. Sin ella, un aviso que aparece siempre es uno que nadie -> lee, que es el mismo defecto reconstruido un nivel más arriba. -> -> ### Y el umbral estaba sobre el eje equivocado -> -> `init.rs` describía el síntoma **antes de que ocurriera** —*«a message arriving -> mid-line steps on it — the machine looks like it stopped listening»*— y aun así -> ocurrió. Filtrar por gravedad contesta *«¿esto importa?»*; lo que arruina una -> interfaz no es que un mensaje importe, es que **vuelva**. Y la repetición no es -> una propiedad del mensaje sino de la serie, así que ningún nivel la ve. -> -> Segundo defecto del mismo hilo: el nivel de consola suprime lo que tenga -> prioridad **>= él**, así que el 4 tiraba las advertencias, mientras -> `is_trouble()` las cuenta **como** problema y la línea del arranque decía -> *«warnings and worse only»*. Un mismo juicio en dos lugares se desincronizó en -> silencio, y hacia el lado peor: la pantalla afirmaba mostrar más de lo que -> mostraba. Las dos reglas están en [[Estrategia-de-Pruebas]]. -> -> ### Lo que falta, y es tuyo -> -> ``` -> git pull -> make -C image -> sudo make -C image installed INSTALLEDSIZE=2G -> ``` -> -> `dd` a la memoria otra vez (paso 4 de [[Arranque-en-Hierro]]), arrancar, y ahora -> sí: **`nucleo`** —que es lo que dice qué pasó con el USB— y **`discos`**, que -> nunca se ha corrido en hierro y que diría si el NVMe y el `sda` aparecen. -> -> **794 pruebas pasan** (789 antes), `clippy` limpio, `cargo fmt` aplicado. -> -> ## Una PC de verdad arrancó Thalyx desde una USB de verdad — 2026-08-07 -> -> **El bloque de arriba es más reciente.** El acto 2a corrió. Firmware real, monitor -> real por HDMI, memoria física, teclado físico. Lo que salió en la pantalla: -> -> ``` -> [ 38.075277] BTRFS: device label thalyx-store devid 1 transid 2 /dev/sdb2 (8:18) scanned by init (1) -> ok store /dev/sdb2 ▪ three subvolumes, found by the label `thalyx-store` -> ok thalyx-lsm 2 hook(s) live, 3 map(s) pinned under /sys/fs/bpf/thalyx -> ok enforcement 2 of 2 hook(s) live: thalyx_socket_c, thalyx_file_ope -> ``` -> -> Cada línea es una afirmación distinta, y **ninguna la podía hacer una VM**: -> -> - **Un firmware real arrancó Thalyx de una memoria física**, por el -> *fallback* `\EFI\BOOT\BOOTX64.EFI`, sin gestor de arranque. -> - **La pantalla funcionó en hierro.** `FB_EFI` adoptó el framebuffer que dejó -> *su* firmware, en un monitor por HDMI. Era la única parte de la pantalla que -> una VM no respondía, y es el punto 3 de la lista de riesgo de -> [[Construccion-del-ISO]] — *«arrancaría bien y no se vería nada»*. -> - **`USB_STORAGE` funcionó en hierro**: la memoria salió como `/dev/sdb2`, -> major 8:18, o sea la capa SCSI de verdad. La línea que faltaba esa mañana. -> - **Encontró su store por la etiqueta**, sin `thalyx.store=`. -> - **El LSM se enganchó**, en un arranque por firmware sobre hardware real. -> - **Y el teclado funcionó.** Cesar tecleó `apagar` y la máquina se apagó. -> -> Ese último punto lo estableció **él, con el control correcto**: volvió a -> arrancar sólo para separar *«el teclado no sirve»* de *«algo me impide -> teclear»*, tecleó antes de que apareciera el error, y funcionó. Es la regla 4 — -> una negativa sin línea base y sin control no dice nada — aplicada por el humano -> sin que nadie se la pidiera. -> -> **Hay un `sda` además del `sdb`**, así que esa máquina tiene otro disco SCSI — -> probablemente SATA por AHCI. `discos` lo habría dicho y no se alcanzó a correr. -> -> ### Y encontró un defecto real, que es para lo que sirve correr las cosas -> -> ``` -> > [ 51.812474] usb 1-6: device descriptor read/64, error -110 -> ``` -> -> `-110` es `ETIMEDOUT`: un dispositivo USB en el bus 1, puerto 6, cuyo descriptor -> no se puede leer. El kernel **reintenta para siempre**, así que el mensaje -> vuelve cada pocos segundos, encima del prompt. La sesión queda inusable aunque -> el teclado funcione. Y los 38 segundos hasta encontrar el store son el mismo -> síntoma: la enumeración se pasó ese tiempo agotando plazos. -> -> **Lo notable es que el código ya había previsto exactamente esto** y eligió el -> umbral equivocado por uno. `init.rs:393` dice, textual: -> -> > *From here there is a human at a prompt, and an info-level message arriving -> > mid-line steps on it — the machine looks like it stopped listening.* -> -> Y baja la consola a `4`. El razonamiento vale para un error que ocurre **una -> vez**; no vale para uno que se repite sin parar. Un mensaje que se repite deja -> de ser información y es ruido, y el umbral no distingue las dos cosas porque -> mira la gravedad y no la repetición. -> -> **Y el mensaje del arranque miente por un nivel.** `set_console_loglevel(4)` -> suprime todo lo que tenga prioridad `>= 4`, o sea **las advertencias se van** — -> pero la línea dice *«warnings and worse only»* y el comentario dice *«warnings -> and errors still come through»*. Mientras tanto `is_trouble()` cuenta la -> prioridad 4 **como** problema, así que `nucleo` las llama problemas y la consola -> las tira. Las dos mitades del mismo criterio no coinciden. -> -> ### Lo que falta, y son dos preguntas distintas que no hay que mezclar -> -> 1. **Qué es `usb 1-6`.** Se contesta desde Fedora, gratis y sin riesgo: -> `lsusb -t` y `dmesg | grep -i "1-6"`. Hasta saberlo, **no se agrega ninguna -> opción de kernel**: sería adivinar, y este proyecto tiene una regla sobre -> creerle a un instrumento antes de descartar al que preguntó. -> 2. **Cómo sobrevive la sesión al ruido del kernel.** Es decisión de Cesar y -> está en [[Tareas-Pendientes]]. -> -> ## Los cuatro grupos de controladores corrieron, y el acto 2 se parte en dos — 2026-08-07 -> -> **El bloque de arriba es más reciente.** Cesar corrió `run-hardware` entero. Lo que -> devolvió la pantalla, con nombres: -> -> ``` -> hid-generic 0003:0627:0001.0001: input: USB HID v1.11 Keyboard [QEMU QEMU USB Keyboard] on usb-0000:00:03 -> BTRFS: device label thalyx-store devid 1 transid 2 /dev/nvme0n1p2 (259:2) -> ok store /dev/nvme0n1p2 ▪ three subvolumes, found by the label `thalyx-store` -> ``` -> -> - **El teclado USB enlazó**: `xhci_hcd` enumeró el dispositivo y `hid-generic` -> creó un dispositivo de entrada. -> - **El NVMe enlazó y las particiones se llaman bien** — `nvme0n1p2`, major 259. -> Era el riesgo más caro que cargaba el acto 2, el que hace que `partitions.rs` -> lea los nombres de sysfs en vez de derivarlos. -> - **Arrancó del NVMe sin medio puesto**: encontró **un solo** `thalyx-store`; con -> la USB conectada habría encontrado dos y se habría negado. -> - Y de ahí se sigue lo que la pantalla no dice: para que ese store exista, -> `instalar-en` tuvo que leer el kernel del medio USB, así que **`USB_STORAGE` -> también funcionó**. La línea que faltaba esa misma mañana. -> -> **Lo que le queda al acto 2 ya no es «¿Thalyx tiene los drivers?».** Es silicio -> concreto. -> -> ### Y la restricción real, que cambia la forma del acto 2 -> -> Cesar tiene **una sola PC** y no va a tener otra: *«no puedo hacerla ni hoy ni -> nunca, no tengo una pc limpia»*. Sí tiene memorias USB (4, 8 y 32 GB). Eso parte -> el acto 2 en dos mitades de costo muy distinto: -> -> - **Arrancar desde la USB en su propia máquina** responde firmware real, xHCI -> real, su teclado real, `USB_STORAGE` real y su NVMe real visto por el driver -> real — **y no escribe un solo byte** en el disco interno. -> - **Instalar en el disco interno destruiría Fedora.** `thalyx install` escribe una -> GPT nueva sobre el disco entero; el módulo abre diciendo *«turning a disk with -> no operating system on it»* y es literal. Instalar al lado **no está construido** -> y es un decreto que Cesar no ha tomado. Se puede hacer sin romper la filosofía -> —particiones en el espacio libre, el kernel en la ESP existente bajo -> `\EFI\thalyx\`, y el **menú del firmware** eligiendo, que es el firmware y no un -> segundo programa— pero lo que cuesta no es el código: es escribir en el disco -> que sostiene la única máquina que verifica este proyecto. -> -> El procedimiento entero está en [[Arranque-en-Hierro]], escrito para contestarse -> desde sí mismo: 642 MiB es el mínimo instalable, así que 2 GiB de imagen basta y -> entra en cualquiera de las tres memorias. Lleva el aviso que importa —**`discos` -> va a listar el NVMe con Fedora adentro y `instalar-en` no se teclea**— y el paso -> de Secure Boot, que es lo más probable que lo detenga y no es Thalyx fallando. -> -> ## El acto 2 habría fallado, y se supo sin correrlo — 2026-08-07 -> -> **El bloque de arriba es más reciente.** Cesar preguntó si GNOME Boxes servía para el -> acto 2, porque no tiene una segunda PC. Buscar la respuesta encontró un defecto -> que habría aparecido con la memoria USB ya puesta en una máquina. -> -> ### `CONFIG_USB_STORAGE` no estaba, y su ausencia no rompe el arranque -> -> El acto 2 es `dd` a una USB, arrancar, `discos`, `instalar-en /dev/nvme0n1`. Sin -> ese driver: -> -> - La máquina **arranca de la USB perfectamente**, porque la especificación UEFI -> obliga al **firmware** a leer el medio con su propio controlador. Monta sus -> siete sistemas de archivos, engancha el LSM, saca su prompt en la pantalla. -> - Y falla **dos comandos después**: `instalar-en` busca el medio del que arrancó -> recorriendo `/sys/block` (`partitions.rs:189`), y el kernel enumeró la USB como -> dispositivo USB sin darle nunca un dispositivo de bloque. -> - El mensaje sería *«no encuentro un medio de Thalyx»* en una máquina que está -> visiblemente corriendo desde uno. **En ningún punto aparece la palabra USB.** -> -> Es la **cuarta vez** que algo de fuera hacía un trabajo que el diseño nunca -> escribió —systemd con los controladores de cgroup, el initramfs externo con el -> `switch_root`, el archivo del kernel con `/dev/console`— y la primera en que la -> capa de abajo **no se quita**: el firmware sigue ahí haciendo su parte, y por eso -> el arranque no se rompe y el defecto es invisible. Regla nueva en -> [[Estrategia-de-Pruebas]], con la pregunta que sí lo encuentra: no *«¿qué hardware -> tiene la PC?»* sino *«¿qué tiene que leer Thalyx además de lo que el firmware ya -> leyó por él?»*. Un inventario de hardware no la contiene, que es por qué no estaba -> en las tres filas de la tabla de riesgo. -> -> Ya está puesta, con **una quinta prueba** que lee `thalyx.config` y la exige, -> comprobada en las dos direcciones —comentando la línea, falla—, porque -> `config-check` atrapa una opción que Kconfig descartó y **no puede atrapar una que -> nadie pidió**. -> -> ### Y Boxes no sirve, pero QEMU sí sirve para más de lo que decía la bóveda -> -> Boxes da discos virtio y teclado PS/2 — o sea lo que el acto 1 ya probó — y no -> tiene interfaz para agregar otros controladores. Pero **QEMU emula xHCI, NVMe, -> AHCI y un disco USB**, y el driver del kernel que habla con un controlador emulado -> es el mismo que habla con silicio real. La bóveda decía «una VM no prueba los -> controladores», que era cierto de la VM que se estaba usando y no de toda VM. -> Corregido en [[Construccion-del-ISO]] con la tabla de qué responde cada cosa. -> -> **`make -C image run-hardware`**, con tres modos: -> -> ``` -> make -C image run-hardware arranca del medio USB; adentro, `discos` -> y `instalar-en /dev/nvme0n1` -> make -C image run-hardware NOMEDIUM=1 la misma máquina sin la USB, que ahora -> tiene que arrancar del NVMe que instaló -> make -C image run-hardware NOPS2=1 sin controlador PS/2, así que una tecla -> que llegue sólo pudo venir por USB -> ``` -> -> Los dos discos en blanco se hacen una vez y se conservan: un disco que se borra -> en cada corrida no puede mostrar que la instalación siguió ahí. -> -> **No es el acto 2 y no lo cierra**, y el objetivo lo dice línea por línea. Lo que -> hace es mover el riesgo de cuatro grupos de controladores nunca ejercidos a cuatro -> ejercidos contra controladores emulados, dejando abierto lo que de verdad pide una -> PC: silicio real y una memoria física en un puerto físico. -> -> ### Lo que falta, y es tuyo -> -> ``` -> git pull -> make -C image # aquí se sabe si USB_STORAGE sobrevivió -> sudo make -C image installed -> make -C image run-hardware -> ``` -> -> Adentro: `discos` tiene que listar `/dev/nvme0n1`, `/dev/sda` y el medio. Después -> `instalar-en /dev/nvme0n1`, `apagar`, y `make -C image run-hardware NOMEDIUM=1`. -> -> **Lo primero que puede fallar sigue siendo la compilación del kernel**, y está -> bien: `config-check` detiene el build si `olddefconfig` descartó `USB_STORAGE`. -> Depende de `USB` y `SCSI` y las dos ya estaban, así que no debería — pero si pasa, -> la lectura es la de `HID_SUPPORT`: buscar el `menuconfig` que la contiene antes -> que sus dependencias. -> -> **789 pruebas pasan** (788 antes de este cambio), `clippy` limpio en 1.97, -> `cargo fmt` aplicado. El bloque de abajo sigue siendo el estado del acto 1. -> -> ## El acto 1 está hecho: una máquina instalada arrancó sola y respondió — 2026-08-07 -> -> **El bloque de arriba es más reciente.** `sudo ./dev/verify.sh` cerró en -> **`proven 135 · not proven 1 · failed 0`** —el único no probado es llama.cpp, que -> es Fase 2— y `make -C image run-installed` arrancó la máquina instalada. -> -> Un firmware UEFI encontró `\EFI\BOOT\BOOTX64.EFI` en un disco escrito por Thalyx y -> lo ejecutó: sin `-kernel`, sin `-append`, sin gestor de arranque. La máquina -> encontró su store sin que nadie se lo nombrara, la sesión salió **por la pantalla**, -> Cesar escribió `apagar` **dentro de la ventana** y se apagó. -> -> Eso último no es un detalle: el teclado entró por PS/2 emulado (`SERIO_I8042` + -> `KEYBOARD_ATKBD` + `VT`) y la pantalla es `FB_EFI` + `FRAMEBUFFER_CONSOLE` + -> `FONT_8x16`. Lo confirma algo que no se puede fingir: al intentar Impr Pant -> aparecieron símbolos raros en la sesión, que es `atkbd` traduciendo scancodes de una -> tecla que no es una letra. -> -> **Falta sólo el acto 2, y es hierro**: `dd` a una USB, arrancar una PC, `discos`, -> `instalar-en /dev/nvme0n1`, `apagar`, sacar la USB, encender. Es lo único que -> responde el teclado **USB** (xHCI + HID) y los discos **NVMe/AHCI**. De los tres -> grupos de controladores nuevos, dos ya están probados en vivo. -> -> Ver [[Criterio-de-Salida-Fase-1]], que lleva el detalle de qué afirmó cada cosa. -> -> ## La Fase 1 está construida entera, y la primera corrida encontró dos cosas — 2026-08-07 -> -> **Es lo primero que hay que leer.** Cesar pidió cerrar la fase sin poder verificar -> entre cambios, aceptando apilar comprobaciones: *«no importa que apilemos 2 -> comprobaciones, nuestro verify.sh nos indica dónde están, no es necesario saber en -> qué momento se introdujeron»*. Así que hubo **cuatro commits del día** y una sola -> corrida por delante — y la apuesta salió como se dijo: el arnés nombró las dos -> cosas rotas, sin que hiciera falta saber cuál commit las metió. -> -> ### Lo que devolvió esa corrida, y el segundo es serio -> -> **1. El kernel no compiló: faltaba `CONFIG_HID_SUPPORT`.** `config-check` nombró -> tres opciones descartadas —`HID`, `HID_GENERIC`, `USB_HID`— y ninguna era la causa: -> las tres viven dentro de un `menuconfig HID_SUPPORT` que es `default y`, y bajo -> `allnoconfig` un `default y` es un `n`. Una línea. -> -> **2. El instalador copió el gestor de arranque de Fedora — y tenía dos causas, no -> una.** La primera corrida en frío encontró una y la segunda encontró la otra, que -> era la que estaba produciendo el mensaje. -> -> **2a. El arnés destruía la partición y no la reparaba.** La etapa 20 daña las dos -> copias del sector de arranque de la ESP para comprobar que un vfat roto no se -> monta —regla 4, bien aplicada— y **la dejaba dañada**. Todo lo de abajo la sigue -> usando. Cinco fallos de una sola causa, y el primero mandaba a mirar el lector de -> FAT, que estaba bien. Ahora el control saca los siete sectores antes, los devuelve -> después, y **afirma que la reparación tomó** — porque una reparación que -> silenciosamente no funciona se ve idéntica al bug original. Séptima vez que *el -> instrumento incluye al arnés*, y la primera en que el arnés no midió mal: dejó el -> mundo peor de como lo encontró. -> -> **2b. Y la búsqueda del medio.** La búsqueda del medio -> pedía `\EFI\BOOT\BOOTX64.EFI`, que **no es un archivo de Thalyx**: es el -> *removable media fallback* de UEFI, o sea la ruta que llevan todos los medios de -> arranque que existen, empezando por la partición EFI de la máquina en la que uno -> está sentado. La etapa 20 instaló un segundo disco sin `--kernel`, la búsqueda -> encontró tu ESP, y Thalyx copió el arranque de otro sistema al disco **reportando -> una instalación correcta**. Lo único que lo dijo fue la comparación byte a byte del -> final. -> -> Ahora el medio se identifica por la **etiqueta del volumen FAT32, `THALYX`**, que -> sí la escribe Thalyx — el mismo cambio que el store por su etiqueta, un día tarde. -> `thalyx disk medium` contesta a qué disco iría a buscar el kernel, y la etapa 20 lo -> usa como afirmación *y* como control: tu máquina tiene una ESP propia, así que -> pasar quiere decir las dos cosas, que encontró el volumen de Thalyx y que no se -> llevó el ajeno. Regla nueva en [[Estrategia-de-Pruebas]]: *un marcador que -> identifica algo tiene que ser algo que sólo eso tenga*. -> -> Sin la etiqueta, aun con el medio sano, habría **dos** respuestas y la instalación -> se habría negado — correcta, pero imposible en la máquina de cualquiera. O sea que -> las dos correcciones hacían falta y ninguna cubría a la otra. -> -> Y un detalle del arnés: el `cmp` que falló decía sólo «difieren», que manda a mirar -> el lector de FAT. Ahora imprime de qué dispositivo dijo el instalador que estaba -> leyendo, y el tamaño de los dos archivos. -> -> ### Al ir a cerrar apareció que faltaba algo grande, y era un decreto sin código -> -> **Una máquina instalada no encontraba su store.** Decretado el 2026-08-06 —*por la -> etiqueta del sistema de archivos*—, escrito con su razonamiento entero, marcado -> `[x]` en [[Tareas-Pendientes]], y **nunca implementado**. `store_disk.rs` leía -> `thalyx.store=` y, sin él, decía que nadie le había dicho cuál era el disco. -> -> En una máquina instalada nunca está: la línea de comandos va compilada dentro del -> kernel, es una sola, y el disco se llama `vda` aquí y `nvme0n1p2` en una PC. -> -> O sea: **el instalador de esta mañana estaba terminado y el disco que produce -> habría arrancado diciendo que no tiene store.** Nada lo habría dicho antes de que -> encendieras la máquina. Regla nueva en [[Estrategia-de-Pruebas]]: un decreto que -> nadie implementó se lee igual que uno implementado, y un `[x]` de *decidido* se ve -> igual que uno de *construido*. -> -> Ya está construido, con los dos caminos en orden: **`thalyx.store=` gana** —lo que -> deja `make run` y todas las etapas exactamente como estaban— y sin él se le -> pregunta a cada disco cómo se llama. Con las dos negativas: **ninguno** se reporta -> diciendo cuántos discos se leyeron, y **dos se niegan** en vez de elegir. Lo -> segundo no es raro: es el caso normal justo después de instalar, con el medio -> todavía puesto. -> -> `thalyx disk find` corre ese código sin ser PID 1 y sin montar nada. Existe porque -> si no, la rama que niega dos discos iguales se ejecutaría por primera vez en tu -> máquina, el día en que equivocarse es más caro. -> -> ### Y la otra mitad: la máquina se instala a sí misma -> -> `thalyx install` recibía el kernel por `--kernel`, y **adentro no hay ruta que -> teclear**. Ahora Thalyx **lee** el medio del que arrancó: `medium.rs` es un lector -> de FAT32 que busca un volumen etiquetado `THALYX` con `\EFI\BOOT\BOOTX64.EFI` -> adentro, se niega si hay dos, y excluye el disco de destino para que reinstalar -> siga siendo posible. -> **No monta nada** — los bytes se leen igual que se escribieron, así que el kernel -> no necesita `CONFIG_VFAT_FS`. -> -> Y dos verbos, que es lo que lo vuelve alcanzable sin shell: -> -> ``` -> discos qué discos veo, y qué tiene cada uno -> instalar-en pon esta máquina en ese disco -> ``` -> -> El kernel se busca **antes** de decir nada sobre destruir el disco: una máquina que -> preguntara, recibiera un sí, borrara el disco y sólo entonces descubriera que no -> tenía kernel lo habría destruido para nada. -> -> ### El medio y la máquina instalada son el mismo archivo -> -> No hay un ISO aparte que construir. Un disco con una partición de arranque que un -> firmware puede iniciar es eso mismo, esté atornillado a una máquina o enchufado en -> un costado: -> -> ``` -> sudo dd if=image/build/installed.img of=/dev/sdX bs=4M status=progress conv=fsync -> ``` -> -> ### Lo que falta, y es todo tuyo -> -> **Acto 1, en una VM** — responde que el medio arranca solo, que la máquina -> instalada arranca sin él, que encuentra su store sin que nadie se lo nombre, y que -> la consola de framebuffer funciona (OVMF entrega un GOP de verdad): -> -> ``` -> git pull -> sudo ./dev/verify.sh # la etapa 20 va en diecinueve líneas -> make -C image # aquí se sabe si las opciones nuevas del kernel sobrevivieron -> sudo make -C image installed -> make -C image run-installed -> ``` -> -> **Acto 2, en una PC** — es lo único que responde el teclado USB y NVMe/AHCI: -> `dd` a una USB, arrancar, `discos`, `instalar-en /dev/nvme0n1`, `apagar`, sacar la -> USB, encender. -> -> **Lo primero que puede fallar sigue siendo la compilación del kernel**, y eso es -> correcto: `config-check` detiene el build si `olddefconfig` descartó alguna opción. -> Ya lo hizo una vez, con `HID_SUPPORT`. Si vuelve a pasar, la lectura es la que dejó -> ese fallo: **un grupo de opciones descartadas juntas comparte una causa**, y hay que -> buscar el `menuconfig` que las contiene antes que las dependencias de cada una. -> -> ### Una cosa que el criterio no pide y conviene no confundir -> -> Una PC recién instalada arranca con un store bueno y **vacío**: la imagen lleva el -> kernel y un programa, así que no hay nada instalado en ella ni nada que instalar, y -> los pasos 2 a 6 de la lista original no se pueden hacer *en ella*. Se siguen -> haciendo en la de desarrollo y se siguen comprobando en cada cambio. No es un hueco -> del criterio vigente —*«ponerla en una PC sin sistema operativo y que ahora tenga -> Thalyx como OS»*, y eso se cumple— sino la pregunta de cómo llega el software a una -> máquina que no es ésta, que es la Fase 2. -> -> **785 pruebas pasan, `clippy` limpio en 1.97, `cargo fmt` aplicado.** - -> ## Y los controladores de una PC de verdad — 2026-08-07 -> -> **El bloque de arriba es más reciente.** Es un commit **aparte** del instalador, a -> propósito: se verifican por caminos distintos y ninguno de los dos tiene que -> esconder al otro si falla. -> -> Son los puntos 2 y 3 de la lista de riesgo de [[Construccion-del-ISO]], y **es -> la única parte del criterio de salida que una VM no puede responder.** -> -> | Qué | Qué falla sin eso | -> |---|---| -> | Pantalla — `FB_EFI`, `FRAMEBUFFER_CONSOLE`, `VT`, `FONT_8x16` | La ISO arranca en una PC y **no se ve nada** | -> | Teclado — `USB_HID`, `USB_XHCI_HCD`, `USB_EHCI_HCD`, `SERIO_I8042`, `KEYBOARD_ATKBD` | Llega al prompt y no se le puede contestar | -> | Discos — `BLK_DEV_NVME`, `SATA_AHCI`, `BLK_DEV_SD`, `PCI_MSI` | El instalador no ve el disco en el que va a instalar | -> -> Tres cosas que no son obvias: -> -> - **`FB_EFI` no es un driver de video.** Es el framebuffer que el firmware **ya -> configuró**, y este driver lo adopta. Por eso no hay aquí un driver de ninguna -> tarjeta: Thalyx dibuja texto, no levanta una GPU, y un driver por chip es un -> userland entero. El costo: la resolución es la que eligió el firmware. -> - **`PCI_MSI` no es un lujo.** NVMe arma sus colas alrededor de interrupciones -> por mensaje y `allnoconfig` lo deja apagado. Nada más de ese archivo lo pedía. -> - **`BLK_DEV_SD` es lo que hace que AHCI sirva.** Sin él el controlador se -> encuentra, sus puertos se sondean, y no aparece `/dev/sda`. -> -> ### La consola, que fue tu decisión -> -> ``` -> CONFIG_CMDLINE="console=ttyS0 console=tty0 lsm=capability,bpf panic=-1" -> ``` -> -> **El kernel imprime en todas, y la ÚLTIMA es la que se vuelve `/dev/console`** — -> el único archivo por el que habla la sesión. Arrancada por firmware no se le pega -> nada, así que gana `tty0` y la sesión sale en la pantalla. Arrancada por QEMU, -> `-append console=ttyS0` va **después** —comprobado en `arch/x86/kernel/setup.c`, -> que concatena la compilada primero— así que el serie sigue ganando y la etapa 16 -> ni se entera. -> -> Hay **cuatro pruebas** que leen `thalyx.config` y afirman esto, porque -> `config-check` atrapa una opción que Kconfig descartó y **no puede atrapar una -> que nadie pidió** — que es el error que costó `CONFIG_SECURITY_NETWORK` y un -> arranque entero. -> -> ### Y `run-uefi` abre una ventana -> -> Con la sesión en `tty0`, `-nographic` la dejaría corriendo perfecta donde nadie -> la ve, que es justo el fallo que el cambio evita. Ahora `run-uefi` y -> `run-installed` abren ventana, con `-serial mon:stdio` para que los mensajes del -> kernel sigan en la terminal. `HEADLESS=1` vuelve atrás. -> -> **Eso hace que la ventana sea la única forma de probar el framebuffer sin -> hierro**: OVMF sí entrega un framebuffer GOP de verdad. El teclado y los discos -> siguen necesitando una PC. -> -> ### Lo que falta, y es tuyo -> -> **Ninguna de estas opciones se ha compilado siquiera.** Este contenedor no -> compila kernels. Y la regla de este proyecto dice que **ninguna comprobación de -> construcción encuentra la siguiente opción que falta** — van tres encontradas -> arrancando (`BPF_LSM` con BTF, `SECURITY_NETWORK`, `FUNCTION_TRACER`), y lo -> razonable es esperar más aquí. -> -> Lo primero que puede fallar es la compilación: `config-check` detiene el build si -> `olddefconfig` descartó cualquiera de estas líneas. Si pasa, **la línea que falta -> es la que hay que mirar**, no el grupo entero — cada una tiene su párrafo al lado. -> -> ``` -> make -C image # aquí es donde se sabe si sobrevivieron -> sudo ./dev/verify.sh # el instalador, etapa 20 -> sudo make -C image installed -> make -C image run-installed # y aquí si una PC arranca -> ``` -> -> **775 pruebas pasan, `clippy` limpio en 1.97, `cargo fmt` aplicado.** - -> ## El instalador existe: un disco se vuelve una máquina — 2026-08-07 -> -> **El bloque de arriba es más reciente y es el otro commit de hoy.** Los dos se -> verifican por caminos distintos y están separados a propósito. -> -> ### Lo que se construyó -> -> `crates/thalyx-install` y **`thalyx install --kernel `**. Es el -> acto que juntaba las dos piezas caras, y lo que sale es esto: -> -> ``` -> LBA 0 MBR protector -> LBA 1..34 la tabla de particiones, y su copia al otro extremo -> 1 MiB partición 1, 512 MiB, FAT32, con \EFI\BOOT\BOOTX64.EFI adentro -> 513 MiB.. partición 2, el resto, btrfs `thalyx-store`, tres subvolúmenes -> ``` -> -> **Un archivo en la partición de arranque, y es el kernel con Thalyx adentro.** -> Es `make -C image count` extendido al disco instalado. -> -> Costó **dos escritores de bytes más**, por el motivo de siempre: `sgdisk` y -> `mkfs.vfat` son lo que usaría una persona, y la imagen lleva el kernel de Linux -> y un programa. Van la cuarta y la quinta vez que este proyecto contesta a un -> binario ausente con el trabajo en vez de con la herramienta — `bpftool`, `cpio`, -> `btrfs`, `partprobe`, `mkfs.vfat` — y una cuarta llamada al kernel propia, -> `BLKRRPART`, porque escribir una tabla en un disco que el kernel ya tiene abierto -> no hace aparecer `/dev/sda1`. -> -> **FAT no es una preferencia, es del firmware.** La especificación UEFI obliga al -> firmware a entender FAT y nada más. Es el único sistema de archivos de Thalyx que -> existe para satisfacer algo de afuera, y conviene que quede dicho. -> -> ### Lo que decidió el diseño, y vale saberlo -> -> - **Los nombres de las particiones se le preguntan al kernel.** `/dev/sda` da -> `/dev/sda1` y `/dev/nvme0n1` da `/dev/nvme0n1p1`; la regla que produce las dos -> es una convención de las herramientas que las imprimen, no una promesa. Si se -> deriva, el instalador anda en SATA y escribe el store **en la nada** en NVMe — -> que es justo la mitad del hierro que aquí no se puede probar. Se leen de -> `/sys/dev/block/:/`. -> - **La ESP es de 512 MiB y la holgura es el punto.** No se agranda después sin -> mover el store, y lo que seguro va a pasar es que una actualización de kernel -> escriba el nuevo **al lado** del viejo. Una máquina que sobrescribe su único -> archivo arrancable y se queda sin corriente no vuelve. -> - **Un disco de 4 KiB por sector se rechaza en vez de escribirse.** -> -> ### Y hay un fallo nuevo que no se parece a ningún otro de este proyecto -> -> **Una GPT con una suma equivocada no se reporta como rota: se ignora.** Linux cae -> al MBR protector, no crea ninguna partición, y el disco vuelve **igual que si -> nadie lo hubiera tocado**. El instalador habría dicho `ok`. -> -> Es distinto del Btrfs de la etapa 18, donde un superbloque dañado hace que -> `mount(2)` conteste un error. Ahí el fallo llega; aquí no llega nada. Por eso la -> etapa 20 no comprueba «el instalador terminó» sino **«el kernel hizo dos -> particiones»**, leídas de sysfs, con línea base de que antes no había ninguna, y -> comparando los tamaños contra lo que `--plan` dijo. Regla nueva en -> [[Estrategia-de-Pruebas]]. -> -> ### El contenedor no pudo establecerlo, y casi acusa a Thalyx -> -> `thalyx install` escribió la tabla aquí y el kernel no hizo particiones. La -> lectura obvia era que la tabla estaba mal. Lo que lo resolvió fue escribir **un -> MBR común** —cuyo parser está en todos los kernels de Linux— y ver que tampoco -> producía nada: `/sys/block/loop0/range` vale `1`, o sea que este `loop` no admite -> particiones de ningún tipo. **Regla 5, novena vez**, y la etapa 20 lleva ese -> discriminador adentro en vez de una nota — cuando no aparecen particiones, -> escribe un MBR y vuelve a mirar; si de ése tampoco salen dice `NOT PROVEN`, y si -> salen, **entonces** el fallo es de Thalyx y lo dice como fallo. -> -> Lo que sí se pudo hacer aquí, y vale como red: las dos sumas de la GPT -> recalculadas con un CRC-32 independiente, y el volumen FAT32 recorrido entero por -> un lector escrito aparte —raíz, `EFI`, `BOOT`, la cadena de clusters— que devolvió -> los 3 000 000 de bytes idénticos. -> -> ### Lo que falta, y es de Cesar -> -> 1. **Correr `sudo ./dev/verify.sh`.** La etapa 20 es nueva y son **once líneas** -> que sólo tu máquina puede establecer. Espero `proven 128 · not proven 1 · -> failed 0`. -> 2. **Y después `make -C image run-installed`**, que es la afirmación de verdad y -> no la ejerce ninguna etapa: -> -> ``` -> make -C image # el kernel, si no está construido -> sudo make -C image installed -> make -C image run-installed -> ``` -> -> Un firmware UEFI recibe **sólo el disco instalado** —sin ISO, sin `-kernel`, -> sin nada— y tiene que encontrar `\EFI\BOOT\BOOTX64.EFI` y arrancarlo. Si -> encuentra nada, se queda en el shell de UEFI o reinicia; eso es lo que hay que -> esperar si el tipo de partición, el FAT o el lugar del archivo están mal. -> -> **El kernel se le pasa por `--kernel` a propósito.** Un instalador corriendo -> *dentro* de la máquina arrancada desde la ISO tendría que sacar el bzImage del -> medio del que arrancó, y eso pide un **lector** de FAT y saber cuál disco es el -> medio. Es su propio cambio y no va encima de éste; está en [[Tareas-Pendientes]] -> junto con la otra cosa que el criterio va a pedir — que el store de una máquina -> recién instalada queda **vacío**, así que los pasos 2 a 6 no se pueden hacer *en -> ella* hasta que exista una forma de que el software llegue a una máquina que no -> es ésta. -> -> **771 pruebas pasan, `clippy` limpio en 1.97, `cargo fmt` aplicado.** El -> contenedor se actualizó a 1.97 antes de empezar, que es la regla del desfase de -> versión del bloque anterior aplicándose por primera vez. - -> ## Thalyx hace los subvolúmenes, y clippy sí era un lint — 2026-08-07 -> -> **El bloque de arriba es más reciente.** -> -> ### Lo que se construyó -> -> **Un sistema de archivos recién escrito ya no es lo único que sale de -> `thalyx disk format`.** Los tres subvolúmenes decretados —`system`, `modules`, -> `user`— los crea Thalyx por **`BTRFS_IOC_SUBVOL_CREATE`**, porque adentro de la -> imagen no hay binario `btrfs`. Tercera vez que este proyecto contesta a un -> binario ausente con una llamada al kernel: `bpftool`, `cpio`, y ahora `btrfs`. -> -> ``` -> ok subvolume system — created -> ok subvolume modules — created -> ok subvolume user — created -> -> ok mountable subvol=system -> ok mountable subvol=modules -> ok mountable subvol=user -> -> This is a store. PID 1 can mount it. -> ``` -> -> **La comprobación es montar, no mirar.** Cada uno se monta con -> `-o subvol=`, exactamente como lo hace PID 1. Preguntar si apareció un -> directorio con ese nombre daría *sí* para un directorio común, que es justo lo -> único que PID 1 no puede montar. -> -> Y **el número del ioctl no se toma de fe.** `_IOW` es un macro de C, aquí no hay -> C, así que la constante está escrita a mano y `tests/ioctl.rs` la recalcula desde -> el header capturado — incluido el tamaño del argumento, que va codificado adentro -> del número. Un tamaño equivocado no falla limpio: el kernel compara la palabra -> entera y contesta `ENOTTY` en un sistema de archivos que soporta la llamada -> perfectamente, lo que se lee como «este kernel es viejo». -> -> ### El fallo de clippy era un lint de verdad, y mi diagnóstico estaba mal -> -> Con el informe arreglado, tu corrida dijo qué era: **`unnecessary_sort_by`**, dos -> veces en `format.rs`. **No era `RUSTUP_HOME`.** Era **desfase de versión**: tu -> clippy es 1.97 y el del contenedor era 1.94, y el lint aprendió ese caso en -> medio. Actualizado el contenedor a 1.97, apareció en el primer intento; el -> arreglo fueron dos líneas. -> -> Las cuatro corridas «con el mismo `rustc`» comparaban 1.94 contra 1.94. Es la -> regla 5 con una vuelta que valía escribir aparte: **el instrumento incluye su -> número de versión**, y un linter es un instrumento cuyo trabajo entero es cambiar -> de opinión entre versiones. Ahora la etapa 2 imprime la versión de clippy en las -> dos líneas, y **el contenedor se mantiene al menos tan nuevo como tu máquina** — -> lo contrario garantiza que cada lint nuevo se descubra en la única máquina que no -> puede arreglarlo. -> -> No se fijó la cadena con un `rust-toolchain.toml`. Sería decisión tuya, te -> obligaría a descargar una versión concreta, y además un proyecto que fija su -> linter deja de enterarse de los lints nuevos — que es lo que se quería. -> -> ### Lo que falta, y es de Cesar -> -> **Correr `sudo ./dev/verify.sh`.** La etapa 19 es nueva y **sólo tu máquina puede -> establecerla**: este contenedor no tiene Btrfs en el kernel, así que aquí sale -> `NOT PROVEN` y eso es lo correcto. -> -> Espero `proven 117 · not proven 1 · failed 0`: tus 110, más los dos que fallaron -> y ya no deberían, más las cinco nuevas. -> -> Las cinco líneas que se agregan, y cada una tiene por qué existir: -> -> ``` -> PROVEN a filesystem Thalyx just wrote has no subvolumes, so there is something to do -> PROVEN Thalyx created the three subvolumes through the kernel, with no btrfs binary -> PROVEN all three mount the way PID 1 mounts them, read back by mount(8) and not by Thalyx -> PROVEN a name nobody created does not mount, so subvol= is really being honoured -> PROVEN run again on a finished store it reports them as already there and changes nothing -> ``` -> -> La primera es la línea base —sin ella, un comando que no hiciera nada pasaría la -> etapa—. La tercera lo lee con `mount(8)` y no con Thalyx, porque preguntarle al -> programa que acaba de hacer el trabajo no prueba nada. La cuarta es el control: un -> kernel que ignorara `subvol=` montaría los cuatro. La quinta es el camino de -> reparación, porque un instalador que falla a la mitad no puede costar el disco. -> -> **Es un cambio solo, a propósito.** El instalador no va encima de esto hasta que -> la etapa 19 haya corrido. -> -> **721 pruebas pasan, `clippy` limpio en 1.97, `cargo fmt` aplicado.** - -> ## El kernel montó el Btrfs que escribió Thalyx — 2026-08-07 -> -> **El bloque de arriba es más reciente.** -> -> ``` -> proven 110 · not proven 1 · failed 2 -> ``` -> -> Fedora 43, kernel 7.1.5, `main @ 9229268`. Lo que la etapa 18 cerró, y ninguna -> corrida anterior podía cerrar: -> -> ``` -> PROVEN Thalyx wrote a Btrfs filesystem with no mkfs.btrfs and no libbtrfs -> PROVEN the kernel mounts a filesystem Thalyx wrote byte by byte -> PROVEN the three decreed subvolumes can be created on it -> PROVEN a file written to it comes back, so the allocator has somewhere to go -> ``` -> -> **El kernel monta lo que Thalyx escribió**, acepta los tres subvolúmenes -> decretados, y un archivo escrito en él vuelve. `btrfs check` ya lo aceptaba; eso -> no era un montaje, y ahora sí lo hay. La máquina puede hacer el disco en el que -> guarda. -> -> ### Y los dos fallos eran míos, ninguno del formato -> -> **1. El control de la etapa 18 dañaba espacio libre y acusaba al kernel.** -> -> ``` -> FAILED the kernel mounted a filesystem with both copies of its root tree damaged -> ``` -> -> El kernel no aceptó basura. **Btrfs es copy-on-write**: la copia que el control -> rompe se sacaba *después* de montar, crear los subvolúmenes y escribir un -> archivo, y a esa altura la primera transacción del kernel ya había escrito un -> árbol raíz nuevo en otro sitio y retirado el que escribió Thalyx. Los bytes que -> se pisaban eran espacio libre de la generación 1. -> -> Lo peor no es el error sino que **el informe acusaba al kernel de un defecto del -> arnés, en una línea escrita precisamente para no dejarse engañar.** Y la prueba -> equivalente de `cargo test` pasaba y sigue pasando, porque ahí el sistema de -> archivos nunca se monta y el bloque sigue vivo. -> -> Arreglado sacando la copia antes de cualquier montaje, y con **línea base para el -> control mismo**: se comprueba que la copia dañada difiera de la original, porque -> un `cp` que falla o un `dd` que no escribe nada dejan una imagen intacta —que -> monta— y eso se reportaría otra vez como el kernel aceptando basura. -> -> Comprobado aquí con `btrfs check`, que es lo que este contenedor puede hacer: -> dañando la copia pristina en los mismos offsets, `checksum verify failed on -> 5242880` en las dos copias y `cannot open file system`. El mecanismo estaba bien; -> lo que estaba mal era cuándo se sacaba la muestra. -> -> **2. Clippy falló en tu máquina y no lo pude reproducir.** -> -> Lo intenté cuatro veces con el mismo código: `rustc` 1.94 en limpio, 1.90 en -> limpio, y 1.90 incremental sobre el cambio. Las cuatro salieron limpias. **No -> puedo decir que esté arreglado**, y no lo voy a decir. -> -> Lo que sí encontré es por qué no se puede saber: `verify.sh` construye su -> directorio con `mktemp -d` y **lo borra al salir**. Unos treinta mensajes de -> fallo terminan en `see $WORK/algo.log`, y los treinta apuntaban a una ruta que ya -> no existía cuando alguien iba a leerla. El único artefacto que podía decir qué -> lint era lo borró el script que lo escribió. -> -> > **Resuelto al día siguiente y no era esto.** Con el informe arreglado, la -> > siguiente corrida dijo el lint: `unnecessary_sort_by`, y la causa era que tu -> > clippy es 1.97 y el del contenedor era 1.94. Ver el bloque de arriba. Lo de -> > abajo queda escrito porque los cuatro arreglos son buenos y porque el punto 4 -> > sigue siendo un defecto real — sólo no era *este* fallo. -> -> Cuatro arreglos, y el cuarto parecía la causa de lo que viste: -> -> 1. **El directorio se conserva cuando algo falló**, y el resumen dice dónde está. -> 2. **Los diagnósticos de clippy se imprimen** en vez de referenciarse. -> 3. **«clippy objetó al código» y «clippy no pudo correr» dejaron de ser la misma -> línea.** El segundo es `NOT PROVEN` y dice qué componente instalar — regla 10, -> en el sitio donde costó un diagnóstico. -> 4. **El arreglo del entorno de rustup bajo `sudo` estaba condicionado a que -> `cargo` no estuviera en el `PATH` de root.** Pero que `command -v cargo` -> encuentre el shim de rustup no dice que ese shim pueda resolver una cadena de -> herramientas: la busca bajo `$HOME/.rustup`, y `sudo` pudo haber puesto `$HOME` -> en `/root`. En tu corrida la etapa 1 encontró cargo, así que `RUSTUP_HOME` -> **nunca se puso**. El fallo que eso deja es por componente: una cadena que -> contesta `build` y `fmt` pero no `clippy` se reporta como clippy encontrando -> problemas. Ahora se aplica siempre que se corra bajo `sudo`. -> -> Y la etapa 1 ahora imprime la **versión** de la cadena de herramientas, no sólo su -> ruta: una corrida contra otra cadena se ve idéntica a una contra la esperada. -> -> **Si en la próxima corrida clippy vuelve a fallar, el informe va a decir qué -> lint es.** Y así fue: dijo `unnecessary_sort_by`, que es el bloque de arriba. -> -> Las dos reglas nuevas están en [[Estrategia-de-Pruebas]], y la cuenta de la regla -> 5 va en diez con la del desfase de versión. - -> ## Thalyx escribe su propio Btrfs — 2026-08-07 -> -> **El bloque de arriba es más reciente. Esto es lo que se construyó.** -> -> `crates/thalyx-btrfs`: ocho árboles, tres chunks y los superbloques, escritos -> byte por byte. **Sin `mkfs.btrfs` y sin `libbtrfs`.** Es el punto 2 del orden de -> trabajo del ISO y era el poste largo — la máquina arrancaba y no podía guardar -> nada. -> -> Lo obliga [[Filosofia-Fundacional]] y no una preferencia: la imagen lleva el -> kernel y un programa, así que `mkfs.btrfs` no puede estar ahí. Misma forma que -> `bpftool` y que `cpio`, misma respuesta. -> -> ``` -> $ thalyx disk format /tmp/store.img --yes -> ok store /tmp/store.img — 8589934592 bytes, labelled `thalyx-store` -> fsid a39c0565af37487b8f8fe806ff352104 -> 2 superblock(s), 131072 bytes of metadata -> ``` -> -> ### El decreto de que PID 1 nunca fabrica se conservó entero -> -> Estaba anotado como *pendiente de confirmar al construirlo*, y se confirmó: -> **nada de `thalyx-btrfs` es alcanzable desde PID 1.** Lo invoca un humano con -> `thalyx disk format`, que es el acto explícito que la tarea preveía. PID 1 sigue -> montando y sin crear. -> -> La confirmación **pide teclear la ruta del dispositivo**, no una `y`. Es lo más -> destructivo que Thalyx sabe hacer, el argumento es una palabra, `/dev/sda` y -> `/dev/sdb` se diferencian en una tecla, y una `y` confirma una frase que el -> humano ya dejó de leer. Antes de preguntar dice qué hay en el disco ahora, -> leyéndolo. -> -> ### Lo que falta, y es de Cesar -> -> **Correr `sudo ./dev/verify.sh`.** La etapa 18 es nueva y **el montaje sólo lo -> puede establecer tu máquina**: este contenedor no tiene Btrfs en el kernel ni -> módulos que cargar. Aquí la etapa sale así, que es lo correcto: -> -> ``` -> PROVEN Thalyx wrote a Btrfs filesystem with no mkfs.btrfs and no libbtrfs -> PROVEN it identifies itself by the label an installed machine looks for -> PROVEN a device nobody formatted is reported as no filesystem, not as no label -> PROVEN btrfs check walks it and finds nothing wrong -> NOT PROVEN this kernel has no Btrfs, so nothing could mount what Thalyx wrote -> ``` -> -> En tu máquina esas cinco líneas se vuelven ocho, y las cuatro que se agregan son -> las que importan: que el kernel lo monta, que acepta los tres subvolúmenes, que -> un archivo escrito en él vuelve, y **que el mismo sistema de archivos dañado se -> niega** — sin ese último, el montaje no demuestra nada. -> -> **Es un cambio solo, a propósito.** No apilé la búsqueda por etiqueta encima. -> -> ### Cómo se sabe que el formato es correcto sin poder montarlo -> -> Dos instrumentos, y ninguno es leer el formato. -> -> Los headers de Linux (`btrfs_tree.h` y `btrfs.h`) están capturados verbatim en -> `crates/thalyx-btrfs/tests/`, y una prueba los parsea y comprueba **cada tamaño -> y cada offset** que el escritor usa. Y `btrfs check` recibe lo escrito y recorre -> los árboles, las referencias inversas y la contabilidad de cada grupo de -> bloques: `no error found`, también en el disco más chico que se permite y -> leyendo desde el superbloque de respaldo. -> -> **`btrfs check` no es un montaje**, y está dicho así en el código: lee con el -> código de btrfs-progs, no con el del kernel, y los dos ya se han contradicho. -> -> btrfs-progs es dependencia **de desarrollo**, nunca de ejecución — el punto del -> crate es que la imagen no lo tiene. Así que se salta donde falta, dice `NOT -> PROVEN`, y hay **una variable por requisito**: `THALYX_REQUIRE_BTRFS_PROGS` para -> el validador y `THALYX_REQUIRE_BTRFS_TESTS` para el montaje. Las dos se -> ejercieron en las dos direcciones. -> -> ### Tres defectos, y los tres dan regla -> -> 1. **Btrfs usa el mismo CRC32C con dos convenciones y no coinciden.** La suma de -> un bloque es CRC32C estándar; el hash del nombre de una entrada de directorio -> es el primitivo crudo desde `~1` **sin complemento final**. La primera versión -> aplicó la estándar a las dos y el hash de `default` salió un número estable, -> plausible, y que hace que el kernel resuelva el subvolumen por omisión -> encontrando nada. Leer el kernel no lo evita: la diferencia está en el -> intermediario. Lo encontró una imagen real de `mkfs.btrfs`. -> 2. **Un bit de versión omitido no falla al parsearse: se parsea como otro -> formato.** Sin `MIXED_BACKREF_REV` en las banderas de cada cabecera, todo -> parseaba perfecto y `btrfs check` reportó **once fallas de referencia**, una -> por extent. El síntoma estaba lo más lejos posible de la causa, y lo delató -> una línea informativa —`backref revision 0`— comparada contra la imagen de -> referencia. -> 3. **El arnés otra vez, y van ocho.** El parser que comprueba los offsets -> descartaba en silencio todo campo con comentario al final de su línea, y dijo -> que `btrfs_root_item` medía 343 cuando el escritor producía 439. **El escritor -> tenía razón.** Ahora hay una prueba que gradúa al parser antes de medir nada -> con él, usando los tamaños que los headers afirman en su propio texto. -> -> Las dos primeras están escritas como reglas nuevas en [[Estrategia-de-Pruebas]]; -> la tercera se sumó a la cuenta de la regla 5. -> -> ### Lo que seguía faltando, y se hizo el mismo día -> -> **Un sistema de archivos recién escrito no tiene subvolúmenes**, y PID 1 monta -> `subvol=system`. Así que un store hecho con `thalyx disk format` **todavía no era -> un store**, y el comando lo decía al terminar en vez de dejar que pareciera que -> sí. Ya los crea, por ioctl — el primer bloque de esta nota. -> -> Y `make -C image store` **sigue usando `mkfs.btrfs`** a propósito: es la red de -> regresión de las etapas 13 y 16, y cambiarla en el mismo commit que introduce lo -> que hay que probar dejaría la red y lo probado siendo el mismo código sin -> ejercer. Misma razón por la que `boot` siguió pasando `-kernel` cuando apareció -> `run-uefi`. -> -> **710 pruebas pasan, `clippy` limpio, `cargo fmt` aplicado.** - -> ## Todo lo que existe está verificado, y la persona ajena se cancela — 2026-08-06 -> -> **Es lo primero que hay que leer.** -> -> ``` -> proven 104 · not proven 1 · failed 0 -> ``` -> -> Fedora 43, kernel 7.1.5, `main @ 9e1c5f8`. Las diecisiete etapas, incluidas -> **las tres que nunca habían corrido enteras**: el arranque frío tecleando los -> seis pasos, el reinicio de verdad, y lo que la auditoría cerró. La única `not -> proven` es `llama.cpp`, que **no es una comprobación que no se pudo hacer sino -> una cosa que no existe todavía**. -> -> Y se corrió otra vez con `THALYX_REQUIRE_IMAGE_TESTS=1`, que convierte en -> fallo cualquier salto de la etapa 16. Mismo resultado: la etapa corrió de -> verdad, no se saltó en silencio. Esa segunda corrida es la que vuelve creíble -> la primera. -> -> **Es la primera vez que no hay nada roto y nada pendiente de ejercer.** -> -> ### El ancla del kernel ya está en el repositorio -> -> Cesar la estableció contra la lista firmada de kernel.org y la puso con `nano` -> en su máquina, donde no la ve nadie más. Ahora está en `image/Makefile`: -> -> ``` -> KSHA256 := 0d21cd11933f49f7151b7c9dbb8cc3fddc8c8abe506434b850feecf41fc28a76 -> ``` -> -> Con **quién la firmó y su huella al lado**, porque un digest a secas dice qué -> se aceptó y no qué lo estableció, y el siguiente que lo herede no tiene cómo -> volver a comprobarlo. `make -C image doctor` ya pasa. -> -> ### Y el procedimiento que la establece estaba equivocado -> -> Lo encontró él corriéndolo, que es la regla 1 otra vez. `pin-kernel` decía: -> -> ``` -> gpg --locate-keys torvalds@kernel.org gregkh@kernel.org -> ``` -> -> Son quienes firman una versión del kernel. **No son quienes firman ese -> archivo**: `sha256sums.asc` lleva la llave automática de sumas de kernel.org, -> así que `gpg --verify` contestó `No hay clave pública` — tres renglones debajo -> de una frase que decía que cualquier cosa que no sea *Good signature* es -> motivo para parar. **El procedimiento imprimía la falla que él mismo define -> como fatal.** Cesar tuvo que encontrar `autosigner@kernel.org` por su cuenta. -> -> Se escribió en este contenedor, cuya red no alcanza kernel.org, así que no -> había cómo correrlo aquí y salió sin correr. Regla nueva en -> [[Estrategia-de-Pruebas]]: **un procedimiento impreso para una persona es -> código sin correr.** Arreglado, con la huella impresa para comparar, el aviso -> de gpg explicado como esperado, y una prueba que exige las dos copias. -> -> ### La persona ajena se cancela — decisión de Cesar -> -> Los seis pasos ya no los va a ejecutar alguien de fuera, por ahora. Su -> razonamiento, entero, está en [[Criterio-de-Salida-Fase-1]]: Thalyx todavía -> son comandos de terminal, el producto terminado será una ISO booteable, y se -> prueba cuando haya algo que probar. Ni siquiera se pudo convencer a la persona -> de hacerlo. -> -> **Lo que no cambia**: los seis pasos siguen siendo lo que el sistema tiene que -> hacer, y se siguen comprobando solos en cada cambio y en cada corrida de -> hardware. Lo que se cancela es quién los teclea. -> -> ### Y arrancó entera: el paso 1 del criterio está cerrado — 2026-08-06 -> -> **Un firmware arrancó Thalyx sin gestor de arranque, y la máquina hizo todo lo -> que sabe hacer.** Con la consola puesta, el segundo intento salió así: -> -> ``` -> ok root moved off the initramfs, so a module can be pivoted into a root -> ok mounted /proc … /sys/fs/cgroup (los siete) -> ok sandbox root the root is attached -> ok controllers memory, pids handed down at /sys/fs/cgroup -> no store no thalyx.store= on the kernel command line -> ok thalyx-lsm 2 hook(s) live, 3 map(s) pinned under /sys/fs/bpf/thalyx -> ``` -> -> Lo que eso demuestra, y no lo demostraba ningún arranque anterior: **todo lo -> que Thalyx hace funciona cuando lo arranca un firmware y no `-kernel` de -> QEMU.** El `switch_root`, los siete montajes, la delegación de controladores, -> **el LSM enganchado**, la sesión, y el apagado limpio. No hay GRUB en ninguna -> parte y no hace falta. -> -> El `no store` es **correcto y estaba previsto**: `thalyx.store=` nombra un -> dispositivo, la línea de comandos va compilada dentro del kernel, y no hay un -> nombre que sirva en las dos máquinas. La máquina lo dice como lo que es — -> *«el disco no falta; nadie me dijo cuál es»*— en vez de adivinar. Es el paso 3. -> -> **Lo que sigue sin probarse es hierro.** Esto fue OVMF dentro de QEMU: discos -> virtio, teclado emulado y consola serie. Una PC de verdad no tiene puerto -> serie, y ahí es donde `console=ttyS0` deja de servir. -> -> ### El firmware arrancó Thalyx sin gestor de arranque — 2026-08-06 -> -> **El paso 1 del criterio nuevo funcionó.** OVMF encontró -> `EFI/BOOT/BOOTX64.EFI`, lo arrancó, el kernel desempaquetó el initramfs que -> lleva adentro y ejecutó `/init`. **No hay gestor de arranque y no hace falta -> ninguno**: el medio lleva un archivo y ese archivo es el kernel con Thalyx -> dentro. -> -> Y murió en su primera instrucción, por algo que no era ninguna de las dos -> cosas que se estaban probando: -> -> ``` -> Warning: unable to open an initial console. -> Run /init as init process -> traps: init[1] general protection fault ip:7fea0faff143 -> Kernel panic - not syncing: Attempted to kill init! -> ``` -> -> La instrucción que falló es `hlt`, que está al final de `abort()` de musl — la -> que sólo se alcanza cuando `SIGABRT` **no** mató al proceso, y no lo mata -> porque el kernel no le entrega señales fatales por defecto a PID 1. Y quien -> llamó a `abort()` no fue código de Thalyx: fue el runtime de Rust **antes de -> `main`**, que se niega a seguir si no puede garantizar descriptores 0, 1 y 2. -> El archivo tenía un `/dev` vacío. -> -> **Ningún arranque anterior podía verlo**: `-initrd` se desempaqueta *encima* -> del initramfs propio del kernel, que trae `/dev/console`. Meter el nuestro -> adentro lo *reemplaza*. La consola venía de regalo de algo que nadie miró — y -> es la **tercera vez** con esa forma exacta, después de systemd con los -> controladores y del `switch_root`. Regla nueva en [[Estrategia-de-Pruebas]]. -> -> Arreglado: el archivo lleva `/dev/console` (carácter 5:1). **Sin `/dev/null` -> al lado, a propósito** — una máquina que no alcanza su consola debe detenerse, -> no correr perfecta hablándole a la nada, que es peor y que este proyecto ya -> cometió una vez. -> -> Y el conteo tuvo que aprender una clase nueva: un nodo de dispositivo no es un -> programa, pero `is_directory()` lo habría contado como uno y `count` habría -> dicho **2**. Ahora hay tres clases, se imprimen los números del nodo, y una -> prueba exige que las tres sumen el total — porque lo que no se cuenta es justo -> por donde entraría un segundo programa sin que el número se moviera. -> -> `make -C image run-uefi` es lo que lo arranca. Necesita OVMF -> (`sudo dnf install edk2-ovmf`); si falta, se niega y dice que **no se probó -> nada** en vez de reportar que Thalyx falló. -> -> Y **no se tocó `make run`**: sigue pasando `-kernel` e `-initrd`, porque es la -> red de regresión de la etapa 16 y cambiarla ahora dejaría la red y lo que se -> prueba siendo el mismo cambio sin ejercer. Se mueve cuando la ruta del -> firmware tenga su propia etapa. -> -> ### Y el criterio nuevo: una ISO independiente -> -> Cesar eligió el sustituto el mismo día: -> -> > una ISO totalmente independiente, es decir: que puedas ponerla en una PC sin -> > sistema operativo y que ahora tenga Thalyx como OS […] el objetivo es que -> > tengamos la ISO y nada más, y con ella sola podamos tener Thalyx corriendo. -> -> **Es la propiedad que la persona ajena aportaba y la lista de componentes -> nunca tuvo: no la puede declarar nadie.** O existe un archivo que convierte -> una máquina sin sistema operativo en una máquina Thalyx, o no existe. -> -> Y es **más** exigente que lo de hoy, no menos. Hoy **QEMU es el gestor de -> arranque**: `make run` pasa `-kernel` y `-initrd`. Nada de lo construido sabe -> arrancar solo. -> -> Se puede ejercer en una VM, y eso está bien **si se dice qué prueba**: una VM -> con firmware UEFI de verdad prueba que la ISO arranca **sola**, que es la -> mitad que importa. **No prueba los controladores** — sus discos son virtio y -> su teclado es emulado. Esa mitad necesita hierro. -> -> Lo que cuesta, en [[Construccion-del-ISO]]. Los cuatro puntos, por riesgo: -> -> 1. **El store, que hoy nadie crea.** PID 1 monta y **tiene prohibido -> fabricar**, con buena razón: una máquina que se inventa un store arranca -> perfecta el día que el disco no estaba. En una PC vacía no hay store, y -> `mkfs.btrfs` no puede ir en la imagen. Es el decreto que hay que revisar, y -> **«es la primera vez» y «no encontré el tuyo» tienen que seguir siendo -> distinguibles**. -> 2. **Los controladores.** `allnoconfig` más virtio y un serie. Falta UEFI, -> framebuffer, teclado USB y almacenamiento real. Van tres opciones de kernel -> encontradas arrancando y hay una regla que dice que ninguna comprobación de -> construcción encuentra la siguiente. -> 3. **La consola es `ttyS0`.** Una PC moderna no tiene puerto serie: arrancaría -> bien y no se vería nada. -> 4. **El gestor de arranque, que es la pregunta de filosofía.** GRUB sería un -> segundo programa y [[Filosofia-Fundacional]] no lo permite — la misma forma -> que el hueco de `bpftool`. La salida que no pide excepción: un kernel con -> `CONFIG_EFI_STUB` **es** una aplicación UEFI, así que el firmware lo carga -> directo y el medio lleva **un archivo**. Es `count` extendido al medio de -> arranque. **Plan, no propiedad**: no se ha construido. -> -> Y que nadie aceptara hacerlo **es un dato y no un contratiempo**: la primera -> medición de [[Por-Que-Elegirian-Este-SO]] no fue una opinión sobre el sistema, -> fue que media hora de terminal ya cuesta más de lo que hoy ofrece. - -> ## Los arreglos de la auditoría rompieron dos cosas, y las dos ya están — 2026-08-05 -> -> **Es lo primero que hay que leer.** Cesar corrió `sudo ./dev/verify.sh` con los -> arreglos de la auditoría puestos y salió `proven 99 · not proven 2 · failed 10`. -> Los diez fallos eran de Thalyx, no suyos, y los diez son **dos defectos**, los -> dos del commit `3976974`. -> -> ### 1. El módulo perdió su `stdout`, y ese `stdout` era el instrumento -> -> El arreglo era correcto —el módulo compartía terminal con el camino confiable, -> podía leer la `y` del humano y podía dibujar el marco— y la respuesta fue -> mandarle `stdout` y `stderr` a `/dev/null`. -> -> **La etapa 6 le pregunta al programa confinado qué ve**, que es la regla 2: -> su pid, su uid, su hostname, sus interfaces, su raíz, si alcanza lo concedido. -> Las seis respuestas viajaban por `stdout`. Con el descarte, las seis -> reportaron `nothing` — **que es también lo que reporta un sandbox que no aisló -> nada**. La medida de contención dejó a la contención sin testigo. -> -> Y costaba la otra dirección: un módulo que muere con un mensaje en `stderr` no -> dejaba mensaje. *Falló* y *falló por esto* eran el mismo evento, que es lo que -> la regla 10 prohíbe. -> -> Ahora la salida es una tubería que Thalyx **drena** y reimprime marcada y -> saneada, igual que el canal. La propiedad que el `/dev/null` compraba se -> conserva, dicha con precisión: **un módulo no puede empezar una línea.** No que -> sus palabras desaparezcan —desaparecer era el error— sino que todo lo que -> escribe llega detrás del marcador de Thalyx: -> -> ``` -> org.thalyx.verify wrote, at descriptors Thalyx does not mediate: -> > uid=700000 -> > pid=1 -> ! and now a diagnostic -> ``` -> -> Decidido por Cesar el 2026-08-05, entre tres opciones. El techo es sobre lo -> que se **guarda** (64 KiB), nunca sobre lo que se lee: un lector que se -> detiene en su propio techo bloquea al módulo en la siguiente escritura, y el -> límite de memoria se vuelve un cuelgue. Hay una prueba con 200 000 líneas. -> -> ### 2. Lo que un módulo dice se truncaba a 72 caracteres -> -> Es **el mismo defecto que la auditoría ya había arreglado para los permisos**, -> sin arreglar donde el mismo razonamiento se aplica. `sanitise` corta a 72 y -> `sanitise_block` lo llamaba línea por línea. El `greeter` dijo: -> -> ``` -> read 27 byte(s) from /tmp/tmp.BCvj7bvl02/greeter-granted/notes.txt: the… -> ``` -> -> Contestó qué leyó y Thalyx tiró la respuesta. Explica las etapas 12 y 13. -> -> Lo peor no es el corte sino **quién decide dónde cae**: lo que gasta el -> presupuesto es la *ruta*, así que el mismo módulo dice menos en una máquina -> con directorios más anidados. Ahora se acota por el **medio** y con mucho más -> aire, como los permisos. -> -> ### Y la etapa 17 llevaba un día sin poder probar nada -> -> Decía `NOT PROVEN: no installed module`, honestamente y **siempre**: la etapa -> 6 revierte el módulo como su última comprobación. Un salto que se dispara -> siempre no es un salto, es una comprobación que nunca se hizo. -> -> Le faltaba además el control que importa: afirmaba que el texto del módulo -> **no aparece**, lo que se satisface borrándolo todo — y de hecho pasó en verde -> la misma corrida en que la etapa 6 se quedó ciega. Ahora exige las dos cosas: -> que aparezca, y que no empiece ninguna línea. -> -> **666 pruebas pasan, `clippy` limpio, `cargo fmt` aplicado.** Tres reglas -> nuevas en [[Estrategia-de-Pruebas]]. -> -> ### Lo que falta, y es de Cesar -> -> 1. **Correr `sudo ./dev/verify.sh` otra vez.** De los diez fallos, los cuatro -> de las etapas 12 y 13 y los de la 6 en modo `--unconfined` se ejercieron -> aquí; **la ruta confinada no**, porque este contenedor no tiene el LSM -> atachado y la prueba del cgroup se salta diciéndolo. El cambio del sandbox -> —`launch::spawn` con tuberías en vez de `/dev/null`— **solo se ejerce en tu -> máquina**. -> 2. **Anclar el digest del kernel.** `make -C image` sigue fallando a propósito -> hasta que lo hagas, y eso es correcto: es la tarea 2 de abajo, no un -> defecto. `make -C image pin-kernel` imprime los cuatro comandos. -> -> ## Y el criterio de salida dejó de depender de que alguien corra un comando — 2026-08-04 -> -> **Léelo después del bloque de la auditoría, que sigue siendo lo primero.** -> -> Al ir a comprobar el paso 6 apareció por qué estaba "escrito y sin ejercer": -> la etapa 15 de `verify.sh` —la que maneja el prompt e implica los pasos 2, 3, -> 4 y 6— necesitaba `script(1)`, que Fedora trae en `util-linux-script`. En la -> única máquina que puede verificar Thalyx, la etapa se saltaba entera. -> -> El salto hizo lo que debía: dijo `NOT PROVEN`. Y no alcanzó, porque nadie -> actúa sobre un informe que ya conoce. Ver la regla nueva en -> [[Estrategia-de-Pruebas]]. -> -> Dos cosas, y las dos ya están: -> -> 1. **Thalyx hace su propia terminal.** `thalyx dev pty` —`posix_openpt`, -> `setsid`, `TIOCSCTTY`— en `thalyx-syscall`, donde vive el `unsafe`. La -> misma decisión que el initramfs y el cargador de BPF: antes ochenta líneas -> propias que una cuarta cosa que nadie eligió. `verify.sh` ya no necesita -> nada que la máquina que corre Thalyx no tenga. -> 2. **Los cuatro pasos que no necesitan hardware son ahora pruebas.** Corren en -> cada cambio, contra el disco y desde fuera de la sesión que dice haber -> hecho las cosas. En `verify.sh` queda lo que sí necesita máquina: arrancar -> la imagen, que el kernel deniegue, un reinicio de verdad. -> -> **Y manejar el prompt de verdad encontró un defecto que la auditoría había -> creado**: el saneador truncaba también las líneas de permiso, y un permiso no -> es una etiqueta. Con un `$HOME` largo, `/home/user/projects/secrets` y -> `/home/user/projects/public` se dibujaban igual, y el humano confirmaba una -> frase cierta de los dos. Ahora los permisos se envuelven en varias líneas — -> solo la primera lleva viñeta, para que una línea envuelta no parezca una -> concesión más— y si hay que acotar se elide el **medio**, no el final: la raíz -> y la hoja son las dos partes que dicen qué se está concediendo. -> -> Regla 1 otra vez: salió de correr el sistema, y hubo que escribir la terminal -> para poder correrlo. -> -> **El `doctor` también aprendió el ancla del kernel.** Al hacerlo fallar sin -> digest, el commit anterior había creado justo el fallo que el `doctor` existe -> para evitar: decir "está todo" y que la pared llegue después de la descarga. -> Ahora se reporta en la misma vuelta que lo demás. -> -> ### Lo que decidí no hacer, y por qué -> -> **Cargar `thalyx_watch` con el cargador propio.** Es el último punto de "lo -> que falta comprobar" y no lo toqué: aquí no se puede compilar el objeto -> —faltan las cabeceras de `libbpf`— así que sería una segunda cosa sin ejercer -> apilada sobre las que ya esperan hardware, que es exactamente lo que -> `CLAUDE.md` prohíbe. El cargador ya recorre varios programas y varios mapas, y -> el único tipo que el watcher usa y el LSM no es `PERCPU_ARRAY`, que es un mapa -> con clave como cualquier otro. Es probable que funcione y **probable no es -> comprobado**. -> -> ## Lo último: una auditoría externa encontró nueve defectos reales — 2026-08-04 -> -> **Es lo primero que hay que leer y lo único pendiente de comprobar.** -> -> Alguien de fuera revisó el repositorio y escribió una auditoría de seguridad. -> Se verificó afirmación por afirmación contra el código: la mayoría eran -> ciertas, tres son críticas, y varias de las que no eran ciertas también se -> respondieron por escrito en vez de quedar en una conversación. -> -> Los tres críticos, en una línea cada uno: -> -> 1. **El lock global decretado no existía.** [[Concurrencia]] lo decretaba -> desde el 1 de agosto y ningún código lo tomaba. Una instalación escribe -> cuatro archivos separados; cada `rename` es atómico y el conjunto no. -> 2. **Una actualización interrumpida podía darle a la versión vieja los -> permisos de la nueva.** El registro se indexaba sólo por id de módulo y la -> comprobación preguntaba "¿está instalado?" en vez de "¿es *esta* versión?". -> Trece pruebas de inyección de fallos cubrían esa ventana y ninguna lo vio, -> porque todas instalaban por primera vez. -> 3. **Un keystore corrupto se leía como uno vacío**, y uno vacío confía en -> todo lo que le ofrezcan. Dañar un archivo degradaba a todos los -> publicadores anclados a un primer avistamiento. -> -> Los seis restantes: los permisos `session` no existían (se guardaban como -> `persistent` y nunca se revocaban); `net/outbound` quitaba el namespace de red -> y seccomp seguía prohibiendo `socket`, así que costaba aislamiento y no daba -> capacidad; el camino confiable dibujaba texto del publicador sin sanear y el -> módulo heredaba la terminal donde se dibuja; el API interna comprobaba la ruta -> y la abría después, con una carrera en medio; un módulo podía hacer crecer sin -> límite la memoria de Thalyx mandando notificaciones; y el journal se negaba a -> leerse entero si su última línea estaba cortada — justo lo que deja un corte. -> -> **Todo está corregido, con pruebas que se comprobó que fallan sin el arreglo.** -> 657 tests pasan, `clippy` limpio, `cargo fmt` aplicado. -> -> ### Lo que falta, y es de Cesar -> -> 1. **Correr `sudo ./dev/verify.sh`.** Nada de esto se ejerció en hardware. Los -> cambios tocan el sandbox (`stdio` del módulo, seccomp según la concesión) y -> el arranque los usa. -> 2. **Anclar el digest del kernel.** `image/Makefile` ahora se niega a -> construir con `KSHA256 := UNPINNED`, porque Thalyx compila su propio kernel -> y ese tarball se bajaba sin verificar nada más que TLS. `make -C image -> pin-kernel` imprime los cuatro comandos; el tercero tiene que decir *Good -> signature*. **Hasta que lo hagas, `make -C image` falla a propósito.** -> 3. **Decidir sobre lo que quedó nombrado y no resuelto**: los módulos son -> binarios de Linux que hablan POSIX, y el decreto dice que no. La distinción -> que sí se sostiene está escrita en [[Sistema-de-Modulos]] —la API es la -> única superficie *mediada*— y cerrar la brecha entera es Fase 2. Ver -> [[Tareas-Pendientes]]. -> -> Un costo que se aceptó a propósito y conviene saber: el API interna ahora usa -> `openat2` con `RESOLVE_BENEATH`, que rechaza **todo** symlink absoluto, -> incluido uno que apunte dentro de la misma concesión. Los relativos siguen -> andando. Hay una prueba que nombra esa pérdida. - -> ## La máquina arrancó — 2026-08-03 -> -> `make -C image run`, en la Fedora de Cesar, con kernel 6.12.101 propio y un -> solo programa dentro. Montó sus siete filesystems, imprimió lo que es y lo que -> no tiene, y esperó una instrucción. **Es la primera vez que Thalyx existe como -> máquina y no como programa sobre la máquina de alguien más.** -> -> Se describió con tres `no`: sin Btrfs, sin enforcement, sin módulos. -> -> ## Y ahora tiene dónde guardar cosas — 2026-08-03, esa misma noche -> -> **Dos de los tres `no` están cerrados.** El store existe: `sudo make -C image -> store` formatea un disco Btrfs con los tres subvolúmenes decretados e instala -> el `greeter` adentro; PID 1 lo monta por el nombre que le da `thalyx.store=` y -> **nunca lo crea**. La sesión sabe `modulos`, `correr ` y `apagar`. -> -> Lo que hace la máquina ahora, entero: arranca, monta su disco, lista el módulo -> que tiene instalado, lo corre, y el módulo le pregunta a Thalyx quién es y qué -> puede leer —porque `correr` no le pasa ningún argumento—, lee lo que le -> concedieron y le niegan `/etc/shadow`. Todo por un socket que él no abrió, -> dentro de la máquina, sin shell. -> -> **Nada de eso se ha ejercido dentro de QEMU todavía.** Ver "Lo que falta -> comprobar" abajo. -> -> ## Y arrancó con su disco puesto — 2026-08-03 -> -> ``` -> ok store /dev/vda — three subvolumes -> ok filesystem btrfs -> ok modules 1: dev.thalyx.greeter 1.0.0 -> ``` -> -> **De tres `no` en el primer arranque a uno.** El que queda es el enforcement, -> que es el hueco de arquitectura que sigue. La máquina arranca, monta su store -> de Btrfs, y sabe qué tiene instalado. -> -> Cuatro cosas se rompieron entre construir el store y verlo montado, y las -> cuatro fueron del constructor y no del sistema — están abajo, en "Los cuatro -> fallos del camino", porque tres de ellas son la misma regla. -> -> ## Y ahora carga su propio enforcement — 2026-08-03, escrito y sin ejercer -> -> El último hueco de arquitectura. `attach_lsm` invocaba `bpftool` —un segundo -> programa, desde una shell, en una imagen que no tiene ninguno de los dos— y -> buscaba `/lib/thalyx/thalyx_lsm.bpf.o`, un segundo archivo. **El mensaje que -> imprimía sugería romper el decreto que estaba reportando.** -> -> Ahora el objeto BPF va dentro del binario y Thalyx hace las llamadas al kernel -> él mismo: `crates/thalyx-bpf` lee ELF, BTF, la forma de los mapas y las -> reubicaciones CO-RE sin una línea de `unsafe`, y `thalyx-syscall` hace las -> cuatro llamadas. Ver [[Cargador-BPF-Propio]]. -> -> **Nada de esto se ha ejercido.** El contenedor no tiene BPF LSM. La etapa 14 -> de `verify.sh` es donde se comprueba, y es lo siguiente que hay que correr. -> -> ## Y el cargador funciona — 2026-08-03, dos fallos después -> -> La etapa 14 en la máquina de Cesar: **cargó, atachó, dejó los mapas donde -> `permd` los busca y se soltó limpio.** El cargador propio es real. -> -> Los dos fallos que costó están en [[Cargador-BPF-Propio]]. El segundo importa -> más que el primero porque no era del cargador: **la demo de denegación se negó -> a correr contra enforcement que estaba vivo**, tres líneas después de que la -> misma etapa demostrara que lo estaba. Preguntaba por un directorio que solo -> crea `bpftool`. -> -> Y tirando de ahí apareció algo peor, que llevaba puesto desde antes: **la -> sesión reportaba enforcement preguntándole a `bpftool` si había un mapa -> fijado.** Dos errores en una línea — la imagen no tiene `bpftool`, así que -> adentro contestaba «no» pasara lo que pasara; y un mapa fijado es un lugar -> donde poner permisos, no algo que los lea. Una máquina con todo fijado y nada -> atachado habría reportado enforcement. -> -> Ahora Thalyx le pregunta al kernel qué programas suyos corre un enlace vivo, y -> lo hace con llamadas `bpf(2)` propias, así que **funciona dentro de la imagen**. -> Hay una respuesta más que antes: *parte de los hooks vivos*, que se nombra -> aparte porque es peor que ninguno. -> -> ## Y la etapa 14 salió verde entera — 2026-08-03 -> -> `proven 72 · not proven 2 · failed 0`. **Thalyx carga su propio enforcement, -> lo atacha, y ese enforcement deniega**: una conexión negada adentro del cgroup -> y permitida afuera, contra hooks que puso Thalyx y no `bpftool`. -> -> **Ese era el último hueco de arquitectura de la Fase 1.** Lo que queda sin -> probar no es arquitectura: es que llegue el modelo de verdad. -> -> Las dos cosas que la máquina de Cesar no puede establecer siguen siendo las -> mismas: `llama.cpp` no está instalado y el camino del modelo real no está -> escrito, y `verify.sh` no arranca la imagen. -> -> ## Y la máquina ya puede instalar, confirmar y revertir — 2026-08-03 -> -> **El objetivo es cerrar la Fase 1**, y el criterio de salida no es una lista -> de componentes: son [[Criterio-de-Salida-Fase-1|seis cosas que hace una -> persona ajena]]. De esas seis, tres pasaban por la sesión y ninguna se podía -> hacer: adentro no hay shell, así que lo que no es un verbo de la sesión no -> existe para esa persona. La sesión entendía seis palabras y ninguna era -> `instalar`. -> -> Ahora entiende `disponibles`, `instalar `, `permisos` y `revertir`. Nada -> de la lógica es nueva —el repositorio local, el camino confiable y el rollback -> ya estaban escritos— y ese era justo el problema: **estaban escritos y no -> alcanzables.** -> -> Y el disco cambió de contenido: lleva el módulo **en un repositorio, sin -> instalar**. Una máquina que arranca con él puesto vuelve el paso 2 -> irrealizable, y el paso 3 —el camino confiable— nunca se alcanza. Hay una -> prueba que lee `image/Makefile` para que eso no se deshaga sin que nadie lo -> note, porque deshacerlo mejora lo que la máquina *aparenta*: arrancaría -> listando un módulo. -> -> La etapa 15 maneja el prompt de verdad, con un pty, y trae el control que -> hace falta: **responder que no no instala.** Sin eso, una sesión que -> instalara pase lo que pase pasaría todas las demás comprobaciones. -> -> ## Y construir esto ya no necesita bpftool — 2026-08-03 -> -> Cesar decidió mandarle la máquina a una persona ajena **cuando los seis pasos -> sean reales**, no antes. Eso convirtió cada dependencia de construcción en un -> sitio donde esa persona se atora, y la peor era `bpftool`: en Ubuntu y -> derivados —Linux Mint, en este caso— viene en `linux-tools-$(uname -r)`, un -> paquete por versión de kernel cuyo nombre a menudo no coincide con el que está -> corriendo. Y se topaba con eso **después** de compilar un kernel entero. -> -> `lsm/vmlinux.h` ahora está escrito a mano: nueve structs, que es lo que los dos -> programas tocan, en vez de las cien mil líneas que generaba bpftool. Ver -> [[Cargador-BPF-Propio]] y la regla nueva en [[Estrategia-de-Pruebas]] — porque -> esto abrió una forma de mentir sin síntoma, y hay una prueba que la muerde. -> -> ## Y los seis pasos existen — 2026-08-04 -> -> **Se puede hacer el criterio de salida entero.** Faltaban dos pasos y los dos -> eran lo mismo: las piezas estaban escritas y no había cómo alcanzarlas. -> -> **El paso 6 no tenía nada detrás.** La sesión no escribía nada en la memoria -> persistente al instalar, así que reiniciar no perdía el contexto — no había -> contexto. Ahora `instalar` y `revertir` escriben por el mismo -> `recollection.rs` que usa `thalyx agent do --task`, y `recuerdos` lo lee. Todo -> vive en `/state/`, que es el subvolumen `system`, que viene del disco: -> hay una prueba que lo afirma contra la tabla de montajes, porque una memoria -> en el tmpfs se ve idéntica hasta el momento de apagar, que es el único que le -> importa al paso 6. -> -> Lo que sale después de instalar, reiniciar y `revertir`: -> -> ``` -> About `session`, you told me: -> · the human asked: instalar dev.thalyx.greeter -> · the human asked: revertir -> -> And this I remember but can no longer confirm: -> ? installed dev.thalyx.greeter 1.0.0 -> ``` -> -> **Eso es lo que distingue una memoria de una bitácora**, y es lo que hace que -> el paso 6 valga sin modelo: nadie le avisó que el módulo se fue. El hecho -> quedó atestiguado contra el enlace `current`, `revertir` lo quitó, y la -> máquina fue a ver. Lo que se le pidió sigue intacto porque ningún archivo -> puede volver falso que alguien lo haya dicho. -> -> Cesar decidió el 2026-08-04 que **eso es el paso 6** y que el modelo real deja -> de bloquear la fase. Sigue decretado en [[Gamas-de-Modelo]]. El razonamiento -> está en [[Criterio-de-Salida-Fase-1]]. -> -> **El paso 1 tenía máquina y no tenía camino.** `make -C image doctor` junta -> todas las herramientas que faltan y las contesta con una línea de `apt`, sin -> descargar ni compilar nada, y `all` depende de él primero. Lo que detiene a la -> persona ajena nunca es Thalyx: es un paquete, encontrado de uno en uno, cada -> uno después de que lo anterior salió bien. El peor era `pahole` — sin él -> Kconfig descarta `DEBUG_INFO_BTF` en silencio y la culpa cae sobre el -> cargador. El README tiene la sección **Boot it**, que son los seis pasos y -> nada más. -> -> **Un defecto encontrado al hacerlo**, y dio regla nueva: la frase que explica -> un hecho no confirmable decía que algo había cambiado *"without going through -> Thalyx"*, y con `revertir` esa causa dejó de ser cierta. Ninguna prueba se -> rompió. Ver la regla del mensaje que nombra la causa en -> [[Estrategia-de-Pruebas]]. -> -> **Nada de esto se ha corrido en hardware.** Es lo siguiente: `sudo -> ./dev/verify.sh`, donde la etapa 15 creció seis comprobaciones, y después -> `make -C image run`. -> -> ## La imagen arrancó con el cargador propio, y le falta un hook — 2026-08-04 -> -> `make -C image run` en la Fedora de Cesar. **El cargador funcionó**: llegó -> hasta preguntarle al kernel por sus hooks y dijo exactamente cuál falta. -> -> ``` -> no thalyx-lsm this kernel does not expose `bpf_lsm_socket_connect` -> ``` -> -> `thalyx.config` tenía `CONFIG_SECURITY` y no `CONFIG_SECURITY_NETWORK`. Todos -> los hooks de socket de `lsm_hook_defs.h` están adentro de ese `#ifdef`, así que -> el símbolo **nunca se compiló**. Arreglado: la línea está en `thalyx.config` -> con su párrafo. -> -> Y `config-check` pasó en verde, correctamente — compara lo pedido contra lo -> obtenido, y nadie había pedido esa opción. **Un punto ciego con forma propia**, -> ver la regla nueva en [[Estrategia-de-Pruebas]]. Ahora existe `hook-check`: le -> pregunta al objeto BPF a qué símbolos se engancha (`thalyx enforce hooks`) y -> los busca en el `System.map` del kernel recién compilado, antes de arrancar -> nada. Probado con sus tres respuestas — falta uno, están los dos, y no hay -> kernel construido. -> -> **El resto del arranque salió bien**, y es la primera vez: de tres `no` a dos, -> con el store de Btrfs montado y `filesystem btrfs`. -> -> **Y la etapa 15 se saltó entera**: Fedora no trae `script` — está en -> `util-linux-script`. Los siete controles del paso 6 no corrieron, así que ese -> trabajo sigue sin ejercer en hardware. -> -> ## Y el hook existía y no se le podía enganchar nada — 2026-08-04 -> -> Con `CONFIG_SECURITY_NETWORK` puesto, el símbolo apareció y el arranque falló -> un paso más adelante: -> -> ``` -> no thalyx-lsm attaching `thalyx_socket_connect`: Resource busy (os error 16) -> ``` -> -> Faltaba `CONFIG_FUNCTION_TRACER`. BPF se engancha a un hook LSM con un -> trampolín, y sin ftrace dinámico el kernel parcha el texto él mismo esperando -> el NOP de cinco bytes que esa opción pone al principio de cada función. No -> estaba, el `memcmp` falló, y ese camino devuelve `EBUSY` — que se lee como que -> algo más tiene el hook tomado, y no había nada. -> -> `CONFIG_FTRACE=y` ya estaba y es solo el menú: no emite ningún NOP. -> -> Dos arreglos, y el segundo importa más: -> -> 1. Las cuatro líneas en `thalyx.config`, con `DYNAMIC_FTRACE_WITH_DIRECT_CALLS` -> pedida explícitamente aunque sea derivada, para que `config-check` reporte -> si no se materializa. -> 2. **`hook-check` pregunta por el artefacto**: `register_ftrace_direct` solo se -> compila bajo esa opción, así que su presencia en el `System.map` *es* la -> propiedad. Probado con sus dos respuestas. -> -> Y el mensaje del cargador ahora dice **las dos** causas de `EBUSY` en ese -> camino y que no las puede distinguir. Con su control: otro errno no lleva el -> párrafo. -> -> **Ya son tres opciones del kernel encontradas arrancando**, y la regla nueva en -> [[Estrategia-de-Pruebas]] dice por qué ninguna comprobación de construcción va -> a encontrar la cuarta. -> -> ## Y ahora `verify.sh` arranca la máquina — 2026-08-04 -> -> Decidido por Cesar después del tercer arranque a mano. **La etapa 16 arranca -> la imagen en QEMU y teclea los seis pasos**: espera a que la máquina diga que -> es la máquina, y escribe `recuerdos`, `disponibles`, `instalar`, la -> confirmación, `permisos`, `correr`, `revertir`, `apagar`. Después arranca otra -> vez y pregunta `recuerdos`. -> -> **Dos arranques, porque eso es lo que dice el paso 6.** Un proceso nuevo no es -> un reinicio; lo único que cruza entre los dos es el disco. -> -> Y la consola serie **es** un terminal: lo que ve el invitado es `/dev/console` -> sobre `ttyS0`, sea lo que sea el stdin de QEMU. Así que el camino confiable se -> ejerce como lo encuentra una persona, sin `script` de por medio. -> -> El disco se copia primero. Arrancar lo modifica, y una etapa que cambiara el -> disco que alguien construyó haría que la segunda corrida empezara desde otro -> lado que la primera. -> -> `make -C image boot` es lo que corre, y **no construye nada**. `run` depende -> del kernel y de la imagen, y la regla del binario depende de `toolchain`, que -> es `.PHONY` — así que pedir `run` puede arrancar un `cargo build`, y bajo -> `sudo` eso corre como root y deja archivos de root en `target/`. Es el mismo -> fallo por el que `store` se partió en dos, y la misma regla: **la frontera de -> privilegio es la frontera de target.** -> -> **El arnés se ejerció contra una máquina falsa** —una que se queda callada -> hasta estar lista, para que teclear temprano se note, y una que se muere de -> inmediato, que tiene que volver como «nunca llegó al prompt» y no como un -> cuelgue—. La etapa en sí **nunca ha corrido contra una imagen de verdad**: el -> contenedor no tiene QEMU ni kernel que arrancar. -> -> ## La imagen atachó su enforcement, y se negó a usarlo — 2026-08-04 -> -> **La etapa 16 corrió por primera vez y sirvió de inmediato.** Lo que salió en -> verde, todo dentro de la máquina y sin shell: arrancó, **atachó su propio -> enforcement** (`ok thalyx-lsm` — el tercero de los tres `no` del primer -> arranque, cerrado), dijo que no recordaba nada, listó su repositorio, presentó -> el camino confiable, instaló sobre su disco Btrfs, revirtió, y se apagó sola. -> -> Y falló en una: **`correr` se negó**, diciendo que el mapa de política no -> estaba cargado — tres líneas después de reportar el enforcement puesto. -> -> `BpftoolStore::is_available()` corría `bpftool map show pinned`. **Adentro no -> hay `bpftool`**, así que contestaba «no» pasara lo que pasara, y esa respuesta -> es la que decide entre confinar un módulo y negarse a arrancarlo. El -> enforcement era real y lo único que no podía verlo era el código que decidía -> si usarlo. -> -> Peor: `set()` también escribía con `bpftool`, así que **ninguna política se -> podía escribir adentro de la imagen**. La comprobación estaba equivocada y -> además tenía razón. -> -> `KernelStore` lo reemplaza entero: `BPF_OBJ_GET` sobre el pin y -> `MAP_UPDATE_ELEM` / `MAP_DELETE_ELEM` / `MAP_LOOKUP_ELEM`, con la rama de -> `union bpf_attr` capturada verbatim del uapi y una prueba que calcula sus -> offsets desde la captura. **`BpftoolStore` se borró**: dos implementaciones de -> lo mismo terminan por no coincidir, y aquí el desacuerdo sería una máquina que -> aplica permisos en un lado y no en el otro. -> -> Es la **cuarta** vez que algo le pregunta a `bpftool` por algo que `bpftool` -> no hizo. La tabla completa está en [[Estrategia-de-Pruebas]]. -> -> **Y el arnés de la etapa 16 tenía su propio fallo**, que Cesar notó antes de -> que terminara: se quedaba callado y colgado. `wait` no alcanzaba, porque la -> sesión termina con EOF y **PID 1 no** — sigue cosechando huérfanos, como debe, -> así que QEMU nunca salía. Ahora imprime cada 15 segundos qué está esperando y -> mata QEMU por la ruta del disco, que es de esa corrida y de nada más. -> -> ## Y el módulo pedía un perfil que no existe — 2026-08-04 -> -> Con `KernelStore` puesto, la etapa 16 volvió a correr y volvió a fallar en la -> misma línea, **por otra razón**: -> -> ``` -> dev.thalyx.greeter did not run: `default` is not a sandbox profile Thalyx knows -> ``` -> -> `session.rs` tenía el nombre del perfil escrito a mano —`"default"`— en lugar -> de tomado de `thalyx_sandbox::profile::MODULE_STANDARD`, que es lo que hace -> `main.rs` tres archivos más allá. **Ningún perfil se llama `default`.** -> -> Vivió ahí desde que el prompt puede correr un módulo, con los 599 tests en -> verde, y salió **en la consola de la máquina, después de que la instalación ya -> había salido bien** — el peor lugar posible para encontrarlo. -> -> Lo escondió el **orden**, no el descuido: el nombre se resolvía *después* de -> comprobar que el mapa de política estuviera cargado. En toda máquina sin ese -> mapa —todas menos la imagen— contestaba primero la puerta, con una respuesta -> honesta, y el nombre no se miraba nunca. Ahora `resolve` va antes: un nombre -> que no existe es un nombre que no existe en cualquier máquina. -> -> Y la razón de fondo es la regla 1 otra vez: **la etapa 15 maneja el prompt de -> verdad y no tecleaba `correr`.** Era el único verbo sin ejercitar, y era el -> único roto. Ahora lo teclea. -> -> **El mensaje de la falla apuntaba al lado equivocado**: decía que la imagen no -> tiene `bpftool`, que era cierto la corrida anterior y ya no. La causa real -> estaba impresa cuatro renglones debajo. Ese texto se quitó. Ambas reglas -> quedaron en [[Estrategia-de-Pruebas]]. -> -> ## Y nadie le había entregado los controladores — 2026-08-04 -> -> Arreglado el perfil, la misma línea falló un paso más adelante: -> -> ``` -> `/sys/fs/cgroup/thalyx` cannot hand down the controller(s) ["memory", "pids"] -> It has: [] -> ``` -> -> La negativa era correcta —sin esos controladores los límites no se aplican y -> el módulo se ve acotado sin estarlo— y lo que faltaba era que **alguien los -> delegara**. En cualquier otro Linux lo hace systemd antes de que corra nada. -> **En la imagen no hay systemd.** No hay nada más que Thalyx. -> -> Que es el decreto fundacional dicho de otro modo: todo lo que otra cosa hacía -> por nosotros es ahora trabajo de Thalyx, lo hayamos notado o no. Y no se -> encuentra leyendo el código —el código no menciona systemd en ninguna parte— -> sino corriendo en la única máquina donde systemd no está. -> -> PID 1 ahora los delega al montar, con la lista tomada del perfil bajo el que -> corren los módulos. Y **la sesión lo reporta**: `cgroup2` decía `mounted at -> /sys/fs/cgroup` en una máquina donde ningún módulo podía recibir un límite — -> pantalla de arranque limpia, primer `correr` roto. Eso es el fallo sin -> síntoma, en el único lugar construido para no tener ninguno. -> -> ## El módulo corrió confinado, y se cayó montando su archivo — 2026-08-04 -> -> **El `pivot_root` funcionó sobre el initramfs.** Era la duda que quedaba, y -> salió bien: el módulo obtuvo su cgroup, su raíz propia, su usuario propio, -> seccomp con 128 llamadas y sus límites de memoria y procesos, adentro de la -> máquina. Lo que falló es el último syscall de un montaje: -> -> ``` -> could not attach the remapped mount at -> /run/thalyx/sandbox/opt/thalyx/data/greeter/notes.txt: Invalid argument -> ``` -> -> El kernel exige que el punto de montaje de un archivo sea un archivo -> (`do_move_mount`: `d_is_dir(new) != d_is_dir(old)` → `EINVAL`). `bind` lo -> sabía; `bind_remapped` llamaba a `create_dir` sin mirar. Dos funciones que -> obedecen la misma regla del kernel, escritas por separado. -> -> Sobrevivió porque **todos los permisos de todas las pruebas son directorios**. -> El único permiso sobre un archivo suelto es el del `greeter`, y el único lugar -> donde el `greeter` corre con usuario propio es la imagen. Un caso de prueba -> que nunca varía no es un caso de prueba. Ver [[Estrategia-de-Pruebas]]. -> -> ## Y nadie había hecho el `switch_root` — 2026-08-04 -> -> El montaje del archivo funcionó y `pivot_root` devolvió `EINVAL`, con el -> módulo ya en su cgroup, con su política, su usuario, sus namespaces, seccomp -> y sus límites. Todo bien menos el último paso. -> -> `do_pivot_root`: `if (!mnt_has_parent(root_mnt)) goto out4;`. **La raíz de un -> namespace de montajes no tiene padre.** En cualquier otro Linux eso no se ve, -> porque la raíz del proceso no es la raíz del namespace — el kernel arma un -> `rootfs` interno y el initramfs monta el sistema real encima con -> `switch_root` antes de que arranque nada. -> -> **La imagen es un initramfs y nada más.** Su raíz de proceso *es* la raíz del -> namespace, y lo sigue siendo después de `unshare`. Nadie había hecho el -> `switch_root` porque en todas las demás máquinas ya estaba hecho — que es -> exactamente lo que pasó con systemd y los controladores dos rondas antes. -> -> PID 1 lo hace ahora, con un bind en lugar de un tmpfs: comparte los mismos -> inodos y las mismas páginas, así que no cuesta memoria. **Se comprobó -> corriéndolo** con los mismos envoltorios de `thalyx-syscall` dentro de un -> namespace desechable: el cambio sale bien, la raíz pasa a tener padre, y -> `pivot_root` después funciona. -> -> Y la máquina lo dice de sí misma: el arranque imprime que el cambio corrió y, -> por separado, que la raíz resultante sirve —leído del kernel, no inferido— y -> la sesión toma la misma lectura. -> -> ## Lo que sigue sin verse -> -> **Que el módulo hable.** Lo que falta es que el `greeter` lea su archivo, pida -> `/etc/shadow`, sea negado, y lo diga por su canal — que es la línea que la -> etapa 16 busca. Todo lo que hay debajo ya se vio funcionando adentro de la -> máquina. -> -> El procedimiento sigue en [[Primer-Arranque]]. Si Cesar pega la salida de un -> comando, casi siempre es de ahí. - -## Dónde estamos, en una frase - -**El 2026-08-03 se quitó la distribución.** La bóveda decretaba en tres notas -una base Alpine y en una —marcada no negociable— que Thalyx no es una -distribución de Linux. Se resolvió a favor de la segunda: **la imagen es el -kernel de Linux y `thalyx`, y nada más.** Ninguna distro, nunca. Ver -[[Construccion-del-ISO]]. - -Eso convirtió la **API interna de módulos** en la pieza que seguía: sin shell y -sin utilidades, un módulo no puede ser un script y no tiene con quién hablar -excepto Thalyx. **Diseñada y construida el 2026-08-03** en -[[API-Interna-de-Modulos]]: protocolo, servidor, el canal por el sandbox, y -`dev.thalyx.greeter`, el primer módulo escrito contra ella. - -La Fase 1 tiene **sus tres primitivas** —de las cuatro decretadas; la cuarta es -el [[Scheduler-Predictivo]] y es de Fase 2— y su flujo canónico **construidos y -verificados en hardware real**: 44 comprobaciones en máquina real. Desde -entonces: **520 pruebas**, el agente mínimo que lleva un enunciado hasta un -módulo instalado sin modelo alguno, `thalyx` como PID 1, la imagen que Thalyx -construye para sí mismo, y el disco donde guarda lo que le instalan. - -**Los huecos de arquitectura de la Fase 1 están cerrados.** El último era el -enforcement dentro de la imagen; el cargador propio salió verde en hardware el -2026-08-03. - -> Matiz del 2026-08-04, al final del día: seguía siendo cierto que no falta -> código **del sistema**, y era falso que no faltara código para *comprobarlo*. -> Cuatro de los seis pasos no se estaban verificando en ningún lado, porque la -> única etapa que los cubría se saltaba sola. Ya son pruebas. - -**Y desde el 2026-08-06 los seis pasos están hechos por la máquina, desde un -arranque frío, con un reinicio de verdad en medio.** En esa corrida no quedaba -código sin ejercer: `proven 104 · not proven 1 · failed 0`. - -> Y desde el 2026-08-07 la máquina puede hacer el disco en el que guarda: -> **el kernel monta el Btrfs que Thalyx escribió byte por byte.** Etapa 18, en -> verde. Lo que queda sin ejercer no es código del sistema: son los tres -> subvolúmenes, que todavía no están escritos. - -**Y ahora la máquina puede hacer el disco en el que guarda**, que es el punto 2 -del ISO y el poste largo de los tres. Falta que ese disco se vuelva un store. - -**Lo que falta para cerrar la fase ya no es código y tampoco es la persona -ajena**, que Cesar canceló ese mismo día. Es **elegir con qué se sustituye** — -ver el punto 0 de "Lo que sigue". El modelo del agente sigue decretado en -[[Gamas-de-Modelo]] y no bloquea la fase, por decisión de Cesar del 2026-08-04. - -## Lo que falta comprobar - -Escrito aparte para que no se confunda con lo que sí está probado: - -| Qué | Estado | -|---|---| -| El mecanismo del store | **Probado**, etapa 13, en verde el 2026-08-03. | -| El store arrancando en QEMU | **Probado**, arrancó con el disco montado y el módulo instalado. | -| El cargador de BPF propio | **Probado**, etapa 14, en verde entera el 2026-08-03. | -| El paso 6 | **Probado el 2026-08-06**, etapa 16, desde un arranque frío y con un reinicio de verdad. Thalyx hace su propia terminal, así que ya no depende de `script`. | -| El `doctor` | **Corrido el 2026-08-06** en la máquina de Cesar: encontró el ancla del kernel ausente y nada más. | -| La imagen con enforcement puesto | **Probado el 2026-08-06**: `ok thalyx-lsm` dentro de la máquina, con el kernel recompilado. | -| Los arreglos de la auditoría por la ruta confinada | **Probados el 2026-08-06**, etapas 6, 12, 13 y 17 enteras. | -| El Btrfs que Thalyx escribe | **Probado el 2026-08-07**, etapa 18: el kernel lo monta, acepta los tres subvolúmenes, y un archivo escrito en él vuelve. Con `btrfs check` en verde y los headers de uapi capturados. | -| Los tres subvolúmenes desde dentro | No construido. Van por ioctl, y hasta entonces un store escrito por Thalyx es un filesystem y no un store. | -| `thalyx_watch` cargado sin bpftool | No intentado. Diez hooks en vez de dos; el mismo cargador debería servir. **Es lo único de la lista que sigue abierto.** | - -## Los cuatro fallos del camino, y por qué tres son el mismo - -Entre que el store quedó escrito y que la máquina arrancó con él montado, nada -de lo que falló fue del sistema. Los cuatro fueron del constructor: - -1. **`sudo make store` no encontraba `rustup`.** `sudo` reinicia el `PATH`. Eso - es lo chico: de haber funcionado habría corrido toda la compilación de Rust - como root, con los scripts de build de cada dependencia con privilegio y - archivos de root en `target/`. **La frontera de privilegio es la frontera de - target** — `store-stage` construye, `store` formatea y se niega a construir. -2. **`NOT STATIC` sobre un binario perfectamente estático.** La comprobación era - `file | grep 'statically linked'` y Rust enlaza musl como *static-pie*, que - `file` llama `static-pie linked`. Ahora lee el segmento `INTERP` del ELF. -3. **QEMU no pudo abrir el disco.** La comprobación era `test -r` y QEMU abre el - disco para escribir. Y el disco había quedado de root: son **dos - pertenencias distintas** —el archivo es del host, lo de adentro es de la - máquina— y confundirlas da un store que o QEMU no abre o la máquina no posee. -4. **Backticks dentro de un mensaje de ayuda**, dos veces. `echo "corre \`sudo - make store\`"` es sustitución de comandos: el mensaje que explica qué correr - lo habría corrido. - -El 2, el 3 y el 4 son **la misma regla**: comprobar un sustituto de la -propiedad en vez de la propiedad, o escribir sobre una herramienta en vez de -preguntarle. Está escrita en [[Estrategia-de-Pruebas]]. - -Y hay una lección de arriba de todas: **el 2 mintió durante un rato y la máquina -ya lo había desmentido.** La imagen había arrancado con ese mismo binario como -`/init`; uno dinámico habría dado `No working init found`. Cuando una -comprobación contradice algo que la máquina ya demostró, la comprobación es la -sospechosa. Van siete. - -Hay pruebas para los cuatro, y tres de ellas leen el `Makefile`. - -## Última corrida verificada - -**2026-08-07, Fedora 43, kernel 7.1.5, Btrfs, `bpf` en el orden de LSM, -`main @ 9229268`.** - -``` -proven 110 · not proven 1 · failed 2 -``` - -**Cerró la etapa 18**: el kernel monta el Btrfs que Thalyx escribió byte por byte, -acepta los tres subvolúmenes, y un archivo escrito en él vuelve. Los dos fallos -eran del arnés y no del formato — el control de la etapa 18 dañaba espacio libre -por copy-on-write, y clippy falló sin dejar rastro porque el script borra su propio -directorio al salir. Los dos están arreglados. - -### Y la corrida corta que resolvió lo de clippy - -**2026-08-07, la misma máquina, con el informe arreglado.** Un solo fallo, y esta -vez con el lint impreso: `unnecessary_sort_by` en `crates/thalyx-btrfs/src/format.rs`, -dos veces. **Era desfase de versión** —clippy 1.97 contra 1.94— y no lo que se -había supuesto. Arreglado, y el contenedor actualizado a 1.97 para que el próximo -lint nuevo no se descubra otra vez en la máquina que no puede arreglarlo. - -**La etapa 19 está sin correr.** Es la que comprueba que Thalyx crea los tres -subvolúmenes por ioctl, y no la puede correr ningún otro sitio. - -### La anterior, y es la que sigue siendo la referencia limpia - -**2026-08-06, `main @ 9e1c5f8`.** - -``` -proven 104 · not proven 1 · failed 0 -``` - -**Nada falló y nada quedó sin ejercer.** Corrida dos veces: la segunda con -`THALYX_REQUIRE_IMAGE_TESTS=1`, que convierte en fallo cualquier salto de la -etapa 16, con idéntico resultado — así que la etapa del arranque corrió de -verdad en vez de saltarse en silencio, que es la única forma en que un `104` -podría estar mintiendo. - -La única `not proven` es `llama.cpp`, y es de la clase que **no existe**, no de -la que no se pudo comprobar. - -Lo que esta corrida cerró y ninguna anterior había cerrado: - -- Los diez fallos del 2026-08-05, todos, por la **ruta confinada** — que era lo - que este contenedor no puede ejercer. -- El **paso 6** de punta a punta: un arranque frío, los seis verbos tecleados, - el apagado, un reinicio de verdad, y la máquina diciendo sola que la - instalación que hizo ya no le cuadra. -- El `doctor` corrido por primera vez en la máquina de Cesar, y el ancla del - kernel establecida contra la lista firmada de kernel.org. -- Las 666 pruebas con los cuatro `THALYX_REQUIRE_*` que esa máquina puede - exigir, ninguna saltada. - -### La anterior, para comparar - -**2026-08-05, `main @ f781ced`**: `proven 99 · not proven 2 · failed 10`. Los -diez fallos eran de Thalyx y eran dos defectos del mismo commit — ver el bloque -de la auditoría más arriba. Esa corrida fue la que los encontró. - -**2026-08-03, kernel 7.0.11, `main @ f1a6dd0`**: `proven 72 · not proven 2 · -failed 0`. Cerró el cargador de BPF propio: cargó los dos programas sin -`bpftool`, los enganchó, dejó los tres mapas donde `permd` los busca, **denegó -una conexión adentro del cgroup y la dejó pasar afuera**, y se soltó sin dejar -un enlace vivo. También ejerció el `EXDEV` en el que descansa el layout, con -línea base y control — porque una afirmación que sostiene un diseño hay que -ejercerla, no citarla. - -Reproducirla: - -``` -git checkout main && git pull && cargo install --path crates/thalyx-cli && sudo ./dev/verify.sh -``` - -> **El encabezado dice qué commit se está probando.** Existe porque una corrida -> contra código viejo se ve idéntica a una donde el arreglo no funcionó: misma -> etapa, mismo fallo, mismo mensaje. Pasó — dos arreglos estaban en `main` y la -> máquina seguía en la rama de la que salieron. Si la línea no dice `main` y el -> commit que esperas, la corrida no significa nada. - -## Qué quedó construido y probado - -| Pieza | Comprobado en hardware | -|---|---| -| Instalación de módulos, commit atómico, journal, permisos | Sí, incluida inyección de fallos | -| `thalyx-lsm` (BPF LSM) | Sí — **deniega de verdad** una conexión dentro del cgroup y la permite fuera | -| Sandbox completo: namespaces, seccomp, `pivot_root`, idmap, límites | Sí — el módulo reporta su propio pid, uid, hostname, red y raíz | -| Un uid por módulo, nunca reutilizado | Sí | -| Índice en grafo + parser mecánico | Sí | -| Contador de mutaciones del kernel, 10 hooks | Sí — 5000 escrituras por descriptor abierto, todas contadas | -| Contador acotado al árbol | Sí — 5000 dentro contadas, 5000 fuera ignoradas | -| El atajo del índice (`graph trust`) | Sí — se gana con verificación, y un cambio real sigue saliendo obsoleto | -| Memoria persistente (3ª primitiva) | Sí — el hecho deja de ser afirmable al editar el archivo por fuera | -| `rollback` | Sí — quita el módulo y sus permisos; se niega la segunda vez | -| Snapshots de Btrfs | Sí — de solo lectura, conservan el contenido viejo | -| `restore` | Sí — restaura, destruye lo posterior, y conserva lo destruido | - -Detalle por crate en [[Estado-de-Implementacion]]. - -## Lo que sigue, en orden - -### 0. La ISO independiente — es lo que cierra la Fase 1 - -**Decretado por Cesar el 2026-08-06.** Una ISO que puesta en una PC sin sistema -operativo la deje corriendo Thalyx. Se ejercerá primero en una VM con firmware -UEFI de verdad, y eso prueba que arranca sola; los controladores necesitan -hierro y eso se dice aparte en vez de confundirse. - -El diseño y lo que cuesta están en [[Construccion-del-ISO]]. El orden de trabajo, -por riesgo descendente: - -1. ~~**Arrancar sin gestor de arranque.**~~ **Hecho y probado el 2026-08-06.** - Un firmware arrancó Thalyx entera: `switch_root`, los siete montajes, los - controladores, **el LSM enganchado** y la sesión. Ver el bloque de arriba. -2. ~~**El store, que Thalyx va a escribir él mismo.**~~ **Hecho y probado el - 2026-08-07**: el kernel monta lo que Thalyx escribió, y acepta los tres - subvolúmenes creados sobre él — etapa 18, en verde. - `crates/thalyx-btrfs`, invocado por `thalyx disk format`. El decreto de que PID - 1 nunca fabrica **se conservó entero**: quien crea el store es un humano y PID - 1 no alcanza ese código. Falta que el filesystem se vuelva un store, que son - los tres subvolúmenes, y van por ioctl. -3. **El instalador**: tabla de particiones GPT, una partición EFI con el kernel, - y el store en la otra. Cesar decidió que la máquina arranca **sin** la ISO - después, así que hay que escribir Thalyx en el disco de la máquina. Va junto - con el 2, porque lo que el instalador escribe es precisamente el store. -4. **La consola sobre el framebuffer y el teclado USB**, más almacenamiento real - (NVMe, AHCI). Sin esto la máquina arranca en hierro y no se ve nada, que es - el fallo que se lee como «no funciona» siendo «no puedes mirar». Va al final - porque es lo único que **una VM no puede sustituir**, y hasta entonces todo se - ejerce con OVMF. - -### 1. El agente — su mitad determinista ya está construida - -Ya no bloquea la fase; ver el paso 6 en [[Criterio-de-Salida-Fase-1]]. Va -primero de lo que queda, y el motivo es de descubrimiento, no de avance. El ISO desbloquea -cinco de los seis pasos del [[Criterio-de-Salida-Fase-1|criterio de salida]] -contra uno del agente, pero el ISO **integra piezas ya probadas: no puede -enseñar nada que no se sepa ya**. El agente sí puede invalidar el diseño del -contrato. Descubrir tarde que la procedencia por campo no sobrevive a varias -inferencias costaría mucho más que un ISO retrasado, y la regla 1 de -`CLAUDE.md` dice que todos los defectos reales salieron de correr el sistema. - -Alcance: router de reglas más un modelo con decodificación restringida por -gramática, sobre **un solo caso de uso** —instalar un módulo—, no un agente -general. - -**Construida ya la mitad que no necesita un modelo**, y probada de punta a -punta: `thalyx agent do "install dev.thalyx.demo@^1.0" --repo ` resuelve -contra un repositorio local de bundles firmados, pide confirmación por el camino -confiable, y deja el módulo instalado y ejecutable. Lo que falta, en orden: - -1. El `Model` real que invoca `llama.cpp` como proceso. -2. La gramática GBNF, que no se puede validar sin `llama.cpp`. -3. El banco de las cuatro gamas, para sustituir las cifras estimadas. - -Los tres necesitan tu máquina: aquí no hay `llama.cpp` y la política de red del -entorno bloquea `huggingface.co`. - -**El decreto que lo bloqueaba ya está escrito:** [[Gamas-de-Modelo]]. No un -modelo anclado sino **cuatro gamas de una sola familia** que el usuario elige -según su hardware, con `llama.cpp` invocado como proceso y decodificación -restringida por gramática. Anclar un modelo de 5 GB dejaría fuera a una máquina -de 8 GB, y el criterio de salida exige justamente que alguien de fuera lo use. -Con la gramática, un contrato mal formado es imposible en las cuatro gamas: lo -que cambia entre ellas es el acierto al interpretar la intención, no la -seguridad. Y **el modelo nunca escribe la procedencia** — la pone el -ensamblador, porque una gramática obliga a la forma y no a la verdad. - -El alcance del primero está en [[Agente-Minimo]]. - -Lo que sí está listo para el agente cuando exista: el contrato estructurado con -marcado de origen, el camino confiable, la memoria persistente, y el principio -de doble ruta implementado (todo lo que el agente podrá hacer, un humano ya -puede hacerlo por la CLI). - -### 2. La imagen: las tres cosas que le faltaban están hechas - -**Cerrado el 2026-08-06.** El primer arranque se describió con tres `no` y los -tres están resueltos y comprobados dentro de la máquina: - -``` - ok kernel 6.12.101 - no filesystem rootfs — snapshots and restore need btrfs and will not work here - ok cgroup v2 mounted at /sys/fs/cgroup - ok lsm order capability,bpf - no enforcement the policy map is not loaded, so no permission would be enforced - no modules nothing installed yet - - 3 are not here. I will not pretend otherwise later. -``` - -Las tres, en el orden en que se resolvieron: - -1. ~~**Cargar `thalyx-lsm` desde dentro de Thalyx.**~~ **Hecho, y probado dentro - de la imagen el 2026-08-06**: `ok thalyx-lsm` al arrancar, sin `bpftool` y - sin shell. El objeto BPF va **dentro** del binario, no junto a él, que es lo - que [[Filosofia-Fundacional]] obliga. Ver [[Cargador-BPF-Propio]]. -2. ~~**El store.**~~ **Hecho el 2026-08-03.** El disco se hace al construir con - `sudo make -C image store` —Btrfs, tres subvolúmenes, el `greeter` instalado - adentro— porque `mkfs.btrfs` no puede estar en la imagen, que es la misma - forma que el problema del LSM y la misma respuesta: el trabajo se mueve al - momento de construir. PID 1 lo monta por `thalyx.store=` y nunca lo crea. Ver - [[Construccion-del-ISO]] y la tabla de montajes en [[Journal-y-Snapshots]]. -3. ~~**La API interna de módulos.**~~ **Hecha el 2026-08-03.** Protocolo, - servidor, el canal atravesando el sandbox y `dev.thalyx.greeter`. Ver - [[API-Interna-de-Modulos]]. - -### 3. Reindexado incremental - -Consumir el ringbuf `thalyx_mutations` para saber *qué* cambió, no solo que -algo cambió. **Ya no hace falta para el atajo** —eso lo resolvió la atribución -por ancestros— así que es una mejora de rendimiento, no de corrección. Ver -[[FS-en-Grafo]]. - -## Decretos abiertos - -Ninguno bloquea excepto el primero. - -- [ ] **Una frontera real que etiquete canales** — hoy `--foreign` es una bandera que un humano pasa a propósito; nada en Thalyx llama a `Segment::foreign()` por su cuenta, porque nada trae texto de terceros todavía. Toda la defensa de procedencia descansa sobre ese código, que no existe. -- [ ] **Correr el banco de las gamas** — el decreto ya está ([[Gamas-de-Modelo]]); faltan las cifras medidas. Necesita `llama.cpp` y los pesos, que el contenedor de desarrollo no puede tener. -- [ ] Métricas de benchmark de la Fase 2 (el umbral ya está decretado; falta el instrumento) -- [ ] Técnicas de interpretabilidad aplicables al agente -- [ ] Arquitectura del índice semántico a mayor escala (SQLite alcanza para Fase 1) -- [ ] Sistema de reputación resistente a Sybil (pospuesto a propósito) -- [ ] Dependencias entre módulos con backtracking (pospuesto hasta que un módulo real las necesite) -- [ ] Condiciones para habilitar llamadas a modelos remotos - -Lista completa y viva en [[Tareas-Pendientes]]. - -## Lo que sigue sin validarse, y se carga a propósito - -**Ningún decreto de esta bóveda ha sido contrastado con una persona ajena al -proyecto.** Todo el razonamiento sobre por qué alguien elegiría Thalyx sigue -siendo a priori. Eso es cierto y sigue siendo un riesgo real. - -**Y no se adelanta.** El [[Criterio-de-Salida-Fase-1|criterio de salida]] pone a -esa persona *después* del ISO, arrancando la imagen: ese es su paso 1. Nadie de -fuera toca el sistema antes. No por miedo a lo que diga —el proyecto nunca -dependió de eso— sino porque lo que esa persona determina es **la escala, no la -validez**, y esta fase es incompatible con la escala. - -El riesgo se lleva con los ojos abiertos hasta entonces, que no es lo mismo que -ignorarlo. El razonamiento completo, y la deriva concreta que previene, están en -[[Criterio-de-Salida-Fase-1]]. - -Ver también [[Por-Que-Elegirian-Este-SO]] y [[Riesgo-de-Ejecucion]]. - -## Cosas que hay que saber para no romper nada - -**El watcher del LSM es todo o nada.** Diez hooks; si el kernel no expone -alguno, declina cargarse entero en vez de cargarse pareciendo completo. Un hook -faltante no es un número más chico, es una forma concreta de que un archivo -cambie en silencio. `make -C lsm hooks` dice cuáles hay. - -**`verify.sh` desengancha el LSM al salir.** Por eso `thalyx graph watcher` -dice "not loaded" después de una corrida. Es correcto, no es un fallo. - -**`verify.sh` compila en `dev/.verify-target`** para no dejar el `target/` del -usuario a nombre de root. Por eso el binario que queda en el PATH es el de -`cargo install`, y hay que reinstalarlo después de cambios en la CLI. - -**El store por defecto es `/opt/thalyx`**, que necesita sudo. Para uso normal: -`export THALYX_ROOT=~/.local/share/thalyx`. - -**El atajo del índice está apagado por defecto en cada índice nuevo**, y -`verify.sh` reconstruye el índice del repo, así que vuelve a apagarse en cada -corrida. Para encenderlo a mano: -`thalyx graph trust ~/thalyx/crates --counter`. - -## Historial de sesiones - -### 2026-08-06 — la primera corrida sin nada roto, y el criterio se queda sin persona -`proven 104 · not proven 1 · failed 0`, dos veces, la segunda exigiendo que la -etapa del arranque no se saltara. Todo lo que existe está comprobado en la única -máquina que puede comprobarlo. La única `not proven` es algo que no existe. - -**Y el único defecto del día lo encontró un humano leyendo instrucciones.** -`pin-kernel` mandaba a verificar `sha256sums.asc` con las llaves de los -mantenedores del kernel, que no firman ese archivo, así que gpg contestó `No hay -clave pública` justo debajo de la frase que define esa salida como motivo de -parar. Escrito en un contenedor sin ruta a kernel.org y publicado sin correr: -**texto impreso para una persona es código con salida**, y va la regla nueva en -[[Estrategia-de-Pruebas]]. - -El ancla quedó en el repositorio con la llave y la huella que la establecieron, -porque un digest solo dice qué se aceptó, no qué lo validó. - -**Y Cesar canceló la persona ajena.** Los seis pasos siguen; quien los teclea, -no. Eso deja a la Fase 1 sin criterio de salida hasta que él elija el -sustituto, y está escrito así en vez de dejar que la nota aparente tener uno. - -### 2026-08-05 — los arreglos de la auditoría rompieron el instrumento -`verify.sh` con la auditoría puesta: `failed 10`, todos de Thalyx. Dos defectos, -los dos del mismo commit, y los dos con la misma forma vista de dos lados: **una -defensa correcta aplicada a algo que no era lo que creía estar protegiendo.** - -Quitarle la terminal al módulo era correcto y le quitó también el `stdout` por -el que contesta qué ve desde adentro del sandbox, que es el único instrumento -que prueba el aislamiento. Truncar a 72 caracteres es correcto para una -etiqueta y lo que un módulo dice no es una etiqueta — el mismo razonamiento que -la auditoría ya había escrito para los permisos, sin aplicar donde valía igual. - -Y la prueba que debía atrapar el primero afirmaba una **ausencia**, que se -satisface borrándolo todo. Pasó en verde la misma corrida en que la etapa 6 se -quedó ciega. - -### 2026-08-04 (2) — la máquina corrió los seis pasos y falló en el quinto -La etapa 16 —arrancar la imagen y teclearle los seis pasos— corrió por primera -vez contra una imagen real. Sirvió de inmediato y encontró dos defectos, uno -detrás del otro, en la misma línea. - -**El primero:** `is_available()` preguntaba por `bpftool`, que adentro de la -imagen no existe, y esa respuesta decide entre confinar un módulo y negarse a -arrancarlo. `KernelStore` lo reemplaza con `bpf(2)` directo y `BpftoolStore` se -borró. Cuarta vez que algo le pregunta a `bpftool` por algo que `bpftool` no -hizo. - -**El segundo:** el prompt pedía un perfil de sandbox llamado `default`, que no -existe. Lo escondió el orden —el nombre se resolvía después de comprobar el -mapa de política, así que solo la imagen llegaba a mirarlo— y lo dejó pasar que -la etapa 15 maneja el prompt de verdad y **no tecleaba `correr`**: el único -verbo sin ejercitar era el único roto. - -**El tercero:** la raíz de cgroups no entregaba `memory` ni `pids`, porque en -todas las demás máquinas eso lo hace systemd antes de que corra nada, y en la -imagen no hay systemd. PID 1 lo hace ahora, y la sesión reporta un cgroup2 que -está montado y no delega nada como lo que es: ausente. - -**El cuarto:** el punto de montaje de un permiso sobre un archivo se creaba -como directorio en la ruta remapeada, y el kernel exige que sean del mismo tipo. -Todos los permisos de todas las pruebas son directorios; el único permiso sobre -un archivo suelto es el del `greeter`. - -**El quinto:** nadie había hecho el `switch_root`. La raíz de la imagen es la -raíz de su namespace de montajes, y esa no tiene padre, así que `pivot_root` -niega todo módulo. En cualquier otro Linux el initramfs ya se bajó de sí mismo -antes de que arranque nada. - -Los cinco son la misma forma vista desde cinco lados: **una comprobación que -depende de una condición solo se hace en las máquinas que la cumplen**, y la -imagen es la única máquina que no cumple ninguna. Seis reglas nuevas en -[[Estrategia-de-Pruebas]], incluida la del mensaje de falla que nombraba una -causa que nadie midió y mandaba a buscar al lado equivocado. - -### 2026-08-04 — los seis pasos existen -El objetivo pasó a ser cerrar la Fase 1, y quedaban dos pasos del criterio de -salida sin nada detrás. Los dos tenían la misma forma: **la pieza estaba escrita -y no había cómo alcanzarla.** - -**El 6.** La memoria persistente es la tercera primitiva y está probada en -hardware desde el 2026-08-02, y la sesión no escribía en ella. Ahora `instalar` -y `revertir` escriben por el mismo `recollection.rs` del agente —no una copia— y -`recuerdos` lo lee. Lo que lo vuelve una prueba y no una demostración: después -de `revertir`, la instalación sale como *no confirmable* **sola**, porque quedó -atestiguada contra el enlace `current` que el rollback quitó. - -Antes hubo que decidir qué cuenta como el paso 6, porque la bóveda decía dos -cosas distintas. Lo decidió Cesar: la memoria sobreviviendo al reinicio; el -modelo real deja de bloquear la fase sin cancelarse. - -**El 1.** `make -C image doctor`. Lo que detiene a la persona ajena nunca es -Thalyx: es un paquete que falta, encontrado de uno en uno y cada uno después de -que lo anterior salió bien. Ahora salen todos juntos, con la línea de `apt` que -los instala, antes de descargar o compilar nada. El peor era `pahole`, cuya -ausencia hace que Kconfig descarte `DEBUG_INFO_BTF` **en silencio** y la culpa -caiga sobre el cargador de BPF varios pasos después. - -Y el `doctor` se comprueba a sí mismo: sin `gcc` no puede probar las cabeceras, -y lo dice en vez de callarlo. Regla 3 aplicada al comprobador. - -**Un defecto propio, y dio regla nueva.** El párrafo que explica un hecho no -confirmable decía que algo había cambiado *"without going through Thalyx"*. -Cierto mientras la única ruta fuera una edición por fuera; con `revertir` pasó a -ser una explicación segura de una causa que ese código no puede ver. Ninguna -prueba se rompió. Ver [[Estrategia-de-Pruebas]]. - -También se corrigió el README, que seguía diciendo *"Phase 1 — Thalyx core on an -Alpine base"* — un decreto derogado el 2026-08-03 que sobrevivió en una de las -cuatro puertas de entrada. Es la regla de que una afirmación de ausencia caduca -sola, en su versión más incómoda: caducan también las de presencia cuando nadie -las vuelve a leer. - -### 2026-08-03 (12) — dos fallos en hardware, y ninguno era de Thalyx en el sentido esperado -La corrida en la máquina de Cesar dio `proven 59 · failed 2`. Los dos se -arreglaron y los dos enseñaron algo. - -**El primero era del arnés.** `verify.sh` activaba -`THALYX_REQUIRE_BTRFS_TESTS` porque había btrfs-progs, y nunca ponía -`THALYX_BTRFS_SCRATCH`, que es lo que ese test necesita para crear un -subvolumen. Exigió una comprobación y le negó su entrada. El error de fondo: -**tener la herramienta y tener dónde usarla son dos hechos**, y en Fedora se -separan de inmediato porque `/tmp` es tmpfs. Ahora se establecen los dos, y el -segundo creando un subvolumen de verdad — `stat -f` dice btrfs también para un -montaje de solo lectura. Séptima vez que el culpable es el instrumento. - -**El segundo era real y estaba en el `allowlist` de seccomp.** El módulo moría -con `SIGSYS` en su primera respuesta. La causa la dio `strace` en tres minutos y -no la habría dado leer el código: **un `UnixStream` de Rust lee con `recv(2)` y -escribe con `send(2)`**, no con `read` y `write`. `recvfrom` y `sendto` no -estaban en la lista. - -Lo que lo explica es más interesante que el arreglo: el `allowlist` se derivó -empíricamente corriendo módulos reales, que es el método correcto — pero **todos -esos módulos eran scripts de shell, y `/bin/sh` no toca un socket**. El método -cubre exactamente los programas que se usaron para derivarlo. De ahí la regla -nueva de [[Estrategia-de-Pruebas]]: **un sustituto que nunca ejerció el -mecanismo no lo probó.** - -`recvfrom` y `sendto` entran; `socket`, `connect` y `bind` siguen fuera. Un -módulo puede **usar** el socket que le dieron y no puede **fabricarse** otro, y -la prueba afirma las dos mitades juntas a propósito: separadas, cada una pasaría -sola y una sola no sirve. - -### 2026-08-03 (11) — hay un módulo, y habla -`dev.thalyx.greeter` existe: el primer módulo desde que se borró el que era un -script de shell. Se instala desde un bundle firmado, corre, y **habla con -Thalyx por un socket que nunca abrió**. Lo que sale por pantalla: - -``` - dev.thalyx.greeter said: - I am dev.thalyx.greeter 1.0.0, speaking protocol 1, holding 1 grant(s). - read 27 byte(s) from .../notes.txt: the vault is the authority - I asked for /etc/shadow and was refused, which is correct. -``` - -Las tres líneas dicen cosas distintas. La primera: **un módulo no sabe quién -es**, pregunta, y lo que le contestan sale del manifiesto firmado. La segunda: -la línea base. La tercera: la denegación — sin la segunda no probaría nada, -porque un Thalyx que negara todo se vería igual. - -Y una cuarta que no sale por pantalla: **ejecutado a mano no arranca**. No -porque compruebe una licencia, sino porque en el descriptor 3 no hay nadie. -Eso es [[Filosofia-Fundacional]] vuelta comprobación. - -Lo construido: `thalyx-syscall` coloca el descriptor (`place_on`, -`spawn_with_channel`, `inherited_channel`), `launch.rs` lo lleva por las dos -etapas del sandbox, y `thalyx-core/api.rs` es el servidor. - -**El hallazgo que más importa está en `api.rs`, y es de seguridad.** El -servidor **no está dentro del sandbox**: corre como Thalyx, con el alcance de -Thalyx. Un módulo que pide una ruta le está pidiendo a *Thalyx* que la abra, así -que la raíz vacía del sandbox y el LSM no protegen nada ahí. Cada ruta se -comprueba dos veces: por el nombre, y por **lo que el kernel resuelve** — que es -lo único que atrapa un symlink plantado dentro de un directorio que el módulo -puede escribir. Esa era la vía que sí habría funcionado. - -Etapa 12 en `verify.sh`, con su control. Y **una guarda mía salió mal primero**: -se disparaba con "cgroup2 montado" cuando la condición real es "el LSM está -cargado", así que exigió a este contenedor algo que no puede hacer y reportó -roto a Thalyx. Es la regla 3 otra vez: un salto que se dispara solo se ve -idéntico a un fallo real. - -Falta la ruta confinada —el canal por dos `exec` y un filtro seccomp— que solo -se puede comprobar en máquina con LSM. - -### 2026-08-03 (10) — la API interna deja de ser una línea de una nota -Decretada en [[API-Interna-de-Modulos]] y construida en `crates/thalyx-abi`: -**un socket que Thalyx entrega ya abierto en el descriptor 3** al ejecutar el -módulo —sin ruta que equivocar, sobrevive a la raíz vacía del sandbox, y su -ausencia es lo que impide que un módulo corra fuera de Thalyx—, mensajes de -longitud explícita más CBOR, y tres familias: archivos, notificar, y preguntar -quién es. **27 pruebas**, incluidas las dos mitades de la conversación -hablando por un socket real entre dos hilos. - -Tres decisiones que valen más que el código: - -- **Denegado y fallido son respuestas distintas.** "No puedes leer esto" y - "esto no se pudo leer" son hechos diferentes sobre el mundo, y un módulo que - solo supiera que falló reportaría un disco ausente como un problema de - permisos. Es la regla 10 de `CLAUDE.md` puesta en el protocolo. -- **Un campo desconocido se rechaza, no se ignora.** Es la dirección incómoda - —rompe con un módulo más nuevo— y la correcta: ignorarlo dejaría al que envía - creyendo que restringió la operación y al que recibe sin haber visto la - restricción, en un canal que gobierna permisos. -- **Un marco ilegible cierra la conexión; un mensaje ilegible se contesta.** - Después de una longitud mala no hay dónde empezar a leer otra vez; después de - un mensaje malo, sí. - -**Y una tercera contradicción del mismo tipo que las anteriores.** -[[Core-Nucleo]] listaba *"ejecutar comandos"* entre las capacidades de esta API. -No hay comandos que ejecutar. Como el login en tty1 y como `bpftool`: una -capacidad que se apoyaba en la base y envejeció callada cuando la base se cayó. -Queda anulada por decreto, no implementada. - -Falta lo que la vuelve real: pasar el descriptor por las dos etapas del -lanzamiento, el servidor contra los permisos verdaderos, y un módulo escrito -contra ella. Eso último es lo que el decreto pone como prueba de que sirve. - -### 2026-08-03 (9) — existe la máquina -`make -C image run` arrancó. Kernel 6.12.101 construido desde `allnoconfig`, -initramfs con **un solo archivo**, `thalyx` como PID 1. Montó los siete -filesystems, arrancó la sesión, y la sesión imprimió el párrafo que dice que no -hay shell detrás — que solo imprime cuando su padre es el pid 1, así que la -frase no está cableada: es una comprobación. - -Y se describió con tres `no` que no oculta: sin Btrfs, sin enforcement, sin -módulos. Los tres eran conocidos y están arriba con su orden de resolución. - -**Lo que esto cierra**: el paso 1 del [[Criterio-de-Salida-Fase-1]] tiene por -fin una máquina detrás. No cierra el criterio —ese exige que lo haga alguien de -fuera, sin ayuda— pero hasta hoy no había nada que esa persona pudiera arrancar. - -**Un hallazgo del arranque**: `attach_lsm` en `init.rs` busca -`/lib/thalyx/thalyx_lsm.bpf.o`. Ese archivo **no puede existir**: sería un -segundo archivo en una imagen que el decreto obliga a tener uno. El mensaje -"is not in the image" es cierto y su arreglo obvio es el equivocado. El objeto -BPF va incrustado en el binario. - -### 2026-08-03 (8) — el kernel no compilaba, y la configuración se perdía sola -El primer `make -C image kernel` en la máquina de Cesar falló entero en -`arch/x86/boot/compressed/`: GCC 15 (Fedora 43) usa C23 por defecto, donde -`bool`, `true` y `false` son palabras reservadas, y ese directorio era el único -del kernel que nunca pasaba `-std=`. **No se puede arreglar desde fuera** —su -Makefile abre con `KBUILD_CFLAGS :=`, que tira lo que venga de arriba, así que -`KCFLAGS` jamás llega. Río arriba lo arreglaron en enero de 2025 y aterrizó en -la serie estable en **6.12.14**, comprobado tag por tag. `KVERSION` pasa a -**6.12.101**, la cabeza de la línea 6.12 LTS. - -**Y al reproducir la configuración a mano apareció algo peor.** `olddefconfig` -descarta en silencio toda opción cuyas dependencias no se cumplan: **nueve de -las de `thalyx.config` no llegaban al `.config` final**, entre ellas -`CONFIG_BPF_LSM` y `CONFIG_DEBUG_INFO_BTF`. La máquina habría arrancado -perfecta y `thalyx-lsm` no se habría podido enganchar nunca, con un síntoma -idéntico al hueco de `bpftool` que ya conocíamos — la culpa habría caído sobre -el cargador, que no tenía nada que ver. También faltaban `VIRTIO_MENU` y -`BLK_DEV`, sin los cuales no hay disco del store, e `IPC_NS`. - -`make -C image kernel` ahora compara lo pedido contra lo que salió y **se niega -a compilar** si falta una línea. Probado con su control: quitando `BPF_LSM` y -`BTF` a mano, los nombra y sale con error. De ahí la regla nueva de -[[Estrategia-de-Pruebas]]: **pedirle algo a una herramienta no es haberlo -obtenido**. - -Con las nueve líneas puestas, 6.12.101 configura y compila limpio en el -contenedor, y el `vmlinux` trae `.BTF`. Eso comprueba la configuración, **no** -el problema de GCC 15: aquí hay GCC 13. QEMU sigue sin correr nunca. - -### 2026-08-03 (7) — el decreto fundacional, y todo listo para arrancar -Cesar escribió el texto que funda el proyecto y quedó **literal** como primera -sección de [[Filosofia-Fundacional]], con la regla de que cualquier decreto que -lo contradiga está equivocado. Está enlazado desde `CLAUDE.md`, el índice y el -README, que son las cuatro puertas de entrada. - -Se registraron los dos decretos que su propio texto invalida: `bpftool` (que ya -no puede estar en la imagen) y `llama.cpp` como proceso (que sería un segundo -programa — probablemente el modelo del agente sea **un módulo**, pero eso lo -decide Cesar). - -`rusqlite` pasa a `bundled`: SQLite se compila dentro del binario. No es -preferencia, es necesidad — no hay libsqlite3 en el disco de la imagen contra el -que enlazar, y era el primer bloqueador del binario estático. - -[[Primer-Arranque]] tiene el procedimiento completo. - -### 2026-08-03 (6) — hay máquina: PID 1, la imagen, y el kernel -`thalyx` es PID 1 (`init.rs`): monta siete filesystems diciendo por qué cada -uno, arranca la sesión, y cosecha huérfanos para siempre. Si un montaje falla no -aborta — la máquina arranca describiéndose a sí misma, porque un sistema que se -niega a arrancar no te dice *por qué* desde una pantalla a la que no llegas. - -**Thalyx construye su propia imagen** (`image.rs`): un cpio `newc` escrito aquí, -sin `cpio` ni herramientas ajenas. Un initramfs, no un ISO — sin gestor de -arranque, sin tabla de particiones, sin una tercera cosa donde algo se esconda. -**Un solo archivo dentro**, `/init`, porque si el decreto dice un programa, -un archivo es lo que lo vuelve cierto en vez de casi cierto. - -Y se cuenta: `make -C image count` parsea el archivo y dice cuántos programas -hay. Si no dice uno, el decreto está roto y el número lo dice antes de que nadie -discuta. - -`image/` lleva el Makefile y `thalyx.config`, un kernel desde `allnoconfig`. -**Jamás ejecutados**: aquí no hay red a kernel.org ni QEMU. - -El hueco grande queda dicho: **`thalyx-lsm` no se carga en el arranque**. El -cargador invocaba `bpftool`, y no hay bpftool en la imagen ni shell para -llamarlo. La máquina arranca y lo dice. - -### 2026-08-03 (5) — se cae la distro, y con ella lo que se apoyaba en ella -Cesar preguntó por qué habría un login al arrancar si nadie lo construyó. La -respuesta —lo pone la base— hizo visible que había una base, y que la bóveda se -contradecía en cuatro notas. Decreto: **cualquier distribución queda fuera para -siempre**; el kernel de Linux nunca estuvo en discusión. - -Borrados por falsos: el esqueleto del ISO escrito esa misma noche, que producía -una distro de Alpine con el getty quitado, y el módulo `dev.thalyx.hola`, que -era un script de shell y por lo tanto corría en cualquier Linux. - -Reescritos: [[Construccion-del-ISO]] entero, y las secciones de -[[Core-Nucleo]] y [[Fases-de-Implementacion]] que decretaban la base. - -### 2026-08-03 (4) — el enunciado llega hasta el disco, y un fallo que solo salió corriéndolo -**El paso 6, ahora con sus dos mitades.** El agente escribe lo que hizo **y lo -lee**: `thalyx agent recall `, y `--task` trae el contexto solo. Lo que -recuerda entra como estado de Thalyx y puede tener efecto, salvo lo que ya no -puede confirmar, que se muestra y no se usa. Falta que retome una conversación -de varios turnos, que necesita un modelo. - -Lo que quedó: `thalyx agent do --task ` -escribe en la memoria persistente qué se pidió y qué se instaló, y -`thalyx memory recall ` lo lee desde otro proceso. Los dos hechos son de -clase distinta a propósito: lo que el humano dijo **no atestigua nada** —ningún -archivo puede volver falso que lo haya dicho— y lo instalado atestigua el enlace -`current`, así que quitar el módulo deja el recuerdo *no afirmable* y lo dice, -en vez de seguir reportando una instalación que ya no está. -`thalyx agent plan` y `thalyx agent do`, más el repositorio local y la -resolución de versiones (`thalyx-core/repo.rs`): **máxima versión que satisface -el constraint y cuya firma valida**, como manda [[Resolucion-de-Versiones]]. La -cadena entera funciona contra bundles firmados de verdad — enunciado, contrato, -resolución, camino confiable, commit atómico, journal, y el módulo instalado -corre. - -**El fallo del día**, y es el más instructivo que ha dado el proyecto: la -atribución tomaba el canal *menos* confiable cuando un valor aparecía en dos. -Eso volvía imposible de instalar por nombre cualquier módulo mencionado en -cualquier página leída. Pasó 39 pruebas y tres mutantes deliberados. Murió a los -tres segundos de existir el comando, tecleando una frase. De ahí la regla nueva -de [[Estrategia-de-Pruebas]]: **un mutante demuestra que una prueba es portante, -no que la decisión que codifica sea la correcta.** - -También quedó `thalyx dev agent-probe`, que existe por la regla 4: sin modelo, -toda inyección se rechaza con "no model is configured", y esa denegación se ve -idéntica a la de la procedencia sin probar nada de ella. - -Antes de eso, `bundle.rs`: un `.thmod` de 768 MB **sin firma** llevaba el -proceso a 1 GB de RSS porque cada miembro se leía entero antes de decidir si -importaba. Ahora hay tamaños por miembro, los desconocidos no se leen, y el -artefacto no puede expandirse más de 50× lo comprimido. - -### 2026-08-03 (3) — el agente mínimo, contra un modelo que miente a propósito -Se decretó [[Gamas-de-Modelo]] —cuatro gamas de una familia, `llama.cpp` como -proceso, gramática restringida, y **el modelo nunca escribe la procedencia**— y -se construyó `crates/thalyx-agent` hasta donde este contenedor puede -comprobarlo: router, atribución, ensamblado y un falso hostil con nueve formas -de portarse mal. 39 pruebas. - -Al construirlo aparecieron dos cosas que el decreto no anticipaba, ya escritas -como revisión en [[Agente-Minimo]]: atribuir un valor por **dónde aparece** -también detecta las alucinaciones, y una *operación* no se puede atribuir -buscándola, así que se atribuye por lo que la conclusión pudo leer — de donde -sale que **en cuanto hay texto ajeno en el transcript, el modelo ya no puede -originar una acción**, y el humano sí, tecleándola. - -Y una regla nueva de [[Estrategia-de-Pruebas]], encontrada rompiendo cada -mecanismo a propósito para ver qué pruebas lo notaban: **dos defensas que se -solapan hacen que la prueba grande no pruebe ninguna**. La prueba de las nueve -malas conductas no falló con ninguno de los tres mutantes. - -### 2026-08-03 (2) — una revisión externa encontró que la bóveda se contradecía -Una lectura externa del repo —solo código y documentación, sin el contexto de -la filosofía— encontró que `Estado-de-Implementacion` afirmaba a la vez que -`restore` estaba construido y que **no existe**, y que los límites de recursos -seguían sin probarse cuando `verify.sh` ya tenía la etapa. Al corregirlo -aparecieron tres más: dos listas incompatibles de "las cuatro primitivas" -(contando [[Parser-Mecanico]], que su propio decreto llama *componente*), un -comentario en `thalyx-sandbox/src/lib.rs` que decía que un módulo corre con el -uid de Thalyx cuando `uids.rs` lleva días dándole uno propio, y "tres -variables" de salto donde hay cuatro. - -Las cinco tienen la misma forma y de ahí sale la regla nueva de -[[Estrategia-de-Pruebas]]: **una afirmación de que algo falta no la rompe -nada**. El código rompe las afirmaciones de que algo funciona; las de ausencia -envejecen calladas. - -También quedó anotado el hueco simétrico: `verify.sh` activa tres de sus cuatro -variables `THALYX_REQUIRE_*`, no la de Btrfs. - -De la misma revisión se descartaron dos cosas: la supuesta inconsistencia de -fechas (2 de agosto 22:13 en CDMX **son** las 04:13 UTC del 3; la bóveda fecha -en UTC) y el reproche de que Thalyx "todavía no es un sistema operativo", que -es [[Decision-Capa-vs-SO-Nuevo|un decreto deliberado]] y no un hallazgo. - -### 2026-08-03 — todo verde en hardware, y las dos operaciones del decreto -Se cerró el ciclo del contador de mutaciones (10 hooks, por CPU, acotado al -árbol), se abrió la puerta del atajo (`graph trust`), y se construyeron las dos -operaciones de [[Rollback-vs-Restore]]: `rollback` y `restore`, con snapshots -de Btrfs debajo. Cuatro defectos encontrados y arreglados, **tres de ellos del -arnés y no de Thalyx** — de ahí las reglas 5 y 6 de `CLAUDE.md`. - -### 2026-08-02 — la tercera primitiva y el enforcement real -Memoria persistente, montajes idmapped, un uid por módulo, `pivot_root`, perfil -`module_standard`, y la primera demostración de que el LSM deniega de verdad en -hardware. - -### 2026-08-01 — los decretos -43 → 61 notas. Modelo de amenaza, formato del manifiesto, commit atómico, -sandbox, permisos JIT, estrategia de pruebas, criterio de salida de la Fase 1. - -## Relacionado -- [[Estado-de-Implementacion]] — qué está construido, por crate -- [[Tareas-Pendientes]] — qué está decidido y qué no -- [[Criterio-de-Salida-Fase-1]] — cuándo se puede decir que la fase terminó -- [[00-Indice/Indice-Principal|Índice principal]] +> **El fallo del día**, y es el más instructivo que ha dado el proyecto: la +> atribución tomaba el canal *menos* confiable cuando un valor aparecía en dos. +> Eso volvía imposible de instalar por nombre cualquier módulo mencionado en +> cualquier página leída. Pasó 39 pruebas y tres mutantes deliberados. Murió a los +> tres segundos de existir el comando, tecleando una frase. De ahí la regla nueva +> de [[Estrategia-de-Pruebas]]: **un mutante demuestra que una prueba es portante, +> no que la decisión que codifica sea la correcta.** +> +> También quedó `thalyx dev agent-probe`, que existe por la regla 4: sin modelo, +> toda inyección se rechaza con "no model is configured", y esa denegación se ve +> idéntica a la de la procedencia sin probar nada de ella. +> +> Antes de eso, `bundle.rs`: un `.thmod` de 768 MB **sin firma** llevaba el +> proceso a 1 GB de RSS porque cada miembro se leía entero antes de decidir si +> importaba. Ahora hay tamaños por miembro, los desconocidos no se leen, y el +> artefacto no puede expandirse más de 50× lo comprimido. +> +> ### 2026-08-03 (3) — el agente mínimo, contra un modelo que miente a propósito +> Se decretó [[Gamas-de-Modelo]] —cuatro gamas de una familia, `llama.cpp` como +> proceso, gramática restringida, y **el modelo nunca escribe la procedencia**— y +> se construyó `crates/thalyx-agent` hasta donde este contenedor puede +> comprobarlo: router, atribución, ensamblado y un falso hostil con nueve formas +> de portarse mal. 39 pruebas. +> +> Al construirlo aparecieron dos cosas que el decreto no anticipaba, ya escritas +> como revisión en [[Agente-Minimo]]: atribuir un valor por **dónde aparece** +> también detecta las alucinaciones, y una *operación* no se puede atribuir +> buscándola, así que se atribuye por lo que la conclusión pudo leer — de donde +> sale que **en cuanto hay texto ajeno en el transcript, el modelo ya no puede +> originar una acción**, y el humano sí, tecleándola. +> +> Y una regla nueva de [[Estrategia-de-Pruebas]], encontrada rompiendo cada +> mecanismo a propósito para ver qué pruebas lo notaban: **dos defensas que se +> solapan hacen que la prueba grande no pruebe ninguna**. La prueba de las nueve +> malas conductas no falló con ninguno de los tres mutantes. +> +> ### 2026-08-03 (2) — una revisión externa encontró que la bóveda se contradecía +> Una lectura externa del repo —solo código y documentación, sin el contexto de +> la filosofía— encontró que `Estado-de-Implementacion` afirmaba a la vez que +> `restore` estaba construido y que **no existe**, y que los límites de recursos +> seguían sin probarse cuando `verify.sh` ya tenía la etapa. Al corregirlo +> aparecieron tres más: dos listas incompatibles de "las cuatro primitivas" +> (contando [[Parser-Mecanico]], que su propio decreto llama *componente*), un +> comentario en `thalyx-sandbox/src/lib.rs` que decía que un módulo corre con el +> uid de Thalyx cuando `uids.rs` lleva días dándole uno propio, y "tres +> variables" de salto donde hay cuatro. +> +> Las cinco tienen la misma forma y de ahí sale la regla nueva de +> [[Estrategia-de-Pruebas]]: **una afirmación de que algo falta no la rompe +> nada**. El código rompe las afirmaciones de que algo funciona; las de ausencia +> envejecen calladas. +> +> También quedó anotado el hueco simétrico: `verify.sh` activa tres de sus cuatro +> variables `THALYX_REQUIRE_*`, no la de Btrfs. +> +> De la misma revisión se descartaron dos cosas: la supuesta inconsistencia de +> fechas (2 de agosto 22:13 en CDMX **son** las 04:13 UTC del 3; la bóveda fecha +> en UTC) y el reproche de que Thalyx "todavía no es un sistema operativo", que +> es [[Decision-Capa-vs-SO-Nuevo|un decreto deliberado]] y no un hallazgo. +> +> ### 2026-08-03 — todo verde en hardware, y las dos operaciones del decreto +> Se cerró el ciclo del contador de mutaciones (10 hooks, por CPU, acotado al +> árbol), se abrió la puerta del atajo (`graph trust`), y se construyeron las dos +> operaciones de [[Rollback-vs-Restore]]: `rollback` y `restore`, con snapshots +> de Btrfs debajo. Cuatro defectos encontrados y arreglados, **tres de ellos del +> arnés y no de Thalyx** — de ahí las reglas 5 y 6 de `CLAUDE.md`. +> +> ### 2026-08-02 — la tercera primitiva y el enforcement real +> Memoria persistente, montajes idmapped, un uid por módulo, `pivot_root`, perfil +> `module_standard`, y la primera demostración de que el LSM deniega de verdad en +> hardware. +> +> ### 2026-08-01 — los decretos +> 43 → 61 notas. Modelo de amenaza, formato del manifiesto, commit atómico, +> sandbox, permisos JIT, estrategia de pruebas, criterio de salida de la Fase 1. +> +> ## Relacionado +> - [[Estado-de-Implementacion]] — qué está construido, por crate +> - [[Tareas-Pendientes]] — qué está decidido y qué no +> - [[Criterio-de-Salida-Fase-1]] — cuándo se puede decir que la fase terminó +> - [[00-Indice/Indice-Principal|Índice principal]] diff --git a/vault/06-Pendientes/Tareas-Pendientes.md b/vault/06-Pendientes/Tareas-Pendientes.md index c5d6d68..649ce34 100644 --- a/vault/06-Pendientes/Tareas-Pendientes.md +++ b/vault/06-Pendientes/Tareas-Pendientes.md @@ -659,9 +659,42 @@ Lo que falta, en orden de lo que decide: cara de programación de Rust sólo existe cuando Thalyx corre sobre una máquina que ya lo tiene. Las dos son respuestas legítimas y **es de Cesar**. -Mientras tanto está dicho en voz alta en [[Semantica-Compilada]] y en el módulo, -y `dev/verify.sh` etapa 57 y 58 dicen `NOT PROVEN` en una máquina sin él en lugar -de callarse. +### Cerrado a medias el 2026-08-30 + +**El punto 1 está construido.** El proveedor arranca por +`thalyx_core::start_foreign` con el perfil `semantic_provider` — cgroup, política +en el kernel, raíz propia con el árbol y el toolchain y nada más, usuario propio, +namespace de pid propio (matar el proceso que Thalyx sostiene mata cada `cargo` y +`rustc` debajo), namespace de red, y el mismo filtro seccomp. Un proceso confinado +y **residente** ya no es una forma que esta máquina no tenga: +`ForeignProcess` la tiene, y es el segundo usuario de `thalyx_sandbox::Held` +después del motor. + +También quedó dicho lo que estaba mal escrito aquí arriba: **«es un lector» es +una propiedad del protocolo LSP y no del árbol de procesos.** rust-analyzer corre +Cargo, y contestar sobre un espacio de trabajo con un proc-macro adentro significa +compilar y ejecutar código arbitrario de un registro. + +Cae al anfitrión donde nada puede denegar, cada respuesta lo dice +(`analyzer_confined`, `analyzer_how`), y `THALYX_REQUIRE_CONFINED_ANALYZER=1` +convierte la caída en negativa. La etapa 59 de `verify.sh` reporta cuál de las +dos ocurrió, aparte del resultado del programa. + +**El punto 2 sigue abierto y sigue siendo de Cesar**: qué pasa adentro de la +imagen, donde el decreto dice el kernel y un programa y rust-analyzer es un +segundo programa. Que el *proceso* esté bajo autoridad de Thalyx no contesta de +dónde sale el binario. + +## Lo que sigue después de la transacción programable — 2026-08-30 + +- [ ] **Correr el banco.** Es la única pregunta que queda abierta sobre este + sprint y la única que no se puede contestar construyendo. Está todo + montado —el programa, la transacción, la semántica, el confinamiento, la + superficie chica— y **nada está medido**: no se corrió ningún banco pagado, + a propósito, porque primero se construye el contendiente. Lo que hay que + medir es si Claude o Codex hacen más trabajo correcto con menos esfuerzo de + modelo, con la superficie compacta contra `--surface legacy` como columna + de control. Ver [[Transaccion-Programable]] y [[Evidencia-de-Agentes]]. ## Pendientes de decreto formal diff --git a/vault/09-Notas-Tecnicas/Estrategia-de-Pruebas.md b/vault/09-Notas-Tecnicas/Estrategia-de-Pruebas.md index 43a69d2..5fc8d3d 100644 --- a/vault/09-Notas-Tecnicas/Estrategia-de-Pruebas.md +++ b/vault/09-Notas-Tecnicas/Estrategia-de-Pruebas.md @@ -5725,3 +5725,106 @@ es un recurso por el que las pruebas compiten, y una métrica sobre él es una medida del planificador. Se arregla dándole una llave: aquí, uno por árbol, con un tope y desalojo del más viejo, que es lo que una sesión real necesita de todos modos. + +--- + +## Reglas nuevas — 2026-08-30 + +### Un campo que desaparece el día interesante + +`exec::tests::a_check_of_bytes_nobody_has_seen_is_run` y su gemela fallaron en +Fedora afirmando `output["cached"] == false` y recibiendo `Null`. La causa no +era el cache: era que el brazo *«no hay cargo en esta máquina»* nunca escribía +el campo, y ése era el brazo que tomaba una máquina donde `sudo` había puesto +`HOME=/root` delante de un toolchain instalado bajo el home de `$SUDO_USER`. + +Dos personas leyeron dos aserciones sobre un cache. Lo que había pasado era un +`HOME`. + +> **Un esquema de respuesta es estable o no es un esquema.** Si un campo aplica +> conceptualmente, se escribe en **todos** los brazos, incluido el que dice que +> nada se pudo hacer. Un campo que sólo aparece el día interesante es un campo +> que nadie maneja el día interesante — y la prueba que lo garantiza recorre los +> brazos, no la corrida que casualmente toma uno. + +Y su mitad gemela: **una negativa nombra dónde buscó.** «No hay cargo» es una +oración sobre la que nadie puede actuar cuando el cargo está a un directorio de +distancia bajo otro home. + +### Tres búsquedas de la misma cosa son tres máquinas + +Había tres lugares que buscaban un binario de Rust —`metadata::cargo`, +`analyzer::find`, `exec::find_cargo`— y discrepaban en todo: qué variables leer, +si confiar en `PATH`, si *correr* un candidato antes de creerle. La cuarta era +`HAVE_ANALYZER` en `verify.sh`, mirando `$HOME`, que bajo `sudo` es `/root`. + +> **Una búsqueda repetida en dos lugares son dos búsquedas, y la segunda es la +> que discrepa en la máquina de alguien más.** Y: un veredicto producido por +> «el compilador que viniera primero en el `PATH` de quien llamó» es un veredicto +> que nadie puede reproducir. Se nombran lugares. + +### Un lenguaje puede dar vueltas, así que cada recurso lleva su techo + +`MOST_STEPS` acotaba una lista por construcción. Un programa con ciclos no se +acota por construcción, y un solo techo no alcanza: tiempo de reloj, presupuesto +de instrucciones, memoria, pila, llamadas a la máquina, procesos lanzados, bytes +que entran y bytes que salen son ocho preguntas distintas. Uno solo para las +ocho es la forma de la regla 3 al revés. + +> **Y una respuesta demasiado grande se niega, no se corta.** Una respuesta +> cortada a la mitad es una respuesta sobre la que un modelo actúa creyendo que +> está completa. + +### Una detención tiene que estar fuera del lenguaje que detiene + +Una aserción que sólo lanza una excepción de JavaScript la atrapa el propio +programa que la falló, y un programa escrito por un modelo de lenguaje envuelve +todo en `try`/`catch`. La corrida seguiría más allá de la premisa que acababa de +refutar, y confirmaría. + +> **Lo que detiene una corrida no puede estar en el lenguaje de la corrida.** La +> aserción queda trabada del lado de Rust y el motor se detiene; el `throw` es +> sólo la mitad que el programa puede ver. + +### El motor reporta su propia interrupción como una excepción del programa + +QuickJS levanta una interrupción como una excepción ordinaria de JavaScript. La +primera versión reportó `while (true) {}` como *«el programa lanzó»* — una +oración sobre el programa en lugar de sobre el techo que alcanzó, que es el +diagnóstico equivocado que la regla 5 persigue. + +> **Cuando la herramienta convierte tu razón en la razón del sujeto, guarda la +> tuya aparte.** El manejador marca que decidió detener, y esa marca le gana a +> lo que el motor haya alcanzado a decir. + +Su gemela, del mismo día: el `error.stack` de QuickJS son sólo los marcos —a +diferencia del de V8, no empieza con el mensaje— así que un envoltorio que +prefería `.stack` reportaba cada falla como una lista de números de línea con la +razón faltando. Regla 5 donde el arnés es el recuerdo que alguien tiene de otro +motor. + +### Lo que se revisa por nombre en una lista, se revisa por llamada en un programa + +`Program::read` rechaza un *paso* llamado `exec` o `attempt` antes de tomar el +snapshot. Correcto para una lista: una lista es un valor que algo puede mirar. +**Un programa no.** Alcanza verbos por nombre en tiempo de ejecución. + +Una prueba pidió `intento abandonar` desde adentro de un programa y recibió +`ok: true` con una línea `confirm_with` cargando el nombre del snapshot y el +testigo de estado exacto — todo lo que una segunda llamada necesita para +abandonar la transacción desde adentro de sí misma, a media corrida. + +> **Un chequeo que sólo se hace sobre la forma estática no se hace.** Se hace +> donde se puede hacer: en el momento de la llamada. Y una negativa no entrega +> lo que hace falta para reintentar. + +### «No escribe, por lo tanto es de sólo lectura» es una oración sobre el protocolo + +rust-analyzer se describió aquí como *un lector* durante una semana: nunca +aplica una edición, un rename vuelve como descripción, Thalyx escribe. Cierto +del protocolo LSP. Falso del árbol de procesos: corre `cargo metadata`, y para +contestar sobre un espacio de trabajo con un proc-macro compila y **ejecuta** +código arbitrario de un registro. + +> **La autoridad de un proceso no se deduce de la forma de su API.** Se deduce +> de qué arranca.