Repository navigation
ADR-011 stage 2: one staleness axis, one identity mode, and no raw file reads - #218
Merged
paddymul merged 20 commits intoSep 24, 2026
Merged
Conversation
The assertions stage 2 has to make true, ahead of the code that makes them true. A scan of a project with three imported sources and two children takes no digest at all and reports no axis it could not evaluate; a manifest records neither sources nor ordered copies; source_identity has no mode, no salt and no stat-memoized digest, and no module names TALLYMAN_SOURCE_IDENTITY; the ordered-copy store and its copy key are gone, so compute_cache holds one kind of file; io folds no parent source records; the rebuild script replays a source entry through an import rather than through build_and_persist; and TALLYMAN_LEGACY_FILE_READS buys an authored recipe nothing. The digest counter is its own positive control: it counts the two digests each import takes (the file as given, then the clone as written) and zero across the scan, so the claim is measured rather than read off the code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
update_and_depend(path, alias, project=X) fails whenever X is not the active project. The recipe the importer generates calls read_project_file with no project argument (source_import.py:348), so the read resolves the ambient active project, the _SOURCE_ENTRY contextvar it is checked against names X, and the two disagree — the importer's own recipe then hits the D2 refusal written for authored recipes and the import dies with an error about a file tallyman does not own. Every call site in the repo passes today because the project fixture also makes its project active. Rewriting the suite onto imports found it: the cache lab warms xorq in a project of its own, without activating it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…11 D6, D8) The source axis, manifest.sources, manifest.ordered_copies, the ordered-copy store and the source-identity modes are all deleted. read_project_file and tallyman_read_csv now always refuse an authored recipe, and the TALLYMAN_LEGACY_FILE_READS escape hatch that let the suite keep authoring raw reads is gone with them. The test rewrite onto imported source aliases is PARTIAL: about half the suite is moved over and the rest still calls the deleted helpers. Committed as-is because four agents were editing this worktree at once and one of them lost eight files to a git restore. Follow-up commits finish it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
update_and_depend(path, alias, project=X) failed whenever X was not the active project. The recipe the importer generates calls read_project_file with no project argument, so the read resolved the ambient active project while the contextvar naming the entry being minted said X. The two disagreed, the importer's own recipe hit the D2 refusal written for authored recipes, and the import died complaining about a file tallyman does not own. project= is an override, so the read now follows the entry being minted rather than whichever project happens to be active. source_entry_context returns the (project, hash) pair it already held instead of filtering on a project passed in, and neither entry point resolves the ambient project any more. Every call site in the repo passed before this because the project fixture also activates its project. Rewriting the suite onto imports is what exposed it: the cache lab warms xorq in a project of its own without activating it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The call-site rewrite left four files still opening a file in a recipe. Each now imports it as a source alias and reads the alias. Two of the four failures were the new behaviour, not breakage: - the page-load profiler measures three entries where it measured two, because an imported source version IS an entry (D1). The corpus fixture says so rather than the assertion excusing it. - the reset scenario has to import extra.parquet AFTER s1. Importing it before keeps its source entry alive across the reset back, so no clone is left referenced only by a retired entry and the case ADR-007 D13 is about stops being exercised. With the import after s1 the test proves what it claims: a backward reset parks the clone in the bullpen instead of unlinking it, and a forward reset restores it. test_replay's storyboard imported the parquet and then authored a raw read of it anyway; the stdio round trip did the same over the real MCP transport. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each of the three ADRs that described how a raw input reaches a build now says what ADR-011 changed, in its status block, so a reader who starts at any of them is not misled: - ADR-002 keeps its clone store and loses the rest: the mode switch and salted_hash, manifest.sources, digest_for and its stat memo, and the reconstruction caveat, which is now an error rather than a fallback to live bytes. - ADR-005's reader runs at import, not in a recipe. The schema DSL, the inference ladder and the suggestion contract are unchanged; they just live inside catalog_import_source now, and the options are fixed per alias. - ADR-008's refusal of a raw read extends to read_project_file itself, and the ordered copy stops being a separate kind of file: it is the source entry's snapshot, named by its content hash. ADR-011 records stage 2 in its implementation notes, marks itself implemented, and answers its first open question: keep the raw bytes, which is also what makes the snapshot cache rather than data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same bytes mint the same entry hash in both projects, but each holds its own entry, snapshot and clone: a heal, a clone sweep or a re-import in one never reaches the other, and the one-alias-per-bytes rule is per project. Also pins the way to give a source a second name: a catalog entry whose recipe reads it. Its hash is xorq's hash of the expression, not the md5 a source entry is named by, so the two never coincide and nothing has to be added to the recipe to force them apart. These pass today; they guard the duplicate-import fix that follows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A second import of bytes another alias already holds minted nothing new but re-ran _mint over the shared entry: it rewrote the first alias's manifest and recipe with the second alias's name, and a failure in the build step rmtree'd the entry the first alias points at. - the second import raises, naming the alias and version that hold the bytes and the catalog_create way to a second name, and leaves the first entry byte-for-byte as it was - the same holds for bytes of an older version of another alias - catalog_import_source returns it as an error and records no revision - a re-import that repairs a missing snapshot keeps the existing entry when the build step fails Replaces test_two_aliases_over_identical_bytes_share_one_entry, which pinned the behaviour being removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A source entry is named by its bytes and reader options, so a second import of the same bytes under another alias landed on the first alias's entry and re-ran _mint over it: the manifest's provenance and the recipe were rewritten with the second alias's name, and a failure in the build step rmtree'd the entry the first alias points at. - update_and_depend refuses a mint whose entry is a version of another alias of the project, at any version. The error names that alias and version and the way to a second name: a catalog entry whose recipe is tracked_expr_from_alias(<that alias>). Keyed on the entry hash, so a CSV read two ways is still two imports (D12); per project. - _mint removes the entry directory on failure only when it created it, so a re-import repairing a missing snapshot keeps the existing entry. - ADR-011 D1 now says one set of bytes is one version under one alias, and records that it first said the opposite and why that changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The kind lives on the alias ("catalog" or "source" in aliases.jsonl), but
whether an entry is a source entry lives on the entry (its manifest carries
provenance), and nothing ties the two together. catalog_alias checks only the
kind of the name, so catalog_alias(<source entry hash>, "x") makes a catalog
alias whose head is an imported file. catalog_revise("x", ...) is then
allowed, which bypasses D1's "a source version has no recipe to revise".
Three tests pin the invariant:
- set_alias refuses a catalog alias onto a source entry;
- set_alias refuses a source alias onto a computed entry;
- MCP catalog_alias on a source entry returns an error naming the source
alias-version (orders-v1) and steering to catalog_create over
tracked_expr_from_alias, creates no alias, and the steer then works.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
manifest.provenance.alias and .version record the name a source version was imported under, once. A rename carries the alias's history and kind to the new name, and an unalias drops it, but three places still read the import-time name as the current one: - materialize.pinned_reason and the _heal_a_source error name the version a_src-v1 after a rename to renamed_src, and the heal error advises catalog_import_source(path, 'a_src', pinned_version=1). Following that advice mints a second source alias, a_src, over the same entry. - The generated recipe's header reads "# a_src-v1: a source version", so the Code tab of renamed_src-v1 presents the old name as the entry's. - scripts/rebuild_native_catalog.py replays a source entry under provenance["alias"] and orders its versions by that alias's history. After a rename the replay mints a_src, never makes renamed_src before the children build, and a child reading renamed_src fails; the versions lose their ordering edge and fall into hash order. These tests pin the current name in both messages, advice that repairs the renamed alias without minting a new one (through update_and_depend and through the MCP tools), wording that does not send an unaliased version back under its dead name, a recipe header that records the import name as history, and a rebuild that replays a renamed source under its current name and in version order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
set_alias now reads the manifest of the entry it is pointing at. A manifest
that records provenance is a source entry, a version of an imported file; any
other manifest is a computed entry. A catalog alias onto a source entry, or a
source alias onto a computed entry, raises AliasKindMismatch. The check lives
in set_alias because every route that names an entry goes through it, so
catalog_alias, a revise, a promoted diff, a recalc and an import all keep an
alias's kind and its entries' kind the same without each tool repeating it.
A hash with no readable manifest is not checked: the alias bookkeeping is used
and tested with hashes that name no entry. A passing test pins that boundary.
For a source entry the message names the source alias-version that holds it
(via version_of_hash) and gives the ADR-011 way to a second name:
catalog_create('<name>', "...expr = tracked_expr_from_alias('<source>')"), a
catalog entry that reads the source and follows it when it is imported again.
MCP catalog_alias returns that message as {"error": ...}.
The other set_alias callers only alias a hash they just built (catalog_create,
catalog_revise, promote_diff in MCP and the companion, the companion's PUT
/api/code, recalc, the rebuild and perf scripts) or just imported
(update_and_depend), so the new check cannot fire for them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
provenance.alias and .version stay what they are: the record of the import. Everything that names a source version as it is now asks the alias store. - source_import.source_version_in finds the source alias whose history holds an entry, trying the name it was imported as first. It is aliases.version_of_hash restricted to source aliases, because an import advances nothing else. current_source_version applies it to a project. - materialize.pinned_reason and the _heal_a_source error name the version by that alias and add "imported as <old>-vN" when a rename changed it. The advised catalog_import_source(...) names that alias and version, so running it repairs the snapshot and mints nothing. When no source alias holds the entry, both messages say so, and the error says an import of the same bytes writes them again as a new version of whichever alias it names. - The generated recipe's first line reads "Generated by catalog_import_source when the file was imported as a_src-v1", which stays true after a rename. - scripts/rebuild_native_catalog.py orders a source's versions and replays each one under the source alias that holds it in the old catalog, pinned to its version there, so an out-of-order replay is an error. The re-point loop now skips only the alias the import pointed and sets any other holder with the kind it had; the pin needs a second source alias over the same bytes to be on that version before its next one replays. - SourceProvenance's docstring says alias and version are the import name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntry' into fix/adr-011-duplicate-import
…rename' into fix/adr-011-duplicate-import
… routes refuse source aliases (ADR-011) Found reviewing #219. - A re-import of a version whose snapshot is gone went through _mint: it rebuilt the entry, rewrote the manifest (provenance path, imported_at, result_digest, row_count) and took whatever it wrote as the new truth. A probe with a reader that drops a row showed 10 -> 9 rows under the same hash and no unfaithful_heal record, where ensure_materialized records one. The repair should rewrite nothing but the snapshot, and be verified like any heal. - An entry directory with no manifest (a crash part-way through an import) could not be repaired by re-importing: the no-op branch read the manifest and raised. - The heal error's advised catalog_import_source(...) dropped a CSV's schema and reader options, so running it as written was refused as "not orders-v1". The pinned-version refusal also blamed the file when only the reader options differed. - PUT /api/code/<source alias> and POST /api/promote_diff onto a target name that is a source alias built an entry, failed to point the source alias at it and answered 500. Both should refuse before building, 409. Replaces test_a_failed_repair_leaves_the_existing_entry_in_place, which pinned the repair going through _mint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
D11's refusal (bytes that match an older version of the same alias) ends "or import these bytes under a different alias". Since #219 that import is refused as well: bytes one alias holds cannot be imported under another. The refusal should offer ways back that work, a reset or reading the old version with pinned_expr_from_alias('<alias>-v<N>'). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es refuse source aliases (ADR-011) - update_and_depend never rewrites an entry that already exists. A no-op (or a new alias over an entry no alias holds) whose snapshot is gone restores the clone from the caller's bytes when it is gone too, then heals through ensure_materialized: from the clone, checked against the recorded result_digest, recorded as an unfaithful heal when the rows differ. It used to go through _mint, which rebuilt the entry, rewrote provenance and recorded whatever digest it wrote. An entry directory with no readable manifest is not an entry, and is written again. - source_import.import_call prints the catalog_import_source call that imports a file the way its entry records it was read, with a CSV's schema= and reader_options=. The heal error's advice uses it, so the advice runs as written. The pinned refusal says the reader options may be what differs, and D11's refusal offers pinned_expr_from_alias instead of a second alias, which D1 refuses. - The source-alias refusal text moves to aliases.source_alias_refusal. The companion's PUT /api/code and promote-diff routes refuse with it, 409, before building; they used to build, fail in set_alias and 500 with the entry left under no alias. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ADR-011: status names PR #219; D1 gains the kind rule and says provenance keeps the name a version was imported as; D3's table gains the one-alias error and a note on what an import of an existing version does; D11 and D12 say "the same bytes read the same way" and that advice carries the reader options; Testing drops the import_once_and_depend bullet (not shipped) and lists the new cases; a "Review fixes (PR #219)" implementation note records what changed and why. Also fixes two stale lines: the arena holds snapshots, not ordered copies, and ADR-002's sources map goes rather than narrowing. - docs/mcp-server.md: catalog_import_source's one-alias rule, the second-name recipe and the healing no-op; catalog_alias refuses a source entry. - tests/conftest.py: orders_parquet's docstring no longer suggests importing it under another alias beside orders_src. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
Review of #217 and #218: findings and how each was handledFixes are on #219 (stacked on this PR), each test first.
|
ADR-011 review fixes: one alias per bytes, alias kinds, current names, verified repairs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
paddymul
added a commit
that referenced
this pull request
Sep 24, 2026
ADR-011 held back docs/architecture.md, caching.md, expression-lifecycle.md and system-contract.md until this PR landed, so they would be rewritten once. Now that the PR is rebased onto the merged ADR-011 stack (#217, #218, #219), they describe what exists: a file enters only by catalog_import_source, as a source entry under a source alias; a source entry's hash is an md5 of its bytes and reader options; its snapshot is cache, healed from the clone under data/.cas, and pinned once the clone is gone; recipes name aliases, never files or bare hashes; alias kinds and the rule that an alias's kind matches its entries; one set of bytes is one version under one alias; and staleness has one axis. The ordered copy, the identity modes, manifest.sources, the source-digest memo and the source axis are gone from every doc except where one says what was removed. Known defects #197, #198, #207, #211 and #191, and the two staleness defects with no issue, move to a note that they no longer apply. reactive-recalc.md's walk-through now imports orders and advances it by a re-import. mcp-server.md, installing.md (TALLYMAN_SOURCE_IDENTITY is gone), the README and the code-derived sections of tallyman_explanation.md follow suit. The #193 to #196 passages are unchanged; those fixes are being ported onto this branch separately. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
paddymul
added a commit
that referenced
this pull request
Sep 24, 2026
ADR-011 held back docs/architecture.md, caching.md, expression-lifecycle.md and system-contract.md until this PR landed, so they would be rewritten once. Now that the PR is rebased onto the merged ADR-011 stack (#217, #218, #219), they describe what exists: a file enters only by catalog_import_source, as a source entry under a source alias; a source entry's hash is an md5 of its bytes and reader options; its snapshot is cache, healed from the clone under data/.cas, and pinned once the clone is gone; recipes name aliases, never files or bare hashes; alias kinds and the rule that an alias's kind matches its entries; one set of bytes is one version under one alias; and staleness has one axis. The ordered copy, the identity modes, manifest.sources, the source-digest memo and the source axis are gone from every doc except where one says what was removed. Known defects #197, #198, #207, #211 and #191, and the two staleness defects with no issue, move to a note that they no longer apply. reactive-recalc.md's walk-through now imports orders and advances it by a re-import. mcp-server.md, installing.md (TALLYMAN_SOURCE_IDENTITY is gone), the README and the code-derived sections of tallyman_explanation.md follow suit. The #193 to #196 passages are unchanged; those fixes are being ported onto this branch separately. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Sep 24, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stage 2 of ADR-011 (
plans/ADR-011-sources-are-aliases.md), stacked on #217. It deletes the staleness source axis (D6) and the source-identity modes (D8), rewrites every call site that authored a raw file read onto imported source aliases, and takes the stage-1 escape hatch with them.Net −616 lines (+2341 / −2957 across 80 files). Raw-read call sites in
tests/go from 357 to 22, and the 22 that remain are the tests of the refusal itself.What is deleted
manifest.sourcesgoes entirely rather than narrowing to a retention record, because once a source version is an entry the retention closure is the DAG.dependents.sources_ofandio._note_parent_records— the function whose folding of a parent's digests into its child is the direct cause of the defect ADR-011 exists to fix — go with it.TALLYMAN_SOURCE_IDENTITY, itsoffandsaltmodes, andsalted_hash. "Identity off" and "every input is versioned" cannot both be true.compute_cache/ordered_sources/,copy_key,manifest.ordered_copies, andensure_ordered_copy/existing_ordered_copy/recreate_ordered_copy. The copy is the source entry's snapshot now.source_digests.jsonanddigest_for's stat memo, which have no callers left: nothing digests a file outside an import.TALLYMAN_LEGACY_FILE_READS, the stage-1 escape hatch, and thetests/conftest.pyline that set it for the whole suite. There is now no way to author a raw file read.A real bug the rewrite exposed
update_and_depend(path, alias, project=X)failed wheneverXwas not the active project. The generated recipe'sread_project_fileresolved the ambient project while the contextvar naming the entry being minted saidX; the two disagreed, so the importer's own recipe hit the D2 refusal written for authored recipes and the import died complaining about a file tallyman does not own.project=is an override, so the read now follows the entry being minted. Every call site in the repo passed before this, because theprojectfixture also activates its project — the cache lab, which warms xorq in a project of its own without activating it, is what found it. Red test2f8350a, fix502c7a5.Two expectations changed because the behaviour is now right
Neither is a test bent to stay green:
manifest.sourcesdid not break retention: a backward reset parks the clone in the bullpen instead of unlinking it, and a forward reset restores it. That was the failure mode that could have destroyed data silently.ADRs amended
ADR-002(keeps its clone store, loses the modes, thesourcesmap and the reconstruction caveat — which is now an error, not a fallback to live bytes),ADR-005(its reader runs at import; the schema DSL, inference ladder and suggestion contract are unchanged, and options are fixed per alias),ADR-008(its refusal extends toread_project_fileitself, and the ordered copy becomes the source entry's snapshot).ADR-011records stage 2 and answers its first open question: keep the raw bytes, which is also what makes the snapshot cache rather than data.TDD
Red
2d01e6f(run 35809031979, failed) and2f8350a(run 35811124364, failed); greena803ba7(run 35854181437) and75381c5, both with ruff, fast and integration passing.Four agents worked on this branch and all four died mid-flight — three on a stall watchdog, one on an API error — after I made the mistake of giving three of them one shared worktree, where they raced on a single git index and one wiped eight of another's files with a
git restore.ec4f86eis their salvaged work, committed as-is to protect it; everything after it was finished by hand.Outstanding, deliberately not here
docs/architecture.md,docs/caching.md,docs/expression-lifecycle.mdanddocs/system-contract.mdall describe the deleted source axis and are now wrong. PR #216 is rewriting all four; a second rewrite here would conflict badly, so they are untouched.The pinned layout costs +57% on high-cardinality numeric data (16.2 MB → 25.5 MB on a 1.5M-row fixture), because 122,880 int64s dictionary-encode to just under pyarrow's 1 MB dictionary page limit and the dictionary never spills to plain. That is a question about
_PARQUET_OPTIONSfor every snapshot, which is ADR-009's to answer; nothing here writes a source snapshot differently from a computed one to avoid it. Measured sizes are in ADR-011's implementation notes.🤖 Generated with Claude Code