Skip to content

feat(ledger): record what a change was derived from - #261

Merged
oratis merged 1 commit into
mainfrom
feat/ledger-provenance
Aug 9, 2026
Merged

feat(ledger): record what a change was derived from#261
oratis merged 1 commit into
mainfrom
feat/ledger-provenance

Conversation

@oratis

@oratis oratis commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Stacked on #252.

Closes the last 未做 item in docs/FLOATBOAT_ADOPTION_PLAN.md §4.1: "制品 provenance … 未做" (Selfware §11.1).

The question the ledger could not answer

It answers "what changed" and "how do I undo it". Not: what was this derived from — which is what you ask when a generated file is wrong and you need to know which input to fix, or when something turns up in a diff and you need to know what the turn had open.

deepcode ledger show chg-lz4k2p-01
#   paths    : src/client.ts
#   from     : config/gen.yaml, schema.json

Built on the existing ledger, not a second store — a provenance log kept separately from the change log is two things to keep in step and one place for them to disagree.

Observed, not declared

These are reads that actually happened, so a tool that ignored its inputs shows nothing rather than a plausible list. Four deliberate narrowings, each tested:

Rule Why
Only Read counts Grep/Glob take a search root and return many paths. Calling the root an input claims a derivation the turn did not make; listing every hit drowns the real inputs in whatever the search swept up. Narrow and true beats wide and approximate.
A failed read is not an input It gave the turn nothing. Crediting it sends someone to fix a file that was never opened.
The file being written is excluded Edit reads its own target by construction — including it makes every edit look self-derived.
Absent, not empty A field that is always present is a field that stops being read.

Collected at the one place every completed tool call passes through — the same site the ledger append already lives at, for the same reason: a per-tool hook is a hook a new tool forgets.

It is a derivation record, not a dependency graph: what this one turn read, not what the file transitively depends on. The docs say so, because the two are easy to confuse and only one of them is true here.

Verification

typecheck, lint, format, docs clean; full suite green. 10 new tests, including two end-to-end through a real runAgent run — provenance is recorded from an actual ReadWrite sequence, and a failed read is not credited.

🤖 Generated with Claude Code

@oratis

oratis commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Review — approve, no changes

The question this answers — what was this derived from — is genuinely not answerable from the existing ledger, and it is the one you ask when a generated file is wrong and you need to know which input to fix.

Building on the existing ledger rather than adding a provenance store is right for the stated reason: two logs of the same events are two things to keep in step and one place for them to disagree.

The four narrowings are what make this trustworthy rather than merely present:

  • Only Read counts. Grep/Glob take a search root and return many paths; calling the root an input claims a derivation the turn did not make, and listing every hit drowns the real inputs in whatever the search swept up. Narrow and true beats wide and approximate — and a provenance field people learn to distrust is worse than no field.
  • A failed read is not an input. if (!tr.isError) — it gave the turn nothing, and crediting it sends someone to fix a file that was never opened.
  • The file being written is excluded via written. Edit reads its target by construction, so without this every edit looks self-derived.
  • Absent rather than empty, in the record and in ledger show. A field that is always present stops being read.

Collected at the single post-dispatch site, same as the ledger append, for the same reason: a per-tool hook is a hook a new tool forgets. That is consistent with the rule AGENTS.md already states, and it is why this stays correct when someone adds a mutating tool.

Calling it a derivation record and not a dependency graph, in the docs, is the right disclaimer — they are easy to confuse and only one of them is true here.

Two observations, neither a change request:

  • readsThisTurn accumulates for the whole runAgent call, so a Read early in a long run is credited to a write much later. That matches "what this one turn read" as documented; just noting it is turn-scoped and not read-recency-scoped, which is the more conservative of the two readings.
  • normalizeContractPath(cwd, raw) ?? raw keeps an absolute path for a file outside the workspace. Correct — falling back to the raw path is more useful than dropping the input — and worth knowing that derivedFrom is therefore not uniformly workspace-relative.

@oratis
oratis changed the base branch from fix/git-env-test-isolation to main August 9, 2026 15:38
The ledger answers "what changed" and "how do I undo it". It could not answer
the third question people actually ask: what was this derived from — which is
what you want when a generated file is wrong and you need to know which input to
fix, or when something turns up in a diff and you need to know what the turn had
open.

`derivedFrom` on each record: the files the turn read before making that change.
Built on the existing ledger rather than a second store, because a provenance
log kept separately from the change log is two things to keep in step and one
place for them to disagree.

Observed, not declared. These are reads that actually happened, so a tool that
ignored its inputs shows nothing rather than a plausible list. Four consequences
of that, each of which is a deliberate narrowing:

- Only `Read` counts. Grep and Glob take a search *root* and return many paths;
  calling the root an input claims a derivation the turn did not make, and
  listing every hit drowns the real inputs in whatever the search swept up.
  Narrow and true beats wide and approximate.
- A failed read is not an input. It gave the turn nothing, and crediting it
  sends someone to fix a file that was never opened.
- The file being written is excluded. Edit reads its own target by construction,
  so including it would make every edit look self-derived.
- Absent, not empty, when there is nothing to say. A field that is always
  present is a field that stops being read.

Collected at the one place every completed tool call passes through — the same
site the ledger append already lives at, for the same reason: a per-tool hook is
a hook a new tool forgets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oratis
oratis force-pushed the feat/ledger-provenance branch from 4b31886 to d66df0c Compare August 9, 2026 16:04
@oratis
oratis merged commit 8812a46 into main Aug 9, 2026
5 checks passed
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