Status: implemented (2026-06-16) on plan/native-catalog-store, plus a
post-implementation bug-hunt + hardening pass (2026-06-17, see below). Refined
through a parallel code investigation and a grilling pass; the decisions below
are settled unless marked open.
The cut landed as: 1a failing-first markers → native recipe store + checkpoint
owns the zip → catalog.yaml decomposition → Cut B → doc/dead-code sweep →
verify. Where reality diverged from the plan text:
- Plan commits 2 and 3 merged. The recipe store (
write_recipe_zip) is inert until the checkpoint calls it, andreset_to's consistency check needs both halves, so splitting them would leavereset_totransiently broken. Landed as one coherent "Cut A" commit. - Line numbers were uniformly stale (~+60 in
build.py, e.g. the registration swallow is :539-541 not :477-478, the re-run prompt append is :396 not :335). Re-derived against the post-#104 tree before writing any test. - #104 already removed
result.parquet. The zip allowlist's "exclude result.parquet" is now about a stray that no longer naturally appears; the real heavy exclusion is.xorq_build_expanded/. The deadmigrate_drop_result_parquetmarker is gitignored (.migrated_*). - Several planned "failing-first" 1a tests were green today (the merged #48
fix already registers every entry; the salted-zip naming already works via the
entry_namecopytree; checkpoint used an explicitgit add catalog.yaml). Per the TDD rule they shipped with their fix commit as guards, not in 1a. - Cut B —
diff.pyis a phantom site. Neitherdiff.pyimportsxorq.api; the realxo.build_exprsite isapp.py._build_compare_expr. Andpost_processing.py/summary_stats.pykeep the fullimport xorq.apibind in_restricted_globals— that is the user-authored exec surface (D4), like recipes; only theirxo.memtableuses were narrowed. - Connect-shim test asserts key + data equivalence, not byte-identical. The baked snapshot parquet is not byte-deterministic even for one backend (datafusion's group-by output order varies), so the meaningful equivalence is the content-addressed snapshot key + the data, not raw bytes.
- Corpus rebuild — mechanism now exists, not yet run on the real corpus.
Eight on-disk projects under
~/.tallyman-notebooksare still old-format (catalog.yaml+ xorq zips withmetadata/sidecars).scripts/rebuild_native_catalog.py(added in the hardening pass) re-execs a project's recipes into the native store; it was validated end-to-end against a disposable copy offirst-projectand bytests/test_rebuild_native_catalog.py, but the eight real projects are the user's to run (--dry-runfirst). Key fact the rebuild rests on: a re-exec does not reproduce the old content hashes (the build pipeline + xorq serialization have moved, and the hash is sensitive to the absolute source path), so the rebuild remaps every hash-keyed artifact old→new, rewrites literaltracked_expr_from_alias(<hash>)refs, and expands the persisted${TALLYMAN_PROJECT_ROOT}placeholder; alias-ref recipes survive untouched.
A fan-out bug-hunt (eight surfaces, each adversarially probed and every finding independently re-reproduced) over the native store + Cut B found the baseline suite green on all eight and surfaced four in-scope regressions, all fixed test-first:
- Allowlist let nested strays through.
assert_catalog_consistentmatchedTRACKED_SURFACEwithfnmatch, whose*spans/, sopost_processing/sub/leak.pymatchedpost_processing/*.py(andentries/sub/x.zipmatchedentries/*.zip). Now a path-segment-aware_surface_match(equal segment count + per-segmentfnmatchcase). - Interrupted atomic-write
.tmpcommitted + wedged reset. A crash betweentmp.write_textandtmp.replace(aliases/notebook/prompts writers,write_recipe_zip) left a*.tmpthe checkpoint'sgit add -Acommitted, which then failed the allowlist on every laterreset_to. Added*.tmpto the repo.gitignore. - Manifest-less mid-build dir recorded as a pointer.
capture_tallyman_statelisted every entry dir, including a mid-build one withoutmanifest.jsonthatzip_pending_entriesskips — committing a pointer with no durable recipe, a self-inconsistent stepreset_topermanently rejected. Capture now applies the same manifest filter the zip writer uses. cached_result_exprself-heal broke once warm. The whole function was@lru_cache-memoised, so an expensive entry's snapshot evicted after a warm read returned a danglingdeferred_read_parquet("At least one path is required"). Split into a memoised plan resolver + a per-call existence check;.cache_clear/.cache_infore-exposed on the public name.
Out of scope (pre-existing, filed for separate decision, not fixed here):
reset-then-recheckpoint skips a step number and orphans the future-step tag;
build_and_persist(project=X) doesn't anchor X for the recipe's ambient
project_path/tracked_expr_from_alias resolution; label_step accepts main (the pinned
branch name); version_of_hash/previous_version use first-match list.index;
corrupt tracked JSON surfaces as a bare HTTP 500.
End-to-end verification still runs on a fresh project
(test_tracked_tree_is_the_decomposed_surface).
tallyman drives xorq's catalog/ package by subprocess from
tallyman_core/xorq_catalog.py (uv run xorq catalog add / add-alias / remove-alias, all --no-sync). That package is the source of the recurring
catalog pain:
assert_consistencyruns on every add, independent of--no-sync(xorqcatalog.py:335, and unconditionally inCatalogEntry.__attrs_post_init__1212-1213). It asserts the tracked tree equals exactlycatalog.yaml+ each entry's zip/metadata + alias symlinks, so any extra tracked file makes it raise. This is #48, and the reason every piece of tallyman state had to be smuggled intocatalog.yamlkeys instead of living in its own files.- The add subprocess commits outside tallyman's flock, racing
checkpoint_catalog's.git/index.lock(catalog_state.py:383-388already apologizes for it and silently returnsNonewhen it loses). - #48 is live on disk:
first-projecthas four build dirs but only one tracked recipe zip — three adds silently no-opped. Reset on that corpus already fails_assert_catalog_consistent. A rebuild is required, which the single-user no-migration rule (projectCLAUDE.md) already permits.
xorq splits into three layers; only the innermost hurts. We cut (1) and keep (2)/(3):
- Catalog repo manager —
xorq/catalog/(~7.7k LOC), driven by subprocess. - Serializer —
xorq/ibis_yaml/(build_expr/load_expr). - Engine —
xorq.vendor.ibis+ datafusion +xorq.expr.
Delete xorq_catalog.py; stand up tallyman_core/catalog.py as the native
content-addressed recipe store; and decompose catalog.yaml so every piece
of mutable catalog state becomes its own git-tracked file. The #48 unlock
(tracking arbitrary files) is what makes the decomposition possible.
import xorq.api transitively loads the entire xorq.catalog package
(xorq/api.py:62 import xorq.catalog.api as catalog; verified: bare
import xorq loads zero catalog modules, import xorq.api loads 12). Replace
tallyman's ~9 import xorq.api as xo sites with narrow catalog-free imports plus
a datafusion connect() shim.
These must land in the same PR (ordered commits): if only Cut A lands the package
stays resident; if only Cut B lands the uv run xorq catalog child still runs it.
This is not a vendored copy of xorq's catalog.py. tallyman already owns the
catalog model; the only remaining xorq tendril is the xorq_catalog.py
subprocess. tallyman_core/catalog.py is the store, written natively. We drop
xorq's assert_consistency, three-way merge, and zip contract — none carry
forward.
After this PR, tallyman code touches xorq's catalog never:
| Touchpoint today | After |
|---|---|
xorq catalog add writes the zip |
stdlib writer in tallyman_core/catalog.py |
| xorq commits zip + metadata + alias symlinks | tallyman's checkpoint is the only committer |
assert_consistency on every add |
never invoked against our repo |
| three-way merge | already dead (no remote, --no-sync); stays dead |
zip internal format / REQUIRED_ARCHIVE_NAMES |
irrelevant — nothing reads the zip back; format is ours |
xorq's entries/aliases catalog.yaml keys |
not written; nothing reads them |
| old on-disk corpus | rebuilt, never read |
The honest boundary: this severs the catalog, not the engine. Entry contents
are still xorq build artifacts (build_expr/load_expr produce the dir we zip;
vendor.ibis runs it), all catalog-free. The one asterisk on "never": a
tracked_expr_from_alias reconstruction re-execs a recipe whose import xorq.api as xo
passively loads (never calls) the catalog package — see Decision D4.
- Nothing reads the zip back. The only consumer of
entries/<hash>.zipis_tracked_recipe_hashes(catalog_state.py:433), which reads names viagit ls-files.load_exprconsumes the expanded build dir, never the archive. So the zip format is entirely ours. - Two load paths read different files.
tracked_expr_from_alias/chaining (cached_result_expr→_recipe_expr,result_cache.py:148) re-importsentry_dir/expr.py; the Buckaroo viewer (load_entry) readsentry_dir/xorq_build/. Bothexpr.pyandxorq_build/must therefore be in the durable artifact — today's zip has onlyxorq_build/, so the durability invariant currently guards the wrong file (Decision D3 fixes this). run_gitusesos.posix_spawn, notsubprocess.run(git_util.py), so the clean "no subprocess" guard test is: monkeypatchsubprocess.runto raise, run a full cycle, assert no raise.- tallyman never merges (no remote,
--no-synceverywhere). No merge surface to port. pyproject.tomlcan't be narrowed —xorq.catalogships in the singlexorq==0.3.26wheel. "Drop" means stop importing/calling, enforced by a test..xorq_build_expanded/is derived + machine-local (portable.py): the placeholder-expanded copy ofxorq_build/, regenerated on load, holding this machine's absolute paths. Never archive or track it.
The catalog repo gets a .gitignore (there is none today; only an explicit
pathspec kept staging honest). Ignore the heavy/derived artifacts:
entries/*/ # untracked build dirs (recipe lives in entries/*.zip)
bullpen/
result_cache/
compute_cache/
diff_stat_cache/
.checkpoint.lock
Then git add -A stages exactly the meaningful state, and the tracked tree is:
entries/<hash>.zip # immutable recipe, one blob per entry
aliases.jsonl # one line per alias
notebook.jsonl # one line per cell (moved under the repo)
entries.jsonl # pointer list: untracked build dirs (bullpen reconcile)
compute_cache.jsonl # pointer list: untracked compute-cache warm set
chart_specs/<hash>.vl.json # one Vega-Lite spec per entry
display_configs/<hash>.json # one Buckaroo/display config per entry
prompts/<hash>.jsonl # append-only authoring-prompt history per entry
post_processing/<name>.py # project-global functions (moved under the repo)
stats/<name>.py # project-global functions (moved under the repo)
pyproject.toml # per-repo env spec — the reproducibility unit
uv.lock # pinned lock; recreate one venv per repo to rebuild
No YAML anywhere. The former catalog.yaml is gone; its residual pointer
bookkeeping is entries.jsonl + compute_cache.jsonl.
Because git add -A is allow-by-default, the .gitignore denylist alone does
not keep the tracked tree honest — assert_catalog_consistent's path allowlist
(Module boundary, #52) does. (The metadata/ *.zip.metadata.yaml sidecars and
the aliases/ symlink tree that git add -A would otherwise stage are xorq
catalog add subprocess output; they stop being written once Cut A removes the
subprocess, and a from-scratch rebuild never recreates them.)
entries/<hash>.ziparchives an allowlist of the entry dir:{expr.py, xorq_build/, schema.json, manifest.json}. Allowlist, not "everything except," so a future derived artifact dropped inentry_dircan't leak into the durable zip. Excludes.xorq_build_expanded/(fact 6),result.parquet(the untracked heavy result), andprompts.jsonl— the last is append-mutable (it grows on a re-run of an existing hash with no rebuild,build.py:335), so a content-addressed zip member would churn the blob in git history on every re-run; it becomes a tracked decomposition file instead (see below). The allowlist-not-denylist membership (and the three exclusions) is pinned bytest_recipe_zip_members_are_allowlist;test_recipe_zip_contains_expr_pyguards thatexpr.pyis present (D3).- Members stored under arcname
<hash>/<relpath>(single top-level prefix, so a future restore can reuse it). Named by the saltedcontent_hashwhen source-identity salt mode is on, notbuild_path.name— otherwise_tracked_recipe_hashes(zip names) diverges fromentries.jsonlpointers. - Deterministic archive (
date_time=(1980,1,1,0,0,0),external_attr=0o644<<16, sorted iteration,ZIP_DEFLATEDat a pinnedcompresslevel) so the blob doesn't churn on identical rebuilds. (Single machine, so cross-zlib reproducibility is moot; pinning the level keeps the blob byte-stable across same-machine rebuilds regardless of zlib defaults.) Pinned bytest_recipe_zip_byte_stable_across_rebuilds— without it a defaultedZipInfomtime embeds the current time and silently re-churns the git blob. - Drop the wheel + requirements.txt the
xorq catalog addsubprocess injects (~178KB → ~6KB per entry). These are xorq's per-expression reproducibility mechanism —WheelPackager/PackagedBuilder/PackagedRunner(xorq/ibis_yaml/packager.py) bundle a built wheel + pinnedrequirements.txtsoxorq runcan re-execute an entry in its own isolateduvenv. tallyman does not use that path: it builds every expression in-process viabuild_expr/load_expr(build.py:317), and captures reproducibility at catalog-repo granularity — a single trackedpyproject.toml+uv.lock(see Tracked tree), one venv per project, recreated to rebuild the corpus. A per-expressionuvenv is the slow, expensive thing tallyman avoids; per-expression venvs are a bridge to cross later if ever wanted. The in-process build output (xorq_build/) never held the wheel anyway (thebuild_exprfile set isexpr.yaml/SQL/deferred_reads.yaml/profiles.yaml); only the catalog zip did, and nothing reads it back (fact 1). So the drop forfeits xorq's per-entry env reconstruction — unused here — not tallyman's reproducibility, which is the tracked per-repo lock. Separately: because the zip is deterministic and content-addressed, git stores each blob once and agit add -Aover an unchanged zip is a no-op that adds no history — so the saving is a smaller durable blob and working tree per entry, not avoided per-checkpoint churn (there is none once the zip is byte-stable). - The checkpoint owns zip creation, not the build. The build persists a
complete
entries/<hash>/on success and cleans up the dir if this call created it and the build failed (no partial dirs, no orphan zips). The checkpoint, under_project_lock, zips any entry dir lacking a tracked zip, thengit add -A+ commit. This is the sole git transaction and sole zip writer: no out-of-lock writes, no loose orphans, a committed pointer always has its recipe in the same commit, and it self-heals (a skipped/failed checkpoint leaves a complete gitignored dir the next checkpoint picks up).
The recipe is immutable; charts, styling, post-processing, stats, aliases, the
notebook, and the per-entry authoring-prompt history are mutable — edited
(or, for prompts, appended to) on an existing entry without rebuilding, so they
must not live inside the content-addressed recipe zip. Today they round-trip
through catalog.yaml (capture_tallyman_state reads live files into keys;
materialize writes them back) only because assert_consistency forbade
tracking extra files. With xorq gone:
- The mutators already write live files (
set_chart,set_display_config, the.pywriters,set_alias, the notebook editors). Those files become tracked directly; checkpoint commits them viagit add -A;reset_to'sgit reset --hardrestores them. capture/materializefor these six sections is deleted.captureshrinks to writingentries.jsonl+compute_cache.jsonl.- Each mutation is already one checkpoint commit (dispatch-boundary), so the
revision granularity is unchanged — but a chart revision becomes a legible
git diffto its file instead of opaquecatalog.yamlchurn. aliases.py's_read/_writeswitch fromcatalog_statekeys toaliases.jsonl(one line per alias:{"alias","latest","history":[...]}); the read-only alias functions ride on_read/_writeuntouched, and the only reader offread_tallyman_stateisaliases.pyitself (verified). Butaliases.pyalso has a module-levelfrom tallyman_core import xorq_catalog as _xcat(aliases.py:24) andset_alias/remove_aliasstill call_xcat.add_alias/remove_alias(:129,:155) — the xorq subprocess. Both must be stripped in the same commit that deletesxorq_catalog.py:aliases.pyis its only remaining caller oncebuild.py's recipe path is rewritten, and because the import is module-level, deletingxorq_catalog.pywithout it makesaliases.py— and the whole package — un-importable.post_processing/,stats/, and the notebook move from outside the repo (artifacts/post_processing,artifacts/stats, andproject_dir/notebooks/respectively) to undercatalog/so they can be tracked.chart_specs/anddisplay_configs/already live undercatalog/(charts.py/display_configs.pywrite tocatalog_dir), so they need no move — onlypost_processing/,stats/, and the notebook do. Layout change, trivially a rebuild.- Prompt history is a seventh tracked file, with a different origin. Unlike
the six above it never round-tripped through
catalog.yaml— it has always been a looseentry_dir/prompts.jsonl(written bybuild.py:35; read back by the entry-detail API atapp.py:580→ the React "N prompts seen for this hash" disclosure). Becauseentries/*/is now gitignored, that loose file would be lost onreset_to/clone-restore, andmanifest.jsonpreserves only the first build's prompt — so the re-run history the disclosure exists to show would be gone. Relocate thebuild.pywriter and theread_promptsreader fromentry_dirto a tracked root-levelprompts/<hash>.jsonl(mirroringchart_specs/). Layout change, trivially a rebuild.capture/materializedo not touch it — it was never incatalog.yaml, so the six-section deletion above is unaffected.
Atomic reset is preserved: git reset --hard <step> restores all tracked files
at once regardless of how many — spreading state across files doesn't weaken it.
Replace import xorq.api as xo with narrow imports (all verified
symbol-identical and catalog-free): xorq.ibis_yaml.compiler (build/load),
xorq.expr.api (deferred_read_*, memtable, to_sql), xorq.caching
(ParquetSnapshotCache). The one non-mechanical swap is no-arg xo.connect()
(used at source_cache.py:133,152). xorq.expr.api is not a drop-in here: it
does export a connect, but it is ibis's connect(resource, **kwargs)
(re-exported via from xorq.vendor.ibis.expr.api import *), a different function
requiring a positional resource — not the no-arg datafusion xorq.api.connect
(verified: xorq.expr.api.connect is xorq.api.connect → False). So a naive swap
resolves the symbol (no ImportError) and then fails at call time on the missing
arg, or silently builds the wrong backend. Add a tallyman datafusion connect shim in
tallyman_xorq (Backend(); do_connect(None); returns the identical Backend),
with a determinism-equivalence test (the shim-built ParquetSnapshotCache must
produce byte-identical content-addressed snapshots, since source-cache keys feed
content-addressing). Sites (8 runtime imports): source_cache.py, io.py,
build.py:419, result_cache.py, post_processing.py, summary_stats.py,
app.py, diff.py. The 9th grep hit, tallyman_mcp/server.py:219, is the
codegen-guidance docstring teaching recipe authors the import xorq.api as xo
namespace — it is not an executed import; left as-is per D4 (the recipe
authoring surface is a separate follow-up).
Definition of done is not a sys.modules assertion (user recipes keep
import xorq.api, D4). It is three named tests: (a) test_uses_no_subprocess_build
test_uses_no_subprocess_alias— asubprocess.runcall-count spy over a full cycle asserting zero xorq invocations (the build path must assertspy.call_count == 0, not "did not raise":build.py:477-478swallows the exception; scope the spy past the legitimatecp -cclone atsource_identity.py:111); (b)test_src_has_no_xorq_catalog_argv— a static grep thatsrc/has noimport xorq.catalog/from xorq.catalogand no split-tokenxorq … catalog … addargv (skipping thetallyman_mcp/server.py:219codegen docstring); and (c)test_connect_shim_matches_xorq_api_connect— the datafusion shim derives the same content-addressed snapshot key and bakes the same data asxo.connect()(key + data equivalence, not byte-identical — datafusion's group-by output order isn't deterministic).
- D1 — zip timing: checkpoint owns zip creation; build owns only the complete
entry dir. (Collapses the orphan-zip /
--allow-empty-masking / partial-failure risks.) - D2 —
catalog_registered: means "entry dir persisted" (a local fact at build time); durability is the next checkpoint's job and always succeeds because the dir is complete. Rewrite the#48"not durable" log accordingly. - D3 — archive scope: the recipe allowlist
{expr.py, xorq_build/, schema.json, manifest.json}as one immutable zip (fixes the wrong-artifact durability gap from fact 2).expr.pyis durable, not regenerated fromxorq_build/. Thetracked_expr_from_aliaschaining path re-execs it so a cheap entry's recompute expression roots on the shared in-process backend and two entriesunion/joinon one backend (#75). Reconstructing the way xorq's own catalog does —load_exprper entry — gives each its own backend and raisesMultiple backends found(verified: twoload_exprresults won'tunion; two re-exec'd sources do). xorq never hits this because its catalog runs one entry at a time and never composes; tallyman's chaining is the feature that needs the shared backend. Two source-regeneration routes were tested and rejected: xorq's decompiler is ibis's generic one and drops the read path (emits non-compiling, path-less source);expr.unbind()composes but likewise strips the read path and can't execute without re-registering each source by name. Both reimplement — more fragilely — what the one already-writtenexpr.py(build.py:367) gives for free, so it stays. (Expensive entries don't actually need it for data — they read a baked snapshot and useexpr.pyonly to locate it — but the cheap-entry recompute case does.)prompts.jsonlis deliberately not a member: it is append-mutable, runtime-read provenance, so it joins the decomposition as a trackedprompts/<hash>.jsonlrather than churning the content-addressed blob. The shared-backend half is already covered in the fast suite (test_result_cache.py:214,232); the durability half — thatexpr.pyround-trips via the zip onceentries/*/is gitignored — is not, sotest_two_entries_union_after_reset(commit 1b, nointegration/cache_lab/perfmarker, since CI deselects those) is the gating test, with the contrapositivetest_two_load_expr_results_cannot_unionpinning whyexpr.pymust be durable. - D4 — recipe imports: keep user recipes on
import xorq.api as xo; catalog-free guarantee scoped tosrc/. Real recipes callxo.connect()/xo.desc, so switching the authoring surface needs its own codegen-validation pass and is a separate follow-up. - Decomposition scope: all six
catalog.yaml-smuggled mutable sections (charts, display, post-processing, stats, aliases, notebook) become tracked files, plus a seventh — the per-entry prompt history, relocated from the now-gitignored entry dir to a trackedprompts/<hash>.jsonl;catalog.yamlreduces to the two JSONL pointer files. One PR, ordered commits. - Formats: JSONL for list-shaped state (aliases, notebook cells, pointer
lists); one pretty-printed JSON object per file for nested blobs (chart specs,
display configs);
.pyfor scripts.
tallyman_core/catalog.py(new — the store):write_recipe_zip/ zip-an-entry-dir,tracked_recipe_hashes,assert_catalog_consistent(#52 — enforces the tracked surface as a path allowlist (deny-by-default): everygit ls-filespath must fnmatch the tracked-surface set or it raises, so a stray dir thatgit add -Awould otherwise stage is rejected instead of committed silently), theentries.jsonl/compute_cache.jsonlpointer bookkeeping, the bullpen reconciliation (prune_entries/restore_from_bullpen), and the.gitignore/tracked-surface definition.catalog_state.py(stays — the versioning engine):_project_lock,ensure_catalog_repo,checkpoint_catalog(capture pointers → zip pending viacatalog.py→git add -A→ commit → tag),reset_to,list_revisions,current_step, genesis. Slims substantially.- Call direction is one-way (
catalog_state→catalog);assert_catalog_consistenttakes the pointer set as an argument to avoid a cycle. Don't renamecatalog_state.py(test churn) — slim it in place. - Keep
entry_hashes+ the consistency check for now. Once we own the tracked zips, the entry set is derivable fromgit ls-files entries/*.zip, makingentry_hashesand the two-views check redundant — but that collapse touches the bullpen reconciliation and is a deliberate separate look.
| # | Risk | Sev | Status / mitigation |
|---|---|---|---|
| 1 | git add -A is allow-by-default; a .gitignore denylist silently tracks any unlisted artifact (build dirs, result.parquet, future strays) |
High | .gitignore (entries/*/ etc.) covers today's heavy artifacts, but the durable guard is assert_catalog_consistent enforcing the tracked surface as a path allowlist (deny-by-default). Regression test asserts no build-dir file — and no unlisted path — is tracked. |
| 2 | sys.modules-based "dropped" assertion is unsatisfiable (recipes load xorq.api) |
High | Definition of done = subprocess guard + src/ static grep, not sys.modules. |
| 3 | xo.connect() survives the xorq.api→xorq.expr.api swap as ibis's connect(resource, …) (present, not absent), so it fails at call time / builds the wrong backend rather than at import — easy to miss |
High | Datafusion connect shim + test_connect_shim_matches_xorq_api_connect (commit 1b) — was prose-only, now a named test asserting key + data equivalence (not byte-identical; see Implementation notes). |
| 4 | test_catalog_xorq_integration.py imports xorq.catalog.catalog.Catalog at module level (:45 also imports the to-be-deleted xorq_catalog) |
High | Delete/rewrite in the step-2 commit (same one that deletes xorq_catalog.py), not step 6 — a module-level import of the deleted module collection-errors the whole suite from step 2 on. Drop _open_xorq_catalog/alias-symlink tests; keep the git ls-files durability guards. |
| 5 | Salt-mode zip must be named by the salted hash | High | Name entries/<salted_hash>.zip; test under TALLYMAN_SOURCE_IDENTITY=salt. |
| 6 | Commit timing inverts (inline → checkpoint); tests that build with no checkpoint and assert tracked fail | Med | test_xorq_catalog_add_registers_after_genesis is a fix-induced break, not failing-first: edit to checkpoint-then-assert in the step-6 cleanup commit. |
| 7 | Orphan loose zip / --allow-empty masks a pointer-without-recipe |
Med | Retired by D1 (checkpoint is the sole zip writer over complete dirs). |
| 8 | In-process git reintroduces the macOS fork-from-threads SIGSEGV | Med | All native catalog git via git_util.run_git (posix_spawn); never GitPython. test_no_bare_subprocess_git_in_src enforces (note: it matches bare git argv only — the xorq catalog argv guard is the separate test_src_has_no_xorq_catalog_argv). |
| 9 | test_capture_seeds_xorq_keys / decomposed-section capture tests pin dropped behavior |
Low | Delete (not rewrite) in the step-6 cleanup commit alongside the decomposition. |
| 10 | Existing corpus is #48-inconsistent; two zip formats coexist | Low | Rebuild from scratch (project rule). |
| 11 | Deterministic-archive claim untested — a defaulted ZipInfo mtime silently re-churns the content-addressed git blob on every rebuild |
High | test_recipe_zip_byte_stable_across_rebuilds (commit 1b): build the same code twice, assert byte-identical archives. |
| 12 | Zip-member allowlist is the "only guard against leak" but lives only as D3 prose; a denylist impl that skips today's three known names passes existence checks yet leaks a future artifact | High | test_recipe_zip_members_are_allowlist (commit 1b): assert namelist() equals exactly the four members; reject an unknown foo.bin. (Distinct surface from test_unlisted_path_not_tracked, which guards the git-tracked repo root, not the zip bytes.) |
| 13 | D3 half (b) — expr.py-via-zip durability for chaining — untested in the gating suite; the only reset+tracked_expr_from_alias file is marker-deselected (pyproject.toml:77), so a load_expr-per-entry refactor would pass the fast suite and silently break chaining |
High | test_two_entries_union_after_reset + test_recipe_zip_contains_expr_py (commit 1b), carrying no integration/cache_lab/perf marker. |
| 14 | display_configs is the one decomposed section with no survives-reset test today; its only coverage is the capture/materialize round-trip tests step 4 deletes |
Med | test_display_config_survives_reset (commit 1b) — new reset test so net coverage doesn't regress. |
| 15 | Commit-1 failing-first is internally inconsistent with the TDD rule: some named tests fail by collection error (test_unlisted_path_not_tracked → missing API), false-green (test_uses_no_subprocess build path swallowed at build.py:477-478; test_prompt_history_survives_reset survives via bullpen until step-3 .gitignore) |
High | Split into commit 1a (assertion-red today) and 1b (lands API/.gitignore stubs in-commit); build-path guard asserts spy.call_count == 0, scoped to the xorq argv (exempt the cp -c clone at source_identity.py:111). |
Every new test must be seen red on CI by a real assertion, never by a
collection/import error (the repo TDD rule excludes those). That forces the
failing-first work into two commits, not one: some assertions go red against
today's inline-xorq tree, but others reference symbols
(assert_catalog_consistent, write_recipe_zip) or a .gitignore that only
later commits create. Commit 1a holds the first kind; commit 1b lands thin stubs
of those symbols + the .gitignore in the same commit as its tests, so the
assertion is what's red, not the import. Confirm run_git is posix_spawn (it is)
before relying on any subprocess.run monkeypatch.
1a. Failing-first, assertion-red against today's tree (use existing public
surfaces only — build_and_persist, checkpoint_catalog, git ls-files —
never the not-yet-created tallyman_core.catalog API, or these collect-error):
test_uses_no_subprocess_alias— monkeypatchsubprocess.runto raise;set_aliasraises today (aliases.py:129→_xcat.add_alias, no try/except).test_uses_no_subprocess_build— assert a call-count spy records zero xorqsubprocess.runinvocations. Must NOT assert "did not raise":build.py:477-478swallows the exception intocatalog_registered=False, so a raising stub false-greens. Scope the spy to the xorq argv so it doesn't trip on the legitimatecp -cCoW clone atsource_identity.py:111.test_zip_staged_not_committed_until_checkpoint(D1 — committed inline today).test_n_builds_track_n_zips(#48 blind spot — 4 build dirs, 1 tracked zip today).test_salt_zip_named_by_salted_hash(Risk #5 — xorq names bybuild_path.name).test_build_dir_files_not_staged(Risk #1 — no.gitignoretoday).test_failed_build_no_orphan_pointer(D1/Risk #7).
1b. Failing-first, assertion-red once this commit's stubs land. Land
stub tallyman_core/catalog.py (assert_catalog_consistent, write_recipe_zip
as no-op/identity) and the .gitignore (entries/*/ etc.) in this commit so
the tests below fail by assertion:
test_unlisted_path_not_tracked— stage a stray file, assertassert_catalog_consistentraises (stub returns without raising → red).test_recipe_zip_byte_stable_across_rebuilds— build the same code twice (res2.content_hash == res1.content_hash, thetest_portable.py:134-145idempotent pattern), zip each entry dir, assert the two archives are byte-identical. Guards the deterministic-archive claim — a defaultedZipInfomtime silently re-churns the git blob on every rebuild and nothing else catches it.test_recipe_zip_members_are_allowlist— drop strays (.xorq_build_expanded/x,result.parquet,prompts.jsonl, an unknownfoo.bin) into a real entry dir, runwrite_recipe_zip, assertset(namelist())under the<hash>/prefix equals exactly{expr.py, xorq_build/**, schema.json, manifest.json}. Pins allowlist-not-denylist: a denylist skipping only today's three known names passes existence checks but leaksfoo.bin.test_prompt_history_survives_reset— re-run appends a prompt (build.py:335), checkpoint,reset_to, assert all prompts present. Red only onceentries/*/is gitignored (pre-staged in this commit); the looseentry_dir/prompts.jsonlis then lost on reset. (Verify the bullpen copytree does not otherwise preserve it before relying on this.)test_two_entries_union_after_reset— twotracked_expr_from_aliasentriesunionafter areset_tothat restoresexpr.pyfrom the zip only (D3 half (b)). Carries nointegration/cache_lab/perfmarker — CI runs bareuv run pytestand deselects those (pyproject.toml:77), so a marked test would share the existing blind spot. Optionally add the contrapositivetest_two_load_expr_results_cannot_unionto pin whyexpr.pyis load-bearing.test_recipe_zip_contains_expr_py— assertexpr.pyis a zip member (D3).test_connect_shim_matches_xorq_api_connect— the shim-built andxo.connect()-builtParquetSnapshotCachemust derive the same content-addressed snapshot key and bake the same data (Cut B / Risk #3; the no-argxo.connect()sites aresrc/tallyman_xorq/source_cache.py:133,152). Key + data equivalence, not byte-identical — datafusion's group-by order isn't deterministic. The central Cut-B correctness claim.test_src_has_no_xorq_catalog_argv— static grep oversrc/forimport xorq.catalog/from xorq.catalogand the split-token catalog argv (_xorq(project, "catalog", "add", …)inxorq_catalog.py:31, so a literal"xorq catalog"string match misses it). Model ontest_no_bare_subprocess_git_in_src(test_reset_to_revision.py:81-89); add a sanctioned skip for thetallyman_mcp/server.py:219codegen docstring (D4).test_display_config_survives_reset— edit a display config on an existing hash, checkpoint,reset_to, assert restored. The one decomposed section with no reset coverage today (test_reset_roundtrip_over_every_kind,test_reset_to_revision.py:215-232, covers pp/stats/charts/notebook, not display).
- Native recipe store —
tallyman_core/catalog.pywrite_recipe_zip;build.pypersists the complete dir + cleans up on failure; deletexorq_catalog.pyand, in the same commit, strip every module-level coupling to it so the tree stays importable (commit 1a's suite can't run red against an un-importable tree). Three module-level imports of the deleted module must go in this commit, not later:aliases.py:24from tallyman_core import xorq_catalog+ the_xcat.add_alias/remove_aliascalls inset_alias/remove_alias(:129,:155).tests/test_catalog_xorq_integration.py:45from tallyman_core import xorq_catalog as xc(and:99from xorq.catalog.catalog import Catalog). The plan otherwise defers this file's rewrite to step 6, but its import is module-level: the momentxorq_catalog.pyis deleted the whole suite goes red by collection error, not assertion. Delete/rewrite it here (drop the_open_xorq_catalog/alias-symlink tests; keep thegit ls-filesdurability guards), so steps 2–5 stay green locally.
- Checkpoint owns zipping —
.gitignore;checkpoint_catalogzips pending +git add -A;prune_entries/consistency move intocatalog.py, withassert_catalog_consistentextended to enforce the tracked-path allowlist. - Decompose
catalog.yaml— aliases →aliases.jsonl; charts/display/pp/stats tracked directly; notebook →notebook.jsonl; pointers →entries.jsonl+compute_cache.jsonl; deletecapture/materializefor the six sections; relocate theprompts.jsonlwriter/reader (build.py,read_prompts,app.py) fromentry_dirto trackedprompts/<hash>.jsonl; relocatepost_processing/,stats/, and the notebook (project_dir/notebooks/) under the repo. - Cut B — narrow catalog-free imports + connect shim across the 8 modules.
- Test-cleanup commit — the remaining edits are fix-induced breaks (an
existing test goes red because the fix landed), not failing-first tests; they
belong with the fixes, never bundled into commit 1:
test_failed_registration_is_observable— delete, don't re-target. Its premise (a swallowed best-effortcatalog_registeredbool) vanishes with the registration path; the stray-file→raise behavior it gestures at is exactly whattest_unlisted_path_not_trackednow covers, so re-targeting would just duplicate.test_capture_seeds_xorq_keys— delete (not "rewrite"): it assertscaptureseeds emptyentries/aliaseskeys incatalog.yamlsoxorq catalog addwon'tKeyError; withcatalog.yamland the subprocess both gone the invariant ceases to exist. Breaks when step 4 stops writingcatalog.yaml.test_xorq_catalog_add_registers_after_genesis— goes red on the fix branch because the zip is now written at checkpoint, not inline at build. Edit to checkpoint-then-assert (passes only post-fix), so it lives here, not in failing-first. Its "best-effort xorq registration must land" docstring is dead.test_catalog_repo_branch_is_main— docstring-only edit (drop the xorq rationale; themain-branch assertion stays valid for our native repo).- The capture/materialize round-trip tests in
test_display_configs.pybreak when step 4 deletes those primitives; this commit removes them, and the newtest_display_config_survives_reset(commit 1b) keeps net reset coverage from regressing.
- Rebuild + verify — rebuild the corpus;
git ls-filesshows only the tracked tree above; genesis→create×2→reset roundtrip passes; full suite green locally before push.
- No change to tallyman's own
pyproject.toml/xorq pin (fact 5 —xorq.catalogcan't be narrowed out of the single wheel). Thepyproject.toml+uv.lockadded to the tracked tree are a separate, per-catalog-repo env lock (the reproducibility unit), not a change to tallyman's deps. No merge/sync surface (fact 4). No xorqassert_consistency/BuildZip/test_zipcontract in the native store (fact 1). - No clone-restore / reconstruct-from-zip implementation; D3 keeps the zip able
to support it (whole recipe +
<hash>/prefix). - No on-disk migration; rebuild the corpus.
- Recipe authoring-surface switch (D4) is a separate, codegen-validated follow-up.
- Collapsing
entry_hashesintogit ls-filesis a separate look. - Runtime per-project venv isolation (build each project inside its own venv,
recreated from the tracked lock, instead of the shared in-process env) is a larger
architecture change — tracked in #106. This plan settles the reproducibility
record (the tracked
pyproject.toml+uv.lock) only; the in-process build stays.
- Rebuild operational path. The env half is settled: recreate the project's
one venv from the tracked
uv.lock, then rebuild every expression in it (one venv per catalog repo, not per expression). Still open: what you actually run to re-execute (re-run storedexpr.pyviabuild_and_persistin a loop vs re-drive the MCP/codegen session), whether atallyman rebuildaffordance is warranted (first-project~119MB,parking_ticket_analysis_1m~10 entries → minutes-to-tens per large project), and whenpyproject.toml/uv.lockget snapshotted/refreshed (snapshot from the active env at genesis, plus a refresh when a recipe needs a new dep). Needs a CLI check before settling. Whatever the mechanism, its verification should assert execution-equivalence (rebuilt entries execute to identical data), not just a structural roundtrip — xorq'stest_rebuild_produces_consistent_targetexists because a rebuild can match on schema/recipe/hash while silently returning wrong rows. - Genesis baseline behavior post-decomposition. (The failing-first list is now
settled above as commits 1a/1b.) The
.gitignorecontents are no longer open: they are concretely defined byGITIGNORE_LINES(src/tallyman_core/catalog.py) and written bywrite_gitignore. The genuinely-open follow-up is the build-time alias-revision limitation (#84).