Repository navigation
Add record_external_artifact: a supported API for artifacts made outside a sandbox - #49
Deep-Sixed wants to merge 4 commits into
Conversation
Applications that produce evidence outside a Stele sandbox (AgentSync's
promoted SKILL.md files) had to drive LedgerStore.create_pending/seal
directly, so the ledger redesign broke them. stele.ledger.external now
provides one supported call:
record_external_artifact(ledger, run_id=, artifact_dir=, artifact_paths=,
producer=, producer_config=, input_snapshot=None)
- Archives, verifies and seals the bundle exactly as for a sandbox run.
- Marks the record backend="external" (EXTERNAL_BACKEND), which no sandbox
backend uses; ledger_transaction refuses a run claiming it.
- Refuses a producer asserting image_digest/module_sha256 (measured
digests), and an input Snapshot the archive does not hold.
- run_id is the idempotency key: the same call again returns the sealed
record or seals one left pending; anything else raises DuplicateRunError.
- Replay reports external records UNREPLAYABLE with an explicit reason.
Documented in docs/ledger.md, docs/replay.md and the README.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnaMtsgmbeB8QWCrqdpSWc
…-7inb1h # Conflicts: # docs/ledger.md
- An external producer could take a sandbox parser's name (e.g. chatgpt-export-split) and have its bytes read as that parser's output: SealedBundle dropped the record's backend, so the extraction normalizers and ChatGPTExportAdapter could not tell. SealedBundle now carries backend (and an external property), and both consumers refuse external records. - A retry after the caller removed its artifact files crashed with FileNotFoundError instead of returning the sealed record, and hid the DuplicateRunError for a run_id owned by a sandbox record. Checks that need no files now run first; with the files gone, the archive vouches for the bundle and only the artifact paths are compared. - The retry check compared only the input digest. It now compares the Source and the snapshot kind, and checks the retry's Snapshot against the archived one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Awkyx4PU86Y3A6qqbzP22
A retry for a recorded run_id skipped the content comparison entirely as soon as any artifact file was missing (build_manifest raised FileNotFoundError). So a retry that had removed one file and changed another was accepted as the recorded run. Missing files are still vouched for by the archive; every file still present must now hash as recorded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013p3FU6gUJ1MGinnmEgvHsm
|
I reviewed this and fixed one bug, in b2127b7. A retry could accept changed content. When a retry for a recorded
A new test covers both the partial retry that should be accepted and the changed file that should be refused. It fails without the fix. What I checked and found sound:
One point to consider, not blocking:
Generated by Claude Code Generated by Claude Code |
Why
Stele's only supported write path records sandboxed parser runs (
ledger_transaction/record_run). AgentSync also needs to ledger evidence it produces itself: validatedSKILL.mdfiles it promotes. With no supported call for that, it droveLedgerStore.create_pending/commitdirectly, and the ledger redesign (#12) broke it (Deep-Sixed/AgentSync#1). This PR adds one small, documented function for that case, so callers outside the sandbox depend on a stable call and not on the store's internals.What
New
stele/ledger/external.py:Same integrity as a sandbox run. The bundle is hashed, archived and verified through
create_pending→seal, sosealedmeans the same thing.The record says where it came from. It gets
backend = "external"(EXTERNAL_BACKEND, defined inledger/models.py). No sandbox backend uses that name (bubblewrap,oci-*,wasmtime), andledger_transactionnow refuses a sandbox result that claims it.Only measured digests are trusted. A producer carrying
image_digestormodule_sha256is refused withProvenanceError, because those digests are measured by a sandbox run, never asserted by a caller. An optionalinput_snapshotmust already be in the ledger's archive;create_pendingalready enforces this.Retries are safe.
run_idworks as the caller's idempotency key:pending→ sealed now;run_id(different content, producer or config; a record made by a sandbox; afailedrecord) →DuplicateRunError.This keeps "one record per run". It does not reuse another run's record.
If sealing fails, the record is marked
failedand the error propagates, as inledger_transaction.Replay (
stele/replay/engine.py): external records areUNREPLAYABLEwith the reason "recorded outside a Stele sandbox … there is no parser run to repeat". Before this they got the less useful "not in the catalog". Nothing is invalidated.There is no schema change: the marker is the existing
backendcolumn, andrecord.createdevents already carry it, soverify_ledgercovers these records unchanged.Docs
docs/ledger.md: new section "Recording an artifact made outside a sandbox", plus entries for the newbackendvalue, the new module and the new test file.docs/replay.md: newUNREPLAYABLEreason.README.md: a pointer to the new section.Tests
tests/test_external_artifacts.py(22 tests):run_idvalidation;external;pendingrecord;failedrecords;failed;UNREPLAYABLE;verify_ledgerpasses.Full suite locally:
812 passed, 120 skipped, 7 deselected; the skips and deselections are the same as onmain.Follow-up
Once this merges, AgentSync's port (Deep-Sixed/AgentSync#10) can call
record_external_artifactinstead ofcreate_pending/seal, and move its Stele pin to the merge commit. That PR currently pins10bbf3f, which predates this change, so it keeps working in the meantime.🤖 Generated with Claude Code
https://claude.ai/code/session_01VnaMtsgmbeB8QWCrqdpSWc
Generated by Claude Code