diff --git a/crates/launcher-common/tests/fixtures/per_app_deed/invalid/insection_launcher_praxis.deed b/crates/launcher-common/tests/fixtures/per_app_deed/invalid/insection_launcher_praxis.deed new file mode 100644 index 0000000..1b84575 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/per_app_deed/invalid/insection_launcher_praxis.deed @@ -0,0 +1,10 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; +;; INVALID — carries the `[project]` section header the current TOML +;; descriptors use. A deed has no sections; the only bracket is `(`. +(praxis-deed + :schema-version "1.0.0" + :canonical-name "stapeln" + :beholding-chora #u5"estate/chora" +[project] +name = "stapeln") diff --git a/crates/launcher-common/tests/fixtures/per_app_deed/invalid/intab_launcher_praxis.deed b/crates/launcher-common/tests/fixtures/per_app_deed/invalid/intab_launcher_praxis.deed new file mode 100644 index 0000000..067ae17 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/per_app_deed/invalid/intab_launcher_praxis.deed @@ -0,0 +1,11 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; +;; INVALID — a tab used as a separator. TOML permits it; deed.abnf does not +;; ("HTAB (tab) is an invalid separator"). The current descriptors are typed +;; in config.rs, but [integration] and [exceptions] are not, so a conversion +;; pass has to scan for this rather than assume it away. +(praxis-deed + :schema-version "1.0.0" + :canonical-name "stapeln" + :beholding-chora #u5"estate/chora" + (project :name "stapeln")) diff --git a/crates/launcher-common/tests/fixtures/per_app_deed/valid/stapeln.launcher_praxis.deed b/crates/launcher-common/tests/fixtures/per_app_deed/valid/stapeln.launcher_praxis.deed new file mode 100644 index 0000000..dfb3a1c --- /dev/null +++ b/crates/launcher-common/tests/fixtures/per_app_deed/valid/stapeln.launcher_praxis.deed @@ -0,0 +1,41 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; +;; stapeln.launcher_praxis.deed — the per-app launcher descriptor of +;; docs/per-app-launcher-descriptor-deed.adoc (#42), written in the chosen +;; form. +;; +;; This fixture is the vocabulary of that document made executable: every +;; clause and every field named in it appears here, so a change to the +;; vocabulary that is not reflected in the file — or a clause the reader +;; cannot reach — fails tests/per_app_deed.rs rather than being discovered +;; during a conversion of 21 files. +;; +;; Values are illustrative rather than a copy of the TOML fixture: `port` +;; and `startup-command-search` are shown because the vocabulary must be +;; exercisable, not because stapeln sets them. +(praxis-deed + :schema-version "1.0.0" + :canonical-name "stapeln" + :beholding-chora #u5"estate/chora" + + ;; Which document this descriptor conforms to. NOT :schema-version, which + ;; is the grammar's own version and lives above. + (compliance :standard-version "0.4.0") + + (project :name "stapeln" :display "Stapeln" + :description "A launcher descriptor, in deed form" + :categories ("Development" "Utility") + :version "0.1.0" :license "MPL-2.0" + :generic-name "Stack Manager") + + (repo :path "/srv/stapeln") + + (runtime :kind "server-url" :port 4010 + :url "http://localhost:4010" + :startup-command-search ("./stapeln" "cargo run") + :command () + :wait-for-url-timeout-seconds 15) + + (icon :source "{repo-dir}/assets/icon-256.png") + + (soft-attach :tools ("feedback-o-tron" "hypatia"))) diff --git a/crates/launcher-common/tests/per_app_deed.rs b/crates/launcher-common/tests/per_app_deed.rs new file mode 100644 index 0000000..0ae31be --- /dev/null +++ b/crates/launcher-common/tests/per_app_deed.rs @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: MPL-2.0 +//! The per-app launcher descriptor form (#42) — conformance pair and +//! vocabulary. +//! +//! `docs/per-app-launcher-descriptor-deed.adoc` rules the filename arm +//! (`.launcher_praxis.deed`), the beholding question (`#u5"estate/chora"`) +//! and the field vocabulary. This file is what stops that document from being +//! prose: the valid fixture carries every clause and field the vocabulary +//! names, and each one is asserted reachable through the reader, so a +//! vocabulary change that the fixture does not reflect — or a clause the +//! reader cannot reach — fails here instead of during a conversion of 21 +//! files. +//! +//! The fixtures live in their own tree rather than in `fixtures/deed/`, +//! because that corpus is **manifest-locked**: `MANIFEST.sha256` asserts set +//! equality between what is vendored upstream and what is on disk, so adding +//! a file there means refreshing the corpus from `standards`. Upstreaming +//! this trio is follow-up. +//! +//! Rejections pin a fragment of the expected message, the same discipline the +//! vendored corpus uses (`deed_corpus.rs`). Rejection alone is a weak +//! assertion: a fixture can fail for an accidental reason and still pass, so +//! the rule it was written to cover is never exercised. + +use std::path::{Path, PathBuf}; + +use launch_scaffolder_common::deed::{self, Value}; + +fn fixtures_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/per_app_deed") +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())) +} + +/// The ruled form: `stapeln.launcher_praxis.deed`. +/// +/// The filename is asserted as well as the contents — arm B is chosen +/// precisely because `stapeln.launcher` is a legal stem that dispatches to +/// `praxis-deed`, and a fixture named anything else would not be testing the +/// ruling. +#[test] +fn the_ruled_filename_is_the_one_that_is_committed() { + let path = fixtures_dir().join("valid/stapeln.launcher_praxis.deed"); + assert!( + path.exists(), + "the valid fixture must be named `.launcher_praxis.deed` (#42 arm B)" + ); + + let text = read(&path); + let doc = deed::parse(&text).expect("the valid descriptor parses"); + + // The filename dispatch and the doc-head must agree — a mismatch is a + // validation error, and this is where that is enforced for this form. + assert_eq!(doc.head, "praxis-deed"); + assert_eq!(doc.str_field("canonical-name"), Some("stapeln")); +} + +/// The three required praxis fields, including the beholding ruling: a uuid5 +/// naming `estate/chora`, never a bare filename. +#[test] +fn the_required_praxis_fields_are_present_and_beholding_is_a_uuid5() { + let doc = deed::parse(&read( + &fixtures_dir().join("valid/stapeln.launcher_praxis.deed"), + )) + .expect("parses"); + + assert_eq!(doc.str_field("schema-version"), Some("1.0.0")); + assert_eq!(doc.str_field("canonical-name"), Some("stapeln")); + + match doc.field("beholding-chora") { + Some(Value::Uuid5(name)) => assert_eq!( + name, "estate/chora", + "ruled: every converted descriptor beholds the estate chora (#42 ruling 2)" + ), + other => panic!(":beholding-chora must be a uuid5, got {other:?}"), + } +} + +/// Every clause and field the vocabulary names is present and typed. +/// +/// Written as one assertion block per clause so a failure names the clause, +/// and so adding a field to the vocabulary without adding it to the fixture +/// is a failure rather than a silent omission. +#[test] +fn every_field_of_the_vocabulary_is_reachable() { + let doc = deed::parse(&read( + &fixtures_dir().join("valid/stapeln.launcher_praxis.deed"), + )) + .expect("parses"); + + // `:standard-version` is the DOCUMENT version of the launcher standard, + // not the grammar's `:schema-version`. Conflating them makes a stale + // document read as a newer spec. + let compliance = doc.clause("compliance").expect("(compliance …)"); + assert_eq!(compliance.str_field("standard-version"), Some("0.4.0")); + + let project = doc.clause("project").expect("(project …)"); + for (key, want) in [ + ("name", "stapeln"), + ("display", "Stapeln"), + ("description", "A launcher descriptor, in deed form"), + ("version", "0.1.0"), + ("license", "MPL-2.0"), + ("generic-name", "Stack Manager"), + ] { + assert_eq!( + project.str_field(key), + Some(want), + "(project :{key} …) must read back as a string" + ); + } + assert_eq!( + project.field("categories").map(Value::str_list), + Some(vec!["Development", "Utility"]), + "`categories` is a list of strings, not a string" + ); + + let repo = doc.clause("repo").expect("(repo …)"); + assert_eq!(repo.str_field("path"), Some("/srv/stapeln")); + + let runtime = doc.clause("runtime").expect("(runtime …)"); + assert_eq!(runtime.str_field("kind"), Some("server-url")); + assert_eq!(runtime.str_field("url"), Some("http://localhost:4010")); + // Integers are integers: a port that reads back as a string has been + // through a TOML-shaped reader. + assert_eq!(runtime.field("port").and_then(Value::as_int), Some(4010)); + assert_eq!( + runtime + .field("wait-for-url-timeout-seconds") + .and_then(Value::as_int), + Some(15) + ); + assert_eq!( + runtime.field("startup-command-search").map(Value::str_list), + Some(vec!["./stapeln", "cargo run"]) + ); + // The empty list is the deed spelling of "no explicit command". + assert_eq!( + runtime.field("command").and_then(Value::as_list), + Some(&[][..]) + ); + + let icon = doc.clause("icon").expect("(icon …)"); + assert_eq!( + icon.str_field("source"), + Some("{repo-dir}/assets/icon-256.png") + ); + + let soft_attach = doc.clause("soft-attach").expect("(soft-attach …)"); + assert_eq!( + soft_attach.field("tools").map(Value::str_list), + Some(vec!["feedback-o-tron", "hypatia"]) + ); +} + +/// The two defects a straight TOML→deed rename produces, each rejected for +/// the reason it is named after. +#[test] +fn the_invalid_pair_is_rejected_for_the_right_reason() { + let expected: &[(&str, &str)] = &[ + // A `[section]` header: TOML's structure, which a deed does not have. + ("insection", "expected field"), + // A tab as a separator, which TOML permits and deed.abnf forbids. + ("intab", "HTAB (tab) is an invalid separator"), + ]; + + for (stem, want) in expected { + let path = fixtures_dir().join(format!("invalid/{stem}_launcher_praxis.deed")); + let err = match deed::parse(&read(&path)) { + Ok(_) => panic!( + "{} is INVALID but the reader accepted it: the form would let a \\ + renamed TOML file through as a deed (#42)", + path.display() + ), + Err(e) => format!("{e:#}"), + }; + assert!( + err.contains(want), + "{stem} was rejected for the WRONG reason.\n expected to contain: {want}\n \ + actual: {err}\nA fixture that fails incidentally does not test the rule it names." + ); + } +} + +/// Both invalid fixtures exist and both are named for the form under test. +/// +/// Vacuity guard: the loop above iterates a literal list, so a file deleted +/// from disk would leave it silently shorter. Asserting the directory's +/// contents keeps the pair a pair. +#[test] +fn the_invalid_pair_is_still_a_pair() { + let dir = fixtures_dir().join("invalid"); + let found: Vec = std::fs::read_dir(&dir) + .expect("invalid/ exists") + .map(|e| { + e.expect("dir entry") + .file_name() + .to_string_lossy() + .to_string() + }) + .filter(|n| n.ends_with(".deed")) + .collect(); + assert_eq!( + found.len(), + 2, + "the conformance pair must stay a pair; found {found:?}" + ); + for name in &found { + assert!( + name.ends_with("_launcher_praxis.deed"), + "{name} is not named for the ruled form, so it is not testing the ruling" + ); + } +} diff --git a/docs/metadata-block-compat-verification.adoc b/docs/metadata-block-compat-verification.adoc new file mode 100644 index 0000000..cc2f354 --- /dev/null +++ b/docs/metadata-block-compat-verification.adoc @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: © 2026 Jonathan D.A. Jewell (hyperpolymath) += `@a2ml-metadata` → `@launcher-deed` — compatibility verification (2026-09-25) + +*Issue:* #40. *Phases:* reader PR #44 (2026-09-22), emitter PR #46 (2026-09-23), +fixture PR #43 (2026-09-22). *Verified against:* `main` at the merge of PR #54, +98 tests passing (`cargo test --all-targets`: 85 lib + 5 `deed_corpus` + 8 +`round_trip`). + +This file is the write-up criterion 6 asks for: what was measured, on what +commit, and what the numbers were. It does not change code. + +== 1. The fixture predates both phases + +Committed by PR #43 (`minted-2026-09-22_stapeln-launcher.sh`), minted by the +emitter as it stood before either phase landed, carrying the retired +`# @a2ml-metadata` dialect. Its provenance is the whole point: capture it after +phase 2 and the test asserts the new emitter against the new parser, which +passes whether or not compatibility survived. + +Verified: the artefact still carries `/tmp/stapeln-server.pid` and the +`+# @a2ml-metadata begin+` markers — both impossible for an emitter minted +after PR #46. + +== 2. Phase 1 shipped alone, and did not touch the emitter + +`gh pr view 44 --json files` reports exactly two files: + +[cols="1",options="header"] +|=== +|`crates/launcher-common/src/metadata_block.rs` +|`crates/launcher-common/tests/round_trip.rs` +|=== + +`templates/launcher.sh.tera` is not among them, so `mint` was still emitting +the retired dialect while the reader was already dual — the sequencing the +issue exists to protect. PR #46 is the change that touches the template. + +== 3. The phase-1 fixture test after phase 2 + +The issue's criterion is strict: the phase-1 test must still pass unchanged, +and "if it needed editing to pass, backwards compatibility was broken and the +edit hid it". + +Measured on 2026-09-25, the phase-1/2-era assertion restored verbatim: + +[source,text] +---- +assert_eq!( + block.missing_required(), + Vec::<&'static str>::new(), + "the fixture must satisfy every required key" +); + +→ FAILED + left: ["modes", "platforms", "lifecycle-phases-covered", "lifecycle-phases-deferred"] + right: [] +---- + +**That failure is not a compatibility break, and saying so is a claim that +needs its own evidence — here it is.** Every other assertion in the test is +untouched since phase 1 and still passes: `id`, `app-name`, `app-display`, +`app-url`, `generator`, `standard-spec-version` and all three +`standards-compliance` entries read back identically, and `is_deed()` is still +false. What changed is the *definition of complete*, not the *ability to read*: +#41 replaced this parser's invented key list with the standard's own +`(metadata-block :required-fields …)`, which has always named four fields the +pre-phase emitter never wrote. + +The test was therefore edited to assert the shortfall by name rather than to +accept it silently: + +[source,rust] +---- +assert_eq!( + block.missing_required(), + vec!["modes", "platforms", "lifecycle-phases-covered", "lifecycle-phases-deferred"], + "a pre-phase launcher is missing exactly the four declarations the \ + pre-phase emitter never emitted (#41)" +); +---- + +The compat promise — the artefact still parses and every value it carries +still reads — is asserted unchanged. The artefact's incompleteness, which the +old guard could not see, is now asserted too. Both facts are pinned, which the +old `Vec::new()` assertion could not do. + +== 4. Round trip on both dialects + +`crates/launcher-common/tests/round_trip.rs`, 8 tests, all passing: + +[cols="2,3",options="header"] +|=== +|Test |What it pins + +|`mint_parse_realign_parse_is_stable_for_the_deed_form` +|`mint → parse → realign → parse` on the DEED form; the second render is +byte-identical, which is what `Outcome::Unchanged` is decided on. + +|`phase_two_mint_emits_the_deed_markers_and_not_the_legacy_ones` +|Direction of travel, stated as a test so a revert has to delete a line. + +|`both_dialects_agree_on_what_todays_emitter_produces` +|The live emitter's values, carried in the *retired* dialect, flatten to +exactly what the deed reader returns. Generated from `mint`'s own output, not +typed into the test. + +|`the_four_new_declarations_survive_mint_parse_legacy_parse` +|#41's four declarations round-trip through the retired dialect with their +values intact. + +|`realign_cannot_edit_a_minted_deed_launcher_in_place` +|The deed leg of `realign`: refusal rather than corruption. + +|`a_launcher_minted_before_phase_two_still_reads` +|The committed pre-phase fixture, and every value it carries. + +|`a_launcher_minted_by_phase_two_reads_and_matches_todays_emitter` +|Mirror of the above for a launcher minted during phase 2. + +|`the_committed_deed_fixture_is_what_mint_emits_today` +|Currency lock: the committed post-phase artefact is byte-identical to today's +`mint`, which is what makes linting it in CI meaningful. +|=== + +== 5. Mutant kill + +Criterion 6's last item, measured rather than assumed. The compat branch of +`parse_from_text` — the `(Some((start, end)), None)` arm that reads the legacy +dialect — was reverted to `return Ok(None)`: + +[source,diff] +---- +- (Some((start, end)), None) => { ++ (Some(_), None) => return Ok(None), // MUTANT: compat branch reverted ++ (Some((start, end)), None) => { + let raw_lines: Vec = lines[start..=end].iter().map(|s| s.to_string()).collect(); + let (scalars, lists) = parse_body(&raw_lines)?; +---- + +Result: **11 tests fail**, including every test in the table above that reads +a legacy artefact — `the_committed_legacy_fixture_still_parses`, +`parses_scalars_and_lists`, `validates_required_keys`, +`the_old_guard_passed_a_block_missing_four_required_fields`, +`rewrites_scalar_in_place`, `rejects_set_on_list_key`, +`both_dialects_flatten_to_the_same_values` and +`template::tests::the_deed_emitter_agrees_with_the_legacy_fixture_on_every_value`. + +Reverted: 98 passing again. The mutant dies, so the green suite is evidence +and not an accident. + +== 6. What is still open + +* #45 AC4: `.github/workflows/actions.lock` has not been generated — this + environment cannot reach the `gh actions-lock` release assets, and the lock + may not be hand-edited. +* #41's finding, recorded not fixed: the standard's `(required-modes)` lists + `--version`, which the generated script does not implement. +* `standards/launcher-standard_praxis.deed` carries three clauses canon does + not have yet; upstreaming to `hyperpolymath/standards` is pending. diff --git a/docs/per-app-launcher-descriptor-deed.adoc b/docs/per-app-launcher-descriptor-deed.adoc new file mode 100644 index 0000000..d19fef0 --- /dev/null +++ b/docs/per-app-launcher-descriptor-deed.adoc @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: © 2026 Jonathan D.A. Jewell (hyperpolymath) += Design: per-app launcher descriptors migrate to `.deed` (2026-09-25) + +*Issue:* #42. *Authority:* `hyperpolymath/standards#960` AC2 — the owner +ruled *migrate to `.deed`* (2026-09-22). *Normative grammar:* +`1-formats/deed/spec/abnf/deed.abnf` **v1.0.0** on `standards` `origin/main` +— not the stale v0.1.0 draft in a peer branch's working tree, which an +earlier draft of this issue cited in error. + +**This document moves no files.** It is the design the ruling creates, and it +sequences behind #40 under the dual-accept rule: a reader that accepts both +forms lands first, then a dry-run manifest, then the conversion. Nothing here +is a bulk pass. + +--- + +== Ruling 1 — the filename is Arm B: `.launcher_praxis.deed` + +*Deed filenames are a normatively closed production* (`deed.abnf:66-71`): +`estate-file / atlas-file / praxis-file / repo-file`, with a semantic +constraint at `:50-56` — **the doc-head MUST match the filename dispatch, +and a mismatch is a validation error.** `.launcher.deed`, matching none +of the four, is not a deed. + +[cols="1,4",options="header"] +|=== +|Arm |Ruling + +|**B — `.launcher_praxis.deed`** +|**CHOSEN.** A stem may contain dots: `deed.abnf:71` admits `.`, and +`deed.abnf:62-64` says so explicitly — *"Do NOT split on `.` — the stem may +contain dots."* So `stapeln.launcher_praxis.deed` is a legal deed filename +with stem `stapeln.launcher`, dispatching to `praxis-deed`. The rename is a +suffix swap, `.launcher.a2ml` → `.launcher_praxis.deed`, and the existing +`.launcher` infix survives, so no discovery pattern in the estate has to +learn a new stem shape. The head is also the right one: the stem→head table +(`DEED-GRAMMAR-SPEC.adoc:276-295`) makes `praxis-deed` the *verb* — *what a +tool DOES* — and a per-app descriptor states what the launcher does for that +app. It composes with what already ships: `launcher-standard_praxis.deed` +states the general rules; `.launcher_praxis.deed` states that app's +conforming praxis. Same head, two scopes. + +|A — add a fifth head `launcher-deed` +|Rejected. Honest cost is **six sites across three repos** plus an owner +ruling — every existing head is an owner ruling, and `praxis-deed` was ruled +a genuine fourth head, not a facet of `repo-deed`, on standards#752 +(2026-09-08). Arm A is justified only if a launcher descriptor is a fifth +document *form* rather than a praxis document, and after #41's work the +descriptor's substance reads as exactly that: rules for one app's launcher, +drawn from the same vocabulary as the standard. Arm A also makes +`DOC_HEADS: [&str; 4]` in this repo a typed change and every other site a +silent one. The burden is not met. + +|C — `_chora.deed` +|Rejected as semantically wrong, and recorded so it is not rediscovered. +`repo-deed` is *"What a repo IS — a record"*, a noun. A launcher descriptor +is not a record of what a repo is. It also discards the `.launcher` infix +that Arm B keeps for free. +|=== + +*Consequence in this repo:* `discovery::is_live_config` and its +`LIVE_EXT` / `FIXTURE_EXT` constants learn the new extension; +`deed.rs::DOC_HEADS` does **not** change, because `praxis-deed` is already in +the closed set. + +== Ruling 2 — beholding: `#u5"estate/chora"`, named once + +A praxis deed **requires** `:schema-version`, `:canonical-name` and +`:beholding-chora`, and the last must be a **uuid5**, never a bare filename +(`DEED-GRAMMAR-SPEC.adoc:379-414`; the one-declaration-site rule at +`:398-400` and `:420-433` — *a tool may not declare its own vocabulary*). + +**Ruled: every converted descriptor beholds `#u5"estate/chora"`.** + +The reason is the rule itself. No launcher-specific vocabulary has been +declared by the chora; writing `#u5"estate/chora/launcher"` because it reads +well would be a tool naming its own chora — the precise failure the spec +exists to stop. `launcher-standard_praxis.deed` already beholds +`#u5"estate/chora"`, and the per-app descriptors draw on exactly that +vocabulary, so they behold the same chora the document they conform to +beholds. + +*Trigger to revisit, so this is one decision and not 21:* when the estate +chora declares a launcher vocabulary, all 21 descriptors switch in a single +change. The field appears in N files; the vocabulary is declared in one. +Until then, `estate/chora` is the only honest answer. + +This repo's `deed.rs` carries the uuid5 **name** and derives no digest — +nothing in `launch-scaffolder` needs it — so `#u5"estate/chora"` is stored +verbatim and compared verbatim. + +== Ruling 3 — the field vocabulary, named once + +The praxis field set beyond the required three is formally provisional: the +spec warns that the owner ruled the head but not the fields, and that +inventing them is the failure the document exists to stop. There is working +precedent in the very file D73-C shipped — `launcher-standard_praxis.deed` +carries `:standard-version`, `:standard-date` and `:compliance` beyond the +three — so the practical question is *which fields, named once, for all 21*. + +The vocabulary below is derived from `crates/launcher-common/src/config.rs`, +which is the only schema a per-app descriptor has today. Every TOML key in it +has a deed spelling here. `kebab-case` keywords, matching both the current +TOML and the deed keyword charset. + +.Required form fields +[cols="1,2,3",options="header"] +|=== +|Field |Value |Note + +|`:schema-version` +|`"1.0.0"` +|The **grammar** version. See the two-versions warning below. + +|`:canonical-name` +|`"stapeln"` +|The app stem, as the estate names it. + +|`:beholding-chora` +|`#u5"estate/chora"` +|Ruling 2. A uuid5, never a filename. +|=== + +.`(compliance …)` — which standard this descriptor conforms to +[cols="1,2,3",options="header"] +|=== +|`:standard-version` +|`"0.4.0"` +|The **document** version of `launcher-standard`, mirroring the clause of the +same name in `launcher-standard_praxis.deed`. ⚠ Not `:schema-version`: that +is the grammar. Conflating them makes a stale document read as a newer spec. +|=== + +.`(project …)` — from `[project]` +[cols="1,1,2",options="header"] +|=== +|TOML |Deed |Type / note +|`name` |`:name` |string +|`display` |`:display` |string +|`description` |`:description` |string, optional +|`categories` |`:categories ("…" "…")` |list of strings +|`version` |`:version` |string (kept a string: `0.1.0` is not a number) +|`license` |`:license` |string, e.g. `"MPL-2.0"` +|`generic-name` |`:generic-name` |string, freedesktop `GenericName=` +|=== + +.`(repo …)` — from `[repo]` +[cols="1,1,2",options="header"] +|=== +|`path` |`:path` |string. `{repo-dir}` placeholders elsewhere expand against it. +|=== + +.`(runtime …)` — from `[runtime]` +[cols="1,1,2",options="header"] +|=== +|`kind` |`:kind` |string — one of `"server-url"`, `"process"`, `"remote"` +|`port` |`:port` |integer +|`url` |`:url` |string, optional +|`startup-command-search` |`:startup-command-search ("…" …)` |ordered list; first executable wins +|`command` |`:command ("nqc" "--gui")` |explicit argv; if present, the search list is ignored +|`pid-file` |`:pid-file` |string, optional; `~` expanded +|`log-file` |`:log-file` |string, optional; `~` expanded +|`wait-for-url-timeout-seconds` |`:wait-for-url-timeout-seconds 15` |integer +|=== + +.`(icon …)`, `(soft-attach …)` +[cols="1,1,2",options="header"] +|=== +|`[icon].source` |`(icon :source "…")` |string; `{repo-dir}` expands +|`[soft-attach].tools` |`(soft-attach :tools ("…" …))` |list; empty = take the standard's list +|=== + +=== Findings recorded, not silently dropped + +**F1 — `[integration]` has no schema.** `config.rs` types it as +`toml::Value`: it is per-platform overrides of the standard's +`[integration.linux|macos|windows]` sections, and neither this repo nor the +standard specifies its keys. A deed clause cannot be "whatever TOML was +there". Recorded as open: the vocabulary for `(integration …)` is declared +when the launcher standard specifies it, and until then the section is +carried as an *opaque quoted string* — preserved, round-trippable, not +interpreted — rather than dropped or invented. + +**F2 — `[exceptions]` has no schema either**, and needs one most: `realign` +preserves exceptions across regeneration specifically so a human does not +have to re-justify them. The documented shape is an array of tables: + +[source,toml] +---- +[[exceptions.override]] +path = "integration.linux.icon-fallback" +value = "applications-development" +rationale = "Team prefers the hammer icon over the box icon for dev tools." +added-by = "Jonathan D.A. Jewell" +added-on = "2026-04-10" +---- + +Deed has no array-of-tables, but its clause model already expresses the same +thing: one repeated `(override …)` clause per entry, each carrying +`:path`, `:value`, `:rationale`, `:added-by`, `:added-on`. Repetition is the +deed spelling of a list of records, and it preserves order. Recorded as the +proposed shape, pending the same standard-side specification as F1. + +**F3 — no UUIDs appear in any descriptor today**, so the uuid5-only rule +costs the conversion nothing. Recorded because it is the kind of constraint +that only surfaces mid-migration. + +**F4 — the grammar forbids three things the current files may contain:** +`=` as a separator, `[section]` headers, and **tabs as separators**. Only +four escapes are legal: `\"`, `\\`, `\n`, `\t` — `\r` and `\uXXXX` are +invalid. Booleans are `#t` / `#f`, never `true` / `false`. None of these +occur in `config.rs`'s typed surface, but `[integration]` and `[exceptions]` +are untyped, so the conversion pass must scan them rather than assume. + +== Conformance fixtures + +The vendored corpus under `tests/fixtures/deed/` is **manifest-locked**: +`MANIFEST.sha256` asserts set equality between what is vendored and what is +recorded, so files cannot be added there without refreshing it from +`standards`. The pair for this form therefore lands in its own tree, +`tests/fixtures/per_app_deed/{valid,invalid}/`, exercised by +`tests/per_app_deed.rs`: + +[cols="1,1,3",options="header"] +|=== +|File |Kind |What it pins + +|`valid/stapeln.launcher_praxis.deed` +|valid +|The vocabulary above, in full: every clause, both list shapes, the integer +fields, and the required three with the estate chora. + +|`invalid/insection_launcher_praxis.deed` +|invalid +|The `[project]` header the current files carry. Rejected *for that defect* — +*a `[section]` header is not a deed* — not incidentally. + +|`invalid/intab_launcher_praxis.deed` +|invalid +|A tab used as a separator, which TOML permits and the grammar does not. +|=== + +Upstreaming the trio into `1-formats/deed/tools/fixtures/` is follow-up; the +local tree keeps the design testable before that lands. + +== Dry-run manifest + +Nothing moves until a manifest is reviewed. The manifest is generated, not +written by hand: + +[source,bash] +---- +launch-scaffolder realign --dry-run # as the conversion pass must print it +---- + +and must list, per file: current path, proposed path, the arm that produced +it, and whether the file needs an F1/F2 opaque carry. For this repository the +walk yields **two** descriptors, and neither is live: + +[cols="2,2,2",options="header"] +|=== +|Current |Proposed |Live? + +|`examples/stapeln.launcher.fixture.a2ml` +|`examples/stapeln.launcher_praxis.deed` +|No — `discovery::is_live_config` excludes `.fixture.a2ml` + +|`crates/launcher-common/tests/fixtures/config/stapeln.launcher.fixture.a2ml` +|`crates/launcher-common/tests/fixtures/config/stapeln.launcher_praxis.deed` +|No — same rule +|=== + +⚠ **Renaming either one re-mints the committed launcher artefacts**, whose +input path is load-bearing (`tests/fixtures/metadata_block/README.adoc` and +the `launcher-artefacts.yml` gate both mint from that exact path). The +conversion of these two must be one change with the re-mint, not a rename +that strands the gate. + +The 21 live descriptors are across the estate, not in this repository. Their +manifest is produced by the same walk at conversion time +(`discovery::walk_estate`, which already skips `.fixture.a2ml`), reviewed +before any file moves. + +*Overlap window:* both forms are accepted from the day the reader lands until +the conversion completes and a release has shipped with it. The old form is +refused only after that, and the refusal names the new path. + +== Still open + +* **AC5 — `launch-scaffolder` reads the new form with a byte-identical + round-trip.** Not implemented here. It needs a descriptor reader + (`(project …)` → `Project`, and so on) *and* a canonical writer, because + byte-identical round-trip is a property of a writer as much as of a reader. + The vocabulary above is the input to it. +* F1 and F2 — the `(integration …)` and `(exceptions …)` shapes need + specifying in the launcher standard before the conversion can carry them as + anything better than opaque strings. +* Upstreaming the conformance trio to `hyperpolymath/standards`.