The ring migration

Between commit 94b9ae1 and 341be8d, the head of

rearchitect/graph-vector-ports as it was merged, redstring was rebuilt from

a service-and-ORM application into a library with pluggable storage. 171

commits, 33912 insertions and 62029 deletions across 362 files, src/

going from 123 Python files to 81.

Those figures are the one part of this document that decays, so they name the

range that produces them rather than standing on their own:


git diff --shortstat 94b9ae1 341be8d
git rev-list --count 94b9ae1..341be8d

341be8d is the second parent of the merge commit 15a948c; work landing on

main afterwards is not part of the migration and is deliberately outside the

range.

This document exists because the reasoning behind that has no other home. The

working notes it was extracted from โ€” eleven briefs, eleven implementer

reports, ten reviews, 4.2 MB โ€” were never tracked and are gone. What survives

is the tree, the commit messages, the ADRs in docs/adr/, and twelve

recovery/* tags.

Everything below is either verifiable from the tree today or names the git ref

that holds the evidence. Where a claim is about something that no longer

exists, the ref is the evidence, and the next section explains how to read

one.

Every recovery ref below is also an annotated tag, so it survives a

squash-merge, a rebase, or gc. git tag -l 'recovery/*' lists all twelve โ€”

the same set the two tables below account for, no more and no fewer โ€” and

each tag message says what it holds and which BACKLOG entry discusses it.

Recover a deleted file with git show recovery/:, e.g.

git show recovery/strategy-router:src/redstring/extraction/strategy_router.py.

Eleven of the twelve mark a deletion. One does not.

recovery/schema-org-preport marks a port: extraction/schema_org.py is

still in the tree today, and the tag exists to hold the state it was in

before it moved off the EntityType enum. The git show recipe applies to

it unchanged โ€” it is an ordinary commit-ish โ€” with one adjustment, because

the tag names the port commit rather than the state before it:

```

git show recovery/schema-org-preport~1:src/redstring/extraction/schema_org.py

```

The tag itself (1b915f8) already has the ported file; ~1 is the pre-port

one, importing EntityType and ExtractionMethod from

models/extracted_entity.py. Reading both is the diff, and the diff is the

point of the tag.

Push the tags along with the branch โ€” git push origin --tags โ€” or they

exist only on the machine that made them.

What the library is now

Five things, and nothing else:

suite each (src/redstring/testing/) that is the actual definition of the

contract. Adapters: in-memory (default gate), Neo4j, pgvector.

read model of projections (projections/). Extraction emits; projections

write. Nothing else writes to a store.

similarity, blocking, interval arithmetic, temporal parsing.

concerns (retry, rate limiting, circuit breaking, caching) behind a Cache

port with an in-memory default, so the library runs with no infrastructure.

place both halves meet.

There is no ORM, no session, no SQLAlchemy, no settings object, no schema this

library expects a caller to have migrated, and no code that fetches a document.

What was deleted, and where to get it back

The contract this section keeps is *every recovery/ tag is accounted for

below* โ€” the twelve git tag -l 'recovery/' prints appear here, and nothing

appears here that is not one of them. That is the property worth checking when

a tag is added or this document is edited.

The tags are not all the same kind of thing, so they are split into two tables.

Deletion refs. Each ref in the table below is the last commit at which the

path exists; the path is gone from the tree today, and git show :

is how you read it back. All were verified resolvable when this document was

written.

Capability Path Ref Replaced by
Document sourcing, scraping src/redstring/scraping/ 94b9ae1
recovery/sourcing
Nothing โ€” out of scope, by decision
Vendor extractors, inference providers src/redstring/inference/ a75015a
recovery/vendor-extractors
LlmProvider port + llm/adapters/
Preprocessing, chunkers, mergers src/redstring/preprocessing/ bd40882
recovery/preprocessing
extraction/chunking.py, extraction/merging.py
Fuzzy merging (SimpleMerger, LLMMerger) src/redstring/services/consolidation/ ff36ec7
recovery/fuzzy-merging
consolidation/ on the ConsolidationLog aggregate
Temporal parser service src/redstring/services/temporal_parser.py d49f56b
recovery/temporal-services
domain/temporal_parsing.py::parse_temporal
Timeline query / export / cache src/redstring/services/ d49f56b
recovery/temporal-services
temporal/ (query); export is a genuine loss, see BACKLOG B47
Strategy router src/redstring/extraction/strategy_router.py 66f589d
recovery/strategy-router
Nothing โ€” 826-line test file supplied every input as a MagicMock
Neo4j client (443 lines, zero callers) src/redstring/graph/client.py 3502900
recovery/neo4j-client
graph/adapters/neo4j.py
The whole service layer src/redstring/services/ c3c88ad
recovery/service-layer
aggregates + events + projections
ORM models, schemas, db.py src/redstring/models/, schemas/, db.py 1b9f9f3
recovery/orm-layer
The two ports. The library owns no schema
Settings object, Redis singleton src/redstring/config.py, cache.py 6a473ff
recovery/settings
Explicit constructor arguments
Prompt library, JSON-schema generator src/redstring/extraction/prompts.py e063faa
recovery/prompts-encryption
extraction/domains/ + prompt_generator.domain_system_prompt
Encryption at rest src/redstring/encryption.py e063faa
recovery/prompts-encryption
Nothing โ€” see BACKLOG B58

That is thirteen rows against eleven tags, because recovery/prompts-encryption

covers two of them: prompts.py and encryption.py went in the same commit.

Refs that preserve a prior state rather than a deleted path

One tag is not a deletion, and reading it as one sends you to the wrong commit.

What it preserves Path Ref Superseded by
schema_org.py before it moved off the ORM enum src/redstring/extraction/schema_org.py 1b915f8
recovery/schema-org-preport
(pre-port state is at ~1)
Free-string entity_type; ExtractionMethod from domain/entity.py

The path still exists in the tree today. What the tag holds is the before

side of a port: at recovery/schema-org-preport~1 the module opens with


from redstring.models.extracted_entity import EntityType, ExtractionMethod

and SCHEMA_TYPE_MAP maps "Person" onto EntityType.PERSON rather than onto

the string "person". The tagged commit itself already has the ported file, so

the diff across the tag is the whole record โ€” thirteen lines each way, which is

exactly why it needed a tag: a change that small is invisible in a 171-commit

range and unrecoverable once the enum's module is deleted.

That deleted module is the second thing the tag preserves. EntityType's

members survive in only two places now: this ref (which still contains

models/extracted_entity.py on both sides of ~1) and

tests/unit/extraction/test_schema_org.py, which continues to assert the

mappings by their string values. recovery/orm-layer (1b9f9f3) is the

last commit at which models/extracted_entity.py exists and is the ref to

use for the enum in full โ€” including the docstring quoted under the

module-docstring decisions below.

The deletions were not uniformly costly. Three of the four modules that looked

like live dependents of the relational layer were dead code that merely still

parsed. The two capabilities genuinely lost are the iCalendar/CSV exporters

(B47) and encryption (B58), and both entries carry the ref and the argument.

Decisions with their own record

These are the ones expensive enough to revisit that they got an ADR:

ADR Decision
0001 Event log schema and granularity
0002 Two store ports, and why there is no delete_entity
0003 Blocking keys are Neo4j nodes, not a list property
0004 Consolidation emits events rather than writing
0005 Temporal inference is computed on read
0006 The public surface is gated by three tests, not curated

Those are the numbers that have been allocated. docs/adr/ also holds a set of

drafts still carrying 0007, and that is the numbering rule working rather

than failing: numbers are allocated at merge, against the highest on main at

that moment, so parallel slices all draft the same next one and whichever

merges second renumbers (.claude/rules/definition-of-done.md;

.claude/rules/recurring-defects.md ยง6). Cite a draft by its filename,

never by 0007 โ€” the number is not yet a fact about it:

Draft Decision
no-ann-index-in-a-multi-tenant-vector-store pgvector carries no ANN index, and the reason is not performance
resilience-behind-the-cache-port Retry, rate limiting and circuit breaking live in llm/, over the Cache port
the-extraction-fold-resolves-through-aliases The extraction fold resolves endpoints through the alias table
one-total-order-for-preference One total order decides which mapping of a thing survives
the-two-non-store-ports Why Cache and LlmProvider are ports, which 0002 does not cover
composition-is-the-only-top-layer composition is the only top layer, and build_graph writes without a log
domain-schemas-prompt-but-do-not-constrain Domain schemas prompt the model; they do not constrain it
exemption-lists-are-empty-and-must-stay-falsifiable Both exemption lists are empty, and an emptied one is deleted rather than kept

A draft constrains a new spec exactly as much as an accepted ADR does, so run

against the content and ignore the number. The two tables together are the

whole of docs/adr/; if a file there appears in neither, one of them is stale.

Several other decisions live in module docstrings rather than here, and that is

deliberate โ€” a reason belongs next to the code it constrains when the code is

the only thing that could contradict it. The substantial ones:

index on the blocking_keys property.

defect that produced it.

the concurrency window (BACKLOG B43).

docstring argues the decision; the port that carried it out is at

recovery/schema-org-preport, whose ~1 side still maps "Person" onto

EntityType.PERSON, so the two readings of SCHEMA_TYPE_MAP sit either

side of one tag. The deleted

enum had conceded the point in its own docstring: String(100) "to support

dynamic domain-specific types", with is_valid/get_or_none helpers whose

only job was to say "legitimately not one of mine" without raising. An enum

that needs those is a vocabulary, not a type.

Backlog entries that were closed

BACKLOG.md carries only open work, and closing an entry deletes it. Docstrings

and tests still cite closed entries by number, and without this index those

pointers resolve to nothing. Seven of the eight below are cited from src/ or

tests/; B26 is cited only by the archived plan and is indexed here because it

is the one whose closure is invisible in the tree โ€” the duplicate it names was

removed, so nothing is left to point at it.

Id What it was Closed by Where the reasoning lives now
B10b Blocking-key lookup scanned the tenant Slice 7 ADR 0003
B10d retry.py read a process-wide settings object, so its tests inserted a MagicMock at sys.modules["redstring.config"] and poisoned every test that ran after them Slice 6, by replacing the read with a plain default llm/retry.py, tests/unit/llm/test_retry.py โ€” both docstrings state it
B26 DatePrecision/UncertaintyMarker duplicated between domain/ and the ORM models Slice 9, by deleting the ORM Nothing left to say; domain/temporal.py is the only definition
B33 A LEGACY_EVENT_MODULES exclusion list in the event-schema gate Slice 9, with the last legacy module (events/scraping.py) tests/unit/events/test_schema.py โ€” an exclusion over an empty set excludes nothing, so it was deleted rather than emptied
B34 A DocumentExtracted folded after an EntitiesMerged silently reverted the merge Slice 7 ports/graph_store.py, projections/graph.py; ADR 0001
B40 Fuzzy merging deleted rather than ported Slice 7 consolidation/policy.py; ADR 0004
B55 Domain schemas had no caller Slice 10 extraction/prompt_generator.py. The residue is open as B57
B56 redstring.config read the environment Slice 10 tests/unit/test_library_reads_no_environment.py

Entries B2, B3, B5, B6, B7, B11, B13, B19, B24, B25 are cited only by the

archived plan and were resolved by the deletions above. B24 โ€” "no migration

path" โ€” is the one worth naming: it asked who owns the relational schema, and

the answer turned out to be that the library owns no schema.

What the campaign learned about testing

CLAUDE.md carries this, and it is the most reused output of the whole

migration. The short version: a sixteen-row table of test inputs that made two

candidate implementations agree, every one of which passed review while proving

nothing, and every one of which was found by mutation testing rather than by

reading. If you read one thing before writing a test here, read that table.

Two campaign-level facts that belong with it:

Slice 5b: 11, "none findable by reading". Slice 6: three tie-break defects in

sequence, each found by the fix for the last. Slice 8's only Critical came

from asking a reviewer to hunt for a fourth defect after the run found three.

both directions, from a venv missing an extra. uv sync --all-extras.

The archived plan

docs/history/2026-08-ring-migration-plan.md is the plan this executed against.

It is history โ€” written in the future tense about work that is done, including

slices that were re-scoped mid-campaign. Its Global Constraints section is the

part still worth reading; it is what the eleven slices were held to.

Where this file lives

This document is docs/plans/ring-migration.md. It was docs/ring-migration.md

for most of the campaign, and it was moved with git mv, so its history is

intact and git log --follow docs/plans/ring-migration.md reads across the

rename.

The old path is dead, and every reference to it has been corrected: README.md,

three lines in BACKLOG.md, and the reciprocal link at the top of

docs/history/2026-08-ring-migration-plan.md โ€” that last one both in its link

text and in its relative target, which is now ../plans/ring-migration.md.

git grep 'docs/ring-migration.md' should return exactly one hit,

.claude/rules/recurring-defects.md, where the wrong path is quoted on purpose

as the example of a stale reference surviving several slices.

That one deliberate hit is why the check is a grep and not a link checker: a

relative link that resolves is not evidence the path in the prose beside it is

right, and a path quoted as an example of a mistake must not be swept. When

this file moves again, grep for the symbol across docs/, README.md,

CLAUDE.md, .claude/ and docstrings rather than a remembered list of files โ€”

the sweep that fails is always the one that fixed the pages it thought of.