Skip to content

Add record_external_artifact: a supported API for artifacts made outside a sandbox - #49

Open
Deep-Sixed wants to merge 4 commits into
mainfrom
claude/friendly-curie-7inb1h
Open

Deep-Sixed wants to merge 4 commits into
mainfrom
claude/friendly-curie-7inb1h

Conversation

@Deep-Sixed

Copy link
Copy Markdown
Owner

Why

Stele's only supported write path records sandboxed parser runs (ledger_transaction / record_run). AgentSync also needs to ledger evidence it produces itself: validated SKILL.md files it promotes. With no supported call for that, it drove LedgerStore.create_pending / commit directly, 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:

record_external_artifact(
    ledger, *, run_id, artifact_dir, artifact_paths,
    producer: ParserIdentity, producer_config,
    input_snapshot=None, source=None,
) -> ArtifactRecord   # SEALED
  • Same integrity as a sandbox run. The bundle is hashed, archived and verified through create_pending → seal, so sealed means the same thing.

  • The record says where it came from. It gets backend = "external" (EXTERNAL_BACKEND, defined in ledger/models.py). No sandbox backend uses that name (bubblewrap, oci-*, wasmtime), and ledger_transaction now refuses a sandbox result that claims it.

  • Only measured digests are trusted. A producer carrying image_digest or module_sha256 is refused with ProvenanceError, because those digests are measured by a sandbox run, never asserted by a caller. An optional input_snapshot must already be in the ledger's archive; create_pending already enforces this.

  • Retries are safe. run_id works as the caller's idempotency key:

    • the same bundle, producer, config and input again → returns the sealed record;
    • a record an earlier call left pending → sealed now;
    • anything else for that run_id (different content, producer or config; a record made by a sandbox; a failed record) → DuplicateRunError.

    This keeps "one record per run". It does not reuse another run's record.

  • If sealing fails, the record is marked failed and the error propagates, as in ledger_transaction.

  • Replay (stele/replay/engine.py): external records are UNREPLAYABLE with 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 backend column, and record.created events already carry it, so verify_ledger covers these records unchanged.

Docs

  • docs/ledger.md: new section "Recording an artifact made outside a sandbox", plus entries for the new backend value, the new module and the new test file.
  • docs/replay.md: new UNREPLAYABLE reason.
  • README.md: a pointer to the new section.

Tests

tests/test_external_artifacts.py (22 tests):

  • recording and sealing, including the bundle surviving removal of the staging directory;
  • run_id validation;
  • recorded and unarchived input snapshots;
  • refused measured digests, and a sandbox run refused for claiming external;
  • idempotent retry and resuming a pending record;
  • refusal for different content, producer or config, and for sandbox or failed records;
  • drift before sealing → failed;
  • replay → UNREPLAYABLE;
  • verify_ledger passes.

Full suite locally: 812 passed, 120 skipped, 7 deselected; the skips and deselections are the same as on main.

Follow-up

Once this merges, AgentSync's port (Deep-Sixed/AgentSync#10) can call record_external_artifact instead of create_pending / seal, and move its Stele pin to the merge commit. That PR currently pins 10bbf3f, 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

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
@Deep-Sixed
Deep-Sixed marked this pull request as ready for review September 25, 2026 22:22
- 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

Copy link
Copy Markdown
Owner Author

I reviewed this and fixed one bug, in b2127b7.

A retry could accept changed content. When a retry for a recorded run_id found any artifact file missing, build_manifest raised FileNotFoundError, and the content comparison was skipped for every file. So a retry that had removed a.md and changed b.md was accepted as the recorded run. The fix:

  • missing files are still vouched for by the archive;
  • every file still present must hash as 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. docs/ledger.md and the module docstring are updated. Full suite: 893 passed, 89 skipped. ruff is clean.

What I checked and found sound:

  • The measured digests (image_digest, module_sha256) are refused.
  • ledger_transaction rejects a sandbox result that claims backend="external".
  • The idempotency rules are right, including the race where create_pending raises DuplicateRunError, and FAILED or sandbox records are never reused.
  • Sealing is the same path as a sandbox run, including the recovery when the SEALED write landed but the read-back failed.
  • Every consumer that reads parser output by name refuses external records:

One point to consider, not blocking: LedgerStore.find_by_parser(name) returns external records together with sandbox runs. An external producer can assert any name, e.g. mineru, so a caller auditing "every MinerU run" gets them mixed in. Options:

  • document that callers should filter on backend;
  • add an include_external=False parameter;
  • refuse producer names that belong to Stele's own parser and extractor catalog.

Generated by Claude Code


Generated by Claude Code

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.

2 participants