Skip to content

api: report the actual fork mode on Instance - #419

Open
mpuig wants to merge 3 commits into
kernel:mainfrom
mpuig:feat/report-fork-mode
Open

api: report the actual fork mode on Instance#419
mpuig wants to merge 3 commits into
kernel:mainfrom
mpuig:feat/report-fork-mode

Conversation

@mpuig

@mpuig mpuig commented Aug 17, 2026

Copy link
Copy Markdown

What

Fork already decides copy-on-write vs full copy — fork.go computes
shareMemFile := HypervisorType == Firecracker && source.State == Standby — but
that decision was a local variable. Callers had no way to know which mode a fork
actually used and had to infer it from the hypervisor. This surfaces it.

  • StoredMetadata.ForkMode records the measured mode at fork time
    (ForkModeShared / ForkModeCopied), mirroring the existing shareMemFile
    decision exactly. Persisted, so GetInstance reports it too. Empty for
    instances not created by a fork.
  • Instance.fork_mode (read-only enum: shared | copied) added to the
    OpenAPI schema, regenerated (make oapi-generate), and mapped in the instance
    API response.

shared = copy-on-write via a shared mem-file (the source is frozen only if it
was Running at fork time); copied = a full private copy (stopped-source forks,
or hypervisors without shared-memory fork).

Why

A consumer that forks needs to record and report the actual mode a fork used
(and whether a large source was frozen), rather than guessing from the backend.
Exposing the mode the server already computed makes that honest.

Compatibility

Additive and backward compatible: existing metadata reads back with an empty
fork_mode, and the field is omitempty on the wire.

Test

TestForkInstance_VZStoppedSourceSupported asserts a VZ stopped-source fork
reports ForkModeCopied.


Note

Medium Risk
Touches fork metadata and the public instance/snapshot APIs (new field plus tag merge on snapshot fork). Additive and omitempty, but tag override and persisted mode affect identity and client-visible fork behavior.

Overview
Forks now persist and return the actual memory-copy mode instead of leaving callers to infer it from hypervisor/state. shared is copy-on-write via a shared mem-file; copied is a full private copy. The field is empty for non-forked instances.

Instance and snapshot fork paths both record ForkMode from the existing shareMemFile decision. The instance API maps it as optional fork_mode.

Fork-from-snapshot tags: ForkSnapshot accepts tags that override cloned source tags per key (other source tags stay). Tags are validated on the manager path the same way as create.

Reviewed by Cursor Bugbot for commit 9f4e208. Bugbot is set up for automated code reviews on this repo. Configure here.

mpuig added a commit to mpuig/barista.sh that referenced this pull request Aug 17, 2026
- CLI: `barista fork <snapshot> [--target-instance-id] [--require-cow]` and a
  `barista capsule` group (export/import/inspect/ls/delete). Export can write the
  manifest (--manifest-out) so it can be moved to another node and imported;
  import reads it and verifies before registering. Capability-aware refusals
  (FORK_MODE_UNAVAILABLE / OBJECT_STORE_UNAVAILABLE / CAPABILITY_MISSING /
  CAPSULE_INCOMPATIBLE) surface as the node's own Status with its exit code — the
  CLI stays a thin client. No app/tenant concepts: only snapshot, capsule, and
  instance ids. render gains capsule/capsules/capsule_op.
- Docs: concepts/forks-and-capsules.md covers fork modes and the measured/
  never-assumed rule, lineage, verify-then-publish export/import, exact
  compatibility, storage tiers, crash-safe deletion/GC, execution epochs and the
  grant carrier, the honest exact-memory limit (capsules are secret-bearing), and
  the boundary with barista-apps. cli.md gains the fork/capsule reference.

openspec validate --strict green; cargo fmt + clippy -D warnings clean across
the workspace; 391 workspace tests pass (only the 4 substrate-gated
hypeman_runtime tests fail, identically to the base commit — no working hypeman
here).

Implements barista-046 tasks 6.1 and 6.2. Task 6.3 (unchanged T3/T5/T8/T9/T10)
and 6.4's measured fork/export/restore evidence need the real substrate and are
gated on kernel/hypeman#419; the new fork/capsule/grant integration matrix
(fork_op, capsule_op, fork_contract, grants, epoch tests) is green.
@mpuig
mpuig force-pushed the feat/report-fork-mode branch from c87dce6 to 958b23b Compare August 17, 2026 18:54
Comment thread lib/instances/fork.go
mpuig added a commit to mpuig/barista.sh that referenced this pull request Aug 17, 2026
Wire HypemanRuntime::fork to the substrate's fork rather than the trait's
refusal, closing the honesty gap where hypeman advertised cow_fork=true but had
no fork implementation (so a real `barista fork` would create a target then fail
it).

- client: fork_snapshot(snapshot_id, name, target_state) → POST
  /snapshots/{id}/fork (kernel/hypeman#419); Instance gains an optional
  fork_mode field (shared|copied), tolerant of an older hypeman that omits it.
- runtime::fork maps the snapshot fork into a new sandbox brought up Running,
  reports the measured fork_mode as ForkOutcome::mode (never inferred, design
  D2), and froze_source=false (forking a retained snapshot does not freeze a
  running source). require_cow fails closed: a full-copy result under a CoW
  demand removes the just-created fork and refuses; an absent fork_mode is read
  conservatively as FULL_COPY so require_cow is refused, not faked.
- capabilities: full_copy_fork now true — both fork modes are backed by a real
  impl.

Tests: fork_mode parses and is optional. 221 lib tests green; fmt + clippy -D
warnings clean.

Implements barista-046 task 3.4. Live end-to-end validation against a hypeman
built from the fork, plus the standby-snapshot fork path and fresh-identity-on-
fork, are the remaining iteration.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8853445. Configure here.

Comment thread lib/instances/snapshot.go
Comment thread lib/instances/snapshot.go
mpuig added a commit to mpuig/barista.sh that referenced this pull request Aug 23, 2026
* barista-046 §2: journal + immutable object model

The durable half of the open app platform: content-addressed capsule
identity, a local immutable-object store, and crash-safe reference
counting/GC. No wire changes — this builds on the §1 contract and every
new capability still reports false until the fork runtime (§3),
export/import (§4), and execution epochs (§5) land.

- capsule.rs: canonical, length-prefixed manifest serialization and the
  sha256 capsule id (not prost re-encoding — objects are a set, sorted by
  (digest,type)); pinned golden determinism fixtures. object_digest is the
  single definition of "the content id of these bytes".
- objects.rs: local content-addressed store — stage (measure length+digest,
  fsync), commit (verify-then-publish via atomic rename, idempotent dedup),
  read_verified, idempotent remove, sweep_staging. run_gc reconciles the
  physical bytes with the journal's decisions (design D6).
- db.rs: object_refs (durable refcount + verified + gc_pending) and capsules
  tables; register_capsule/delete_capsule take and release references in one
  transaction so the count never disagrees with the live capsule set across a
  crash; collectable_objects + finalize_object_gc never touch a live object.
  Instances carry durable lineage + execution_epoch (additive migrations);
  a directly-created instance reads back lineage: None, not empty strings.

Tests: canonical determinism + compatibility-key binding, store
stage/commit/dedup/mismatch/sweep, shared-object survival, idempotent
delete, GC-intent resurrection across a delete/re-import race, and a
restart-with-pending-GC reconciliation test. 208 lib tests green;
cargo fmt + clippy -D warnings clean.

Implements barista-046 tasks 2.1–2.5.

* barista-046 §3.1: fork runtime contract on the Runtime trait

The neutral fork mechanism at Contract B, ahead of the journaled operation
(§3.2–§3.5). No substrate work yet — this is the honesty contract the ops
layer negotiates against.

- runtime::fork(source, source_snapshot, target_spec, guest, require_cow)
  returns a ForkOutcome { handle, mode, froze_source }. Defaulted to a
  refusal so a runtime acquires the capability by answering, never by silence
  (the rule pause/resume already follow). Handle gains PartialEq/Eq so an
  outcome is comparable in tests.
- ForkOutcome reports what the runtime *did*, not what its capabilities say
  it can do (design D2): mode is measured (COW vs FULL_COPY), and froze_source
  is reported independently rather than inferred from the mode — a CoW fork of
  a paused source froze nothing regardless.
- StubRuntime gains opt-in cow_fork/full_copy_fork, a fork impl that reports
  the real mode and fails closed on an unmet require_cow, and a forked_targets
  log so the operation's effect is observable.

Tests (tests/fork_contract.rs): CoW reports CoW and does not freeze; full-copy
reports FULL_COPY and admits the freeze; require_cow fails closed with
CapabilityMissing without CoW; a runtime with neither bit refuses and its
capabilities agree. 208 lib tests green; fmt + clippy -D warnings clean.

Implements barista-046 task 3.1.

* barista-046 §3.2/§3.3: journaled ForkInstance operation

Branch a retained snapshot into a new, independently owned instance, with
durable lineage and honest mode reporting. Fork is a Create-shaped verb —
it journals a *new* target row — but the child comes up RUNNING from the
source's cloned state rather than CREATED from a cold spec.

- ops: OpKind::Fork (Creating→Running) and OpPayload::Fork { spec,
  source_instance_id, source_snapshot_id, lineage, require_cow }. The target
  spec is treated as a create spec so the row, guest token, and channel
  identity are journaled atomically; the executor reads them back and calls
  runtime.fork, records the measured mode (set_op_fork_mode) as its own step,
  emits LINEAGE_RECORDED, and reports a full-copy freeze on the operation.
  A failed fork removes the half-made target sandbox exactly as Create does;
  kill -9 mid-fork converges the CREATING target to FAILED (reapable) via the
  existing recovery arm, leaving the source untouched.
- db: submit_atomically writes lineage onto the forked child's row in the same
  statement as the spec; operations gain actual_fork_mode (additive migration)
  with set_op_fork_mode and it round-trips on the proto Operation.
- service: fork_instance resolves snapshot→source→cloned target spec + lineage,
  preflights capabilities fail-closed (require_cow / no-fork →
  FORK_MODE_UNAVAILABLE) before any target row exists, and refuses an unknown
  snapshot with NOT_FOUND.

Tests (tests/fork_op.rs, 8): two divergent children with the source unchanged,
duplicate target refused, replayed key returns the same op, require_cow fails
closed with no target created, no-fork runtime refuses, full-copy reports mode
+ freeze, unknown snapshot refused, kill -9 recovery. 208 lib + 12 fork tests
green; fmt + clippy -D warnings clean. Pre-existing hypeman_runtime substrate
tests fail identically on the base commit (no reachable working hypeman here).

Implements barista-046 tasks 3.2, 3.3, 3.5. Task 3.4 (hypeman native fork)
remains blocked on the substrate's fork API.

* barista-046 §4 (part 1): capsule export mechanics + object store wiring

The unambiguous half of §4: wire the §2 immutable-object store into the Agent
and implement verify-then-publish capsule export from a retained snapshot. The
RPC/operation-journal surface is deferred pending a design decision (see below).

- Agent now owns an ObjectStore rooted at <data_dir>/capsules, and bootstrap
  runs objects::run_gc after crash recovery — sweeping staging files a crashed
  upload left and collecting objects whose last reference is gone (design D6).
  This closes the §2 loose end: the store finally has an owner and a lifecycle.
- Runtime trait gains export_snapshot(&SnapshotId) -> Vec<SnapshotObject>,
  defaulted to a refusal so a runtime acquires capsule_export by answering.
  SnapshotObject's Debug prints type+len only — exact memory/disk is secret.
- capsule_ops::export_capsule: read the snapshot's objects, stage/verify/commit
  each into the store (digest+length measured from the bytes themselves),
  build the canonical manifest with the snapshot's compatibility keys and
  lineage, and register the capsule — verify-then-publish, idempotent by
  content id (design D3). The object-store tier fails loudly with
  OBJECT_STORE_UNAVAILABLE until §4.4 configures a backend.
- StubRuntime gains capsule_exporter() + a deterministic export_snapshot so the
  export/verify/register/GC path is testable without a substrate.

Tests (5): objects staged + capsule registered + refcounts, idempotent
re-export by content id, capability refusal, object-store tier refused,
unknown snapshot refused. 213 lib tests green; fmt + clippy -D warnings clean.

Implements the export mechanics of barista-046 task 4.1. Import (4.2),
restore-from-capsule (4.3), and the remote tier (4.4) follow.

* barista-046 §4 (part 2): capsule import + the 5 capsule RPCs

Wire the capsule verbs onto Contract A. Capsule operations are instance-free
and idempotent by content id, so they live in their own journal rather than the
instance-centric operations table (design decision B): a capsule_operations
table records only successful outcomes under the idempotency key, and
GetOperation falls back to it.

- capsule_ops::import_capsule: verify every referenced object is present and
  intact (length + digest re-checked), preflight schema + cpu-class
  compatibility, then register the capsule and a restorable snapshot row — no
  boot (design D4). A tampered/truncated/missing object refuses the whole
  import; an incompatible cpu class refuses before restore.
- db: capsule_operations table + CapsuleOpRow; record_capsule_op is
  concurrency-safe by content id (UNIQUE key; loser returns the winner's row);
  get_capsule_op/capsule_op_by_key back replay and GetOperation.
- service: export/import/delete/get/list wired; export and import replay by key,
  failures map to typed Status and leave the key free (verify-then-publish means
  no partial state), delete logically removes then runs run_gc to collect freed
  objects (design D6). GetOperation resolves capsule ops too.
- StubRuntime: capsule_import capability + capsule_porter().

Tests (tests/capsule_op.rs, 8): export→list→GetOperation, idempotent export,
import verifies+registers, truncated/missing/incompatible refused, shared-object
retention across delete + GC, idempotent delete. 213 lib + 8 capsule tests
green; fmt + clippy -D warnings clean.

Implements barista-046 tasks 4.1, 4.2, 4.5. Task 4.3 (exact restore/fork from
an imported capsule) needs runtime materialization of capsule objects; 4.4
(configured object-store tier) is refused with OBJECT_STORE_UNAVAILABLE until a
backend lands — both alongside the deferred hypeman work (3.4).

* barista-046 §5.1: execution-epoch issuance, persistence, and rotation

Every boot/resume/fork now receives a fresh execution epoch, bound to the
instance before its guest is reached, so a platform-mediated grant can be tied
to it and the prior epoch revoked (design D5). The grant carrier itself (§5.2)
and the guest-side rebind (§5.3) build on this.

- db: a global, monotonic epoch counter (journal_meta.next_execution_epoch) —
  global, not per-instance, so an epoch is unique across the node and two
  sibling forks can never share one. issue_execution_epoch bumps+reads under the
  connection mutex (atomic); set_instance_epoch binds it to a row, and
  persisting a new epoch is what revokes the prior one.
- ops: the executor issues and persists an epoch for every run verb (Start,
  Resume, Fork) before the runtime action, delivers it over Contract C in the
  restore duties (replacing the §1 placeholder 0), and emits EPOCH_ROTATED on
  success — after STATE_CHANGED, only when the run succeeded, carrying the epoch
  number but no grant material (§5.4).
- events: epoch_rotated helper.

Tests: epochs are unique + monotonic across 1000 issuances; set_instance_epoch
replaces the prior epoch; two divergent forks get distinct non-zero epochs
(sibling separation). 215 lib + 33 guest-agent + 8 fork_op green; fmt + clippy
-D warnings clean.

Implements barista-046 task 5.1. The grant carrier (5.2), guest rebind duties
(5.3), required/best-effort semantics + redaction (5.4), and the full test
matrix (5.5) follow.

* barista-046 §5.2/§5.3: grant carrier + Contract C restore-duty ordering

The guest half of epoch-bound grants: a platform-mediated grant carrier that is
delivered fresh on every restore, bound to the run's execution epoch, and lives
only in RAM.

- bootstrap: DEFAULT_GRANT_CARRIER at /run/barista/grant-carrier — the writable
  tmpfs the runtime already provides, so the carrier has no disk-snapshot
  representation. Exact-memory snapshots still capture RAM (design D5), which is
  why this is replacement, not a scrub, and why safe_grant_rebind stays narrow.
- duties::run now replaces the carrier as its third normative step (entropy →
  clock → carrier), and by the node's sequencing that lands before the separate
  post-restore rebind hook, so the hook reconnects using the new epoch's grant
  rather than the revoked one. A non-empty carrier is written 0600 (truncating
  any prior); an empty carrier removes a stale one, so a revoked epoch's grant
  cannot survive into the next restore. grant_rebound/rebind_detail report the
  guest's half honestly.
- Redaction (§5.4): rebind_detail and degraded carry the epoch and byte count
  only — never the carrier contents.

Tests: a delivered carrier is written 0600 and its detail is redacted (no
bytes); an empty carrier removes a prior one (a revoked epoch's grant cannot
persist). 35 guest-agent + 215 node lib green; fmt + clippy -D warnings clean.

Implements barista-046 tasks 5.2 and 5.3. Full redaction across ops/events
(5.4) and the remaining test matrix — old-epoch refusal, readiness ordering
(5.5) — follow; safe_grant_rebind stays honestly false until a runtime
demonstrates it.

* barista-046 §5.4/§5.5: grant-epoch validation, required-rebind semantics, tests

Closes §5.

- grants module: validate_grant_epoch(current, presented) refuses a grant that
  is not bound to the instance's current epoch with EPOCH_REVOKED — a grant from
  a prior run of this instance, or from a sibling fork (which drew a different
  epoch in §5.1), is stale by construction (design D5). Messages carry the two
  epoch numbers only, never the grant (§5.4 redaction). EXACT_MEMORY_WARNING
  states the honest limit: epoch rotation replaces mediated grants but says
  nothing about values the workload copied into its own RAM, which an
  exact-memory snapshot still captures.
- ops: the post-restore hook now distinguishes required vs best-effort rebind
  failure (§5.4). When the guest placed a fresh grant carrier for the epoch,
  a failed/timed-out rebind hook is a required-rebind failure ("the workload
  holds a fresh grant it has not bound"); with no carrier there is nothing to
  rebind and the ordinary best-effort reconnect degradation stands.

Tests (§5.5): current-epoch accepted; old-epoch refused; sibling epochs refused
in both directions (separation); zero-epoch refused; honest exact-memory
warning. Plus the §5.1/§5.2 tests already covering sibling separation on fork,
no persistent carrier, and carrier redaction. 220 node lib + 35 guest-agent
green; fmt + clippy -D warnings clean.

Implements barista-046 tasks 5.4 and 5.5. §5 complete.

* barista-046 §6.1/§6.2: fork + capsule CLI and operator docs

- CLI: `barista fork <snapshot> [--target-instance-id] [--require-cow]` and a
  `barista capsule` group (export/import/inspect/ls/delete). Export can write the
  manifest (--manifest-out) so it can be moved to another node and imported;
  import reads it and verifies before registering. Capability-aware refusals
  (FORK_MODE_UNAVAILABLE / OBJECT_STORE_UNAVAILABLE / CAPABILITY_MISSING /
  CAPSULE_INCOMPATIBLE) surface as the node's own Status with its exit code — the
  CLI stays a thin client. No app/tenant concepts: only snapshot, capsule, and
  instance ids. render gains capsule/capsules/capsule_op.
- Docs: concepts/forks-and-capsules.md covers fork modes and the measured/
  never-assumed rule, lineage, verify-then-publish export/import, exact
  compatibility, storage tiers, crash-safe deletion/GC, execution epochs and the
  grant carrier, the honest exact-memory limit (capsules are secret-bearing), and
  the boundary with barista-apps. cli.md gains the fork/capsule reference.

openspec validate --strict green; cargo fmt + clippy -D warnings clean across
the workspace; 391 workspace tests pass (only the 4 substrate-gated
hypeman_runtime tests fail, identically to the base commit — no working hypeman
here).

Implements barista-046 tasks 6.1 and 6.2. Task 6.3 (unchanged T3/T5/T8/T9/T10)
and 6.4's measured fork/export/restore evidence need the real substrate and are
gated on kernel/hypeman#419; the new fork/capsule/grant integration matrix
(fork_op, capsule_op, fork_contract, grants, epoch tests) is green.

* barista-046 §6.1: surface the portability capabilities in `node info`

The CLI's node-info renderer predated the barista-046 capabilities, so
`barista node info` (both human and --json) omitted full_copy_fork,
object_store_snapshots, capsule_export, capsule_import, and safe_grant_rebind.
An operator negotiating what a node can do could not see them. Add all five to
both renderings.

Verified against a live fake-runtime node on Docker: node info now reports every
portability capability (honestly false for fake), fork refuses with
FORK_MODE_UNAVAILABLE, capsule export --tier object-store refuses with
OBJECT_STORE_UNAVAILABLE, capsule ls works (journal-only), and a full
create/start/destroy lifecycle runs real containers through the deployed node.

* barista-046 §3.4: adopt hypeman's native snapshot fork

Wire HypemanRuntime::fork to the substrate's fork rather than the trait's
refusal, closing the honesty gap where hypeman advertised cow_fork=true but had
no fork implementation (so a real `barista fork` would create a target then fail
it).

- client: fork_snapshot(snapshot_id, name, target_state) → POST
  /snapshots/{id}/fork (kernel/hypeman#419); Instance gains an optional
  fork_mode field (shared|copied), tolerant of an older hypeman that omits it.
- runtime::fork maps the snapshot fork into a new sandbox brought up Running,
  reports the measured fork_mode as ForkOutcome::mode (never inferred, design
  D2), and froze_source=false (forking a retained snapshot does not freeze a
  running source). require_cow fails closed: a full-copy result under a CoW
  demand removes the just-created fork and refuses; an absent fork_mode is read
  conservatively as FULL_COPY so require_cow is refused, not faked.
- capabilities: full_copy_fork now true — both fork modes are backed by a real
  impl.

Tests: fork_mode parses and is optional. 221 lib tests green; fmt + clippy -D
warnings clean.

Implements barista-046 task 3.4. Live end-to-end validation against a hypeman
built from the fork, plus the standby-snapshot fork path and fresh-identity-on-
fork, are the remaining iteration.

* barista-046 §3.4: re-identify and re-credential the hypeman fork

Live testing against a real vz substrate surfaced two integration gaps that
left a forked child either reaped or unreachable; both are fixed so the child
comes up RUNNING, correctly identified, on the source's channel credentials.

- Tags: hypeman's snapshot fork clones the source's tags, so the fork carried
  the source's barista.instance_id/node_id and the zero-orphan sweep reaped it
  as a duplicate (child → FAILED). HypemanRuntime::fork now passes the child's
  identity tags in fork_snapshot (kernel/hypeman fork tag override), so the
  sweep and every tag-keyed lookup see the fork as itself.
- Credentials: a fork inherits the source's guest token and channel identity
  rather than minting fresh — the forked VM is a memory clone already running
  the guest agent with the source's credentials, so a fresh token/cert could
  never complete the channel handshake. Threaded through OpPayload::Fork and
  used in the submit path in place of minting (fresh *platform grants* remain
  the §5 concern, rebound per epoch; the base channel credential rides the clone).

Verified live: create → start → snapshot → fork now yields a child in RUNNING,
correctly tagged (not reaped), sharing the source's credentials. 221 lib + 8
fork_op tests green; fmt + clippy clean.

Known remaining gap (substrate, not barista): the forked guest keeps the
source's in-VM network config, so it does not answer at the new host-assigned
IP until it renews — a hypeman fork-networking behavior to address upstream,
tracked alongside the fork_mode/tags work on kernel/hypeman.

* barista-046: dev deploy script for the live fork/capsule stack

Reproducible bring-up of hypeman (vz, built from the fork) + a barista node
wired to it, so the fork/capsule verbs can be run end to end. Encodes the two
environmental lessons from live testing: a short hypeman data_dir (macOS vz
vsock unix-socket path must stay under 104 chars) and non-conflicting
caddy/metrics ports. `up`/`down`.

* barista-046: document the forked-guest network-identity substrate finding

Live fork testing on vz surfaced that a memory-forked guest keeps the source's
in-VM IP (network is configured once at initrd boot; a fork resumes without
re-running boot), so it is unreachable at the new host-assigned IP even though
the fork is RUNNING and correctly identified.

- docs/upstream-issues/07-…: filable draft with the root cause (restore
  reconfigures via the guest agent's ReconfigureNetwork RPC gated on
  allocatedNet; the fork pre-allocates so that branch is skipped), reproduction,
  suggested fix, and the open question about the guest agent under a consumer's
  own injected entrypoint.
- concepts/forks-and-capsules.md: a Known substrate limitation section so the
  boundary is honest for readers.

Every other fork guarantee (lineage, measured mode, source preservation) holds.

* perf: single-allocation hex encoding on content-addressed hot paths

Applies an upstream Rust technique (openai/codex #38823, "avoid allocating per
character"): the codebase's byte-wise hex —
`bytes.iter().map(|b| format!("{b:02x}")).collect::<String>()` — heap-allocates
a temporary String for *every byte* (32 allocations per sha256 digest). New
`hex::to_lower(&[u8])` encodes into one pre-sized String, byte-for-byte
identical output, one allocation total.

Routed the hottest, content-addressed paths through it:
- objects::stage — a digest per object staged into the capsule store
- capsule::capsule_id / sha256_hex / object_digest — per capsule id + object id
- snapshot_key::template_hash — per snapshot restore-key derivation

Correctness is pinned: the capsule golden-id fixture and a hex-equivalence test
(to_lower matches the exact format! output it replaces) both pass, so the swap
is safe under content ids. 223 lib tests green; fmt + clippy clean.

The same pattern remains at ~5 lower-frequency call sites (identity, guest,
node_info, agent_volume, runtime, and guest-agent bootstrap); routing those
through the helper is a mechanical follow-up. The other two codex techniques
reviewed (O(n^2) iter().find() → two-pointer; &[u8] instead of Vec<u8>) have no
compelling barista hot-path home.

* perf: route the remaining production hex paths through hex::to_lower

Completes the single-allocation hex sweep (openai/codex #38823): node_info::hex16
(node-id fingerprint) and agent_volume::hash_binary (agent binary digest, per
volume). The remaining .map(format!) sites are leak-detection test needles,
deliberately independent of the helper they guard, and left as-is.

* barista-046 §4.3: exact restore from an imported capsule, no cold fallback

An imported capsule was registered but unreachable: import writes an
instance-free snapshot row, and both candidate entry points dead-ended on
that empty instance id — fork required a source row, resume submitted
against "". So a verified capsule could be held and never restored.

ForkInstance becomes the restore verb for one, with the compatibility gate
the spec requires and deliberately no permissive branch:

- `restore::decide_capsule` returns Proceed or Refuse, and nothing else. The
  cold-boot variant that `decide` has for node-local resumes is absent by
  design: a cold semantic import is an app's job above the Host API, and
  answering an exact restore with a fresh boot would present one as the
  other. CPU class is checked unconditionally here, unlike the local tier —
  the reasoning that makes it skippable there (restored on the machine that
  took it) is exactly what a capsule violates.
- Template and bundle are checked at last: import deferred them because no
  target spec existed yet, and nothing checked them anywhere until now.
- `Runtime::restore_from_objects`, the inverse of `export_snapshot`,
  defaulted to a refusal so a runtime acquires the ability by answering.
  Returns a Handle, not a ForkOutcome: no fork happened, so there is no mode
  to report and none is journaled.
- `ForkInstanceRequest.target_spec` (additive, field 5): a capsule manifest
  carries compatibility hashes, never a spec, so the machine to restore into
  comes from the caller — and is refused unless its template hash matches.
  buf lint and buf breaking against main stay green.
- Objects are re-verified at restore, not trusted from import time. Import
  proved they were intact then; the window since is where a swept or
  corrupted object would otherwise reach a memory restore.
- Credentials are minted fresh, and cannot be otherwise: the token the
  restored guest holds was issued by another node. The consequence is
  reported rather than hidden — the guest channel does not authenticate
  until the guest re-reads its material, which is a substrate fact for §6.3
  to measure, and restore_duties already treats an unreachable guest as a
  degradation rather than a failed restore.

Found while testing: the fork-mode preflight ran before the capsule branch,
so a capsule restore was refused for lacking cow_fork/full_copy_fork it does
not use. The branch point is now resolved first — which kind of branch this
is decides which capabilities apply.

tests/capsule_restore.rs: 8 cases, every refusal also asserting no instance
row was left behind. 230 lib + 8 restore tests green; fmt and clippy clean on
the touched files.

* openspec: check off barista-046 §4.3

Implemented in 53faffb. §4.5's template/bundle-mismatch and source-node-loss
rows are now genuinely covered too (tests/capsule_restore.rs), which they
could not be before a restore path existed.

* docs: record why unwrap_used/expect_used stays off (clippy scoping evaluated)

Evaluated codex's clippy.toml allow-unwrap-in-tests / allow-expect-in-tests as
the 'scope to non-test code' mechanism the prior comment was waiting for. It
silences unit tests (#[test] fns, #[cfg(test)] modules) but not helper functions
in tests/*.rs integration files, and this tree is integration-test-heavy — so
enabling the lints still fires in hundreds of test helpers. The daemon crate is
already clean, so the marginal catch does not justify blanketing every
integration-test file with #![allow], forever. Comment updated so the next
reader does not re-investigate.

* fix(cli): set ForkInstanceRequest.target_spec after §4.3 added the field

§4.3 (53faffb) added ForkInstanceRequest.target_spec to the proto but did not
update the CLI's fork caller, so HEAD did not compile (barista-cli, E0063). A
plain `barista fork` clones the source's spec, so target_spec is None; only
capsule restore supplies one. Workspace + all-targets build green again.
Puig, Marc added 3 commits August 23, 2026 11:25
Fork already decides copy-on-write vs full copy (fork.go: shareMemFile =
Firecracker && Standby source), but that decision was a local variable —
callers had no way to know which mode a fork actually used and had to infer it
from the hypervisor. Surface it.

- StoredMetadata.ForkMode records the measured mode at fork time
  (ForkModeShared / ForkModeCopied), mirroring the existing shareMemFile
  decision exactly. Persisted, so GetInstance reports it too. Empty for
  instances that were not created by a fork.
- Instance.fork_mode (read-only enum: shared | copied) added to the OpenAPI
  schema and regenerated; mapped in the instance API response.

"shared" is copy-on-write via a shared mem-file (the source is frozen only if
it was Running at fork time); "copied" is a full private copy (stopped-source
forks, or hypervisors without shared-memory fork). Additive and backward
compatible: existing metadata reads back as empty.

Test: TestForkInstance_VZStoppedSourceSupported asserts a VZ stopped-source
fork reports ForkModeCopied.
Two gaps found wiring a consumer (Barista) onto snapshot fork:

- forkSnapshot did not set ForkMode (only the instance-fork path did), so a
  fork via POST /snapshots/{id}/fork reported an empty mode. Set it from the
  same shareMemFile decision: shared mem-file = shared (CoW), else copied.
- ForkSnapshotRequest gains optional `tags` that override the tags cloned from
  the snapshot's source (request wins per key; unrelated source tags kept). A
  consumer forking many instances from one source must be able to re-identify
  each fork rather than inherit the source's identity labels — otherwise the
  consumer's own bookkeeping (which keys sandboxes by tag) sees every fork as a
  duplicate of the source.

Regenerated oapi.go; fork tests pass.
Bugbot flagged that ForkSnapshotRequest now carries Tags and forkSnapshot writes
them into instance metadata, but validateForkSnapshotRequest never called
tags.Validate — unlike create-instance and create-snapshot. Invalid keys/values
could reach metadata on this direct manager path, which the OpenAPI layer does
not guard. Mirror the create-snapshot validator's tags.Validate check.
@mpuig
mpuig force-pushed the feat/report-fork-mode branch from 8853445 to 9f4e208 Compare August 23, 2026 09:27
@mpuig

mpuig commented Aug 23, 2026

Copy link
Copy Markdown
Author

Rebased onto latest main (resolved the conflict with #434's instance-expiration controls: openapi.yaml merged cleanly with both fork_mode and expires_at, and lib/oapi/oapi.go was regenerated via make oapi-generate rather than hand-merging the embedded spec; cmd/api/api/instances.go keeps both the ForkMode and ExpiresAt mappers).

Also addressed the Bugbot review:

  • Fork snapshot skips tag validation (9f4e208d): validateForkSnapshotRequest now calls tags.Validate(req.Tags), mirroring create-instance and create-snapshot, so caller-supplied tags can't reach instance metadata unvalidated on this direct manager path. Added TestValidateForkSnapshotRequestValidatesTags.
  • Snapshot fork omits ForkMode: already handled — forkSnapshot computes shareMemFile and writes forkMeta.ForkMode (shared/copied), same as the instance-fork path.

go vet clean; fork/snapshot/tags tests green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant