From aed2f5bf1f700cedfc0c263f93b8c6e41304be46 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 11:31:37 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20a=20clone=20clears=20mode.asked=20?= =?UTF-8?q?=E2=80=94=20nobody=20asked=20THIS=20box=20anything=20(#103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clone's re-stamp split provenance into two columns: event facts re-stamped (schema/version/created/origin/origin.from), lineage facts left alone (template/user/image/role/rig/mode). Review found one key that sits in neither: user.box.mode.asked. It is a mint-EVENT fact by the split's own criterion — only the mint knew whether a container was asked for or fallen back into for want of /dev/kvm — but the asker was the SOURCE's operator. A clone refuses --vm/--container outright, so nobody was asked anything about this instance. Riding through 'incus copy' untouched, it made 'box info' on a clone print MODE vm (asked: auto) describing a demand never made of it, with nothing marking it ancestral. There is no true value to re-stamp it with, so it is CLEARED — cleared and not set-to-empty, because an empty value is still a key a reader would find. The unset tolerates failure: a source that predates the stamp never carried the key, and a clone must not die over a key that was already absent. It lands with the re-stamp and before the start, the same rule and the same reason as the rest: a clone is never observable wearing an 'asked' its operator never gave. The read side needs no special case. The MODE line was already gated on 'asked', so absence renders as silence — the same absence-is-silence rule the whole provenance block uses — while TYPE still reports VM or CT off the instance type 'incus copy' preserves. +7 checks (529 -> 536), each proven to bite. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 11 +++++++++++ bin/box | 16 ++++++++++++++++ test/cli.sh | 38 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e9577e..47dfaca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,17 @@ which records not just what changed but what each drill run proved. records **one hop** — a clone of a clone names its parent and forgets its grandparent, because the alternative is an unbounded chain in a config value and the parent is the box an operator can go look at. + One key sits in **neither** column and is therefore *cleared*: + `user.box.mode.asked`. It records whether a container was asked for or + fallen back into, which makes it a mint-*event* fact — and the asker was + the **source's** operator. A clone refuses `--vm`/`--container` outright, + so nobody was asked anything about this instance, and there is no true + value to re-stamp it with; inheriting it made `box info` print + `MODE vm (asked: auto)` on a clone, describing a demand never made of it. + The clear lands with the re-stamp, before the start, and the read side + needs no special case: the `MODE` line is gated on `asked`, so absence + renders as silence while `TYPE` still reports VM or CT off the preserved + instance type. - **`box info` grew a provenance block** (#103) — it printed `NAME / STATE / TYPE / IPV4`, exposures and snapshots, and surfaced *none* of the `user.box.*` keys, including the two that already existed. A stamp diff --git a/bin/box b/bin/box index 40b54b3..f36ca3f 100755 --- a/bin/box +++ b/bin/box @@ -1079,6 +1079,17 @@ cmd_new() { # that rig role — reading them off the source is the whole point of a clone. # ('incus copy' preserves the instance type too, so mode stays true as well.) # + # 'mode.asked' is the one key that sits in NEITHER column, and so it is + # CLEARED rather than re-stamped or inherited. It is a mint-event fact — + # only the mint knew whether a container was asked for or fallen back into + # for want of /dev/kvm — and the asker was the SOURCE's operator. A clone + # refuses --vm/--container outright (nobody was asked anything here), so an + # inherited 'asked' makes 'box info' print a demand that was never made of + # this instance. There is no true value to re-stamp it with: the honest + # answer is absence, and absence is already how the whole block renders + # what it does not know — the MODE line simply does not print, while TYPE + # above still says VM or CT off the preserved instance type. + # # origin.from records ONE hop. A clone of a clone names its parent and # forgets its grandparent: the alternative is an unbounded chain in a config # value, and the parent is the box an operator can actually go look at. @@ -1088,6 +1099,11 @@ cmd_new() { user.box.created="$(mint_time)" \ user.box.origin=clone \ user.box.origin.from="$srcref" + # Cleared, not set-to-empty: an empty value is still a key, and a reader + # that greps the config would find it. Tolerated failure because the source + # may predate the stamp and never have carried the key at all — a clone + # must not die over a key that was already absent. + incus config unset "$instance" user.box.mode.asked >/dev/null 2>&1 || true incus start "$instance" wait_agent "$instance" reset_identity "$instance" diff --git a/test/cli.sh b/test/cli.sh index f1d4657..c9e2abb 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -1256,6 +1256,17 @@ check "clone: does NOT re-stamp the image — the disk really came from it (#103 restamp_has "$CLONELOG" 'user\.box\.image' check "clone: does NOT re-stamp the role — the source's rig converged it (#103)" 1 "" \ restamp_has "$CLONELOG" 'user\.box\.role' +# The third column, and the one review found: 'mode.asked' is neither lineage +# nor re-stampable. It is a mint-event fact whose asker was the SOURCE's +# operator, and a clone refuses --vm/--container, so nobody was asked anything +# here. It is CLEARED — there is no true value to give it. +check "clone: clears the inherited mode.asked — nobody asked THIS box (#103)" 0 "" \ + grep -qF 'config unset w2 user.box.mode.asked' "$CLONELOG" +check "clone: ...and does not re-stamp it with a fabricated answer (#103)" 1 "" \ + restamp_has "$CLONELOG" 'user\.box\.mode\.asked' +# Cleared, never set-to-empty: an empty value is still a key on the instance. +check "clone: clears it rather than setting it empty (#103)" 1 "" \ + grep -qE 'config set .*user\.box\.mode\.asked=($|[[:space:]])' "$CLONELOG" # Order: the re-stamp lands on the copied instance BEFORE it is started, so a # clone is never observable wearing its source's provenance. Fail-closed — an # absent line makes the arithmetic fail, not pass. @@ -1267,6 +1278,17 @@ restamp_precedes_start() { } check "clone: the re-stamp precedes the start (#103)" 0 "" \ restamp_precedes_start "$CLONELOG" +# The clear rides the same rule for the same reason: a clone must never be +# observable — not for one moment, not to 'box info' — wearing an 'asked' its +# operator never gave. Fail-closed the same way. +clear_precedes_start() { + local unset_ln start + unset_ln="$(grep -n 'config unset .* user.box.mode.asked' "$1" | head -1 | cut -d: -f1)" + start="$(grep -n '^incus start ' "$1" | head -1 | cut -d: -f1)" + [ -n "$unset_ln" ] && [ -n "$start" ] && [ "$unset_ln" -lt "$start" ] +} +check "clone: the mode.asked clear precedes the start too (#103)" 0 "" \ + clear_precedes_start "$CLONELOG" # --- the read half: 'box info' surfaces it --------------------------------- # A stamp nothing can read is not done. cmd_info printed NAME/STATE/TYPE/IPV4 @@ -1304,13 +1326,25 @@ check "info: surfaces the origin (#103)" 0 "ORIGIN mint" infobox "$STAMPED" # Still the box it always was: the new block is additive, above the snapshots. check "info: still prints the state block it always did" 0 "IPV4 10.1.2.3" infobox "$STAMPED" -# A clone reads back as a clone, naming its source. +# A clone reads back as a clone, naming its source. Modelled on what the +# --from branch actually leaves behind: origin re-stamped, mode.asked cleared. CLONECFG="$MWORK/clone.cfg" -{ grep -v '^user.box.origin ' "$STAMPED" +{ grep -v -e '^user.box.origin ' -e '^user.box.mode.asked ' "$STAMPED" echo 'user.box.origin clone' echo 'user.box.origin.from work/authed'; } > "$CLONECFG" check "info: a clone says so, and names the box it came from (#103)" \ 0 "ORIGIN clone of work/authed" infobox "$CLONECFG" +# ...and stays silent about a demand nobody made of it. The MODE line is gated +# on 'asked' precisely so absence renders as silence rather than as a guess; +# TYPE above still reports VM off the instance type, so nothing is lost. +info_has_mode() { infobox "$1" | grep -q '^MODE'; } +check "info: a clone prints no MODE line — nobody asked IT anything (#103)" 1 "" \ + info_has_mode "$CLONECFG" +check "info: ...while TYPE still reports what it actually is (#103)" \ + 0 "TYPE VM" infobox "$CLONECFG" +# The mint keeps its MODE line — there, the operator really was asked. +check "info: a MINT still surfaces what was asked for (#103)" \ + 0 "MODE vm (asked: auto)" infobox "$STAMPED" # --- legacy boxes: the promise that they keep working under every verb ------ # A box carrying the boundary tag and NOTHING else — every box minted before