Round two: five lenses on structure, every finding dispositioned
Date: 2026-08-29
Status: implemented 2026-08-29 — sixteen commits 0829857..5e8d2ca (four
parallel file-owner tasks, then three more, then one fix wave), 69 files,
+1848/−354; every "fixed" disposition landed with its named test, the refused
list is untouched, and the whole-branch review (Feathers-style sensing on
four mutants: three dead on arrival, one — _config_ladder precedence —
killed in the fix wave) found one Critical (a raising callable empty=
escaped @tool) and three Important, all fixed and re-verified. Deviations
and findings are recorded below.
Depends on: onion seams (2026-08-29-onion-seams-design.md), the
structure note (docs/architecture/structure-and-integration-points.md),
which proposed this round and its lenses.
Base: main at d9dd19b, all gates and CI green.
Situation, complication, answer
The seams round settled module internals. The structure note named the next lenses — Ousterhout on the tool surface, Wickham on data shapes, Wilson + Broman on what a workspace must provide, and two designated dissents, Howard (notebooks-first) and Petrov (declare the DAG) — and the instruction for this round is to address every issue they raise.
"Address" has one meaning here: each finding is either fixed, with the test that names it, or refused in writing with the reason a reader can overturn. A dissent's finding that does not survive the repo's measured constraints is addressed by the refusal; silence is not a disposition. Five reviewers produced 34 findings; 22 are fixed below, 11 refused, and one is a comparison the reviewer made for the record. The three seam candidates the seams round's final review named are carried in as fixes.
Method
Same protocol as the seams round: five read-only reviewers in parallel,
the same measurements (46 tools — feed 21, sym 8, runs 7, kg 6, cite 4;
schema+description bytes min 138, median 608, max 2083, total 32.6 KB;
three zero-argument tools), each asked for file:line, a test, a "leave
alone" list, and — for the dissents — an honest "Adopt / Argued, not
adopted" split. Reports are in the plan's workspace and summarised here.
Dispositions
Ousterhout — the tool surface (6 findings: 5 fixed, 1 recorded)
- O1 fixed.
feed.personasandfeed.persona_statusare two shallow readers for one question. Fold the zero-argument list intofeed.persona_status(user: str | None = None): omituserfor every persona's name and interests (today'sfeed.personaspayload, no training computation), pass it for one persona's detail. The surface becomes 45 tools (feed 20); the routing rules gain "who are the personas" →feed.persona_status. Test:test_persona_status_with_no_user_lists_every_persona. - O2 fixed.
explain.explain()collapses three causes intoNoneand its docstring apologises for it. ReturnExplainResult(text: str | None, reason: Literal["ok", "unknown_user", "model_unreachable", "no_answer"]);_explain_itemraises a reason-specificToolErrorforunknown_userand keeps today's wording for the other two. Test:test_explain_distinguishes_unknown_user_from_model_down. - O3 fixed.
mcp/feed._resolved_tagandmcp/knowledge._path's inline resolve loop write the same canonicalise-check-refuse shape twice.kg.resolve_or_raise(name, members, *, kind) -> strraises aValueErrorwhose message names the kind and the recovery tool; both callers use it with their own membership set (the sets stay different —_resolved_tag's comment records why). Test:test_resolve_or_raise_reports_the_right_kind_in_its_message. This is also the carried-over seam "_resolved_tagover a vocabulary set". - O4 fixed.
runs.claims_checkandcite.checkstate their pairing only in prose a small model was measured to miss. Each response gainschecked: list[str]—["numeric", "citation"]/["numeric"]/["citation"]— declared inempty=too. Test:test_claims_check_response_states_what_it_checked. - O5 fixed.
sym.derivationreturns prose-shapedstepsfordifferentiatewith nothing in the response saying so. Addtraced: boolto the payload and to_EMPTY. Test:test_sym_derivation_flags_untraced_differentiate. - O6 recorded.
classifier_probs'sNoneis one-dimensional and contained;explain's was multi-cause — the comparison is why O2 is scoped toexplainalone. No change.
Wickham — data shapes (9 findings: 5 fixed, 3 refused, 1 tested)
- W1 refused.
ranking_quality's value-dependent keys are the measured answer to a 2000-character response budget (tests/test_response_size.py); always-presentcaveat: nullwould cost 40–60 bytes on every ranked response for a field callers already.get. The reviewer reached the same conclusion. - W2 fixed.
RankedItem,_item_rowand theitemsrow are three shapes for one item with silent renames (id→item_id, a join →source).RankedItem.to_row(*, summary=False) -> dictowns the projection;mcp/feed.pycalls it at its three sites. Test:test_ranked_item_to_row_matches_item_row_fields(literal object, no DB). The carried-over seam "_read_item/_list_itemsshaper" lands here: the 3-tag/n_tagsbudget rule is applied insideto_row, so it is tested with no database. - W3 refused (already tidy).
compare()'sarmsis long-format; a client never re-melts. - W4 fixed.
Verdict.split/Verdict.stepare the matched run's values under the same names asClaim.split/Claim.step, the requested ones. Rename tomatched_split/matched_step; call sites inclaims.py,mcp/claims_tools.py,cli.py, tests. Test:test_verdict_matched_split_is_distinct_from_claim_split. - W5 fixed (documentation). "persona" (prose) and "user" (code,
schema) name the same row with no glossary line saying so. One
sentence in
personas.py's module docstring and indocs/concepts.md's persona entry. No rename. - W6 refused (already tidy).
tag(storage) vsconcept(graph node after frequency filter and aliasing) is a real boundary at the module edge. - W7 fixed.
feeds.pystill saysadd_feed/remove_feed/preview_feed/suggest_feedswhile the tool surface already sayssource_*. Rename toadd_source/remove_source/preview_source/suggest_sources; callers and tests follow;CLAUDE.md's "add_feed is register-only" line follows. Test:test_feeds_module_names_match_the_source_tool_vocabulary. - W8 fixed.
mcp/citation._as_dictre-listsReference's fields and silently dropsarxiv_id.Reference.to_row()owns the projection and its docstring states the omission. Test:test_reference_to_row_omits_arxiv_id_on_purpose. - W9 tested, fixed if red.
_arms_for_runwrites a nested-arm key (arms.Treatment_Eigen) and a genuine eval split (test) into the samesplitcolumn;_caveats' "different splits" warning could describe arm keys as splits. Writetest_caveats_do_not_confuse_nested_arm_keys_with_eval_splitswith literal dicts; if it fails,_caveatsskips rows whose split came fromnested_arms(the smallest change), and the outcome is recorded here.
Wilson + Broman — the workspace contract (5 findings: 5 fixed)
- WB1 fixed. A results CSV needs a label column (
config_name, config, name, run, variant, arm, label, id) and no document says so before the first scan. One sentence indocs/guides/ledger.md; a test that every name in_label_of's tuple appears there. - WB2 fixed.
corpora.toml— the declare-it-yourself corpus manifest — exists, is tested, and is documented only in a design spec. A "Declaring a corpus" subsection indocs/guides/ledger.mdand a line indocs/concepts.md; a test that the guide names the file andLEDGER_CORPUS_FILE. - WB3 fixed. A run has
started(the artifact's own time) and no record of when the ledger read it. Migration addsruns.scanned_at(NULL for rows recorded before it);scan()sets it;detail()andruns.detailsurface it. Test:test_detail_reports_when_it_was_scanned. - WB4 verified, fixed if unstable. Nothing says how to cite a run.
Test whether
runs.idsurvives a re-scan of an unchanged project; if it does,docs/guides/ledger.md's Browsing section says the Datasette row URL is the citation and why the id is stable; if it does not,scan()upserts by(project, name)so it becomes stable, and the same sentence is written after. Outcome recorded here. - WB5 fixed.
CONTRIBUTING.md's bug-report row asks for a hand- scrubbedfindbesideattest runs scanoutput, whosediagnose_emptymessage is already scrubbed by construction. Lead with the scan output;findbecomes the fallback; note the message fires only on an empty scan.
Howard — notebooks-first, the dissent (7 findings: 4 adopted, 3 refused)
- H1 adopted.
test_the_readme_commands_are_the_run_sh_commandsaccepts a match inside a#comment;examples/ranking/run.shpasses that way. The test requires the match on an executed line;examples/ranking/is brought in line with how the other twelve paths satisfy it. - H2 adopted, narrowed. One pinned line per path is asserted; the rest
of "What it prints" can rot. For
none-prerequisite paths, every line of every fenced block in "What it prints" must appear in real stdout, except elision lines (.../[...]). READMEs whose blocks do not match are corrected — that is the point. - H3 adopted.
examples/mlflow/README.md's AUC table is hand-typed and nothing reads it.test_the_committed_mlflow_table_matches_a_live_compareparses the Markdown table and compares it toledger.compare(...)["arms"]row for row. - H4 adopted. Each
examples/<name>/README.mdopens with<!-- checked by tests/test_golden_paths.py -->; the discovery test requires it. Legibility only; discovery-by-directory is unchanged. - Refused:
.ipynbanywhere (the attribution guard has no JSON-output scrubber;mkdocs.ymlhas no notebook renderer; diff noise); per-examplecheck.py(trades "no test edit to enrol a path" for legibility the pointer gives); CI re-running--live(the offline guarantee and a 10–15 minute cost).
Petrov — declare the DAG, the dissent (7 findings: 2 adopted, 5 refused)
- P3 adopted.
dvc.lockrecords an md5 per output;_dvc_runsparses the file for params and walks pastouts:. A hand-editedmetrics/0.01.jsonrescanned silently asauc=0.5(verified in a scratch copy). On scan, hash each DVC metric file and compare to the lock's digest; on mismatch setRunRecord.notesto name it, and surface it as a caveat incompare().hashlibonly. Test:test_dvc_lock_hash_mismatch_is_recorded. - P5 adopted, narrowed.
metric_direction.tomlandcorpora.tomlshare one env-then-workspace-then-~/.hermesladder written twice;corpus.py's own comment says the repo should not grow a second convention, then does. One helper (ledger._config_ladder(env_var, filename)or equivalent) used by both; the two files and their names stay (they are documented and tested). - Refused: corpus identity from a
deps:hash (helps only projects thatdvc addtheir inputs — not the corpus problemcorpus.pysolves); arms from a declared DAG (already true wherever advc.yamlexists;family_ofruns only where there is nothing to declare);ADAPTER_CAVEATSas a defect (a recorded trade in the tracker-adapters spec); git SHA and dirty flag per run (offline and cheap per run, butgit log -1 -- pathis ~10 ms and the live ledger holds >1000 runs — a measured cost for a guarantee that cannot be promised across dormant, non-git projects; revisit if a per-project rather than per-run form is wanted); reportingforeachitems neverdvc repro'd (Datasette'sunevaluated_configsanswers it one layer up).
Carried over from the seams round's final review (3: all fixed)
mcp/feed._read_item/_list_itemsshaping → insideRankedItem.to_row(W2)._resolved_tagover a vocabulary set →kg.resolve_or_raise(O3).ingest.run_ingest's per-feed policy (theembedder_downlatch and per-feed failure decision) → a pure_ingest_outcome(results) -> dictover per-feed outcomes; the loop only fetches. Test:test_ingest_outcome_latches_embedder_down_over_plain_outcomeswith literal outcomes.
What is tested
Every fixed item names its test above; each test uses no database or
model unless the item is I/O by nature (WB3, WB4, P3 use seeded_db or a
scratch copy of examples/dvc). The tool count and per-namespace counts
in CLAUDE.md and docs/guides/agents.md follow O1 and are asserted by
test_claude_md_tool_counts_match_the_live_surface. Envelope changes (O4,
O5) go through empty=/_EMPTY so tests/test_tool_envelope.py stays
structural.
Not in scope
- Anything under "refused". Each is written to be overturned by editing this section, not by re-arguing it in a review.
- Renaming the
userstable or thefeed.*namespace (W5, W7 keep both).
Success criteria
- Every one of the 34 findings appears above with a disposition; every "fixed" item has landed with its named test; every "refused" item has a reason a reader can check.
- The suite, the eight gates,
mkdocs build --strictand CI are green; the tool surface is 45 with the counts inCLAUDE.mdanddocs/guides/agents.mdmatching the live surface. - W9 and WB4 record what the test found, not what was hoped.
Deviations and findings
Recorded during implementation of WB3, WB4, P3, P5, and W9 (ledger code task, this wave).
-
W9: GREEN on arrival.
test_caveats_do_not_confuse_nested_arm_keys_with_eval_splitspassed against_caveatsunmodified. Traced why:_split_rank("arms.A")and_split_rank("arms.B")both fall through every_EVAL_SPLITS/_TRAIN_SPLITSprefix check and returnlen(_EVAL_SPLITS)(7) -- the same value_split_rank(None)returns for an unlabelled split. Two nested-arm keys therefore always land at the same rank as each other (and as "unlabelled"), so_caveats'len(ranks_seen) > 1check never sees a difference between them and the "arms are judged on different splits" caveat cannot fire on a nested-arm family. This is accidental correctness, not a documented invariant: the risk Wickham's finding 9 named (a real new eval split colliding in rank with a nested-arm key, e.g. an arm literally namedtest.something) is still live, since_split_rankwould then return a rank less than 7 for that one key and trip the mismatch check for the wrong reason. No change made per the brief (_caveatsstays byte-identical); flagged here rather than silently trusted. -
WB4: unstable as found; made stable by upsert.
test_run_ids_survive_a_rescan_of_an_unchanged_projectwas RED before any change:_replace_projectdeleted every row for a project and re-inserted, soruns.id(anINTEGER PRIMARY KEYrowid) advanced on every scan even when the same(project, name)pairs were found again -- despiterunsalready declaringUNIQUE (project, name), which prevented duplicates but did nothing for id stability under delete-then-insert. Fixed: migration 006 adds a named unique indexidx_runs_project_name(a no-op on a fresh database, which already gets the same index via the inlineUNIQUEplus an explicit named copy added toSCHEMAfor consistency; real work only on a database migrated from before it existed), and_replace_projectnow upserts each record viaINSERT ... ON CONFLICT(project, name) DO UPDATE, deleting only theruns(and theirrun_metrics) rows for names that vanished from the artifacts -- a bulkDELETE ... WHERE project = ?up front, same as before, but now scoped to just the stale ids rather than every row in the project.run_metricsfor a surviving run is no longer bulk-deleted per project either: each surviving run's own metric rows are deleted and re-inserted per run, inside the same upsert loop, immediately after that run's id is resolved -- functionally equivalent to the old per-project bulk delete (every metric row for every touched run is still replaced on every scan), just scoped per run instead of per project, which is what lets a metric that disappeared from one run's artifact vanish without touching a sibling run's rows. Regression test:test_a_run_that_disappears_from_disk_is_removed_on_rescan(fix round 1 after review) -- confirmed RED by temporarily disabling the stale-id deletion branch, GREEN with it restored. One implementation pitfall worth recording:cursor.lastrowidafter an upsert that takes the UPDATE branch is NOT reliable in Python's sqlite3 (measured: it returned the previous INSERT's rowid -- a sibling run from earlier in the same loop -- rather than the row just updated, corruptingrun_metrics.run_idforeign keys on the second scan of a multi-run project). Fixed by looking up each existing id from aSELECTtaken before the upsert loop runs, and falling back tocursor.lastrowidonly for a genuinely new row.docs/guides/ledger.md's Browsing section is NOT edited here (owned by another task this wave); this paragraph is the factual basis for whoever writes "the Datasette row id is stable across a re-scan of the same project/name pair" there. -
Config ladder (P5): the two ladders differed in two ways, not one.
_metric_direction_path()(a) took noworkspaceargument -- nothing had ever asked for a per-workspace override ofmetric_direction.toml-- and (b) always returned aPathregardless of whether the file existed, because its callers use it to name where to create the file in a refusal message (no metric with a known direction ... Declare one under [metric_direction] in <path>), and the common case for that message is precisely that the file does not exist yet.manifest_path()(a) took an optionalworkspaceand checked it, and (b) checkedis_file()itself and returnedNonewhen nothing was found, because its caller (load_manifest) needs to distinguish "no manifest" from "a manifest at an empty path". Resolution:ledger._config_ladder(env_var, filename, workspace=None) -> Pathis the shared path-resolution step only (env, then workspace file if present, then~/.hermes/<filename>), always returning a path and never checking existence -- each caller keeps its own existence-checking behaviour on top._metric_direction_path()calls it with no workspace and returns the bare path.corpus.manifest_path()calls it with the workspace and applies its ownis_file()check, preserving itsPath | Nonereturn exactly as before (verified: env-path missing, workspace file present, and nothing-anywhere all reproduce the pre-refactor behaviour).corpus.pyimportsledger._config_ladderlazily inside the function body, not at module level, sinceledger.pyalready importscorpuslazily insidescan()/_link_corpora-- a module-level import back would cycle. -
P3 (dvc.lock hash mismatch): adopted as specified.
_dvc_lock_outsparsesdvc.lock'souts:block the same way_dvc_lock_paramsparsesparams:(line-indentation walk, no YAML library)._dvc_runshashes each metric file withhashlib.md5whendvc.lockrecords a digest for it and compares; a mismatch setsRunRecord.notesto"dvc.lock hash mismatch: <relpath> changed since dvc repro".ledger._family_rowsnow selectsr.notes(previously omitted) and a new_dvc_hash_caveatshelper surfaces only notes containing that exact substring ascompare()caveats -- deliberately narrow, sincenotesalso carries unrelated adapter text (a config file's prose header) that must not be reprinted as a trust warning. -
W7:
list_feedsrenamed too, though the brief's Produces list only named four. The brief's interface line listsadd_source,remove_source,preview_source,suggest_sources-- four functions, matching Wickham's finding 7 one-for-one with thesource_*tools. But the brief's own RED test asserts over feeds.py's whole public surface ({n for n in dir(feeds) ... n.endswith(("feed", "feeds"))}), andlist_feeds(backingfeed.sources) ends in "feeds" -- left alone it fails that literal test. Renamed tolist_sourcesfor consistency with the other four and to make the stated test pass; the private@toolwrapper name_list_feedsinmcp/subscriptions.pyis untouched (it is aliased bymcp_server.py's_list_feeds_impl, a file outside this task's ownership) -- only its body's call tofeeds_mod.list_feeds(conn)becamefeeds_mod.list_sources(conn). -
C3:
_ingest_outcome'sembedder_downkey is conditional, not always-present. The brief's illustrative RED test only exercises the true case (out["embedder_down"] is True) and leaves the false case unspecified. The global constraint saysrun_ingest's return keys stay byte-identical to today's, and today'sstatsdict never carriesembedder_downunless it latched true (tests/test_ingest.py:: test_ingest_adds_items_and_vectorsasserts exact dict equality with no such key on a clean run)._ingest_outcomematches that: it setsstats["embedder_down"] = Trueonly when at least one outcome reports it, otherwise the key is absent -- so a normal run's dict is exactly{added, skipped, failed_feeds}, unchanged from before the split. -
Task 2 (O2/O3/W2):
model_unreachablevsno_answerneeded a new signal the brief did not specify -- corrected in fix round 2.ExplainResult.reasonpromises three distinct failure causes, but the pre-existinggenerate_explanationnode caught every exception the same way and returned a bareNone-- nothing in it distinguished "the chat backend is unreachable" from "the model replied but the reply failedExplanation.model_validate". AddedExplainState.explanation_reason, set bygenerate_explanation's except clause. The FIRST version of this (fix round 1's predecessor) matched on a hand-picked(OSError, ConnectionError)tuple, which review caught as wrong:httpx.ConnectError-- whatllm.ChatClientactually raises for a dead backend -- is not a subclass of either, so a real dead-backend failure was misclassified as"no_answer", and the one test guarding it raised a bare PythonConnectionErrorthat does not occur in production, so RED->GREEN validated a mapping against a fixture that did not match reality. Fixed in fix round 2: classify withattestation.ports.backend_unreachable(exc)(the repo's one classifier for this, already shared byingest.py's embedder-down latch andfeatures.py's tagging pass) inside a singleexcept Exception as exc--model_unreachablewhen it returnsTrue,no_answerotherwise.explain.pystill imports nothing fromattestation.llm(onlyattestation.ports, which itself does not importllm.py).test_explain_never_raises_on_chat_exceptionnow raises the realhttpx.ConnectError(matching the construction intest_features.py/test_ingest.py's own backend-down tests); a new siblingtest_explain_reports_no_answer_for_a_non_transport_exceptionraises a plainValueErrorand asserts"no_answer". Mutant-proofed: with the old(OSError, ConnectionError)tuple restored, thehttpx.ConnectErrortest is RED (AssertionError: ... 'no_answer' == 'model_unreachable'). -
Task 2:
_explain_item's newunknown_userbranch is dead code under normal MCP calls, by design of@tool(needs_user=True)._tool.py's wrapper resolvesuser_rowfrom the caller'susername before_explain_item's body runs, and returns the failure envelope directly if the name does not resolve -- soexplain_item_fn(conn, user_row["id"], ...)is always called with an id that was valid a moment earlier.ExplainResult.reason == "unknown_user"can therefore only be reached if the persona is deleted in the window between that lookup and this call (a real but narrow race), or by a caller ofexplain()outside the MCP tool layer (server.py's/explanationroute, which does not branch onreasonat all and just falls back to""). Implemented anyway per the brief's explicit instruction ("_explain_itemraises a reason-specificToolErrorforunknown_user"); no test exercises this branch through_explain_itemitself for that reason --test_explain_distinguishes_ unknown_user_from_model_downexercisesexplain()directly instead, as the brief's own test code does. -
Task 2: shared-tree resets. Twice during this task a concurrent implementer's
git reset/git stash(on files outside this task's ownership) reverted the whole working tree, including uncommitted edits in this task's own files (kg.py,rank.py,mcp/feed.py). Both times the loss was caught by re-running the target tests immediately after and seeing them fail RED again; both times the edits were reapplied from scratch rather than recovered from a stash, since by the time it was noticed the affected files had already diverged further. Final state was cross-checked line-by-line against the dropped stash commit the coordinator later pointed at (ed5dd33) and found to be a strict superset of it for every file this task owns. No code or test content was lost in the end, but the sequence is worth naming: a shared-tree wave without per-task worktrees can silently discard a concurrent task's uncommitted work, and the only defence available mid-task was committing sooner and re-verifying after any large gap between edits and test runs. -
Task 1: the callable
empty=form of@toolis a new general capability, not a one-off._profile_status(user=None)needed two distinct envelope shapes from one tool, and a fixedempty=dict cannot express that -- sotool()grew a secondemptyform: a callable(kwargs) -> dict, resolved once per call from the tool's own arguments. This is the largest single deviation of the round: every tool on the surface goes through@tool, so the decorator's contract changed for all of them, not justfeed.persona_status. Documented in_tool.py's own docstring (the shape, theconn/user_rowexclusion, the positional/ keyword binding, and a "prefer a plain dict" steer for every other tool). This fix wave's item 1 (C1) closes the one place that capability was left unprotected: a raising callable escaped thetryblock that shields every other kind of failure in the same wrapper. -
Task 1: touched three files outside its declared list.
symbolic_ops.py(thetracedflag O5 needed),tests/test_response_ size.py(a two-line trim once its budget imports moved toattestation.rankunder W2), and three docs' tool counts (CLAUDE.md,README.md,docs/guides/agents.md) — all forced by the fold itself (a tool disappearing changes the count everywhere it is stated) rather than scope creep, and flagged to the task's reviewer as named risks at the time, but never written up here. -
Task 1:
_profile_statusdroppedneeds_user=True. The oldfeed.personastool never took auserargument and the old per-persona detail tool always did; folding them into one function means the no-user branch must stay reachable, which@tool(needs_user=True)cannot do -- it resolves-or-refuses a user before the body ever runs. The merged_profile_statusinstead callsrank.get_userandunknown_user_messageby hand only whenuseris not None, preserving the old refusal wording without the decorator flag. A deliberate and correct call, explained in the function's own docstring, but it is a caller-visible change to which tools ask the decorator to resolve a user and which resolve one themselves, and it was missing from this section. -
Task 5 (WB3/WB4/P3):
hashlib.md5(..., usedforsecurity=False)was required to clear the security gate, not a stylistic choice. P3's dvc.lock hash comparison uses MD5 because that is whatdvc.lockitself records per output -- comparing to anything else would compare against a digest DVC never wrote. Bandit's B324 flags any unqualifiedhashlib.md5call as a weak-hash finding regardless of use case, and the gate failed on it untilusedforsecurity=Falsewas added. The code carries a comment saying why MD5; it did not, until now, say that the gate is what forced the keyword. -
Task 3 (W4/W8): the brief's own code snippet had two wrong constructor facts. The task brief's illustrative snippet for adapting
VerdictandReferenceconstruction did not match the real constructors' field order/defaults in two places; the implementer built to the real constructors rather than the brief's snippet, per the brief's own instruction to prefer the code over the illustration. Recorded in the task's own ledger entry but not carried into this section. -
T7's
git stashin the shared tree was the first of two incidents, not the second. §6 of the final review and this section's "Task 2: shared-tree resets" paragraph above both describe T2's twogit reset/git stashincidents and the coordinator's resulting no-stash rule in detail. That rule was adopted after T7 stashed first -- T7's own incident is recorded in the wave's ledger but was never written up here, so the record understated the pattern to one occurrence when there were two. No content was lost from T7's stash either (its 13/13 pointer claim is confirmed at HEAD), but the omission is the same shape as the others above: a real deviation that happened during implementation and did not make it into the one section meant to hold all of them.