Every ticket as the root of its provenance graph — goal, patch, approval, runs and (as the ticket-class build lands) mandate, manifestations, guarantees, journey, artifacts and children. Rendered through the <<<TicketGraphList>>> tag.
Forge evolution PART 2 of 2: EVOLUTION TIMELINE (root -> fork-point -> tip dates + fork width, plus omni ingest stamps as a second clock) + mechanical CONTAINMENT retirement gate + the 118-blob orphan/merge-home worklist. Read-only; consumes #445's JSON.
goal
PART 2 OF 2. Part 1 (#445) LANDED on main: tools/forge_family_tree.py clusters 264 bare repos into
109 lineages, classifies each, and emits --json. It deliberately never retires or merges anything.
THIS ticket adds the two things Part 1 does not do: the EVOLUTION TIMELINE and the RETIREMENT GATE.
DELIVERABLE: checkouts/current/congruency/tools/forge_evolution.py — standalone, stdlib + git only.
CONSUME Part 1, do not re-derive it: run `forge_family_tree.py --json -` (or accept --family-json PATH)
and build on its lineages/members/tips/classification. Part 1 already solved tip resolution (183 of
264 repos have an UNRESOLVABLE HEAD — a symref to a refs/heads/master that does not exist; `rev-parse
HEAD` prints the literal string "HEAD" and EXITS 0). Never re-resolve tips yourself; never trust HEAD.
1. EVOLUTION IN STAGES — the temporal dimension. Part 1 carries NO time data at all (verified: zero
ts/date keys in its JSON). Git already has every date for free. For each lineage emit a TIMELINE:
* root commit date (`log -1 --format=%ai <root>`)
* FORK POINT date per member (`log -1 --format=%ai $(merge-base <canonical-tip> <member-tip>)`)
* tip date per member
* the fork WIDTH — tip date minus fork-point date — i.e. how long the fork ran alone.
Order members by fork-point date so the tree reads as stages, oldest first.
WORKED EXAMPLE to reproduce (measured, agent_tools_suite lineage, root 379ac05319):
2026-07-08 20:38 root 379ac05 "agent_tools_suite: crankimg ..."
2026-07-11 02:23 FORK POINT = agent_tools_suite tip 888d3447c6 ("add deploy_test loop")
2026-07-12 14:07 jail tip 38e95ad16d -> jail ran 4 days alone, adding 6 files
2. THE SECOND CLOCK — omni ingest stamps. omni.git's refs are `refs/archives/<TS>/<repo>/heads/<b>`:
when a snapshot was OBSERVED, distinct from when code was WRITTEN. Where a member's tip appears
under an archive ref, report its ingest ts alongside the git dates — that is what reveals a repo
was already stale when captured. TWO HARD RULES: (a) omni is ENRICHMENT ONLY and is INCOMPLETE
(measured: it is MISSING agent_tools_suite's tip 888d3447c6) — config/forge/remotes/*.git stays
authoritative, a missing omni stamp is simply omitted, never an error; (b) `occ.ts` is SOMETIMES
THE LITERAL STRING 'gitlab', not a date — never render it as if it were always a timestamp.
3. RETIREMENT GATE — CONTAINMENT PROOF (mechanical, not a judgement). A fork is SAFE TO OMIT IF AND
ONLY IF every file at its tip resolves, by CONTENT IDENTITY, to a blob present in the home repo(s).
Use the SAME module-sha the ticketer computes:
sha256("raw:" + bytes)[:16] — non-Python, or Python that does not parse
sha256("ast:" + codebean._ast_norm(src))[:16] — Python that parses
AST-normalized identity means a file that came home with only whitespace/comment drift still counts
as CONTAINED (same module), while a semantically changed file correctly FAILS and surfaces as work.
* every blob resolves -> report "SAFE TO OMIT (proven)" — proven, never assumed.
* any blob unresolved -> emit the exact file list. The fork MUST NOT be dropped while that list
is non-empty. THE UNRESOLVED LIST *IS* THE MERGE-HOME WORKLIST; it
shrinks to empty exactly when the fork is safe to drop.
4. THE ORPHAN SET. Measured across the forge: 118 blobs (2.1% of 5,741 unique code blobs) exist ONLY
in non-canonical tips — content that would be SILENTLY LOST by naively unifying on canonical tips.
Emit them as a first-class report: blob sha, path, which member holds it, which lineage. This is
small enough to review by hand and is the highest-value output of this tool.
5. DIRECTION IS NOT YOURS TO DECIDE. Part 1 already accepts --override / --override-file for the
operator's per-PACKAGE home mapping (a grab-bag fork like jail has packages belonging to SEVERAL
different homes). Honour that mapping; PROPOSE only; never infer direction from topology. LINEAR
and FORK-CAME-HOME are the same git shape and differ only in intent, which is not in the DAG.
NOTE: for LINEAR the direction cannot lose code (canonical supersets the rest) — it is a naming
decision. Only DIVERGED/SPLIT-STORE put content at risk. Say so in the report.
6. READ-ONLY, ALWAYS. Never write to config/forge/remotes/*.git — the bare repos are the archive.
Use `git show`/`cat-file`/`ls-tree`/`log`/`merge-base` plumbing only. Never write to the
governance db. This tool PLANS retirement; it never performs it.
7. --selftest on THROWAWAY git repos in a temp dir: a fork fully contained in home (-> SAFE TO OMIT),
a fork with one un-merged file (-> worklist of exactly 1, NOT safe), whitespace/comment-only drift
(-> contained via ast identity), a semantic change (-> correctly NOT contained), and a fork-width
timeline assertion. No network, no live db, no writes outside the temp dir.
ACCEPTANCE: --selftest green; a live read-only run reproduces the measured 118-blob orphan set and
prints the agent_tools_suite timeline above (root 2026-07-08 20:38 -> fork point 2026-07-11 02:23 ->
jail tip 2026-07-12 14:07, width 4 days, 6 files added); jail reports NOT-safe-to-omit with its 6
files as the worklist (they have not come home yet); no writes anywhere. RUN ON CLAUDE.
patch
none
approval
unapproved
runs
no runs recorded
mandate (clauses)
not yet recorded — lands with the ticket-class build
manifestations
not yet recorded — lands with the ticket-class build
guarantees
not yet recorded — lands with the ticket-class build
journey (blunders & successes)
not yet recorded — lands with the ticket-class build
artifacts (forge)
not yet recorded — lands with the ticket-class build