Compare commits
17 changed files with 78 additions and 6819 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -482,7 +482,6 @@ dependencies = [
|
|||
"libc",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"toml",
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ serde = { version = "1", features = ["derive"] }
|
|||
ed25519-dalek = "2.2"
|
||||
toml = "0.8"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
58
README.md
58
README.md
|
|
@ -58,64 +58,6 @@ idunn status --command <id>
|
|||
the bindings, so `idunn up profile:full-gamecult` brings up everything bound
|
||||
into that profile.
|
||||
|
||||
### Steward orders
|
||||
|
||||
A second root unit from the same binary, `idunn-orders` (`idunn orders serve`),
|
||||
takes typed orders from one steward Mind over `/run/idunn-orders/steward.sock`.
|
||||
It has its own store, `orders.cc`, and its own binding, `steward.toml`; the
|
||||
survival daemon takes no order. There is still no command string:
|
||||
|
||||
```bash
|
||||
idunn order package-install <pkg>[=version]... --reason TEXT # apt-get update, install named packages, check them
|
||||
idunn order unit <start|stop|restart|reload> <unit> --reason TEXT
|
||||
idunn order deploy <service|profile:name> --reason TEXT # the same commit as `idunn up`
|
||||
idunn order show <id> # status and output tails
|
||||
idunn order list
|
||||
```
|
||||
|
||||
A unit order submits exactly one job, for the unit it names: every verb runs
|
||||
`systemctl <verb> --job-mode=ignore-requirements -- <unit>`, so systemd builds a
|
||||
transaction of one job. It does not start what the unit requires or restart what
|
||||
is part of it: order the dependency first, and order `postgresql@16-main.service`
|
||||
rather than the `postgresql.service` wrapper. Targets are not orderable. The job is one; what the unit does afterwards is
|
||||
not: systemd still runs full transactions of its own for `Restart=` after a
|
||||
failure, for a `StopWhenUnneeded=` dependency left without a user, and for a
|
||||
`Conflicts=` partner of a unit that fails, so an ordered stop of a unit
|
||||
that something restarts does not last, and a start of a `Restart=always`
|
||||
unit can report failure and be active seconds later. A unit
|
||||
order may act on any unit except Idunn's own (`idunn-*`) and the minds' own
|
||||
(`mind-self@*`, `mind-persona@*`), and the units that end or take the host
|
||||
(`systemd-suspend`, `systemd-hibernate`, `systemd-hybrid-sleep`,
|
||||
`systemd-suspend-then-hibernate`, `rescue`, `emergency`). That set is compiled
|
||||
into the binary and read from no configuration. An order is refused, recorded
|
||||
and posted when the ordered unit, or an alias it resolves from, is one of them;
|
||||
when it has an emergency action (`SuccessAction=`, `FailureAction=`,
|
||||
`StartLimitAction=`, `JobTimeoutAction=`) other than `none` or an `OnFailure=`
|
||||
or `OnSuccess=` unit (not for a reload); or, for a stop or restart, when another
|
||||
unit is `BoundBy=` it, because systemd stops that unit with it whatever the job
|
||||
mode; for a start or restart, when it has an `Upholds=` unit, which systemd
|
||||
starts in a full transaction whatever the job mode; or, for a stop, restart or
|
||||
reload, when it has a `ConsistsOf=` member, a wrapper such as
|
||||
`postgresql.service` whose verbs would exit 0 and touch nothing. A unit
|
||||
`systemctl show` cannot resolve fails the order. Slices, scopes,
|
||||
targets and globs are not units an order can name.
|
||||
|
||||
A package order installs the packages it names and nothing else: apt runs with
|
||||
`--no-remove`, each name is passed as an exact-name pattern so `.` and `+`
|
||||
match themselves, and a last step fails the order unless every named package
|
||||
is installed.
|
||||
|
||||
An order runs once, after the steward's uid is checked and the instance's
|
||||
brake check (`mind-control --instance <principal> check`) exits 0. The check
|
||||
that the order's principal and uid are the bound steward's runs again at every
|
||||
execution, so an order accepted before `steward.toml` changed is refused and
|
||||
recorded, not run. The socket belongs to the steward user alone (mode 0600);
|
||||
root may `show` and `list` orders but never issue one. An order that had
|
||||
started when the service died is recorded as lost and never run again. No order releases a brake. Acceptance and result are posted to
|
||||
`#operator` as a retried projection of `orders.cc`: a chat outage delays the
|
||||
posts and not the orders. A refusal names the field and a code, never the value
|
||||
it refused.
|
||||
|
||||
## Authority
|
||||
|
||||
**Owner.** Deployment admission, and the continuity of installed bodies.
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
[Unit]
|
||||
Description=Idunn steward orders: typed package, unit and deploy orders from the steward
|
||||
# Deliberately no After= or Requires= on any managed target, Synapse or
|
||||
# idunn-yggdrasil: orders are how an operator repairs those, so none of them
|
||||
# may be a condition of this unit starting. Posts are a retried projection and
|
||||
# wait for Synapse on their own.
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
# Root, from the same binary as idunn-yggdrasil, with its own store and socket.
|
||||
# idunn-yggdrasil keeps its no-imperative-input invariant and its write set:
|
||||
# no order code runs in `idunn serve`.
|
||||
#
|
||||
# The unit name matches idunn-*, which is in the always-refused set, so no
|
||||
# order can stop or restart this service.
|
||||
User=root
|
||||
# No Group= and no UMask=: the service gives its socket to the steward user
|
||||
# (mode 0600) itself and sets Idunn's own umask for the files it writes, so
|
||||
# nothing here reaches control.cc. The steward user is the only peer the
|
||||
# service answers: it checks SO_PEERCRED on every connection.
|
||||
ExecStart=/usr/local/bin/idunn orders serve \
|
||||
--store /var/lib/gamecult/idunn-orders \
|
||||
--state-store /var/lib/gamecult/idunn/control.cc \
|
||||
--steward-binding /etc/gamecult/idunn/steward.toml \
|
||||
--socket /run/idunn-orders/steward.sock
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=20
|
||||
|
||||
RuntimeDirectory=idunn-orders
|
||||
RuntimeDirectoryMode=0755
|
||||
|
||||
# The Synapse access token of @idunn, copied from a root-only file, and read
|
||||
# through $CREDENTIALS_DIRECTORY; the service names it in steward.toml.
|
||||
LoadCredential=idunn-matrix-token:/etc/gamecult/idunn/matrix-token
|
||||
|
||||
PrivateTmp=yes
|
||||
ProtectHome=yes
|
||||
ProtectSystem=strict
|
||||
# Its own order store, and the control store a deploy order commits a command
|
||||
# to. Transient units started with systemd-run run in the manager's namespace,
|
||||
# so packages and units are not limited by this list.
|
||||
ReadWritePaths=/var/lib/gamecult/idunn-orders /var/lib/gamecult/idunn
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -104,11 +104,6 @@ ReadWritePaths=/etc/gamecult/ghostlight/runtime /var/lib/gamecult/ghostlight-wor
|
|||
ReadWritePaths=/etc/gamecult/streampixels-service/runtime /var/lib/gamecult/streampixels-service /srv/streampixels-idunn/releases/service
|
||||
# StreamPixels web is stateless; it needs only its runtime bundle and releases.
|
||||
ReadWritePaths=/etc/gamecult/streampixels-web/runtime /srv/streampixels-idunn/releases/web
|
||||
# Vili (motion worker): runtime bundle, state, and releases. Prefixed "-" so a
|
||||
# target whose roots are not created yet cannot stop Idunn itself from starting
|
||||
# (Idunn startup never depends on a managed target). A root created after Idunn
|
||||
# starts is writable only from Idunn's next restart.
|
||||
ReadWritePaths=-/etc/gamecult/vili/runtime -/var/lib/gamecult/vili -/srv/vili-idunn/releases
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
|
|||
|
|
@ -1,394 +0,0 @@
|
|||
# Deployment provenance: map
|
||||
|
||||
Status: map, Imagination, 2026-10-23 (campaign `idunn-deploy-provenance`,
|
||||
session `self-2026-10-23-eureka-b`). The ends are in
|
||||
[`deploy-provenance-target.md`](deploy-provenance-target.md). Questions,
|
||||
rulings, cut specs and follow-ups live in Eureka's mind; this page holds what
|
||||
has no kind: body facts, rationale and sequencing. Line numbers in T1-T12 are
|
||||
at `d74ac8d` (origin/main on 2026-10-23); T4, T9 and T13-T16 are at `fd040f0`,
|
||||
the head of Hands attempt 1, which revision 2 of the spec builds on.
|
||||
|
||||
## Cut `tree-executable-members`
|
||||
|
||||
Spec `idunn-deploy-provenance:cut_spec:cut-tree-executable-members.r2`
|
||||
(revision 2, after Soul pass 1 found the real interpreter tree refused). It
|
||||
serves `motion-pipeline:follow_up:idunn-tree-executable-members` (Vili's
|
||||
admission waits on it) and will serve gamecult-chat C3 too: the presence
|
||||
launcher runs a child such as `upstream/bin/daemon` from inside a tree artifact
|
||||
(README, launcher fragment), and Synapse needs a Python interpreter there.
|
||||
|
||||
### Body facts
|
||||
|
||||
- T1. `ArtifactOutput` (`src/deployment.rs:106-117`) carries one `executable`
|
||||
bool. `ArtifactReceipt` (`src/deployment_plan.rs:731-737`) copies it and
|
||||
`validate` (`:740-760`) requires it to equal the declaration.
|
||||
- T2. `harden_installed_release` (`src/drivers.rs:6848-6855`) builds its
|
||||
executable set from `root.join(receipt.destination)` only. `harden_root_tree`
|
||||
(`:6858-6892`) seals every directory 0555 and every file 0444 unless that
|
||||
exact path is in the set. A directory artifact with `executable = true` is
|
||||
therefore silently meaningless today: its files are all 0444. Nothing refuses
|
||||
it. No live recipe declares one (CodexConnector, Ghostlight, Heimdall, Odin,
|
||||
StreamPixels service and web surveyed): `executable = true` is on files only.
|
||||
- T3. A tree output is copied by `copy_artifact`/`copy_tree`
|
||||
(`src/drivers.rs:8053-8126`), which recreate symlinks verbatim. The digest
|
||||
(`digest_tree`, `:8142-8175`) covers names, file bytes and link targets, not
|
||||
mode bits. So runner or git exec bits never reach the release: harden is the
|
||||
only mode authority.
|
||||
- T4. `validate_release_symlink` (`src/drivers.rs:6897-6916`) keeps a symlink
|
||||
only if its target is relative with no `..`. It runs only inside
|
||||
`harden_root_tree`, at install, so a tree with a `..` or absolute link copies,
|
||||
digests and seals, then fails at `install_release` (Soul s1: copying and
|
||||
digesting the real tree twice took 160 s before harden refused it). The rule
|
||||
is sound but too narrow: it refuses the real interpreter tree (T13). The
|
||||
frozen-source path has a physical resolver (`resolve_frozen_source_symlink`,
|
||||
`:6617`) that walks the filesystem with a 40-link budget and a memo. Its
|
||||
first version was exponential (Soul S4-1, recorded in `d562a12`), and it needs
|
||||
the tree on disk to answer. Revision 2 does not reuse it (T14).
|
||||
- T5. The sealed release id hashes `(schema, plan_id, artifacts,
|
||||
external_inputs)` (`src/deployment_plan.rs:1000-1011`), and `plan_id` hashes
|
||||
the whole plan including `recipe_blob`. A changed member list is therefore
|
||||
already a new plan and a new release. The receipt must still carry the list,
|
||||
because `harden_installed_release` reads receipts only; its two callers
|
||||
(`install_release` `:2788`, `validate_installed_release` `:2822`) re-harden
|
||||
every installed release on each observation.
|
||||
- T6. Encoding is `rmp_serde::to_vec`, positional. A trailing field with
|
||||
`#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]` keeps every
|
||||
existing receipt's bytes and release id: the plan's `deadlines` field
|
||||
(`:574-577`) is the precedent.
|
||||
- T7. `digest_artifact` and `digest_tree` (`:8128-8175`) `fs::read` each file
|
||||
whole. A torch CPU tree's largest file (`libtorch_cpu.so`, hundreds of MB)
|
||||
is held in Idunn's memory three times per install (seal, install, every
|
||||
validate). `hash_frozen_source_tree` (`:6788-6840`) already streams in 64 KiB
|
||||
chunks (S9). The cut collapses both onto one streaming helper.
|
||||
- T8. `require_leaf_path` (`src/deployment.rs:1951-1958`) only keeps the
|
||||
artifact destination a single filename. Members are paths inside that
|
||||
destination, so it does not change. `require_relative_path` (`:1936-1949`)
|
||||
with `allow_current = false` already refuses absolute, `..`, root and prefix
|
||||
components.
|
||||
- T9. The host actuator re-spells the seal (`src/host.rs:1050-1086`): copy,
|
||||
mode check, digest, pinned-digest check and receipt, the same steps as
|
||||
`collect_artifact` (`src/drivers.rs:2305-2326`). Soul s1: deleting its mode
|
||||
check survives every test. Revision 2 collapses both onto one `seal_artifact`
|
||||
in `drivers.rs`. Each caller keeps only how it finds the source path.
|
||||
- T10. The transient unit runs with `ProtectSystem=strict` and the release in
|
||||
`ReadOnlyPaths` (`src/drivers.rs:3675-3698`), `/tmp` mounted `noexec`, and no
|
||||
`MemoryDenyWriteExecute`. Executing a 0555 file from the release works.
|
||||
Shared libraries need no exec bit.
|
||||
- T11. Live body on Yggdrasil: `/usr/local/bin/idunn` sha256 `44cf8a14...`,
|
||||
installed 2026-10-20 02:34 CEST, before `6e2a31c` (02:40 CEST). `src/` is
|
||||
identical between `8760a4f` and `6e2a31c`, so the live code is the
|
||||
reincarnation c2 merge. `origin/main` is 26 commits ahead (+4194/-112 in
|
||||
`src/`: the presence launcher and member route). Installing this cut ships all
|
||||
of them. Idunn's own body changes only through gamecult-ops
|
||||
`scripts/install-idunn-yggdrasil-release.sh` (staged binaries, unit,
|
||||
bindings). The stage at `/home/gamecultadmin/idunn-stage` is stale (2026-09-11).
|
||||
- T13. Link census of the real interpreter tree (Yggdrasil probe of
|
||||
`cpython-3.12.15+20261021-x86_64-unknown-linux-gnu-install_only.tar.gz`,
|
||||
sha256 `5ff9f060...77191124`; torch adds no links, as Soul s1 counted 1,049
|
||||
after pip): 1,049 links. 0 are absolute, 749 have no `..`, and 300 have all
|
||||
their `..` components before the first name (every one is
|
||||
`share/terminfo/<c>/<name> -> ../<c2>/<name2>`, one `..` each). 0 have a `..`
|
||||
after a name, 0 climb above the tree, 0 dangle and 0 resolve outside it.
|
||||
Blender 5.2.2 has 79 links, none absolute or with `..` (Soul s1).
|
||||
- T14. Lexically normalising `<link's directory>/<target>` does not check
|
||||
containment. The kernel resolves a target one component at a time, so a `..`
|
||||
after a symlinked name climbs from where that link points, not from where its
|
||||
name sits. Probe on Yggdrasil: `root/a/s -> ..` and `root/a/L -> s/../x`
|
||||
normalise to `root` and `root/a/x`, both inside, yet `L` reads the file `x`
|
||||
beside `root`. `root/a/d -> .` with `root/a/M -> d/../../x` escapes the same
|
||||
way. Idunn has met this before: verify-transaction-cut F5 (`D -> .`,
|
||||
`L -> D/D/../../../etc/passwd` passed a lexical check).
|
||||
The sound lexical rule constrains the target's shape: all its `..` components
|
||||
come before its first name, and they number no more than the link's depth
|
||||
below the artifact's top. The climb then starts from the link's own
|
||||
directory, which the walk reached through real directories only (copy and
|
||||
digest never descend through a link), so it lands on a real ancestor inside
|
||||
the artifact. After that the target only descends, and any link it passes
|
||||
through obeys the same rule. By induction on the kernel's hops (capped at 40,
|
||||
then ELOOP), every hop stays inside. The check is one pass over the target's
|
||||
components with no filesystem reads, so its cost is linear in the tree and no
|
||||
resolver is needed. The shapes it refuses (`s/../x`) occur in neither real
|
||||
tree (T13).
|
||||
Prior art, from documentation as known to Imagination (high confidence for Go
|
||||
and securejoin, medium for the tar details):
|
||||
- Go `path/filepath.IsLocal` (1.20) is lexical and says so: it does not account
|
||||
for symlinks. It would admit `s/../x`. Used alone, it is the T14 bug.
|
||||
- Go `os.Root` (1.24) and `cyphar/filepath-securejoin` (used by runc,
|
||||
containerd and umoci) resolve each component inside the root when a path is
|
||||
opened, refusing or clamping an escape. They guard one later access, need
|
||||
the tree on disk, and check again on every open. Idunn instead certifies a
|
||||
sealed tree once, so that the kernel's own resolution stays inside on every
|
||||
later access.
|
||||
- Zip-slip defences and archive extractors check entry names lexically and
|
||||
protect against link targets by refusing to write through links
|
||||
(libarchive's `ARCHIVE_EXTRACT_SECURE_SYMLINKS`, and `SECURE_NODOTDOT` for
|
||||
names; GNU tar creates such links last). They protect the extraction, not
|
||||
what a kept link points at afterwards.
|
||||
- OCI layer extraction keeps link targets verbatim, absolute ones included,
|
||||
because the runtime resolves them inside the container's root. Idunn runs a
|
||||
release in the host's mount namespace, so absolute targets stay refused.
|
||||
- Nix keeps link targets verbatim and tracks dependencies by scanning for
|
||||
references. The whole store is its root, so it has no per-artifact
|
||||
containment to compare with.
|
||||
None of these is a static per-link rule for a tree that runs unconfined. The
|
||||
T14 shape rule fits that case.
|
||||
- T15. Host targets: `idunn-host` observes, starts and stops processes on
|
||||
Windows only (the non-Windows `platform` module in `src/host.rs` bails), and
|
||||
`host.rs` sets no file modes. On Windows a file runs by its type, not by a
|
||||
mode bit, so `executable` and `executable_members` have nothing to set there.
|
||||
The file `executable` flag has always been carried and never applied on host
|
||||
targets. A recipe does not know which workload kind its binding will choose.
|
||||
- T16. The release root holds only artifact destinations. Runner workspaces,
|
||||
where external inputs land, are removed before `SealedRelease::new`
|
||||
(`src/drivers.rs:2421-2423`). `install_release` (`:2781-2788`) and
|
||||
`validate_installed_release` (`:2815-2822`) digest every receipt's
|
||||
destination before calling harden. So a rule in the digest walk runs at seal,
|
||||
at install and at every validate, over every link harden would see.
|
||||
- T12. Disk: `/` on Yggdrasil has 1.3 TB free. A tree release costs about three
|
||||
copies (runner workspace, staging, installed). Nothing collects old releases or
|
||||
staging (`ghostlight-verse:follow_up:idunn-generation-retention`), so every
|
||||
Vili deploy leaves about 7 GiB behind.
|
||||
|
||||
### Contract, corrected against the body
|
||||
|
||||
- Any directory artifact may declare `executable_members` (a set of relative
|
||||
paths). The follow-up limited it to runner-output; that limit protects no
|
||||
invariant, and the launcher's worktree or fetched trees need the same feature.
|
||||
- `executable = true` names a file artifact. On a directory output the release
|
||||
is refused, and `executable_members` on a file output is refused (T2).
|
||||
- A member is refused at recipe parse if it is empty, absolute, or holds `.`,
|
||||
`..`, a root or a prefix. At seal it is refused if it is missing, is a
|
||||
symlink, passes through a symlink or non-directory, or is not a regular file.
|
||||
Refusal happens before `SealedRelease::new`, so no sealed release can name a
|
||||
member it cannot seal.
|
||||
- Harden seals each member 0555 and fails if any declared executable path was
|
||||
not visited as a regular file. "Every declared executable is sealed 0555, or
|
||||
the install fails" holds structurally, not by a lookup that can miss.
|
||||
- Symlinks: a link in a tree artifact is kept when its target is relative, its
|
||||
`..` components all come before its first name, and they number no more than
|
||||
the link's depth below the artifact's top (T14). One function,
|
||||
`check_release_link`, decides this. Only the digest walk calls it, so a bad
|
||||
link refuses the release at seal, before `SealedRelease::new`, and again at
|
||||
install and validate (T16). Harden no longer judges links. The climb is
|
||||
bounded by the artifact's top, not the release root: no live release has a
|
||||
`..` link (the old rule refused them all), so nothing installed is newly
|
||||
refused, and each artifact's digest describes a closed tree.
|
||||
- Host targets carry `executable` and `executable_members` in the receipt,
|
||||
shape-checked at seal by the shared `seal_artifact`, and do not apply them
|
||||
(T15). Refusing members there would make a recipe's validity depend on its
|
||||
binding.
|
||||
- Vili's trees must
|
||||
not come from `python -m venv`: its `bin/python` is an absolute link and its
|
||||
`pyvenv.cfg` names the build workspace. Install packages into the relocatable
|
||||
interpreter tree instead (`motion-pipeline:follow_up:vili-relocatable-python-tree`).
|
||||
|
||||
### Authority
|
||||
|
||||
Owner: the seal (`seal_artifact`, called by `collect_artifact` for systemd
|
||||
targets and by `materialize` in `host.rs` for host targets) decides
|
||||
membership from the recipe and writes it into the receipt. Harden enforces the
|
||||
receipt and decides nothing. Runner exec bits and git `100755` modes are dead
|
||||
inputs for release modes. Link containment belongs to `check_release_link`,
|
||||
which only `digest_tree` calls. `validate_release_symlink` is deleted, and
|
||||
harden's symlink arm leaves links as they are.
|
||||
|
||||
### Acceptance sizing
|
||||
|
||||
The big-tree job (spec `verification.operator`) needs about 12 GiB of scratch
|
||||
on Yggdrasil: a 3.5 GiB tree, its staging copy, downloads and pip cache. It
|
||||
removes it at the end. Revision 2 runs it on the trees as shipped, with the 300
|
||||
terminfo links (T13). If a tree holds a link the T14 rule refuses, the job
|
||||
prints it and stops, and the link is reported to Self, not deleted.
|
||||
|
||||
## Cut `isolation-credential-evidence`
|
||||
|
||||
Spec `idunn-deploy-provenance:cut_spec:cut-cut-isolation-credential-evidence.r1`,
|
||||
at `112a7a5` (origin/main, live on Yggdrasil since 2026-10-24 11:29 UTC). It
|
||||
serves Vili's admission (`motion-pipeline:cut_report:cut-vili-admission.h4`,
|
||||
first deviation): every `idunn up vili` is refused before fencing.
|
||||
|
||||
### Body facts (probed read-only on Yggdrasil, 2026-10-24)
|
||||
|
||||
- I1. `idunn up vili` runs 6 and 7 failed with
|
||||
`systemd workload isolation properties differ from the Idunn contract`, the
|
||||
message of `observe_unit` (`src/drivers.rs:2975-3005`). Of its conjuncts,
|
||||
only one differs on the failed Vili unit: `LoadCredential`. Idunn expects
|
||||
`""` because Vili has no service credential besides the presence identity
|
||||
(`service_credential_names` empty); systemd shows `[unprintable]`. Every
|
||||
other conjunct matches, InaccessiblePaths included (one value across all
|
||||
five idunn-vili/odin units).
|
||||
- I2. systemd 257.13 `systemctl show -p LoadCredential` prints
|
||||
`LoadCredential=[unprintable]` for every unit, whatever the credentials:
|
||||
`ssh.service` and `systemd-journald.service` (not DynamicUser, zero
|
||||
LoadCredential), the four failed `idunn-vili-*` units (zero), and the Odin
|
||||
unit (one). The display form carries no information, so the conjunct was
|
||||
vacuous whenever credentials existed and always false when none did. Odin
|
||||
and StreamPixels passed only because each carries a regular credential.
|
||||
- I3. The exact value is readable: D-Bus
|
||||
`org.freedesktop.systemd1.Service.LoadCredential` (`busctl get-property`)
|
||||
answers `a(ss) 0` for the Vili units and ssh, `a(ss) 1
|
||||
"ODIN_TOPOLOGY_IDENTITY" "<path>"` for Odin.
|
||||
- I4. What the workload can actually read is the delivered directory. On the
|
||||
host, `/run/credentials/` holds a directory only for units with credentials
|
||||
(getty, journald, Odin, StreamPixels service and web). Inside Odin's MainPID
|
||||
root, `/run/credentials/<unit>/` holds exactly `ODIN_TOPOLOGY_IDENTITY`
|
||||
(`-r--r-----+ root root`). The presence identity is a parent-only `OpenFile`
|
||||
descriptor, never a credential.
|
||||
- I5. The rule arrived whole in `1fc0dad` (2026-09-03, "Rebuild Idunn
|
||||
deployment transaction authority"), with its twin clause in
|
||||
`validate_launch_observation` (`:3468-3472`, `:3513-3516`); no later commit
|
||||
touched it. No ruling or finding in any campaign concerns workload isolation
|
||||
verification; gamecult-chat's foreign-workloads cut touched
|
||||
`CREDENTIALS_DIRECTORY` reservation, not this check.
|
||||
- I6. `SystemdWorkloadObservation` is persisted by previous Idunns in both
|
||||
MessagePack encodings, compact (positional) included
|
||||
(`persisted_systemd_observations_decode_as_the_systemd_variant`,
|
||||
`:9915-9930`), so deleting the `load_credential` field shifts every later
|
||||
slot of a live incumbent's record. The field is also in the drift list
|
||||
(`:2462`) that `stop` (`:4064-4070`) uses to refuse stopping a changed
|
||||
workload: changing what the field holds without leaving that list would make
|
||||
the next Idunn refuse to stop the live Odin and StreamPixels incumbents.
|
||||
- I7. Fixtures: the verbatim 257.13 `systemctl show` output of base's 27
|
||||
properties for a Vili unit (presence identity only) and the Odin unit
|
||||
(presence identity plus one service credential), captured 2026-10-24 into
|
||||
this Imagination's scratchpad `fixtures/`.
|
||||
- I8. The new rule holds for every live systemd incumbent: inside each
|
||||
MainPID root the delivered entries equal its binding's `secret_files` keys
|
||||
minus `GAMECULT_RUNTIME_PRESENCE_IDENTITY` (Odin 1, StreamPixels service 11,
|
||||
StreamPixels web 1; key names compared, no values read). An Idunn restart onto
|
||||
this cut re-observes them (`:3955-3970`) and passes.
|
||||
|
||||
### Decision
|
||||
|
||||
A property systemd cannot display does not belong in the comparison. The
|
||||
invariant it reached for, that the workload holds exactly the credentials Idunn
|
||||
delivered, is observed where it is decided: the entries of
|
||||
`/run/credentials/<unit>` in the MainPID's root equal Idunn's service
|
||||
credential names, absent directory equal to none. That covers
|
||||
`LoadCredential`, `SetCredential`, `ImportCredential` and drop-ins alike, and
|
||||
needs no new program. The D-Bus read (I3) is exact but sees only one of those
|
||||
properties and adds a second systemd client and parser; rejected.
|
||||
|
||||
### Authority
|
||||
|
||||
Owner: `observe_unit` decides the credential rule, from the delivered
|
||||
directory. `LoadCredential`'s display form is no longer read or compared; the
|
||||
`load_credential` field is a retired wire slot, written empty, out of the drift
|
||||
list, kept only so persisted records decode. Forbidden writers: the
|
||||
`observe_unit` conjunct and the `validate_launch_observation` clause. Dropping
|
||||
the slot waits on the next schema revision of the observation.
|
||||
|
||||
### Deploy
|
||||
|
||||
Idunn's body changes only through gamecult-ops
|
||||
`scripts/install-idunn-yggdrasil-release.sh` (stage `idunn` only; unit and
|
||||
bindings unchanged). After it, Vili's Hands reruns from step 4 of
|
||||
`motion-pipeline:cut_report:cut-vili-admission.h4`'s undone list.
|
||||
|
||||
## Cut `runner-failure-evidence`
|
||||
|
||||
Spec `idunn-deploy-provenance:cut_spec:cut-runner-failure-evidence.r1`, at
|
||||
`804ed75` (origin/main, live on Yggdrasil). It serves the StreamPixels-area
|
||||
Self: `idunn up vili` (command `up-4866467c-ee98-4a2b-b4a9-a008d7c44acd`,
|
||||
transaction `tx-a04b68c7-c742-4d7d-8220-8aa08b9febae`, 2026-10-25 00:50-00:55
|
||||
UTC) failed in Sealing with `Docker runner exited with exit status: 1: ` and
|
||||
nobody could tell which recipe step failed.
|
||||
|
||||
### Body facts (source read at `804ed75`; host read-only, 2026-10-25)
|
||||
|
||||
- R1. The runner is `docker run --rm` (`src/drivers.rs:2088-2090`), launched by
|
||||
`DockerRunnerDriver::docker` (`:2173-2195`) with stdin null and
|
||||
`Command::output()`: stdout and stderr are each buffered whole in the
|
||||
daemon's memory, unbounded. On a non-zero exit the error is
|
||||
`"Docker runner exited with {status}: {stderr}"`; stdout is dropped. The
|
||||
container is gone the moment it exits, so `docker ps -a` and `docker logs`
|
||||
hold nothing.
|
||||
- R2. The empty message: the failing process wrote nothing to stderr, and
|
||||
stdout, the only stream that carried its report, is discarded at
|
||||
`:2182-2193`. The same interpolation echoes whatever a step does write to
|
||||
stderr into `last_error`, `pre_fencing_abort.error` and
|
||||
`completion.error` (`bounded_detail`, 2048 bytes), and from there into
|
||||
`idunn up`'s `deployment failed:` line and `idunn status`.
|
||||
- R3. No step identity reaches the error. `materialize` (`:2368-2391`) loops
|
||||
over `declaration.steps` and calls `run_in_workspace` with argv only;
|
||||
`step.id` is used only in the environment precondition. External inputs
|
||||
(`curl` in the runner, `:2247-2258`) fail through the same helper with the
|
||||
same anonymous message.
|
||||
- R4. Which step failed on 2026-10-25, inferred from the failed transaction's
|
||||
staging root, which survives (926 MiB,
|
||||
`/var/lib/gamecult/idunn/staging/tx-a04b68c7-.../`): in `.runner-node-build`,
|
||||
`node_modules/.package-lock.json` was written at 00:51:24 (so
|
||||
`install-vili-dependencies`, `npm ci`, finished), nothing outside
|
||||
`node_modules` is newer, and `release/vili` (the next step's output) is
|
||||
absent; the python workspace holds only fetched inputs. The failing step was
|
||||
`test-daemon` (`node --test ... scripts/test-vili-daemon.mjs`), whose test
|
||||
reporter writes to stdout: exactly the stream R1 discards. Inference from
|
||||
residue, not from output.
|
||||
- R5. The failure path: `resume_one_transaction` (`src/control_plane.rs:6291-6320`)
|
||||
sends a pre-fence error to `begin_pre_fencing_abort` (`:9916-9935`), which
|
||||
persists `bounded_detail(format!("{error:#}"))`. Nothing reaches the journal
|
||||
on this path: the idunn-yggdrasil journal for 00:45-01:10 holds only
|
||||
provider-exclusion lines.
|
||||
- R6. Stores. `control.cc` (root 0640) holds commands, transactions
|
||||
(`idunn.deployment_transaction.v4`, keys 0-36), generations, supervision and
|
||||
expiry requests; `ControlSnapshot::read` refuses any other type
|
||||
(`:3420`). Records must re-encode byte-exactly (`decode_record`,
|
||||
`:3717-3727`), so a new transaction field is a schema bump and a v4 lift
|
||||
(`:4100-4110`). Terminal transactions move verbatim to `history.cc`
|
||||
(`:4366-4396`; 39.8 MB on the host), which status reads for `--command`.
|
||||
`incidents.cc` lives in the published projection directory and by its own
|
||||
doc (`src/incident.rs:1-11`) carries no free text and is never `control.cc`,
|
||||
because a type added there stops continuity for every target on a rollback.
|
||||
- R7. The daemon runs as root (`User=root`). Runner secrets reach the
|
||||
container as read-only file mounts named by env (`:2041-2047`), so any text
|
||||
a step derives from them can land in its output.
|
||||
- R8. Staging residue: 475 transaction roots, 55 GiB under
|
||||
`/var/lib/gamecult/idunn/staging`; a failed Sealing leaves its workspaces
|
||||
because `materialize` returns before `remove_tree_inside`.
|
||||
|
||||
### Decision
|
||||
|
||||
The failure becomes a typed value at the runner, `RunnerStepFailure`: the
|
||||
subject (recipe step or external input, by id), the runner id, the exit code or
|
||||
signal, and the last 8 KiB of stdout and of stderr with each stream's total
|
||||
byte count. Its `Display` carries the subject and status, never output.
|
||||
Capture streams both pipes through bounded tails, so a verbose build no longer
|
||||
buffers whole in the daemon.
|
||||
|
||||
The tail lives in a new private store, `runner-evidence.cc`, beside
|
||||
`control.cc` (mode 0600), one record per transaction
|
||||
(`idunn.runner_failure.v1`), newest 32 kept by the writer. Not in the
|
||||
transaction record: that is a v5 bump whose rollback makes every older Idunn
|
||||
refuse its control store (R6), the scar `incident.rs` already names. Not in
|
||||
`incidents.cc`: published, and free text is barred there. Not in the journal:
|
||||
journal readers are a wider set than root, so the journal line names the
|
||||
transaction, the subject, the status and the store, and no output.
|
||||
|
||||
No redaction. Matching secret bytes would make the daemon read every secret,
|
||||
and still misses any encoding, prefix or transform of one; a claimed redaction
|
||||
that leaks is worse than a stated rule. The rule instead is the reader set: the
|
||||
store's only readers (root) can already read every secret the runner was
|
||||
given.
|
||||
|
||||
Keeping the failed container (dropping `--rm`) is worse: its output then lives
|
||||
in Docker's json-file log, unbounded, untyped, outside Idunn's state and
|
||||
readable by the docker group; containers accumulate with no owner to reap them;
|
||||
`docker system prune` erases the evidence; and nothing ties a container to the
|
||||
transaction in `idunn status`.
|
||||
|
||||
### Authority
|
||||
|
||||
Owner: the runner decides what failed and keeps the bounded tail; the engine's
|
||||
pre-fence failure path is the one writer of `runner-evidence.cc`.
|
||||
`last_error`, `pre_fencing_abort.error` and `completion.error` keep only the
|
||||
failure's `Display`. Forbidden writers: the `stderr` interpolation in
|
||||
`DockerRunnerDriver::docker`. Retention is by count inside the writer, so the
|
||||
store needs no external owner; staging roots (R8) stay with
|
||||
`ghostlight-verse:follow_up:idunn-generation-retention`.
|
||||
|
||||
### Deploy
|
||||
|
||||
Idunn's body changes only through gamecult-ops
|
||||
`scripts/install-idunn-yggdrasil-release.sh` (stage `idunn` only; unit and
|
||||
bindings unchanged). The next failed Sealing then shows its step and tail on
|
||||
`sudo idunn status --command <id>`.
|
||||
|
|
@ -42,14 +42,11 @@ use crate::drivers::{
|
|||
ChallengeFailure, CultCacheTopologyDriver, CultCacheWriteLeaseDriver, DockerRunnerDriver, FrozenSourceReceipt,
|
||||
GitSourceDriver, InstalledReleaseObservation, IsolationEvidence, NginxRouteDriver,
|
||||
LockContended, ProcessIdentity, RouteActuation, RouteActuationGate, RouteActuationRefused, RouteActuators,
|
||||
RouteObservation, RoutePreflightReceipt, RunnerPort, RunnerStepFailure, SourcePort,
|
||||
RouteObservation, RoutePreflightReceipt, RunnerPort, SourcePort,
|
||||
SystemdTransientWorkloadDriver, TopologyPort, WorkloadObservation, WorkloadPort,
|
||||
WriteLeasePort,
|
||||
};
|
||||
use crate::incident::{CloseReason, IncidentCondition, IncidentStore, render_incidents};
|
||||
use crate::runner_evidence::{
|
||||
self, Kept, RunnerFailureRecord, runner_evidence_store_path, unreadable_store_path,
|
||||
};
|
||||
use crate::host_actuator::{
|
||||
HostActuatorAccess, HostActuatorHub, HostActuatorRunnerDriver, HostActuatorWorkloadDriver,
|
||||
HostUnobservable, IdunnHostActuatorIdentity, SharedHostActuatorHub, spawn_hub_service,
|
||||
|
|
@ -2934,10 +2931,6 @@ enum Command {
|
|||
recipe: PathBuf,
|
||||
binding: Option<PathBuf>,
|
||||
},
|
||||
#[cfg(unix)]
|
||||
OrdersServe(crate::steward_orders::ServeOptions),
|
||||
#[cfg(unix)]
|
||||
Order(crate::steward_orders::OrderCommand),
|
||||
}
|
||||
|
||||
pub fn run(args: impl Iterator<Item = String>) -> Result<()> {
|
||||
|
|
@ -2977,10 +2970,6 @@ pub fn run(args: impl Iterator<Item = String>) -> Result<()> {
|
|||
requested_by,
|
||||
} => expire(&state_store, &command_id, &requested_by),
|
||||
Command::Validate { recipe, binding } => validate(&recipe, binding.as_deref()),
|
||||
#[cfg(unix)]
|
||||
Command::OrdersServe(options) => crate::steward_orders::serve(options),
|
||||
#[cfg(unix)]
|
||||
Command::Order(order) => crate::steward_orders::run_order(order),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3033,10 +3022,6 @@ fn parse(args: impl Iterator<Item = String>) -> Result<Command> {
|
|||
"cancel" => parse_cancel(args),
|
||||
"expire" => parse_expire(args),
|
||||
"validate" => parse_validate(args),
|
||||
#[cfg(unix)]
|
||||
"orders" => crate::steward_orders::parse_serve(args).map(Command::OrdersServe),
|
||||
#[cfg(unix)]
|
||||
"order" => crate::steward_orders::parse_order(args).map(Command::Order),
|
||||
"--help" | "-h" | "help" => bail!(usage()),
|
||||
_ => bail!("unknown Idunn command {command:?}\n\n{}", usage()),
|
||||
}
|
||||
|
|
@ -4742,21 +4727,20 @@ fn resolve_selector(
|
|||
Ok(targets)
|
||||
}
|
||||
|
||||
/// The one commit primitive for a deployment command: `idunn up` and a steward
|
||||
/// deploy order both write theirs here. Returns the commit time.
|
||||
pub(crate) fn commit_deployment_command(
|
||||
fn submit(
|
||||
store_path: &Path,
|
||||
command_id: &str,
|
||||
selector: &str,
|
||||
requested_by: &str,
|
||||
) -> Result<u64> {
|
||||
wait: bool,
|
||||
timeout_seconds: u64,
|
||||
) -> Result<()> {
|
||||
if let Some(parent) = store_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let now = now_millis()?;
|
||||
let command = DeploymentCommand {
|
||||
schema_version: DEPLOYMENT_COMMAND_SCHEMA.into(),
|
||||
command_id: command_id.into(),
|
||||
command_id: format!("up-{}", Uuid::new_v4()),
|
||||
kind: CommandKind::Deploy,
|
||||
selector: selector.into(),
|
||||
requested_by: requested_by.into(),
|
||||
|
|
@ -4774,33 +4758,7 @@ pub(crate) fn commit_deployment_command(
|
|||
)?,
|
||||
"deployment command id collided"
|
||||
);
|
||||
Ok(now)
|
||||
}
|
||||
|
||||
/// A command's present state as `idunn status --command` derives it, for the
|
||||
/// steward order poster: `queued` and `running` are not terminal.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn command_state(
|
||||
state_store: &Path,
|
||||
command_id: &str,
|
||||
) -> Result<(&'static str, String)> {
|
||||
let snapshot = ControlSnapshot::read(state_store)?;
|
||||
let archived = read_history_transactions(state_store);
|
||||
let mut transactions = snapshot.transaction_for_command(command_id);
|
||||
transactions.extend(archived.iter().filter(|value| value.command_id == command_id));
|
||||
Ok(derived_command_status(&transactions))
|
||||
}
|
||||
|
||||
fn submit(
|
||||
store_path: &Path,
|
||||
selector: &str,
|
||||
requested_by: &str,
|
||||
wait: bool,
|
||||
timeout_seconds: u64,
|
||||
) -> Result<()> {
|
||||
let command_id = format!("up-{}", Uuid::new_v4());
|
||||
let now = commit_deployment_command(store_path, &command_id, selector, requested_by)?;
|
||||
println!("{command_id}");
|
||||
println!("{}", command.command_id);
|
||||
if !wait {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -4811,10 +4769,10 @@ fn submit(
|
|||
snapshot
|
||||
.commands
|
||||
.iter()
|
||||
.any(|stored| stored.value.command_id == command_id),
|
||||
.any(|stored| stored.value.command_id == command.command_id),
|
||||
"submitted deployment command disappeared"
|
||||
);
|
||||
let transactions = snapshot.transaction_for_command(&command_id);
|
||||
let transactions = snapshot.transaction_for_command(&command.command_id);
|
||||
if !transactions.is_empty() && transactions.iter().all(|value| value.is_terminal()) {
|
||||
if let Some(error) =
|
||||
transactions
|
||||
|
|
@ -4839,7 +4797,7 @@ fn submit(
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn status(
|
||||
fn status(
|
||||
out: &mut impl Write,
|
||||
store_path: &Path,
|
||||
incident_store: &Path,
|
||||
|
|
@ -4919,13 +4877,6 @@ pub(crate) fn status(
|
|||
}
|
||||
if let Some(abort) = &transaction.pre_fencing_abort {
|
||||
writeln!(out, " pre-fencing abort {abort:?}")?;
|
||||
// The step that failed and its output tail live in their
|
||||
// own private store. An unreadable store costs its lines.
|
||||
runner_evidence::show_status(
|
||||
out,
|
||||
&runner_evidence_store_path(store_path),
|
||||
&transaction.transaction_id,
|
||||
)?;
|
||||
}
|
||||
if let Some(abort) = &transaction.post_fencing_abort {
|
||||
writeln!(out, " post-fencing abort {abort:?}")?;
|
||||
|
|
@ -5423,32 +5374,6 @@ impl Engine {
|
|||
IncidentStore::new(&self.options.incident_store)
|
||||
}
|
||||
|
||||
/// A failed runner step keeps its output tail in the runner evidence
|
||||
/// store and says so on the journal, naming no output. The store write is
|
||||
/// best effort: its failure is named in the line and never stops the
|
||||
/// abort that follows.
|
||||
fn keep_runner_failure(
|
||||
&self,
|
||||
transaction: &DeploymentTransaction,
|
||||
error: &anyhow::Error,
|
||||
) -> Option<String> {
|
||||
let failure = error.downcast_ref::<RunnerStepFailure>()?;
|
||||
let path = runner_evidence_store_path(&self.options.state_store);
|
||||
let recorded = now_millis().and_then(|now| {
|
||||
runner_evidence::record(
|
||||
&path,
|
||||
&RunnerFailureRecord::from_failure(
|
||||
&transaction.transaction_id,
|
||||
&transaction.command_id,
|
||||
&transaction.target,
|
||||
failure,
|
||||
now,
|
||||
),
|
||||
)
|
||||
});
|
||||
Some(runner_failure_journal_line(transaction, failure, &path, &recorded))
|
||||
}
|
||||
|
||||
/// Settle the outcome of one incident operation. A failure never leaves
|
||||
/// this function: it is traced once per episode, under the operation and
|
||||
/// the subject it was decided for (`*` for a pass over the whole store),
|
||||
|
|
@ -6390,9 +6315,6 @@ impl Engine {
|
|||
if latest.value.pre_fencing_abort.is_none()
|
||||
&& error.downcast_ref::<LockContended>().is_none()
|
||||
{
|
||||
if let Some(line) = self.keep_runner_failure(&latest.value, &error) {
|
||||
eprintln!("{line}");
|
||||
}
|
||||
self.begin_pre_fencing_abort(latest, error)?;
|
||||
} else {
|
||||
self.record_resumable_error(latest, &error)?;
|
||||
|
|
@ -10631,13 +10553,7 @@ fn required<'a, T>(value: &'a Option<T>, label: &str) -> Result<&'a T> {
|
|||
value.as_ref().with_context(|| format!("{label} is absent"))
|
||||
}
|
||||
|
||||
/// The control store `idunn up` and `idunn serve` use unless told otherwise.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn default_state_store() -> PathBuf {
|
||||
RuntimeOptions::default().state_store
|
||||
}
|
||||
|
||||
pub(crate) fn now_millis() -> Result<u64> {
|
||||
fn now_millis() -> Result<u64> {
|
||||
Ok(SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.context("system clock predates Unix epoch")?
|
||||
|
|
@ -10657,12 +10573,12 @@ fn sibling_path(path: &Path, suffix: &str) -> PathBuf {
|
|||
PathBuf::from(value)
|
||||
}
|
||||
|
||||
pub(crate) fn string_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
fn string_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
args.next()
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
|
||||
pub(crate) fn path_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
fn path_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(string_value(args, name)?))
|
||||
}
|
||||
|
||||
|
|
@ -10678,7 +10594,7 @@ fn u64_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<u64>
|
|||
.with_context(|| format!("{name} requires a u64"))
|
||||
}
|
||||
|
||||
pub(crate) fn require_selector(value: &str) -> Result<()> {
|
||||
fn require_selector(value: &str) -> Result<()> {
|
||||
let value = value.strip_prefix("profile:").unwrap_or(value);
|
||||
require_id(value, "deployment selector")
|
||||
}
|
||||
|
|
@ -10725,43 +10641,6 @@ const DETAIL_EMPTY: &str = "(no detail)";
|
|||
/// only judge, and this output always satisfies it. NUL is dropped, empty
|
||||
/// input gets a fixed placeholder, and an over-long value keeps its head and
|
||||
/// its tail (where a build log's error is) cut on char boundaries.
|
||||
/// The journal line for a failed runner step: the transaction, the subject and
|
||||
/// status (the failure's `Display`), and where the evidence is. Never output.
|
||||
fn runner_failure_journal_line(
|
||||
transaction: &DeploymentTransaction,
|
||||
failure: &RunnerStepFailure,
|
||||
store_path: &Path,
|
||||
recorded: &Result<Kept>,
|
||||
) -> String {
|
||||
let head = format!(
|
||||
"Idunn transaction {} (command {}, target {}) {failure}",
|
||||
transaction.transaction_id, transaction.command_id, transaction.target
|
||||
);
|
||||
match recorded {
|
||||
Ok(kept) => {
|
||||
let aside = match kept {
|
||||
Kept::Stored => String::new(),
|
||||
Kept::SetAside { replaced } => format!(
|
||||
"; an undecodable evidence store was set aside as {}{}",
|
||||
unreadable_store_path(store_path).display(),
|
||||
match replaced {
|
||||
Some(last_written) => format!(
|
||||
", replacing an earlier set-aside store last written at unix millis {last_written}"
|
||||
),
|
||||
None => String::new(),
|
||||
}
|
||||
),
|
||||
};
|
||||
format!(
|
||||
"{head}; evidence in {}{aside}; read it with idunn status --command {}",
|
||||
store_path.display(),
|
||||
transaction.command_id
|
||||
)
|
||||
}
|
||||
Err(error) => format!("{head}; evidence was not kept: {error:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_detail(value: &str) -> String {
|
||||
let value = value.replace(' | ||||