Skip to content

Lightcone CLI rebuild with uv first, container option, datalad compatibility - #190

Merged
EiffL merged 24 commits into
mainfrom
clean_rebuild
Aug 21, 2026
Merged

Lightcone CLI rebuild with uv first, container option, datalad compatibility#190
EiffL merged 24 commits into
mainfrom
clean_rebuild

Conversation

@EiffL

@EiffL EiffL commented Aug 21, 2026

Copy link
Copy Markdown
Member

This merges the clean rebuild of lightcone-cli onto main — the full re-architecture from the Snakemake shim to the model where the locked environment is the execution environment. The pre-rebuild codebase was deliberately stripped and functionality re-added one layer at a time, each layer landing with its own tests, dependencies, and (since the rewrite) docs. 21 PRs, all individually reviewed and merged into clean_rebuild; this PR is the promotion.

What lightcone-cli is now

A project is pyproject.toml + uv.lock + astra.yaml — uv is the only environment substrate, and mode is derived, never configured: direct by default (recipes in the project's .venv under Landlock/Seatbelt), containerized when [tool.lightcone.image] is declared (recipes in a content-addressed image archived in the repository itself). Five verbs: init, materialize, status, run, build.

  • Identity: definition_version (what an output is) and env_version (what it ran under) are separate on purpose — a spec contradiction is stale and remade; an environment move is behind, reported and left alone. --refresh widens by exactly that one state.
  • Storage: the DataLad model — git carries history, git-annex carries bytes, behind ordinary git add/git commit. Every output is committed with a manifest and a run record datalad rerun can replay; runs start and end on a clean tree; foreign writes are detected by history and classify stale.
  • Execution: one exec boundary (Policy → pure argv rewrite → honest attestation) shared by recipes and the lc run probe; a recipe writes its own output directory and nothing else; denials carry copy-pasteable remedies. Network is uncontrolled on every mechanism, symmetrically attested.
  • Venues: detected, never configured — a SLURM allocation is spanned with one worker per node; known centers get a login guard whose refusal prints the center's own salloc/sbatch spellings; podman-hpc makes multi-node containerized runs real.
  • Publication: declaring [project].license turns on the RO-Crate view — ro-crate-metadata.json converged by materialize, validated against Provenance Run Crate 0.5 in CI. Deposit is git archive.
  • The engine is the host's uv tool, never a project dependency — a project's lock carries only what the analysis imports, and an engine upgrade rebuilds nothing.

The layers, as merged

PR Layer
#173 Layer 5: the exec boundary and lc run (landed first — the smallest honest consumer of the boundary)
#175 Layers 2 + 4: identity, provenance, lc materialize
#178 The engine is the host's uv tool (deletes layer 3 by decision)
#180 Layer 6: the container hatch
#181 Layer 7: venues — SLURM in-allocation, podman-hpc
#182 Layer 8: the publication view
#184 Hardening: the recorded residues closed (UV_* scrub, write-scope narrowing, symmetric network attestation, machine-level uv.toml advisory)
#183 The agentic eval reactivated: uv tool install of the branch under test, empty-environment task, RO-Crate gate — three green runs
#185#189 The documentation rewritten end to end: README, user guide, CLI reference, developer corner, governance — every command block executed before shipping, every refusal quoted from a real run

Verification

  • The suite (tests, lint, mypy strict) is green on clean_rebuild, including the three gated suites CI requires: sandbox enforcement (Landlock on Linux, Seatbelt on macOS), container smoke (real build → archive → bytes-free-clone datalad rerun), and crate smoke (the official validator, REQUIRED clean).
  • The agentic eval passes end-to-end on this branch: an agent builds a supernova-cosmology analysis from the spec, manages the environment with uv add, materializes through the real engine, and publishes a crate — $0.70–$1.22 per run.
  • zensical build renders the rewritten docs with zero issues; the site deploys on the next release.

Known deferred work (tracked, not blocking)

  • The one-time Perlmutter spike (layer 7's pending items are listed in CLAUDE.md) — HPC support ships honestly labeled "early days".
  • hub/GKE + Cloud Build venue and apptainer/singularity runtimes — design headroom recorded, deliberately not stubbed.
  • Multi-arch image archives; the design-doc drop and CLAUDE.md slimming when the rebuild formally closes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DJzmp2MUhwiNHR94cB91dx

EiffL and others added 22 commits August 17, 2026 16:05
Lands the sandbox layer and the one verb that exercises it.

Per spec §4, `lc run CMD` is the **probe** verb: byte-for-byte the
recipe environment — same lock, same `.venv` — under the same boundary
recipes will get. A probe has no output, so it needs neither manifests
(layer 2) nor the fabric (layer 4). That is what let layer 5 land ahead
of them, and it makes it the smallest honest consumer of the boundary
that layer 4 will plug recipes into.

No new dependencies.

## The abstraction

**The seam is a pure argv rewrite.** Every mechanism is
`Backend.wrap(policy, argv) -> argv'` — a function turning a command
into *a different command that sandboxes itself*.

```
                    lc run CMD
                         │
              engine/run.py  ── mode check · rename guard · uv hop
                         │
              sandbox.run(backend, policy, argv, prefix=uv…)
                         │
              backend = detect()          ← the ONLY sys.platform branch
              argv    = backend.wrap(…)   ← pure
              Popen(…)                    ← mechanism-blind
                         │
     ┌───────────────────┼───────────────────┐
 LandlockBackend    SeatbeltBackend      Unavailable
 [python, -m        [sandbox-exec,       argv unchanged
  lightcone.         -p <sbpl>,
  _sandbox_exec,     -DREAD_0=…, --,
  --policy <json>,   *argv]
  --, *argv]
```

Seatbelt is natively that shape; **Landlock is not** — it is a
self-restriction, which is precisely what `lightcone/_sandbox_exec.py`
exists to fix. Once both reduce to argv, nothing above the seam branches
on platform, and **every backend is testable on a host that cannot run
it**: `tests/test_sandbox_wrap.py` checks the Landlock wrap *and* the
generated macOS SBPL on Linux CI, with no privileges and nothing
spawned.

Three types stay deliberately distinct — `Policy` (what we will
enforce), `Capability` (what this host can do), `Attestation` (what was
actually enforced, spec §7's manifest field). Collapsing any two is how
a sandbox starts lying.

Prior art surveyed: OpenAI's codex CLI and this design's own earlier
implementation on `redesign_prototype`. They agree on the shape and
disagree on one mechanism; this takes codex's answer.

## Deviations from the spec, all recorded in CLAUDE.md

- **The Landlock policy travels as JSON on argv, not as an inherited
ruleset FD.** §7 specifies building the ruleset before fork and passing
it with `pass_fds`. The shim builds and applies it itself instead —
codex's shape. This makes `wrap()` pure and closes §11's own *blocking*
spike ("does the Landlock FD survive `uv run`'s spawn/exec chain? an FD
cannot be reopened") by making the question moot. It also survives into
a container later, where a host FD cannot.
- **Network is not controlled on either platform**, by decision. The
generated SBPL explicitly allows it and both platforms attest `network:
allowed` — rather than §7's matrix value of `denied`. Nothing pretends
to a control it does not apply.
- **`--require-sandbox` is a bare flag.** §7's `=declared-fs` form would
be the same flag today, since every existing mechanism scopes the
filesystem. It arrives with the first one that doesn't.
- **The denial's system-layer remedy is trimmed** to `uv add` + the
ASTRA input declaration. §7's `[tool.lightcone.image]` TOML arrives with
the container hatch; printing it now would be a copy-pasteable fix that
does nothing.
- **No manifest is written** — a probe has no output.
`Attestation.to_manifest()` exists and is tested; layer 4 is its first
consumer.
- **One tightening §7 does not contemplate:** `/tmp` leaves the write
scope when the project lives under it. Granting it unconditionally would
make such a project's tree writable and silently void the read-only-tree
guarantee. `TMPDIR` points into the private scope regardless, so
`tempfile` works either way.

## Three grants that turned out to be load-bearing

Each found by a real failure while building this, each now pinned by a
test:

| Grant | Without it |
|---|---|
| the realpath'd **ELF loader** in the exec set | *every* dynamically
linked binary fails EACCES — bash and python included |
| **read** on the uv-managed interpreter's install root | `Failed to
import encodings module` — the stdlib sits beside the binary, outside
the project and outside `/usr` |
| **read** on `/dev/urandom` | `failed to get random numbers` — CPython
seeds hash randomization before `main` |

## Verification

`170 passed`, ruff clean, mypy strict clean. The suite splits along the
seam:

- **Pure, every OS** — policy construction, both backends' argv, the
golden SBPL, the denial renderer.
- **Real subprocess, no kernel needed** — the shim's contract: its argv,
and the reserved exit code `97` that means "lc's own setup broke" and
never "the command ran unsandboxed".
- **Real enforcement, no fakes** — `test_sandbox_landlock.py`,
module-level `skipif` on `abi() == 0`. A mocked sandbox proves nothing,
so where Landlock is absent the file skips whole. Policies there are
hand-built so each test grants exactly what it is testing.
- **Pre-exec decisions** — discovery, the containerized refusal, the
rename guard, declared inputs, the uv hop.

Exercised by hand end to end against a real scaffolded project (kernel
7.1, Landlock ABI 9): runs in the project `.venv`; project reads
allowed; in-tree writes denied *with the file verifiably unchanged*;
declared `astra.yaml` inputs become readable; undeclared tools and files
denied with their respective remedies; `$HOME` redirected to a private
scope; no in-tree `__pycache__`; `--sandbox-debug`, `--no-sandbox`,
`--require-sandbox`, and the rename guard all behaving.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DqTza2GqRLZ4yY5ywAusQH

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Standing in $HOME, `lc run` answered "/home/francois is missing
pyproject.toml, uv.lock, .venv — not a project root. Run `lc init` to
converge one." — a list of files nobody expected to be there, and advice
to scaffold a project in your home directory.

The two ways the check fails are different mistakes. A directory with no
project markers is the wrong *place*: it now says so and says to cd to
the root of one, with no mention of `lc init`. A directory that holds a
pyproject.toml or an astra.yaml but lacks the built environment is the
right place, unconverged — a fresh clone is exactly this, since git
carries no .venv — and there `lc init` really is the answer, so the
missing pieces are still named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqTza2GqRLZ4yY5ywAusQH
…#174)

Two small changes to `lc run`, on top of layer 5.

## The project tree is writable now

The boundary is meant to be a no-install stand-in for running the
project
in a container. What it should catch is a command reaching **outside**
the
project for a tool, library, or data file the image would not have.
Forbidding writes *inside* the project served none of that — a container
bind-mounts the working tree read-write — so the project joins the write
set.

This cannot be narrowed to "everything but `.venv`": Landlock unions
rights over ancestors and has no way to subtract, so a writable project
is
necessarily a writable `.venv`. What keeps the environment honest is
`uv sync --exact` at the next convergence, not the sandbox.

Two things existed *only* to serve the read-only tree and go with it:

- the filter that dropped `/tmp` from the write scope when the project
  lived under it;
- `PYTHONPYCACHEPREFIX`, which redirected bytecode into a `$HOME`
deleted
  after every run, so every in-tree module recompiled every time. A
  writable tree caches in place — what a container does, and what the
  scaffolded `.gitignore` already expects.

The denial for a readable-but-not-writable path now means *a write
outside
the project* (a system path, or a declared input) and says so.

Verified by hand against a real project: writing `results/out.csv`
works;
reading an undeclared file under `$HOME`, running `git`, and writing
`/etc/...` are all still denied with their remedies.

### One test note worth reading

The enforcement suite's `outside` fixture moves from `tmp_path` to
`$HOME`. `/tmp` is in the write baseline, so anything `tmp_path` hands
us
is **granted** — those denial tests had been passing only because a
project under `/tmp` used to drop `/tmp` from the policy. Left alone
they
would have gone green while testing nothing.

## `lc run` outside a project

`/home/francois is missing pyproject.toml, uv.lock, .venv — not a
project
root. Run \`lc init\` to converge one.` listed files nobody expected to
be
there and advised scaffolding a project in your home directory.

The two failures are different mistakes and now get different advice. No
project markers at all is the wrong *place*: it says so and says to `cd`
to the root of one, with no mention of `lc init`. A `pyproject.toml` or
`astra.yaml` present but no built environment is the right place,
unconverged — a fresh clone is exactly this, since git carries no
`.venv` — and there `lc init` really is the answer.

---
🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DqTza2GqRLZ4yY5ywAusQH

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`lc materialize` executes an ASTRA analysis: it plans the graph, runs
each recipe under the layer-5 boundary, and commits every output
together with the manifest that describes it. Layer 2 folds in, because
the fabric is what needs identity and nothing else did.

Layer table: rows **2** and **4** → ✅.

## Results are versioned in the repository

On the DataLad model — **git carries the pointers and the history,
git-annex carries the bytes** — without taking the `datalad` package.
Every operation the layer needs is one git or git-annex command, and
`uv.lock` is `env_version`'s first term, so 43 transitive packages would
stale every output in every project on each boto3 bump.

Compatibility is a commitment rather than a coincidence: `lc init`
writes a `datalad.dataset.id`, so a project is a DataLad dataset **from
birth** (verified: `datalad status` needs no `--force` adoption step),
and the run record is the format `datalad rerun` reads. lc never imports
datalad, never requires it, and never parses `.datalad/`.

git stops being optional — a project without version control had nowhere
to put a result.

## Identity

```
env_version  = sha256(uv.lock ‖ .python-version ‖ canonical install settings)
code_version = sha256(recipe ‖ canonical decisions ‖ env_version)
```

Both length-framed, so a boundary shift between fields cannot produce
one digest from two inputs. The git commit is **recorded with every
output and hashed into neither** — a commit must not stale the world;
per-output code invalidation is available by declaring source files as
ASTRA inputs.

The lock scan **refuses** path/directory/editable dependencies (the lock
records where they were, not what was in them, so two syncs can install
different code while every hash agrees), **reports** registry packages
built from sdist, and is **advisory** on non-default dependency groups.

## Execution

| Module | Role |
|---|---|
| `plan.py` | spec × universes → tasks; sub-analyses flatten to
`<analysis>.<output>`, a second nesting level is refused rather than
ignored |
| `assets.py` | the output directory, the manifest, and `staleness()` —
one predicate, two callers |
| `worker.py` | one output: gates, reset, recipe, hash, manifest. Never
raises, never writes git. Also the `python -m` entry point the run
record names |
| `materialize.py` | the driver: dirty-tree refusal, Dask, and the
serialized save/restore loop |
| `dataset.py` | the one git + git-annex seam, routed through the
existing `project._run` |
| `identity.py` | the two hashes and the lock scan |

- **Dask owns the ordering.** Tasks are submitted with their upstream
futures as arguments; there is no ready-set loop and no hand-rolled
topological sort in the execution path.
- **The driver owns git alone.** Concurrent git operations on one
repository race on the index lock — this is `datalad-slurm`'s
schedule/finish split.
- **A run leaves the tree exactly as clean as it found it.** `ok` →
save, `failed`/`blocked`/never-reported → restore, in a `try/finally`.
That is what makes the dirty-tree refusal survivable rather than a trap.
- **`--check` differs from a worker by one input value**, not by logic:
`None` for anything already classified as would-run, meaning "this is
going to change".

`sandbox/policy.py` gains `recipe_policy()` beside `probe_policy()`,
sharing one baseline: a recipe may write its own output directory and
nothing else under `results/`. Reads are unchanged — a carve-out there
is something Landlock cannot express at all.

## Three things running it turned up that the design did not have

- **`.gitattributes` needs a leading `* annex.largefiles=nothing`.**
`git annex add` annexes whatever it is handed, so without it the
documented save turns `src/fit.py` into a read-only symlink into the
object store and the next edit fails with `EACCES`.
- **`git annex add -A` is not a thing.** `git add` needs `-A`; git-annex
rejects it. A test extracts the line from `data/README.md` and runs it,
because that spelling was wrong once.
- **`git check-ignore` must be asked with a trailing slash and
`--no-index`**, or the `results/*` an older scaffold wrote — which
ignores the directory's *contents*, not its name — goes unseen, and a
materialize reports success while committing nothing.

## Dependencies

`git-annex` (0 transitive; wheel-only, so it sets the CLI's install
floor — see the risk note in the plan) and `distributed`. **`datalad` is
a dev-group dependency only**: the run record's format fails *silently*
(its parser returns nothing on a mismatch and `rerun` then exits 0), so
the suite asserts through datalad's own parser **and** runs a real
`datalad rerun`, rather than golden-testing our own JSON.

## Verification

```
348 passed          ruff: clean          mypy: clean
```

End-to-end from a clean `uv pip install .` into a throwaway venv (not
the global tool env): `lc init` → commit → `--check` → `materialize` →
idempotent re-run → decision change cascades to dependents → old bytes
recovered from an old commit. Landlock enforced and recorded in
`hermeticity`, results annexed, manifests in plain git, `datalad status`
clean, one `[DATALAD RUNCMD]` commit per output.

Also pinned by tests, against a real annex and the real boundary: a
recipe cannot write into a sibling output (mutation-checked against
`Unavailable()`), a recipe *can* read an annexed input through its
symlink, a failing recipe commits nothing and leaves `git status` empty,
and `python -m lightcone.engine.worker` imports neither click nor rich.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01GaahDTd6djz9PCm1Rh6SP4

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The note said the residue was unguarded but not that it is unguardable
with the tools uv provides — so the next reader's first instinct is the
one that was already measured and rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaahDTd6djz9PCm1Rh6SP4
`lc init` pinned `lightcone-cli` into every project's own lock, which is
what forced **layer 3** — a launcher that discovers, mode-detects,
scrubs `UV_*`, converges, and delegates to the lc inside the project's
venv. Remove the pin and there is nothing to delegate to: the `lc` that
was invoked is the engine that runs. Layer 3 is now marked removed by
decision rather than pending.

This reverses spec §2's engine-in-lock rule. The spec is a reference the
rebuild drifts from deliberately; the reversal lands as a Recorded
decision in `CLAUDE.md` rather than waiting on a spec rewrite.

## What changes

A scaffolded `pyproject.toml` declares **no dependencies**. A project's
resolution is no longer constrained by the engine's own pins (no
astra-tools/click/rich/distributed/git-annex in any project lock), and
`requires-python` now comes from the interpreter that wrote
`.python-version` instead of from lightcone-cli's metadata — one source,
so the bound and the pin cannot disagree. The git-annex wheel floor
gates installing the tool and nothing else.

## What the pin bought, and where each guarantee went

- **The engine inside `env_version`** — given up deliberately. An lc
upgrade now moves nothing; no output ever reads `behind` over an engine
release. This completes the reversal that already took `env_version` out
of `definition_version`. The engine becomes attestation (`lc_version` in
every manifest), not identity.
- **Reruns reconstruct the exact engine** — re-provided in the record
itself. `cmd` pins the engine by version through an ephemeral `uv run
--no-project --with lightcone-cli==<v>`, keeping the **module spelling**
so the worker stays out of `lc --help` and off `$PATH`. A dev build's
version is unpublished, so its record is the bare module — which only
exists in a checkout whose own interpreter imports it.
- **Driver/worker skew "structurally impossible"** — moot while workers
are in-process threads; noted as a layer-7 connect-probe obligation.
- **Layer 6** — the generated image can no longer get its in-image
engine from the lock; noted that the Containerfile must install it as an
explicit layer and that the version becomes a tag input.

## The worker now owns the project environment

It has to. A rerun checks out the lock but never the `.venv`, and **`uv
run --no-sync` against a missing `.venv` silently creates an empty one**
(measured, uv 0.12.5) — recipes would have run in it while the manifest
recorded the lock's `env_version`. `worker.main()` therefore syncs
before anything executes, which also inherits the loud stale-lock
refusal the outer `uv run --locked` used to provide.
`current_project(synced=False)` is the affordance: the lock is
repository content, the `.venv` is not.

A new test reruns on a **fresh clone**, because the in-place rerun
passes with or without the sync.

`_engine_path()` is deleted — the suite faked the pin by injecting
`src/` onto `PYTHONPATH`, and there is no pin left to fake.
`test_the_run_record_is_what_datalad_reads` tightens its `.endswith` to
a full-string match so a prefix appearing or vanishing cannot slip past.

## Verification

- `uv run pytest` — 413 passed; `ruff` and `mypy --strict` clean.
- End-to-end by hand: scaffolded a project (no `lightcone-cli` in
`uv.lock`), materialized, `lc status`, then `datalad rerun` on a fresh
clone with no `.venv` — the worker created it and the output came back
byte-identical.
- The pinned shape run literally, from a wheel built off this branch: an
ephemeral engine env reproduced the output with a matching
`data_version`, and git-annex is present beside that env's interpreter
so `put_our_bin_first()` still resolves.

Incidentally fixes a latent bug in `eval.yml`: the eval project's pin
resolved a *released* engine from PyPI rather than the branch under
test. No workflow change needed.

## Follow-up, not in this PR

The `UV_*` ambient scrub (spec §4 step 3) is worth having and is
independent of the pin — it protects `env_version`'s install-settings
term, not the delegation that is gone.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01PGfhkvU3Ee2hhHcwAkeidg

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## What this is

The container hatch, landed whole: declaration, identity, `lc build`,
containerized `lc run` and `lc materialize`, manifest v2, and the `lc
status` headers. The design decisions were taken interactively and are
recorded in CLAUDE.md; the load-bearing ones:

- **Engine on host.** The container is the *recipe's* execution world,
never the engine's — driver, git, annex, dask and classification all
stay the invoked `lc`. This deviates from spec v6.1's
full-stack-in-image rule, recorded: v6.1's reason (the host-sync
deadlock) is solved by converging `.lightcone/venv` *inside* the image,
and the spec's own Perlmutter row ("recipe wrap, step 3 only") is this
exact shape. No delegation machinery, no git through a
`--userns=keep-id` bind mount, no engine version in the image tag.
- **The user never sees a Containerfile.** The whole surface is
Modal-shaped TOML — `base` / `apt-install` / `run-commands` / `env`, a
closed, fully-hashed key set (`pip_install` deliberately has no
equivalent; the lock owns Python). The render exists only in a transient
build context; `LABEL io.lightcone.image` keeps the image
self-describing.
- **The dataset is the image store; runtime stores are caches.** `lc
build` saves the image as a docker-archive at
`.datalad/environments/<tag>/image` (the `datalad containers-add`
layout), annexed and committed — exact bytes to every clone via `git
annex get`, no registry. Execution pins the id read from the archive
itself. A dropped archive never substitutes; a rebuild is a new commit
under a new id.
- **The mount table is the mechanism.** One `OCIBackend` (podman
recommended, docker accepted — data-parameterized spellings, one shape)
maps the same `Policy` to `:ro`/`:rw` mounts with `--network none`; the
attestation (`fs: declared`, `network: denied`) is derived
flag-for-flag. No in-container Landlock, no seccomp probe: the engine
container never gets the tree `:rw`, so mounts alone express the whole
policy. The archive format is chosen so apptainer/singularity become
thin layer-7 backends.
- **The record stays runtime-neutral through the worker.** Verified
against datalad-container's source: templates expand at *record* time
and `rerun` executes the literal string, so a bare-recipe `cmd` was
rejected. The containerized record adds `extra_inputs: [<archive>]`,
which stock `datalad rerun` fetches through the annex before executing.

## Notable traps found on the way

- git-annex routes dotfiles to git regardless of `annex.largefiles` —
without a per-add `annex.dotfiles=true` the archive lands as a full git
blob, silently. Pinned both ways in `test_dataset.py`.
- A standalone `chmod -R /opt` layer copy-on-writes the whole
interpreter into every archive, doubling it; the chmod rides inside the
layers that write `/opt`.
- `boundary.env_argv`'s host-resolved `env` binary need not exist in the
image (NixOS), so world-backends apply the overlay natively via `--env`
(`contains_prefix`, declared on every backend).
- The dirty check now runs *before* the converge in materialize: the
image commit would otherwise sweep the user's staged edits.

## Testing

- `tests/test_image.py`, `test_sandbox_oci.py` — pure (declaration,
document, render ordering, tag sensitivity, mounts, attestation, seam
composition).
- `tests/test_container.py` — the lifecycle against a stubbed
`project._run` (three `ensure_image` strictnesses, refusals
mutation-checked, build/save/commit argv).
- `tests/test_container_smoke.py` — real podman end to end, gated
(`skipif` + `LC_CONTAINER_TESTS_REQUIRED=1` on Linux CI + two guard
tests): build → id/label verification → probe (refusal → success → mount
denial → loopback/egress) → full materialize with manifest assertions →
**`datalad rerun` on a bytes-free clone fetching the archive through the
annex and reproducing the output**.
- 483 tests pass; ruff and mypy clean; `lc --help` still 65 ms.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_012oVCfc4eZdyKqT1zY95Xi2

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The first HPC venue, NERSC-focused, in two commits.

## The SLURM venue (7a)

`lc materialize` inside a SLURM allocation now spans every node it was
granted: `cluster_for_run()` detects `SLURM_JOB_ID`, binds the Dask
scheduler in the driver process to `SLURMD_NODENAME`, and launches one
worker per node with a single `srun` on the driver's own interpreter —
so driver and workers are the identical installation off the shared
filesystem, which is all a worker needs (`lightcone.engine` importable
at the driver's version; no git, no annex). Detected, never configured:
the allocation *is* the resource declaration, the same shape as mode
derivation. No new dependency.

```
salloc -N 4 -C cpu -q regular -t 2:00:00
lc materialize            # runs across all 4 nodes

# or, fire-and-forget from a login node:
sbatch -N 4 -C cpu -q regular -t 2:00:00 --wrap 'lc materialize'
```

A NERSC login node refuses `lc materialize` first thing, naming both
commands above; `--check`, `status` and `lc run` work anywhere. A dead
srun reports its own exit code; teardown retires workers so srun ends
silently. A dask-jobqueue submission venue, if ever wanted, is one more
branch in the same ladder plus the config table it genuinely needs.

The suite fakes the *host*, never the code: SLURM env vars plus a bash
stub for srun drive the real graph through the real detection → bind →
launch → teardown path in CI, with real worker processes. A new
`LocalCluster(processes=True)` test pins the cross-process pickling
contract the recorded decision asked layer 7 to re-verify.

## podman-hpc (7b)

A containerized project now runs on NERSC: podman-hpc lands as a
spelling inside the existing `OCIBackend` — no new shape — plus one
step, `podman-hpc migrate <id>`, placed outside the load branch (a fresh
build never loads, yet compute nodes only see migrated images) and run
driver-side once for all nodes. Detection prefers the wrapper over bare
podman (whose node-local store is exactly what compute nodes cannot
see); it is build-capable, making a login node the source of
matching-arch archives. A foreign-arch archive refuses before the load,
naming both arches and the fix. apptainer/singularity stay deferred;
CLAUDE.md records the design headroom they need (Runtime stays facts;
honest `network: allowed` for a runtime that cannot deny; the exit-125
note is a podman/docker-family fact).

## Verification

- 519 tests pass locally (`uv run pytest`), ruff and mypy clean.
- The container smoke suite gains podman-hpc as a parametrization that
fires only where the site tool exists — running it on a Perlmutter login
node is the spike vehicle; the checklist (srun `--overlap`, `--network
none` on compute, `--module` mounts vs `fs: declared`, migrate-by-id,
Landlock in the SLES LSM list, Lustre annex timings) is recorded in
CLAUDE.md.

Deviation from the approved plan, recorded: the plan called for two PRs;
repo convention is one PR per layer (#175, #180), so both parts land
here as separate commits.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_012oVCfc4eZdyKqT1zY95Xi2

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…a status fact (#182)

## Summary

The last layer of the rebuild table, rescoped during planning from the
spec's "`lc verify`, WRROC export" to two mechanisms with **no new
verbs**:

**The project is the crate.** `ro-crate-metadata.json` lives at the
project root as a derived artifact, converged by `lc materialize` the
way `uv.lock` is: rendered from repository state, string-compared,
committed alone only on a difference. Deposit is `git archive`/`datalad
export-archive` on a repository that is already an RO-Crate — nothing
copied, no bundle, no `lc export`. Maintenance is derived, never
configured: a declared `[project].license` turns it on (RO-Crate
requires one; materialize must not refuse science over a missing key,
and inventing CC-BY-4.0 — the pre-rebuild default — asserts terms over
someone's data). Absence is one report line.

**Integrity is a status fact, not a verb.** The hole layer 4 recorded —
a hand-edited-and-committed output reads `current` forever because a
skip returns the recorded digest (the agent-forged-file scenario) — is
closed by history, not hashing: every output is committed, so a hand
edit *requires* a commit, and `dataset.last_writer` names it. `lc
status` flags any output whose directory's last commit is not its own
run record, with sha, author, date and a `git show` remedy. O(history),
answers on a bytes-free clone, still exits 0.

## The crate itself

- `engine/crate.py`, the one new module — a **pure** builder over
`plan.build()` and the manifests; git enters only as an injected
`writer` callable.
- Targets **Provenance Run Crate 0.5 structurally, not vacuously** (the
pre-rebuild exporter passed REQUIRED only because the SHACL shapes are
vacuous over an empty target class): outputs sharing a manifest
`git_sha` are one run — the driver's single HEAD read makes run identity
free — giving `OrganizeAction` + workflow-level `CreateAction` per run,
`ControlAction` per execution, `HowToStep` per output id.
- The workflow-run `@context` makes
`containerImage`/`sha256`/`ContainerImage` real vocabulary (the old
exporter emitted them as undefined terms JSON-LD silently drops). The
committed image archive is one entity: `File` + `ContainerImage`,
config-blob id and payload together.
- The manifest stays canonical: in the crate as a `File`, `subjectOf`
its `Dataset`; `env_version`/`definition_version`/`hermeticity` are
deliberately not transliterated.
- Deterministic by construction: sorted iteration, `sort_keys`, no clock
(`datePublished` = newest manifest `finished_at`, overriding rocrate's
stamp-now default), no `mimetypes` (it reads `/etc/mime.types`, which
would make the render host-dependent).
- The crate's `Person` is the author of each output's **saving** commit
via `last_writer` — never the manifest's `git_sha`, which is the commit
the run *started* at.

## Validation

Official `rocrate-validator` against the Provenance profile, on a real
materialized project: **REQUIRED clean**; RECOMMENDED failures pinned as
a *set* (`test_crate_smoke.py::_FLOOR`) of checks the crate cannot
truthfully satisfy (the workflow's id is its in-crate path and the shape
wants `^http`; an annex-stored image has no registry; lc knows no
publisher/affiliation). A new failure is a regression; a disappearing
one is the floor to shrink. Gated with `LC_CRATE_TESTS_REQUIRED=1` on
every CI runner — the validator is a dev dep, so none may skip.

## Also in here

- The worker stamps `started_at`/`finished_at` (millisecond ISO 8601 —
crate consumers parse `endTime` with at most three fractional digits).
Attestation like `lc_version`; `classify` never reads them;
`SCHEMA_VERSION` stays 1.
- `materialize.run_subject` is now the one spelling of the run record's
subject, shared by `run_record` (composer) and the foreign-write check
(comparator) — byte-identical to the previous inline string, and the
datalad parser + real `datalad rerun` tests all pass.
- Recorded rejections, with reasons in CLAUDE.md: no `lc verify`
(O(bytes), blind on unfetched outputs, no lifecycle moment enforcing it;
`git annex fsck` owns object corruption), no `lc export`, and the honest
residues stated (a forged run-record subject defeats the history check;
a manifest lc itself mis-recorded is the one case only a rehash would
catch; the rerun entry point does not regenerate the crate).
- One trap the suite found live: forging a result **in place** dirties
its byte-identical sibling through the shared thin annex object — the
recorded thin hazard demonstrating itself. Tests unlink first; CLAUDE.md
records it.

## Test plan

- 556 passed (31 new), ruff and mypy clean, `LC_CRATE_TESTS_REQUIRED=1`
locally.
- New: `test_crate.py` (pure, 14 tests, no git — determinism, run
grouping, the Provenance edges, ContainerImage, Person, license
spellings), `test_crate_smoke.py` (real materialize → real validator,
two guard tests), `last_writer` against real repos incl. the
enclosing-repository case, the foreign-write fact end-to-end incl. a
bytes-free clone, crate convergence (created once / idempotent re-run
commits nothing / license added later / license removed keeps the file /
spec-dropped output excluded and named).
- Manual e2e: `lc init` → license → materialize (crate lands in its own
trailing commit) → re-materialize (no commit) → validator (0 REQUIRED) →
forge + commit → `lc status` names the foreign commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Q9ikHqeMA9tTX1Jrx5DdNL

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The eval workflow and its prompt were written for the pre-rebuild CLI —
the Snakemake shim, `lc run <output_id> --universe`,
`ok`/`stale`/`missing` states, the Containerfile + `requirements.txt`
container model. With layer 8 landed, this brings the eval back in line
with what `lc` actually is.

## `evals/prompt.md` (rewritten)

- `lc materialize` is the build verb; `lc run <command>` is the probe.
States are `current`/`behind`/`stale`, `lc status` always exits 0, and
`lc materialize --check` is the gate.
- Recipes import from the project's own locked environment — packages
arrive via `uv add` in the project root (what the denial messages
recommend); `uv pip` reaches nothing a recipe sees.
- A run refuses a dirty tree, so the agent is told to commit its edits
with plain `git add`/`git commit` (the annex filter is transparent —
nobody runs a git-annex command).
- `results/` is the engine's to write; a hand-placed file is now
detected as a foreign write and remade — the old "fails verification"
warning got teeth.
- The seed spec is stated to be **deliberately incomplete**: the recipe
commands carry no placeholders and the dependency contracts are partial,
so completing the spec (`{output}`, `{inputs.<id>}`, `{decisions.<id>}`,
and the matching `inputs:`/`decisions:` lists) is part of the task. This
makes the existing seed's shape an intentional test of spec literacy
rather than a leftover.
- The recipe template grammar section is kept — verified it still
matches astra's own grammar (`astra guide`, astra-tools 0.2.16).

## `.github/workflows/eval.yml`

- **Check step**: the old jq expression (`.universes[].outputs[]`,
`"ok"`, `"alias"`) matches nothing any verb emits now. Replaced with the
designed gate — `lc materialize --check --json | jq -e '.up_to_date and
((.current | length) > 0)'` — which exits nonzero while anything is
planned or failed, plus the non-emptiness guard the old check had.
`behind` deliberately does not fail the gate.
- **Setup step**: no scientific deps are pre-seeded — the project starts
empty and declaring what the recipes import (`uv add`, spelled out in
the prompt) is part of what the eval measures; the repo-venv `uv pip
install` is dropped (it never reached recipes). Setup commits the seed —
`lc init` never commits, and the first `lc materialize` would otherwise
be refused on a dirty tree before the agent did anything.
- **Trigger**: the dormant PR plumbing (comment step, concurrency group)
gets its trigger back — `pull_request: types: [labeled]` gated on a
`run-eval` label, alongside `workflow_dispatch`.
- **Artifacts**: `.snakemake/**` exclusion → `.lightcone/**`; keeping
`.git` via `include-hidden-files` is now a feature — the run records and
annexed results are the provenance.
- **Install**: lc and astra are installed as uv tools — `uv tool
install` of the checkout (the commit under test, stamped into the
version by hatch-vcs) links `lc` and the four git-annex executables
through the mirrored entry points, exercising the exact mechanism a user
install relies on; astra-tools is its own tool install, pinned to the
branch dependency. No venv is activated, so the runner needs no system
git-annex and `VIRTUAL_ENV` is never set.

## Seed + trace prompt

- `evals/tasks/snae/astra.yaml` drops `container: Containerfile` — the
directive is ignored by recorded decision, and no Containerfile exists
to point at.
- `trace-analysis-prompt.md` names the sandbox instead of the container
environment.

## Verified locally

- The seed validates against astra-tools 0.2.16 after the edit.
- The full setup + check plumbing runs end-to-end against today's `lc`:
`lc init` → overlay → `astra universe generate -n baseline` → `uv add` →
seed commit → `lc status` (three outputs, direct mode, landlock) → the
`--check` gate correctly failing with all three outputs planned on the
unbuilt project.

The agent step itself (Claude Code + astra plugin) is unchanged and
needs a real dispatch run to shake out — suggest a `workflow_dispatch`
on this branch once merged, or label this PR `run-eval`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DJzmp2MUhwiNHR94cB91dx

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
First pass of the post-rebuild documentation rewrite — the entry points:
`README.md`, `docs/index.md`,
`docs/user/{index,install,getting-started}.md`. Style deliberately
follows the previous versions (tabs, admonitions, numbered steps, the
"what just happened" recap); the content is brought to the rebuilt CLI.

## What changed

- **README**: quick start shows the real loop (`init` → spec + scripts →
`uv add` → commit → `materialize`); the Capabilities section replaces
`lc verify` / `lc export wrroc` / Snakemake with what exists —
multiverse materialization, provenance by construction (manifests + run
records, annex-carried bytes), locked sandboxed execution, derived
containerized mode + SLURM, and the license-derived RO-Crate view.
- **docs/index.md**: light touch — drops "skills" from the alpha notice,
updates the lightcone-cli card's description.
- **user/index.md**: quick start updated (commit before materialize,
with a sentence on why the commit isn't ceremony); covered-pages list
wording refreshed.
- **user/install.md**: rewritten. Prerequisites are uv + git; git-annex
ships as a wheel with the install (with the platform floor stated
honestly, and WSL for Windows); a new "tell git who you are" step
(materialize checks identity up front); container runtime section
reflects detection (podman-hpc → podman → docker) and the absence of any
config file — `~/.lightcone/config.yaml` no longer exists. Upgrade
section notes an engine upgrade never invalidates results.
- **user/getting-started.md**: new worked example — a line fit over a
synthetic dataset with one methodological decision (keep vs clip
outliers), swept across `baseline` and `robust` universes, ending with
the license declaration and the auto-committed `ro-crate-metadata.json`.
It walks through the scaffold, declared inputs and the annex, the
dependency contract and placeholders, `uv add`, the clean-tree rule,
real `lc materialize` / `lc status` output, incremental rebuilds, and
the fresh-clone story.

## Verification

- Every command block in the walkthrough was executed against the
current `lc` before landing; the quoted console output is captured from
that run (slope numbers included).
- The fresh-clone claim was tested: a bare clone refuses, `lc init` +
`lc materialize` reports up to date with no annex content fetched — the
guide says exactly that.
- `zensical build` passes; the two remaining link warnings are in frozen
pages (`api/snakefile.md`, `hpc/targets.md`) that later passes replace.

Next passes: (2) core concepts + cluster/troubleshooting/glossary, (3)
CLI + JSON reference, (4) developer corner, (5) nav + workflow refresh.

Two cosmetic engine warts observed while verifying, deliberately not
touched here: Rich markup swallows the literal `[project]` in the
license hint (renders as "no .license in pyproject.toml"), and a stray
Dask teardown `TimeoutError` line can appear on stderr after a run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DJzmp2MUhwiNHR94cB91dx

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…186)

Second pass of the documentation rewrite — the rest of the user guide: a
new **Core Concepts** page plus rewrites of **Running on a Cluster**,
**Troubleshooting**, and the **Glossary**. Style follows the frozen
predecessors.

## What's in it

- **`docs/user/concepts.md`** (new, added to nav + user index): the
mental model in one page — a project is spec + lock; an output's three
recorded facts and why they age differently; the
`current`/`behind`/`stale` table with the contradiction-vs-circumstance
line; the commit discipline (clean tree in, clean tree out, git log as
build log, foreign-write detection); direct vs containerized mode; `lc
status` vs `lc materialize --check`; the license ⇒ crate rule.
- **`docs/user/cluster.md`**: the venue model replaces the old
Snakemake/Dask-Gateway/site-registry content. The allocation *is* the
resource declaration (no `--jobs`, no venue config); login-node prep vs
in-allocation materialize with interactive/batch tabs; the login guard's
refusal quoted verbatim; containers on HPC (podman-hpc detection +
migrate, build-on-login-node with the arch gate, the multi-node
shared-store refusal); an honest "early days" admonition inviting site
reports.
- **`docs/user/troubleshooting.md`**: organized around the CLI's actual
messages, each captured live against a real project before being quoted
— dirty tree (and its commit/discard path split, verified against the
source), not-a-project, the sandbox trailer with the three denial
remedies (`uv add`, declare the input, write to `{output}`/mkdtemp),
behind-after-`uv add` as a non-error, stale-after-spec-edit (including
the deliberate "code edits don't cascade" position), unfetched annex
content, the NERSC login guard, git identity, and the three
containerized refusals.
- **`docs/user/glossary.md`**: ASTRA-side entries kept (ASTRA, spec,
recipe, decision, universe, sub-analysis, insights/findings);
execution-layer half replaced — materialize, manifest,
`definition_version` / `env_version` / `data_version` /
`input_versions`, the three states, the two modes, image + runtime,
sandbox, git-annex, run record, RO-Crate. Snakemake, `code_version`,
`ok`/`missing`/`alias`, and the `lc verify` failure kinds are gone with
the architecture that defined them.

## Verification

- Every quoted refusal/denial was produced by actually triggering it:
dirty tree, not-a-project, `NERSC_HOST` login guard, a sandboxed read
denial, `ModuleNotFoundError` under the sandbox, and the
`behind`-everywhere state after a real `uv add` (plus the `--check`
verdict line with its `--refresh` hint).
- The dirty-refusal "discard these (lc writes results/)" branch was
verified against the source.
- `zensical build` passes; the only warnings remain the two frozen pages
(`api/snakefile.md`, `hpc/targets.md`) that pass 4 replaces.

Next: (3) CLI + JSON reference, (4) developer corner, (5) nav + workflow
refresh.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DJzmp2MUhwiNHR94cB91dx

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The rebuild is functionally complete through layer 8; this PR closes the
recorded residues that do not need Perlmutter access, one commit per
decision. Each choice below was settled explicitly before
implementation.

## What lands

- **Ambient `UV_*` scrub** (closes #179): `project.child_env` drops
every
  `UV_*` variable outside a closed plumbing allowlist (cache dir,
  timeouts, TLS, air-gap mode, index credentials, uv's recursion guard),
so an exported `UV_NO_BINARY` or `UV_PYTHON` can no longer steer a sync
  while `env_version` reports nothing moved. The run verbs warn with the
  names of any non-empty variable dropped, from the same predicate.
- **Machine-level uv.toml advisory** (#176, option 2): the scan checks
  uv's two documented user/system config paths for audited
  install-settings keys — key presence, not file presence, because list
  settings concatenate across levels — and reports a hit beside
  `sdist_built`. Never hashed.
- **Network uncontrolled on every mechanism**: the OCI wrap drops
  `--network none`, its one restriction, so all three mechanisms attest
  `network: allowed` symmetrically and no consumer can read a promise
  into "containerized". `denied` stays in the type for a mechanism that
  genuinely emits a denial flag. Retires the Perlmutter spike item about
  `--network none` hanging on compute nodes.
- **Recipe write scope narrowed to its own output directory** (reverses
the recorded "not narrowed" decision): closes the cross-write residue —
  a concurrent task landing bytes in a sibling's directory before the
  sibling hashed produced a manifest that was self-consistent and wrong,
  undetectable by any checksum, so prevention is the only fix. A probe
keeps `results/` whole (no output id) — the one probe/recipe asymmetry,
  and it excludes exactly the accident being prevented. The new
  enforcement test is mutation-checked through `Unavailable()`.
- **`uv_version` manifest attestation**: the engine-closure decision's
  concrete loss, recorded — probed once per run by the driver and handed
  down (the HEAD discipline), beside `lc_version`. Attestation, never
  identity.
- **Per-file sha256 in the crate, from annex keys**: every output file
is
  a `File` under its dataset's `hasPart` with `sha256`/`contentSize`
  parsed from its SHA256E key — verifiable by `sha256sum` after a
`git archive` deposit, available bytes-free because keys are repository
  state (`--include=*` is load-bearing). Non-SHA-256 keys yield size and
  no digest; git-carried files hash their bytes. Also fixes a latent
  honesty bug: out-of-tree inputs published lc's *framed* digest under
  the workflow-run `sha256` term; they now publish none. Validator floor
  unchanged at five.
- **`crate:` line in `lc status`**: the rerun-lag residue made visible
by
  comparing the document's own `datePublished` against the newest
  manifest `finished_at` the walk already read — no git, no rocrate
  import on status's path.
- **End-of-run edit warning**: an edit made while the graph ran (the
  `git_dirty` window) is now named in the report; still no manifest
  field, by decision.
- **`[project.scripts]` absence pinned**: the recorded review item — a
  metadata test asserts the worker and the shim never become console
  scripts.

## Re-examined and deliberately left (recorded in CLAUDE.md)

- The forged-run-record-subject residue: the threat model excludes
  adversaries, and a copied subject is already deliberate.
- The validator RECOMMENDED floor stays pinned at five — no
  publication-metadata config surface.

## Housekeeping

Closed stale pre-rebuild issues #166, #128, #153 with pointers;
commented triage on #152 and the advisory landing on #176.

## Verification

Full suite: 582 passed (sandbox enforcement real on Linux; crate smoke
with the official rocrate-validator, REQUIRED clean and the floor
unchanged). ruff and mypy clean. Cross-write denial mutation-checked
through `Unavailable()` (rc 0, bytes replaced, unsandboxed).

Closes #179.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_016CRERrB5zWdVXD6uPv2BRa

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Third pass — `docs/cli/` rebuilt around the five verbs that exist, in
the frozen pages' structure (Synopsis / semantics / options table / JSON
/ examples).

## What's in it

- **Overview** (`cli/index.md`): the three global behaviors (current
directory is the project — no walk-up, no global config; nothing waits
on a human; refusals carry their remedy) and the exit-code contract,
including `lc run`'s proxying and the `--check` gate semantics.
- **`lc init`**: the four convergence classes (created / repaired /
**blocked** / warned — with blocked counting against convergence and
warnings never affecting exit), the real scaffold tree, what it
deliberately doesn't create (`src/`, dependencies), and the deliberately
bare flag surface.
- **`lc materialize`** (new page): target grammar (`fit` vs
`robust/fit`, matching-nothing is an error), the remake rules (stale vs
behind vs current under `--refresh`), the run's contract (clean in/clean
out, fetches declared inputs, commits as it goes, reports every
independent failure, converges the crate), check mode's dirty-tree
exemption, the absent-flags rationale (`--jobs`, `--force`), and the
JSON report with `ok`/`up_to_date` called out as the branching keys.
- **`lc status`**: the header facts, the commit column as the verb's
reason to exist, the three states, the report-vs-gate split, and the
JSON shape including `foreign_write`.
- **`lc run`**: the probe — same environment and sandbox as a recipe,
environment converge first, exit-code proxying (`128+N`), denial notes,
works on any uv project, no sandbox opt-out.
- **`lc build`**: the system-layer-only image, the closed declaration
key set, the committed `docker-archive` as the store (id-pinned, no
registry), the arch refusal, the clean-tree requirement, and the JSON
result (`action: built | present`).
- `verify.md` and `export.md` deleted with their verbs; nav updated
(materialize added, order follows the workflow).

## Verification

- All `--help` texts, the init/materialize/status JSON shapes, and the
console blocks were captured from the current CLI against a real
project.
- The build JSON keys and `action` values were read from
`commands.py`/`container.py`, and the tag format corrected against
`image.py` (`lc-env-<16 hex>` — my first draft had the pre-rebuild
`lc-<project>-<hash>` spelling).
- `zensical build`: no new warnings; the two remaining are in frozen
pages PR 4 replaces.

Next: (4) developer corner, (5) nav + workflow refresh + final
reconciliation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DJzmp2MUhwiNHR94cB91dx

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Fourth pass — the maintainer half of the site: `maintainer.md`,
`architecture.md`, an **Engine Internals** section replacing the old
Python API pages, and the contributing trio.

## What's in it

- **`architecture.md`**: the CLI/engine/ASTRA split; the run pipeline as
one annotated diagram (guards → converge → plan → fetch → venue →
workers → driver loop → crate); identity (two hashes, three states, the
one-classify-two-callers discipline); storage (DataLad model, run
records, thin commits); the exec boundary (policy → argv rewrite →
attestation); the container hatch; venues; the crate. This is
CLAUDE.md's invariants translated for humans who didn't live the
rebuild.
- **`maintainer.md`**: the welcome page in its old shape — what the
corner covers, the three-command dev loop, and the house rules (no dead
code/foreshadowing, no escape hatches, literal behavior, one
implementation per rule, honest reporting).
- **Engine Internals** (`docs/api/`, 12 pages): an overview map plus one
hand-written tour per module — project, dataset, identity, plan, assets,
worker, materialize, venue, sandbox, image & container, crate. Each
page: responsibility, key-symbols table, "what must stay true", and
where its tests live. Hand-written rather than autodoc, deliberately:
the engine is not a public API (projects don't depend on lightcone-cli),
so contract beats signatures. The 17 old pages documented modules that
no longer exist; all deleted.
- **Contributing**: `setup.md` (uv-only loop, the gated-suite table,
docs/wheel builds, pre-PR checklist), `testing.md` (the
`tools`/`real_tools` seam, a where-does-this-question-belong table, the
enforcement suite's four properties, the mutation-check rule and its two
traps), and a new `extending.md` (the change-map table plus the
everywhere-rules and conventions).
- **Deleted**: `docs/hpc/` (site-registry model),
`contributing/backends.md`, `contributing/hpc-sites.md`. Nav updated
(Python API → Engine Internals; Extending added).

## Verification

- `zensical build` now reports **zero issues** — the two warnings that
survived passes 1–3 lived in pages this one deletes.
- Module pages were anchored on the modules' own docstrings and
CLAUDE.md's invariants; symbol names spot-checked against the source
(e.g. `runtime_for_run`'s `(Runtime, "built"|"present")` return,
`datalad_run_subject`, `_SITES`).
- No remaining references to any deleted page anywhere in `docs/`.

Next and last: (5) final polish — `check-docs.yml` refresh and CLAUDE.md
reconciliation (the docs-frozen rules, the design-doc pointer).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DJzmp2MUhwiNHR94cB91dx

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Final pass of the documentation rewrite — no doc pages this time, just
bringing the two things that *govern* the docs in line with their new
reality.

## CLAUDE.md

- **The freeze rule is replaced** by its successor: docs are live (PRs
#185#188), two tracks, and a change lands with its docs — a verb change
updates its `docs/cli/` page, an engine change its `docs/api/` module
page, user-visible behavior the user guide. The docs inherit the
codebase's own discipline: document only what exists, quote refusals
from real runs, verify command blocks by executing them.
- **The design-doc pointer** now records the decision taken during this
rewrite: the design records stay in the sibling checkout and are
**dropped when the rebuild completes** — never imported into `docs/`
(reverses the paragraph's earlier plan).
- **The workflow inventory** catches up: eval runs on dispatch or the
`run-eval` label (re-trigger by re-adding it), `check-docs.yml` reviews
merged PRs, `docs-deploy.yml` tracks releases; the docs build command is
stated.
- The extending table's "add the next layer" row loses "Docs are
deliberately deferred".

Deliberately **not** done: slimming CLAUDE.md's invariant sections now
that the developer corner overlaps them. CLAUDE.md is still the working
memory for the remaining rebuild (Perlmutter spike, deferred layers),
and gutting it mid-rebuild trades a known-good reference for dedup.
Worth revisiting when the rebuild closes and the design docs drop.

## check-docs.yml

The post-merge doc-drift check was written before the freeze and knew
nothing about the structure. Its prompt now carries:
- a **map from change kind to doc home** (verb → `docs/cli/`, module →
`docs/api/` + architecture, user-visible → user guide + README,
workflow/conventions → contributing);
- the two failure modes that matter most for these docs: **quoted
console output drifting** from what the CLI prints, and **documenting
anything the code no longer delivers** (the no-foreshadowing rule
applied to docs);
- an updated skip list (CLAUDE.md and `evals/` are maintained
separately) replacing the stale `skills/` rule.

## Verification

- `zensical build`: zero issues.
- check-docs.yml validates as YAML.
- Grep for `frozen`/stale workflow descriptions in CLAUDE.md comes back
clean.

This closes the 5-PR docs rewrite. The site deploys with the next
release (or a manual `docs-deploy` dispatch).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DJzmp2MUhwiNHR94cB91dx

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ywhere

PR #184 dropped the OCI wrap's --network none so all three mechanisms
attest network: allowed symmetrically; the container internals page
still said recipes exec network-denied. Found by the drift check the
new check-docs prompt describes — a quoted flag the code no longer
delivers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJzmp2MUhwiNHR94cB91dx
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

✅ Eval

Metric Value
Outputs check success
Agent run success
Turns 46
Tool calls 44
Cost $1.96
Agent wall time 6m32s
Model claude-sonnet-5
lc status
  mode:    direct
  sandbox: landlock (fs: declared, network: allowed)
  crate:   up to date with the outputs

  · current  baseline/best_fit        ce007de
  · current  baseline/hubble_diagram  ce007de
  · current  baseline/residuals       ce007de

3 current
Confusion & pain points (Claude analysis)

Confusion & pain points

  • No documented schema for the supplied data file's columns. The agent had to reverse-engineer SCPUnion2.1_mu_vs_z.txt's five columns (name, z, mu, mu_err, low-mass-host probability) via awk statistics and three failed WebSearch/WebFetch attempts (one blocked by a 403 on supernova.lbl.gov, one returning an unhelpful fetch) before falling back to inferring the systematic-error model from the header comments itself. The eval fixture should ship a column-description comment or README alongside the raw data file so agents aren't forced into web research to interpret domain data.
  • Discovering the recipe/output grammar required drilling into astra-tools CLI help rather than the spec being self-evident. The agent ran uvx astra-tools spec output and spec recipe after already reading astra.yaml and the guide, suggesting the initial astra:astra skill invocation and guide output didn't surface recipe-writing conventions (e.g., no fixed output filename requirement) clearly enough on the first pass.
  • Tool-search indirection added overhead for WebSearch/WebFetch. Both had to be located via ToolSearch before use even though they were the obvious next step after a failed inline search path — minor friction but repeated twice in the trace.
  • Otherwise the run was clean: uv add, script authoring, local dry-runs in /tmp, lc materialize, license declaration, and RO-Crate publication all succeeded on the first attempt with no failed commands in the lc/astra toolchain itself.

Full trace: agent-trace artifact on this run.

…tion

Brings PR #127 (mike-based docs versioning) across the rebuild:
- zensical.toml keeps [project.extra.version] provider = "mike"
- pyproject docs group keeps the squidfunk mike fork dependency
- docs-deploy.yml is the mike-based workflow (deploy <version> + latest
  to gh-pages on release; manual redeploy via workflow_dispatch)
- justfile stays deleted (removed intentionally in the rebuild); its
  mike recipes live in the workflow, CLAUDE.md now points at the raw
  mike commands for local use

Verified: uv sync --group docs installs mike 2.2.0+zensical-0.1.0 and
zensical build succeeds with the version provider enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PC18whir9otmXpQbaEgir
Follow-up to the PR #190 eval trace, which surfaced three
harness-attributable frictions (the other findings were agent-side
noise).

## The `lc run` convention question, settled

Checked the ecosystem before touching anything: `uv run "python -V"`
fails with ``Failed to spawn: `python -V` `` (verified live), `docker
run img "echo hi"` fails with `exec: "echo hi": executable file not
found` — argv with no shell parsing is the convention across `docker
run`, `uv run`, `kubectl exec`, `pipx run`, `poetry run`, `srun`. **`lc
run` already matches it**, so the behavior stands unchanged and the
syntax gets one line in the two places it was missing:

- `evals/prompt.md`: "Argv style, like `docker run` or `uv run`: `lc run
python scripts/fit.py --output /tmp/x`, never a single quoted shell
string; for shell syntax use `lc run bash -c '...'`."
- `docs/cli/run.md`: same sentence, in the synopsis.

In the trace, the missing line cost one round trip through a genuinely
cryptic failure (`/usr/bin/env: '<whole string>': No such file or
directory` + the sandbox trailer insinuating a denial).

## Harness environment

- **`UV_PYTHON` noise**: `setup-uv`'s `python-version` input exports
`UV_PYTHON`, and the #184 install-settings scrub then (correctly) warned
`ignored ambient UV_PYTHON — …` on *every* `lc run`/`lc materialize` — a
dozen-plus unactionable lines the agent ended up `grep -v`'ing to read
its own output. The input is dropped; determinism is kept by pinning
`--python 3.12` on both tool installs. Same category as the
`VIRTUAL_ENV` cleanup earlier.
- **Truthful dev versions**: the shallow checkout had no tags, so
hatch-vcs stamped `0.1.dev1+g<sha>` into the installed engine — and into
every manifest's `lc_version`. `fetch-depth: 0` fixes it.

Validation: label this PR `run-eval` to see the updated prompt and clean
environment in action.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DJzmp2MUhwiNHR94cB91dx

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@EiffL EiffL changed the title The clean rebuild: the locked environment is the execution environment Lightcone CLI rebuild with uv first, container option, datalad compatibility Aug 21, 2026
@EiffL
EiffL merged commit 4fa2d1b into main Aug 21, 2026
8 of 9 checks passed
@EiffL
EiffL deleted the clean_rebuild branch August 21, 2026 10:45
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