Skip to content

Commit 22cd58c

Browse files
committed
feat(s013): the linux package builds the markdown NIF for musl, and the smoke path says whether it rendered
Owner decision 2026-09-20 (NOTES.md, the open decision after finding 14): keep mdex and build its NIF from source where the precompiled artifact does not load. rustler ~> 0.38 joins the tree at build time only (VERSIONS row; versions.verify 84 locked, 48 pins). Three compile-time settings, named together in config/config.exs and set by the package workflow's linux job only: MDEX_NATIVE_BUILD=1, TRINITY_NIF_TARGET (given to Rustler as the cargo target) and the linker scripts/zig-cc-musl (zig cc -target x86_64-linux-musl; Rust's own self-contained musl link refuses a cdylib without crt-static). rust-toolchain.toml names the musl target so rustup installs it. Trinity.Smoke prints a second line, TRINITY_SMOKE_MARKDOWN=ok, after rendering one line through the NIF, and exits 3 otherwise; the package workflow's smoke step reads it on all three targets, so a bundle whose NIF does not load can no longer pass on a boot. The line is computed inside Application.start/2 rather than in the Task: Kernel.CLI treats the plain argument --smoke as a file once the application is up ("No file named --smoke", exit 1), a race the Task only wins by halting at once, lost on the first packaged run with the render inside it. TrinityWeb.Markdown logs at :error, once, when it falls back to escaped text. Measured here on a fresh install of the binary built exactly as the workflow builds it: the NIF needs libc.so only (readelf -d); --smoke prints ok and exits 0 three runs in a row; with the gnu artifact swapped into the payload it prints failed:%MDEx.DecodeError{} and exits 3; the packaged chat renders the real provider's answer as markdown with no on_load warning. mix gate exit 0, 177 tests; credo --strict --all clean. docs/packaging.md gains the native-code section; R24 lifted in docs/06. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent 55999e0 commit 22cd58c

13 files changed

Lines changed: 145 additions & 10 deletions

File tree

‎.github/workflows/package.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,19 @@ jobs:
102102

103103
# --overwrite is not optional. Without it, and with a release directory already
104104
# present, mix release prompts, gets no stdin, and exits 0 having built nothing.
105+
# Slice 013: on Linux, mdex's NIF is built from source for Burrito's musl ERTS, linked
106+
# through Zig (scripts/zig-cc-musl; config/config.exs names the three settings and
107+
# NOTES.md finding 14 the measurement). rustup installed the musl standard library from
108+
# rust-toolchain.toml above. macOS and Windows load the precompiled artifact for their
109+
# native ERTS; the smoke step below is what says whether it loaded.
105110
- name: Build the Burrito binary
106111
shell: bash
107112
env:
108113
BURRITO_TARGET: ${{ matrix.target }}
109114
MIX_ENV: prod
115+
MDEX_NATIVE_BUILD: ${{ runner.os == 'Linux' && '1' || '' }}
116+
TRINITY_NIF_TARGET: ${{ runner.os == 'Linux' && 'x86_64-unknown-linux-musl' || '' }}
117+
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: ${{ runner.os == 'Linux' && format('{0}/scripts/zig-cc-musl', github.workspace) || '' }}
110118
run: |
111119
set -euo pipefail
112120
# `mix deps.get` above runs in the default env. On the Windows runner the prod build
@@ -188,6 +196,8 @@ jobs:
188196
sleep 3
189197
ours > ps-after.txt
190198
grep -q '^TRINITY_SMOKE_PORT=[0-9][0-9]*$' smoke.log
199+
# Slice 013: the renderer's NIF loaded and rendered in this binary (exit 3 otherwise).
200+
grep -q '^TRINITY_SMOKE_MARKDOWN=ok$' smoke.log
191201
echo "--- ours, before ---"; cat ps-before.txt
192202
echo "--- ours, after ----"; cat ps-after.txt
193203
diff ps-before.txt ps-after.txt
@@ -202,6 +212,9 @@ jobs:
202212
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_PORT=\d+$')) {
203213
throw "no port line"
204214
}
215+
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_MARKDOWN=ok$')) {
216+
throw "the markdown renderer did not render in this binary (slice 013)"
217+
}
205218
206219
# AC1's property on a runner, and AC6's per-OS cold start, which used to be skipped.
207220
# `bash` on every OS, Windows included: the job that never curled was the one gap in

‎VERSIONS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,8 @@ never pin a version hex marks as retired or vulnerable.
134134
| `nostrum` | ~> 0.10 | 🔍 not yet a dependency | Discord. ⚠️ No release in roughly 18 months. R11's trigger already fires. Check intents and components against the current gateway before Slice 072. |
135135
| `telegex` | **not pinned** | 🔍 not a single package | Telegram. ⚠️ The latest release on hex is a release candidate, roughly two years old, and this file's own rule forbids pinning an `-rc`. Alternative: ex_gram. Slice 071 decides with the measurement. |
136136
| `phoenix_streamdown` | not used (measured at Slice 013, 2026-09-20) | 🔍 not a single package | Streaming markdown renderer for LiveView. Measured at Slice 013: 1.0.0-beta.4 (2026-05-03) was still the latest release with no stable behind it, and this file's own rule forbids a pre-release. It is 801 lines over `mdex`, whose own `streaming: true` option completes fragments; the rest is a rendering rule Slice 013 keeps anyway. The row stays so the decision is visible where a reader would look for the package. |
137-
| `mdex` | ~> 0.13 | ✅ in `mix.lock` | Markdown renderer for the chat (`TrinityWeb.Markdown`), streaming fragments completed by its `streaming: true` option, raw HTML omitted (`unsafe: false`) and the default sanitizer on top. Added at Slice 013 after the measurement in its NOTES.md: `earmark` 1.4.49 is retired on hex with an open XSS advisory (EEF-CVE-2026-48591), which the gate refuses. ⚠️ A Rust NIF (`mdex_native`, precompiled through `rustler_precompiled` for the four packaging targets): the first NIF in the bundle; the `package` workflow proves it on three operating systems, cited in Slice 013's PROOF.md. |
137+
| `mdex` | ~> 0.13 | ✅ in `mix.lock` | Markdown renderer for the chat (`TrinityWeb.Markdown`), streaming fragments completed by its `streaming: true` option, raw HTML omitted (`unsafe: false`) and the default sanitizer on top. Added at Slice 013 after the measurement in its NOTES.md: `earmark` 1.4.49 is retired on hex with an open XSS advisory (EEF-CVE-2026-48591), which the gate refuses. ⚠️ A Rust NIF (`mdex_native`): the first in the bundle. Measured at Slice 013 (NOTES finding 13): neither precompiled artifact loads in Burrito's musl ERTS on Linux, so the linux package builds it from source for musl with Zig as the linker (`rustler` below, `scripts/zig-cc-musl`, the three settings in config/config.exs); macOS and Windows load the precompiled artifact. The `--smoke` path prints whether it rendered, and the `package` workflow reads that line on every target. |
138+
| `rustler` | ~> 0.38 | ✅ in `mix.lock` | Build time only (`runtime: false`): what `rustler_precompiled` needs to compile `mdex_native` from source when `MDEX_NATIVE_BUILD=1`, which the linux package sets (owner decision 2026-09-20, Slice 013 NOTES finding 14). Nothing in the tree calls it. |
138139

139140
### Dev and quality
140141

‎config/config.exs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ config :trinity,
3636
other -> raise "TRINITY_DB must be sqlite or postgres, got #{inspect(other)}"
3737
end)
3838

39+
# Slice 013 (owner decision, 2026-09-20): the packaged Linux binary runs on Burrito's musl ERTS,
40+
# in which neither precompiled mdex_native artifact loads (both need glibc's libgcc_s), so the
41+
# linux package builds that NIF from source for the musl target with Zig as the linker
42+
# (NOTES.md finding 14). Three settings travel together, all compile time:
43+
# MDEX_NATIVE_BUILD=1 mdex_native compiles instead of downloading
44+
# TRINITY_NIF_TARGET=x86_64-unknown-linux-musl the cargo target, given to Rustler here
45+
# CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=scripts/zig-cc-musl
46+
# Unset, development and the test gate use the precompiled artifact for the host as before.
47+
nif_target = System.get_env("TRINITY_NIF_TARGET", "")
48+
49+
if nif_target != "" do
50+
config :mdex_native, MDExNative.Native, target: nif_target
51+
end
52+
3953
# Slice 011: the model registry lives in its own file so the live test suite can read it
4054
# without evaluating the environment-specific imports below.
4155
import_config "llm.exs"

‎docs/06-risk-register.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@
2222
| R21 | The tree carries IP that is not this project's to publish | Med | High | Any design in Trinity that reproduces a third party's protected mechanism | Trinity's receipt and policy design is its own. Policy identifiers stay out of `signed_payload` until legal review clears them; 024's field set is reviewed before that slice starts | 024, 120, 122 |
2323
| R22 | Single maintainer; Growth needs two unaffiliated production users and commits from two orgs | High | Med | 6-month Sandbox checkpoint | Sandbox tolerates it; GOVERNANCE.md documents intent to grow; recruit co-maintainers via the Elixir community once public | 120, 122 |
2424
| R24 | A native NIF in the desktop bundle: `mdex_native` (the markdown renderer's Rust core, added at 013) is the first, and slice 001 proved the packaging chain with a pure-BEAM release | Med | Med | The `package` workflow fails on one of its three operating systems after 013, or 100's cross-target build finds no artifact for a target | `rustler_precompiled` ships artifacts for every target the chain builds for (013 NOTES.md, measured against the checksum file); the `package` run on 013's `mix.lock` change is the first proof on three runners; lift condition: that run green on all three, cited in 013's PROOF.md; a fallback is `MDEX_NATIVE_BUILD=1` with the pinned Rust toolchain already in the tree | 013, 100 |
25+
| R24 | Fired 2026-09-20, in slice 013: neither precompiled `mdex_native` artifact loads in Burrito's musl ERTS (both need glibc's `libgcc_s`; `_dl_find_object: symbol not found`). Lifted the same day by the owner's decision: the linux package builds the NIF from source for musl with Zig as the linker (`scripts/zig-cc-musl`, config/config.exs, `rustler` at build time), measured on a fresh install of the packaged binary rendering the real provider's answer; the `--smoke` path now prints `TRINITY_SMOKE_MARKDOWN=ok` and exits 3 otherwise, and the `package` workflow reads that line on every target, so a dead NIF cannot pass again. Residual: macOS and Windows use the precompiled artifact, and the `package` workflow has yet to run green there since the slice/011 tag (an older failure at "Serves HTTP 200", now diagnosed by the step's log). The row above stands as written | none | none | none | none | 013 |
2526
| R23 | EMA / ID-JAG is beta everywhere (vendors label it so); IETF draft still moving | Med | Med | Spec revision breaks the exchange | Keep the AS small and behaviour-driven; pin the draft version in the ADR; conformance tests against two IdPs' documented flows | 062 |

‎docs/packaging.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,29 @@ something it is not.
168168
A screenshot of a real window on a real desktop does not exist for macOS, Windows or Linux, and
169169
Slice 001 exits saying so rather than counting those criteria as met.
170170

171+
172+
## Native code in the bundle (slice 013)
173+
174+
Burrito's Linux ERTS is a musl build, and every NIF in the bundle has to be one too. Burrito cross-compiles C
175+
NIFs (exqlite) with Zig by itself; a Rust NIF it does not touch. `mdex_native` (the markdown renderer's core)
176+
ships precompiled artifacts, and neither the gnu nor the musl one loads in that ERTS: both need glibc's
177+
`libgcc_s.so.1` (`_dl_find_object: symbol not found`). The linux package therefore builds it from source for
178+
`x86_64-unknown-linux-musl` with Zig as the linker:
179+
180+
```
181+
MDEX_NATIVE_BUILD=1 TRINITY_NIF_TARGET=x86_64-unknown-linux-musl \
182+
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=$PWD/scripts/zig-cc-musl \
183+
MIX_ENV=prod BURRITO_TARGET=linux_x86_64 mix release desktop --overwrite
184+
```
185+
186+
`rust-toolchain.toml` names the musl target so rustup installs its standard library; `rustler` is a build-time
187+
dependency for this alone. The result needs `libc.so` only (`readelf -d`), the same shape as the exqlite NIF.
188+
macOS and Windows load the precompiled artifact for their native ERTS. Whether it loaded is not inferred from
189+
a boot: `--smoke` prints `TRINITY_SMOKE_MARKDOWN=ok` after rendering one line through the NIF and exits 3
190+
otherwise, and the `package` workflow's smoke step reads that line on every target.
191+
192+
Two things measured on the way (slice 013 NOTES.md, findings 12 to 14): Burrito reuses the payload it
193+
extracted to `~/.local/share/.burrito/<name>_erts-<v>_<app v>` for as long as the app version stays the same,
194+
so a local run of a new binary at the same version runs the old code until that directory is removed; and the
195+
Burrito wrapper starts `erlexec` directly, so `RELEASE_NAME` is unset in the packaged app (the migrator no
196+
longer keys on it).

‎lib/trinity/smoke.ex‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,37 @@ defmodule Trinity.Smoke do
5555
@spec port_line(:inet.port_number()) :: String.t()
5656
def port_line(port), do: "TRINITY_SMOKE_PORT=#{port}"
5757

58+
@doc """
59+
The second line (slice 013): whether the markdown renderer's NIF loaded and rendered in this
60+
binary. A packaged build whose NIF fails to load still boots and serves, showing escaped
61+
text instead of markdown (NOTES.md finding 13), so booting proves nothing about it; this
62+
does. `ok` or `failed:<reason>`, one line, no prose.
63+
"""
64+
@spec markdown_line() :: String.t()
65+
def markdown_line do
66+
case MDEx.to_html("**smoke**", render: [unsafe: false]) do
67+
{:ok, html} ->
68+
if html =~ "<strong>smoke</strong>",
69+
do: "TRINITY_SMOKE_MARKDOWN=ok",
70+
else: "TRINITY_SMOKE_MARKDOWN=failed:#{inspect(html)}"
71+
72+
{:error, reason} ->
73+
"TRINITY_SMOKE_MARKDOWN=failed:#{inspect(reason)}"
74+
end
75+
end
76+
5877
@doc """
5978
Runs the smoke check: report the listening port, then stop the OS process.
6079
6180
`say` and `halt` are injected so the whole path is exercisable from a test without ending
6281
the test runner's own OS process.
6382
"""
64-
@spec run(say_fun(), halt_fun()) :: any()
65-
def run(say \\ &IO.puts/1, halt \\ &System.halt/1) do
83+
@spec run(say_fun(), halt_fun(), String.t()) :: any()
84+
def run(say \\ &IO.puts/1, halt \\ &System.halt/1, markdown \\ markdown_line()) do
6685
{:ok, {_ip, port}} = TrinityWeb.Endpoint.server_info(:http)
6786
say.(port_line(port))
68-
halt.(0)
87+
say.(markdown)
88+
halt.(if markdown == "TRINITY_SMOKE_MARKDOWN=ok", do: 0, else: 3)
6989
end
7090

7191
@doc """
@@ -79,8 +99,19 @@ defmodule Trinity.Smoke do
7999
the VM from within the OTP boot sequence: a boot crash rather than a clean exit. `ps`
80100
cannot tell those apart from the outside; the exit code can, and AC7 reads both.
81101
"""
102+
#
103+
# The markdown line is computed here, inside `Trinity.Application.start/2`, not in the Task:
104+
# `Kernel.CLI` reads the same plain arguments once the application has started and treats
105+
# `--smoke` as a file to run ("No file named --smoke", exit 1), so the Task wins only by
106+
# halting at once. Measured at slice 013 when a few milliseconds of rendering inside the
107+
# Task lost that race on the first packaged run.
82108
@spec children([String.t()]) :: [Supervisor.child_spec() | {module(), term()}]
83109
def children(args) do
84-
if requested?(args), do: [{Task, &run/0}], else: []
110+
if requested?(args) do
111+
markdown = markdown_line()
112+
[{Task, fn -> run(&IO.puts/1, &System.halt/1, markdown) end}]
113+
else
114+
[]
115+
end
85116
end
86117
end

‎lib/trinity/versions.ex‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,14 @@ defmodule Trinity.Versions do
265265
pin: "~> 0.13",
266266
lock: "mdex",
267267
note:
268-
"Markdown renderer for the chat (`TrinityWeb.Markdown`), streaming fragments completed by its `streaming: true` option, raw HTML omitted (`unsafe: false`) and the default sanitizer on top. Added at Slice 013 after the measurement in its NOTES.md: `earmark` 1.4.49 is retired on hex with an open XSS advisory (EEF-CVE-2026-48591), which the gate refuses. ⚠️ A Rust NIF (`mdex_native`, precompiled through `rustler_precompiled` for the four packaging targets): the first NIF in the bundle; the `package` workflow proves it on three operating systems, cited in Slice 013's PROOF.md."
268+
"Markdown renderer for the chat (`TrinityWeb.Markdown`), streaming fragments completed by its `streaming: true` option, raw HTML omitted (`unsafe: false`) and the default sanitizer on top. Added at Slice 013 after the measurement in its NOTES.md: `earmark` 1.4.49 is retired on hex with an open XSS advisory (EEF-CVE-2026-48591), which the gate refuses. ⚠️ A Rust NIF (`mdex_native`): the first in the bundle. Measured at Slice 013 (NOTES finding 13): neither precompiled artifact loads in Burrito's musl ERTS on Linux, so the linux package builds it from source for musl with Zig as the linker (`rustler` below, `scripts/zig-cc-musl`, the three settings in config/config.exs); macOS and Windows load the precompiled artifact. The `--smoke` path prints whether it rendered, and the `package` workflow reads that line on every target."
269+
},
270+
%{
271+
name: "rustler",
272+
pin: "~> 0.38",
273+
lock: "rustler",
274+
note:
275+
"Build time only (`runtime: false`): what `rustler_precompiled` needs to compile `mdex_native` from source when `MDEX_NATIVE_BUILD=1`, which the linux package sets (owner decision 2026-09-20, Slice 013 NOTES finding 14). Nothing in the tree calls it."
269276
}
270277
]
271278

‎lib/trinity_web/markdown.ex‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ defmodule TrinityWeb.Markdown do
1111
in-progress message uses; a finished message renders without it, once.
1212
"""
1313

14+
require Logger
15+
1416
# Sobelow reads `@sobelow_skip` from the source; the compiler would call it unused (the same
1517
# measure `Trinity.Paths` takes, slice 001 line 4).
1618
Module.register_attribute(__MODULE__, :sobelow_skip, persist: true)
@@ -36,10 +38,20 @@ defmodule TrinityWeb.Markdown do
3638
]
3739

3840
case MDEx.to_html(markdown, options) do
39-
{:ok, html} -> Phoenix.HTML.raw(html)
40-
# A parse failure is the renderer's, not the text's: show the text escaped rather than
41-
# nothing, so a message is never blank on screen.
42-
{:error, _} -> Phoenix.HTML.html_escape(markdown)
41+
{:ok, html} ->
42+
Phoenix.HTML.raw(html)
43+
44+
# A failure is the renderer's, not the text's: show the text escaped rather than nothing,
45+
# so a message is never blank on screen, and say so once at :error, because the one
46+
# failure seen so far was a NIF that did not load in a packaged binary (NOTES finding
47+
# 13), which must never pass for a rendering choice.
48+
{:error, reason} ->
49+
unless :persistent_term.get({__MODULE__, :failed}, false) do
50+
:persistent_term.put({__MODULE__, :failed}, true)
51+
Logger.error("markdown renderer failed; showing escaped text: #{inspect(reason)}")
52+
end
53+
54+
Phoenix.HTML.html_escape(markdown)
4355
end
4456
end
4557
end

‎mix.exs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ defmodule Trinity.MixProject do
103103
# Slice 013: the chat's markdown renderer, behind TrinityWeb.Markdown. Chosen by the
104104
# measurement in the slice's NOTES.md; it brings a Rust NIF (mdex_native), precompiled.
105105
{:mdex, "~> 0.13"},
106+
# Slice 013 (owner decision, 2026-09-20): the linux package builds mdex's NIF from
107+
# source for musl (MDEX_NATIVE_BUILD=1 and TRINITY_NIF_TARGET in config/config.exs),
108+
# because neither precompiled artifact loads in Burrito's musl ERTS (NOTES finding 13).
109+
# rustler_precompiled needs Rustler present to run that build; build time only.
110+
{:rustler, "~> 0.38", runtime: false},
106111
{:phoenix_html, "~> 4.1"},
107112
{:phoenix_live_reload, "~> 1.2", only: :dev},
108113
{:phoenix_live_view, "~> 1.2.0"},

0 commit comments

Comments
 (0)