ADR 0052: GraphBridge.GraphRag toolkit¶
Date: 2026-10-24. Status: Accepted on 2026-10-24, after the owner's review instruction ("if the ADRs make full sense, approve them"); the review's corrections are folded in below. It fixes the packages, the dependency pins, the public API of the first three toolkit waves and the semantics they keep. The spike that tests its four risky assumptions ran on 24 October 2026; its results are under Spike. Later waves amend this ADR before their code.
Context¶
GraphBridge maps EF Core entities to Neo4j. A GraphRAG toolkit on top of it gives .NET applications the building blocks of graph-based retrieval-augmented generation: store documents as typed graphs, retrieve evidence, answer with verified citations, and keep the graph healthy. The toolkit is building blocks, not a framework. EF Core stays the single owner of entity state. Microsoft.Extensions.AI supplies the model abstractions, and everything is testable without a model.
GraphBridge provides, on 24 October 2026: - nodes, native references, association edges and identified relationships, each with its own refusals (model validation); - typed node upserts (ADR 0051), SaveChanges and bounded bulk mutations (ADR 0038); - typed vector and full-text search over existing indexes, with provenance (graph search); - native Cypher with write counters (ADR 0050), and Doctor's search-index checks.
The toolkit needs three provider features it doesn't have yet. Each gets its own ADR and is built before the wave that needs it: - embeddings as EF properties (before wave 1); - vector and full-text index steps in migrations (before wave 1); - typed relationship upserts (before wave 4).
Until a feature lands, the toolkit uses a named workaround: native Cypher with a // Workaround for <gap> comment and a line on the docs page.
Spike¶
Four assumptions are tested before code. Each answer comes from a test or from the dependency's published API, as noted.
| # | Question | Answer | Evidence |
|---|---|---|---|
| a | Can several identified-relationship classes (GraphRagMention<Person>, GraphRagMention<Place>) share one relationship type? |
No. Model validation refuses a second owner of an identified relationship type. One relationship type per mention class works. | Unit tests GraphRagSpikeTests, passing, and the model validation source |
| b | Can vectors be written on mapped chunk nodes before embeddings are EF properties? | Yes, with a native write by key. Typed vector search finds the chunks, and an EF update of another property keeps the vector, because EF reads and writes only mapped properties. | Live test GraphRagSpikeTests, passing on Neo4j 2026.09.0 |
| c | Can a GraphBridge writer run inside a Microsoft.Extensions.DataIngestion pipeline? | Yes. IngestionChunkWriter<T> has one method, WriteAsync(IAsyncEnumerable<IngestionChunk<T>>, CancellationToken). In a pipeline run, it was called once per document and never concurrently; it added each chunk as an EF entity and saved them with SaveChanges, and a fresh context read them all back. |
A throwaway pipeline run of Microsoft.Extensions.DataIngestion 10.11.0-preview.1.26513.7 (HeaderChunker, o200k tokenizer, 30-token chunks), offline and on Neo4j 2026.09.0. The wave 5 adapter's tests repeat it |
| d | Do DataIngestion chunks carry positions, or can they be recovered? | They carry none, and recovery is partial. IngestionChunk<T> has Content, Document, Context and Metadata only. HeaderChunker starts each chunk's Content with its heading path (the chunk's Context) and a line break. With that prefix removed, 4 of 5 chunks were found byte-exact in the source. The fifth spans a paragraph and a list, which the chunker joins with one line break where the source has a blank line. |
The same run, on a Markdown file with multi-byte characters and an emoji |
What (d) means for wave 5. The core chunk model requires positions, and citations are checked against them. So the DataIngestion adapter can't store every chunk it receives as a positioned chunk. Wave 5's amendment of this ADR chooses between two options: - adapter chunks whose position may be missing, cited as a whole chunk; - the adapter keeps DataIngestion's readers but chunks the text with the toolkit's own chunker.
Waves 1 to 3 don't depend on the choice.
Three more constraints surfaced while designing, from the same validation source and the references guide:
- A node has at most one native-reference foreign key, and self-references and reference cycles are refused.
- A native reference points from the parent to the child, (:Document)-[:HAS_CHUNK]->(:Chunk), and carries no properties.
- An association edge points from its source foreign key's node to its target's, and can carry properties.
The lexical layer therefore uses association edges for every structural link, pointing from the child to its parent or successor (see the lexical model). That gives the direction neo4j-graphrag writes, (:Chunk)-[:FROM_DOCUMENT]->(:Document), and lets every edge carry its run ID.
Decision¶
1. Packages and dependencies¶
| Package | Depends on | Notes |
|---|---|---|
GraphBridge.GraphRag |
GraphBridge.EntityFrameworkCore (same version), Microsoft.Extensions.AI.Abstractions 10.11.0 |
The core. No preview dependency. Microsoft.Extensions.VectorData.Abstractions is the only other dependency it may take, and only with the first public API that uses it; waves 1 to 3 have none |
GraphBridge.GraphRag.DataIngestion |
the core, Microsoft.Extensions.DataIngestion 10.11.0-preview.1.26513.7 |
Wave 5 |
GraphBridge.GraphRag.AgentFramework |
the core, Microsoft.Agents.AI.Abstractions 1.26.0 |
Wave 5 |
GraphBridge.GraphRag.AspNetCore |
the core, the ASP.NET Core shared framework | Wave 8. No package dependency: the framework reference comes with the SDK |
- Targets and versions:
net10.0only. The packages ship with GraphBridge's version and are marked experimental in their descriptions and readmes. - Server: Neo4j 2026.09 or later. Retrieval uses typed search, which needs a 2026 release with Cypher 25, and full-text search needs 2026.09.
- The experimental marker: each assembly carries
[assembly: Experimental("GBRAG001")], so the compiler asks callers to acknowledge the experimental API once, as Microsoft.Extensions.AI does for its own previews. - Pins: builds use exact central pins with lock files, recorded in the dependency inventory and refreshed with the monthly dependency refresh. The packages declare those versions as minimums, as the provider does for its optional dependencies, so applications can take later compatible releases.
- Test-only dependencies: the evaluation harness's packages are test-only and pinned in wave 6.
2. The lexical model (wave 1)¶
Public, sealed entity classes, mapped by modelBuilder.AddGraphRag(...). All keys are strings computed by the toolkit (section 5), so writes are idempotent. Times are DateTimeOffset in UTC.
| Entity | Stored as | Properties | Relationship |
|---|---|---|---|
GraphRagDocument |
node | Id, SourceUri, Title?, MediaType, ContentHash, LengthBytes, RunId |
— |
GraphRagSection |
node | Id, DocumentId, Title, Level, Order, StartByte, EndByte, RunId |
— |
GraphRagChunk |
node | Id, DocumentId, Order, Text, StartByte, EndByte, TokenCount?, Embedding? (float[]), EmbeddingSetId?, RunId |
— |
GraphRagChunkDocument |
association edge | ChunkId, DocumentId, RunId |
IN_DOCUMENT: chunk → document |
GraphRagSectionDocument |
association edge | SectionId, DocumentId, RunId |
SECTION_OF: section → document |
GraphRagChunkSection |
association edge | ChunkId, SectionId, RunId |
IN_SECTION: chunk → section |
GraphRagNextChunk |
association edge | ChunkId, NextChunkId, RunId |
NEXT_CHUNK: chunk → next chunk |
GraphRagSubsection |
association edge | SectionId, ParentSectionId, RunId |
SUBSECTION_OF: section → parent section |
GraphRagIngestionRun |
node | Id, StartedUtc, CompletedUtc?, Status, SettingsFingerprint, DocumentsIngested, DocumentsUnchanged, DocumentsFailed |
— |
GraphRagLease |
node | Key, RunId, ExpiresUtc |
— |
The chunk and section keep DocumentId as a plain stored property too, not an EF foreign key, so LINQ can filter by document without a join.
- Text lives in chunks. The document stores its content hash and length, not its text. Every byte of the document is in at least one chunk, and citations are checked against the chunk that holds them.
- Embeddings:
Embeddingis mapped withIsGraphVector(ADR 0054), with the dimensions passed toAddGraphRag, because a vector property needs them when the model is built. - The toolkit's services refuse to run when the embedding set's dimensions differ from the model's.
- One embedding set is active at a time: a vector index covers one label and property, and Neo4j allows one vector index per schema. A chunk's
EmbeddingSetIdnames the set that wrote its vector. - Changing the set means re-embedding the chunks and replacing the index through a reviewed migration plan.
- Spike (b) showed that a native write by key also works, as a fallback if wave 1 ever had to start before ADR 0054.
- Layouts:
GraphRagLayoutnames the labels, relationship types and stored property names. GraphRagLayout.DefaultusesDocument,Section,Chunk,IngestionRun,GraphRagLeaseand the types above.GraphRagLayout.Neo4jGraphRaguses neo4j-graphrag's names for the document and chunk layer:(:Chunk)-[:FROM_DOCUMENT]->(:Document),(:Chunk)-[:NEXT_CHUNK]->(:Chunk), and chunk propertiesid,index,text,embedding. Sections keep the default names, which that library ignores. A unit test checks the stored direction of each relationship.- A custom layout validates every name as a Cypher identifier. GraphBridge maps a label to one entity type, so an application that already maps
DocumentorChunkchooses a layout with other labels; model validation names the collision.
3. Public API of waves 1 to 3¶
The parameter lists are binding; overloads may be added in later waves.
namespace Microsoft.EntityFrameworkCore;
public static class GraphRagModelBuilderExtensions
{
// embeddingDimensions: 1 to 4,096, the chunk embedding's IsGraphVector dimensions (ADR 0054).
public static ModelBuilder AddGraphRag(this ModelBuilder modelBuilder, int embeddingDimensions, Action<GraphRagModelOptions>? configure = null);
}
namespace Microsoft.Extensions.DependencyInjection;
public static class GraphRagServiceCollectionExtensions
{
// Uses the IEmbeddingGenerator<string, Embedding<float>> and IChatClient registered in the container.
public static IServiceCollection AddGraphRag<TContext>(this IServiceCollection services, GraphRagEmbeddingSet embeddingSet,
Action<GraphRagOptions>? configure = null) where TContext : DbContext;
}
namespace GraphBridge.GraphRag;
public sealed class GraphRagModelOptions { public GraphRagLayout Layout { get; set; } = GraphRagLayout.Default; }
public sealed class GraphRagLayout { public static GraphRagLayout Default { get; } public static GraphRagLayout Neo4jGraphRag { get; } /* labels, types, property names */ }
public sealed class GraphRagOptions
{
public GraphRagChunkerOptions Chunking { get; set; } = new();
public GraphRagIngestionOptions Ingestion { get; set; } = new();
public GraphRagRetrievalOptions Retrieval { get; set; } = new();
}
// Embedding sets and schema (GR-33)
// GraphVectorSimilarity is the provider's migration enum (ADR 0053), reused rather than duplicated.
public sealed record GraphRagEmbeddingSet(string Model, int Dimensions, GraphVectorSimilarity Similarity = GraphVectorSimilarity.Cosine,
bool Normalize = false, string? ModelRevision = null)
{ public string Id { get; } public string VectorIndexName { get; } } // computed in their getters, so `with` copies stay consistent
public static class GraphRagSchema
{
// The uniqueness constraints and the vector and full-text indexes the toolkit needs. Deterministic; reads nothing.
public static IReadOnlyList<GraphMigrationSchemaDefinition> Definitions(GraphRagLayout layout, GraphRagEmbeddingSet embeddingSet);
// The same as creation steps, for the application's own migration plan.
public static IReadOnlyList<GraphMigrationStep> CreateSteps(GraphRagLayout layout, GraphRagEmbeddingSet embeddingSet);
}
// Loading and chunking (GR-03)
public static class GraphRagSources
{
public static GraphRagSource File(string path);
public static IEnumerable<GraphRagSource> Folder(string path, string searchPattern = "*.md", bool recursive = true);
public static GraphRagSource Text(string id, string text, string mediaType = "text/plain");
}
public sealed record GraphRagLoadedDocument(string SourceUri, string MediaType, string? Title, ReadOnlyMemory<byte> Utf8);
public interface IGraphRagDocumentLoader { bool CanLoad(GraphRagSource source); Task<GraphRagLoadedDocument> LoadAsync(GraphRagSource source, CancellationToken cancellationToken = default); }
public interface IGraphRagChunker { GraphRagChunkPlan Chunk(GraphRagLoadedDocument document); }
public interface IGraphRagTokenCounter { int Count(string text); }
public sealed class GraphRagChunkerOptions
{
public int MaxUtf8Bytes { get; set; } = 2_048; // 64 to 16,384; exact, always enforced
public int? MaxTokens { get; set; } // enforced only with an IGraphRagTokenCounter
public int OverlapParagraphs { get; set; } = 1; // 0 to 4 whole paragraphs
}
// Ingestion (GR-24)
public interface IGraphRagIngestion
{
Task<GraphRagIngestionReceipt> IngestAsync(IEnumerable<GraphRagSource> sources, CancellationToken cancellationToken = default);
}
public sealed class GraphRagIngestionOptions
{
public int EmbeddingBatchSize { get; set; } = 32; // 1 to 256
public int Lanes { get; set; } = 2; // 1 to 8 parallel model calls
public int MaxDocuments { get; set; } = 10_000; // 1 to 100,000 per call
public int MaxDocumentBytes { get; set; } = 16 << 20; // 1 KiB to 256 MiB; a larger document fails with an error code
public TimeSpan LeaseDuration { get; set; } = TimeSpan.FromMinutes(10);
public Func<Exception, bool>? IsTransient { get; set; } // null: the default classification in section 4
}
public sealed record GraphRagIngestionReceipt(string RunId, IReadOnlyList<GraphRagDocumentOutcome> Documents,
int ChunksWritten, int EmbeddingCalls, long? InputTokens, TimeSpan Elapsed, IReadOnlyList<GraphRagLimit> LimitsReached);
public sealed record GraphRagDocumentOutcome(string DocumentId, string SourceUri, GraphRagDocumentStatus Status, string? ErrorCode);
public enum GraphRagDocumentStatus { Ingested, Unchanged, Failed }
public sealed record GraphRagLimit(string Name, int Value);
// Retrieval and answers (GR-04, GR-19, GR-35, GR-05)
public interface IGraphRagRetriever
{
Task<GraphRagEvidence> RetrieveAsync(string question, GraphRagRetrievalOptions? options = null, CancellationToken cancellationToken = default);
}
public sealed class GraphRagRetrievalOptions
{
public GraphRagRoutes Routes { get; set; } = GraphRagRoutes.Vector | GraphRagRoutes.FullText;
public int CandidatesPerRoute { get; set; } = 20; // 1 to 256
public int MaxItems { get; set; } = 8; // 1 to 128
public int MaxUtf8Bytes { get; set; } = 16_384; // 64 to 65,536
public int NeighbourChunks { get; set; } = 1; // 0 to 4 along NEXT_CHUNK, labelled context
public GraphRagRetrievalOptions WithCypherExpansion(string cypher, IReadOnlyDictionary<string, object?>? parameters = null);
}
[Flags] public enum GraphRagRoutes { Vector = 1, FullText = 2 }
public static class GraphRagRetrievers // neo4j-graphrag's retriever names; hybrid fuses ranks (section 4), not that library's normalised scores
{
public static GraphRagRetrievalOptions Vector();
public static GraphRagRetrievalOptions Hybrid();
public static GraphRagRetrievalOptions VectorCypher(string cypher);
public static GraphRagRetrievalOptions HybridCypher(string cypher);
}
public sealed record GraphRagEvidence(string Question, IReadOnlyList<GraphRagEvidenceItem> Items, IReadOnlyList<GraphRagContextItem> Context,
int Utf8Bytes, int OmittedItems, IReadOnlyList<GraphRagLimit> LimitsReached, GraphRagProvenance Provenance);
public sealed record GraphRagEvidenceItem(string ChunkId, string DocumentId, string Text, int StartByte, int EndByte,
double Score, IReadOnlyList<GraphRagRouteRank> Routes);
public sealed record GraphRagRouteRank(GraphRagRoutes Route, int Rank, double NativeScore, double RankScore);
public sealed record GraphRagContextItem(string ChunkId, string Text, string Reason); // never a citation
public sealed record GraphRagProvenance(string ServerVersion, string EmbeddingSetId, IReadOnlyList<string> Indexes, string? Similarity);
public interface IGraphRagAnswers
{
Task<GraphRagAnswer> AnswerAsync(string question, GraphRagRetrievalOptions? options = null, CancellationToken cancellationToken = default);
}
public enum GraphRagAnswerDisposition { Answered, InsufficientEvidence }
public sealed record GraphRagAnswer(GraphRagAnswerDisposition Disposition, string? Text, IReadOnlyList<GraphRagCitation> Citations,
string? Reason, GraphRagEvidence Evidence);
public sealed record GraphRagCitation(string DocumentId, string ChunkId, int StartByte, int EndByte, string Quote);
public static class GraphRagCitationVerifier
{
// Finds a model's quote in the chunk; returns the exact stored text and its document byte span, or null.
public static GraphRagCitation? Locate(GraphRagEvidenceItem item, string quote);
}
// Maintenance (GR-36, GR-38)
public interface IGraphRagMaintenance
{
Task<GraphRagIngestionReceipt> ReingestChangedAsync(IEnumerable<GraphRagSource> sources, CancellationToken cancellationToken = default);
Task<GraphRagRemovalReport> RemoveSourceAsync(string documentId, CancellationToken cancellationToken = default);
Task<GraphRagRemovalReport> ResetAsync(GraphRagResetScope scope, CancellationToken cancellationToken = default);
Task<GraphRagRemovalReport> RemoveOrphansAsync(CancellationToken cancellationToken = default);
Task<GraphRagHealthReport> CheckAsync(CancellationToken cancellationToken = default);
}
public sealed record GraphRagResetScope(IReadOnlyList<string>? RunIds = null, IReadOnlyList<string>? DocumentIds = null);
GraphRagSource, GraphRagChunkPlan, GraphRagRemovalReport and GraphRagHealthReport are immutable records. Their members are fixed in wave 1 or 3 with the tests that use them, inside the shapes above.
4. How the pieces behave¶
- Writes go through EF, one document per transaction.
- A new document's nodes and edges are added and saved with SaveChanges in one transaction.
- A changed document's previous chunks, sections and edges are deleted by key, and the new ones added, in the same transaction.
- Vectors are chunk properties (ADR 0054), so they are written in the same transaction.
- A run killed mid-way therefore leaves each document either complete, with its vectors, or absent.
- Runs are written with typed node upserts.
- The application owns the schema. The toolkit creates no schema itself.
GraphRagSchemareturns the uniqueness constraints on every key, the vector index on the chunk embedding with the set's dimensions and similarity, and the full-text index on chunk text, as typed migration definitions and steps (ADR 0053).- The application puts the steps in a plan of its own, with its own ID and predecessor, because the migration journal is one linear chain per database. It reviews and applies the plan like any other, and the migration waits until each index is ONLINE.
- The steps are deterministic, so the plan's checksum is stable. An existing equivalent index under another name stops creation for review, as for any migration.
IGraphRagMaintenance.CheckAsyncreports missing or different schema without changing it. Ingestion can't write before the constraints exist, because SaveChanges requires a uniqueness constraint on every key it writes, and retrieval can't search an index that doesn't exist.- Ingestion owns one run at a time per document. Leases are never upserted, because an upsert would overwrite another run's lease.
- A run takes a document's lease by inserting a
GraphRagLease; the uniqueness constraint on its key refuses a second holder. - An expired lease is taken over with a conditional bulk update, matching the key and an expiry in the past, that must report exactly one row changed.
- Each document's transaction renews its lease first, with a conditional update on the key, the run ID and an unexpired time, that must change exactly one row. The update takes the lease's write lock, so a takeover and the document's commit can't both succeed.
- The run deletes its leases by key and run ID when the document is done.
- Unchanged documents are skipped. A document whose content hash and settings fingerprint are unchanged is
Unchangedand isn't rewritten, so a resumed run skips the documents already written. - Model calls run in parallel lanes. They are retried only for transient failures, at most twice, with exponential backoff. Each document ends with a recorded outcome.
- By default, a transient failure is an
HttpRequestExceptionwith status 408, 429 or 5xx, or a timeout. Clients report failures differently, soIsTransientreplaces the test. - A client that already retries can set
IsTransientto refuse every exception, so failures aren't retried twice. - Retrieval reads only what it returns. Routes and evidence project chunk fields and never read embeddings. A Cypher expansion runs in a read transaction with the retrieval's bounds, and its rows become labelled context.
- Retrieval fuses ranks, never scores. Each route returns at most
CandidatesPerRoutechunks, de-duplicated by chunk ID. Fusion is reciprocal rank fusion with k = 60, keyed by chunk ID, ties broken by chunk ID. The evidence budget then takes items in rank order and stops atMaxItemsorMaxUtf8Bytes, cutting only at UTF-8 code-point boundaries and reporting what it left out. A cut item'sEndByteis the cut, so itsTextis still exactly its span. A failed or cancelled route makes the whole retrieval fail; it never quietly falls back to the routes that worked. - Answers are verified or refused. The chat client is asked for an answer and the exact supporting quote for each claim, with the chunk it comes from.
GraphRagCitationVerifierlocates each quote in that chunk, normalising only whitespace runs, quotation marks, typographic apostrophes and dashes, and allowing at most two...elisions in order. It never matches fuzzily or across chunks. The citation records the stored text, not the model's. If any citation fails, or no citation supports the answer, the disposition isInsufficientEvidencewith a reason. Graph neighbours are labelled context and are never cited. - Maintenance deletes explicitly. Removal and reset delete by key, in bounded statements, and never with a hidden
DETACH DELETE: edges first, then nodes. They refuse nodes and edges that carry no toolkit run ID, and their report counts what was removed.
5. Semantics to keep¶
- Loading: the loader removes a leading UTF-8 byte-order mark and refuses invalid UTF-8; the document then fails with an error code.
- Byte positions: offsets into the document's UTF-8 bytes as loaded, end exclusive. A chunk's
Textis exactly the bytes[StartByte, EndByte), and a citation'sQuoteis exactly its span. Positions never split a UTF-8 code point. - Identity:
GraphRagDocument.Idis the SHA-256 (lower-case hex) of the normalised source URI. A file's URI is its absolutefile:URI; another URI has its scheme and host lower-cased and its default port removed; a text source's istext:followed by its ID;ContentHashis the SHA-256 of the document's bytes;- a chunk's ID hashes the document ID, the content hash, the chunker's version and settings, and the chunk's span;
- the run's settings fingerprint hashes the chunker, the embedding set, and later the extractor version and the ontology's fingerprint.
- Embedding sets: vectors must have exactly the set's dimensions, finite coordinates within float32 range, and at least one non-zero coordinate, whatever the similarity (ADR 0054). A violation is refused before the write.
- Ownership: every node and edge the toolkit writes carries the
RunIdthat wrote it. - Bounded work: every batch, lane, candidate list, graph walk and budget has a default and a maximum. A limit reached is reported in
LimitsReached, never silently truncated. - Provenance: evidence carries the server version, the embedding set and the index names used.
6. Wave 1 members (amendment, 24 October 2026)¶
Wave 1 fixes the members that section 3 left to it, with the tests that use them:
public sealed record GraphRagLayout // labels, relationship types, chunk property names, and the schema name prefix
{
public string DocumentLabel { get; init; } public string SectionLabel { get; init; } public string ChunkLabel { get; init; }
public string RunLabel { get; init; } public string LeaseLabel { get; init; }
public string ChunkDocumentType { get; init; } public string SectionDocumentType { get; init; }
public string ChunkSectionType { get; init; } public string NextChunkType { get; init; } public string SubsectionType { get; init; }
public string ChunkIdProperty { get; init; } public string ChunkOrderProperty { get; init; }
public string ChunkTextProperty { get; init; } public string ChunkEmbeddingProperty { get; init; }
public string SchemaPrefix { get; init; } // "graphrag": constraint and index names start with it
}
public sealed record GraphRagSource { public string SourceUri { get; } public string MediaType { get; } public string? Path { get; } }
public sealed record GraphRagChunkPlan(IReadOnlyList<GraphRagSectionSpan> Sections, IReadOnlyList<GraphRagChunkSpan> Chunks, string Fingerprint);
public sealed record GraphRagSectionSpan(int Order, int Level, string Title, int StartByte, int EndByte, int? ParentOrder);
public sealed record GraphRagChunkSpan(int Order, int StartByte, int EndByte, int? SectionOrder, int? TokenCount);
public sealed class GraphRagLoadException(string errorCode, string message) : Exception(message) { public string ErrorCode { get; } }
- A layout is a record, so an application changes names with
with:GraphRagLayout.Default with { ChunkLabel = "Passage" }. Both layouts validate every name, distinct labels and distinct types. GraphRagOptionshasChunkingandIngestionin wave 1;Retrievalcomes with wave 2.- The chunker reads ATX headings outside fenced code; a section runs to the next heading of the same or a higher level. Paragraphs pack whole; a paragraph over budget splits at line breaks, then whitespace, then a code point boundary. Chunks never cross a heading, and together they cover every byte.
- Run status is
Running,Completed(every document has an outcome) orFailed(the call was cancelled or threw). - Document error codes:
unsupported-media-type,read-failed,invalid-utf8,document-too-large,lease-held,lease-lost,embedding-failed,embedding-count,embedding-dimensions,invalid-embedding,write-failed. Receipts carry codes, never messages or text.
7. Wave 4: ontologies and extraction (amendment, 24 October 2026; accepted 25 October 2026)¶
Status: accepted on 25 October 2026, after the review at the end of this section. It uses typed relationship upserts (ADR 0051, section 6).
What the provider allows, checked against model validation on 24 October 2026: - an association edge's relationship type can be shared, as long as each (source label, type, target label) triple has one owner; - an identified relationship type has exactly one owner (spike a).
Ontology relationships ("facts") are association edges with the ontology's own type names, so Cypher reads as the design's tables do.
- LOCATED_IN from Person, Organization, Event and Place is four association classes sharing one type, which the rule above allows. The generic document's RELATED_TO (any to any) is 25 classes.
- Classes with one shape across pairs are generic types closed per pair, so LINQ reads db.Set<LocatedIn<Person>>() or db.Set<RelatedTo<Person, Place>>().
- An association holds one relationship per pair. Its payload is the design's properties (role, kind, description), plus DocumentIds, RunId and ExtractorVersion.
- DocumentIds lists the documents the fact was extracted from, one ID per line, sorted, as Microsoft GraphRAG keeps text-unit IDs on relationships. A fact is kept only when both of its ends are grounded in the same chunk. So for each listed document, the fact's evidence is that document's chunks that mention both ends, and citations always come from chunks (BP11).
- Writing a fact happens in one transaction, so concurrent runs can't lose each other's document IDs:
1. an upsert with an empty selector creates missing facts and locks both ends of each one;
2. the stored DocumentIds are read under those locks;
3. a second upsert writes the union.
The other payload properties keep the first writer's values.
Mentions are identified relationships, one type per class, from the thing to the chunk: MENTIONED_IN_PERSON, MENTIONED_IN_PLACE, and so on.
- The stem comes first, so type(r) STARTS WITH 'MENTIONED_IN_' finds every mention.
- Each carries StartByte, EndByte, Quote, RunId and the extractor version.
- Its key is a hash of the chunk ID, the start byte and the thing's ID. Re-extracting the same chunk yields the same keys, and one thing mentioned twice in a chunk keeps two relationships.
- This is a named workaround. The Neo4jGraphRag layout can't write neo4j-graphrag's shared FROM_CHUNK until a provider change lets several classes own one identified type. The change goes on the gap list (GR-06), and it would also allow one fact relationship per assertion.
- SAYS (novel) and QUOTED (article) have one owner each, so they keep their names. Their key is a hash of the chunk ID, the start byte and the speaker's ID. They carry the spoken words' bytes and text; SAYS also carries the addressee's name when the text gives one.
Classes ship per ontology, in GraphBridge.GraphRag.Ontologies, exactly as the design's tables (sections 5.3 to 5.5) define them.
- Properties: every entity has:
- a computed Id (the kind and the resolved name, hashed);
- Name;
- Aliases (one string, aliases separated by line breaks and sorted, because GraphBridge maps scalars only);
- Description?;
- IsPlaceholder;
- RunId and ExtractorVersion.
Event dates and chapters are kept as the text gives them, never parsed into more precision than it has.
- The article ontology's document properties (published date, publisher, URL) are nullable properties of the lexical Document. They come from the source's metadata, never from extraction.
- Labels are the class names. An application that already maps one of them uses ontology.WithLabelPrefix("Rag"), which stores RagPerson with MENTIONED_IN_RAG_PERSON; the kinds the model sees, the entity IDs and the fact and quote types stay the same. Or it registers its own classes.
- Registration: modelBuilder.AddGraphRag(dimensions, o => o.Ontology = GraphRagOntologies.Article), or o.Ontology = GraphRagOntology.Custom(...) with the application's classes.
- Schema: GraphRagSchema.Definitions adds, for the chosen ontology:
- a uniqueness constraint on each class's Id;
- one on the key of each mention type, SAYS and QUOTED;
- one full-text index over every class's Name and Aliases.
The model description (GR-32) is GraphRagModelSchema, built from the finalized IModel: the node classes, the relationship types and the allowed (source, type, target) triples. It has ToJsonSchema(), ToPromptText() and Fingerprint. The name avoids GraphRagSchema, which already returns the migration steps.
Extraction (GR-34) runs per chunk, through IChatClient with the JSON schema and instructions written for the toolkit (not ported), and keeps nothing the checks reject:
- check and prune: each of these is pruned, and the receipt records each pruned item's reason:
- an unknown kind;
- a relationship outside the allowed triples, or to a pruned entity;
- a missing name;
- a quote not found in the chunk;
- anything over the per-chunk limits (MaxEntitiesPerChunk, MaxRelationshipsPerChunk);
- quarantine: a reply that isn't valid JSON for the schema is kept as a quarantined receipt entry, truncated to a bounded length, never as graph data;
- grounding: each mention's and each SAYS/QUOTED quote is located with GraphRagCitationVerifier's rules, and its stored span is the located bytes;
- a fact's ends are things kept from the same chunk, found by name, then alias, then one of their mention phrases in that chunk (a model often names "the Rabbit" in a relationship after listing it as a mention of "White Rabbit"); or the document itself;
- resolution: exact match on kind and normalized name (Unicode NFC, case-folded, whitespace collapsed).
- Within a document, candidates for one thing merge in memory: aliases are unioned, and the first description in chunk order wins.
- Across documents, a stored thing keeps its name and description, and only its aliases are unioned, written by the same lock-then-read pattern as facts.
- A placeholder name ("the man", "someone", a pronoun) never merges. Its Id is derived from its chunk and span, and IsPlaceholder is true, so queries can leave it out. Each ontology lists its placeholders;
- IGraphRagExtractionVerifier: an optional hook that sees each candidate after the checks and can refuse it with a reason;
- writes: entities with node upserts, facts, mentions and quotes with relationship upserts. Re-running on unchanged input writes nothing new.
Ingestion and maintenance with extraction:
- Settings fingerprint: the ontology's Fingerprint and the extractor version join the run's settings fingerprint, so changing either re-ingests.
- Unchanged check: a document counts as unchanged only when its last run completed extraction with the same settings.
- Extraction runs after embedding and before the document's single write transaction. A failed extraction marks the document Failed with extraction-failed and writes nothing of it; an earlier version stays as it was, and the next run retries it (changed during implementation, 25 October: the proposal kept the new lexical content searchable, which would have needed a second transaction and an extraction marker).
- Removing or re-ingesting a document deletes its mentions and quotes with its chunks, before the chunks.
- It then drops the document from each fact's DocumentIds, by the same lock-then-read pattern, and deletes facts left with none.
- Orphan clean-up deletes things that no mention points to, with their facts first.
- The health report counts orphan things and facts whose listed documents don't exist.
Sample texts:
- Sample 08: Alice's Adventures in Wonderland (the owner approved it on 25 October 2026). It ships the public-domain opening of chapter I with extraction replies written by hand (not recorded from a model; replaced by recorded real replies in wave 7, section 11); a fetch command downloads the whole book from Project Gutenberg when the user asks.
- Sample 09: two short fictional articles written for the sample and labelled as fiction, instead of NASA press releases (decided 25 October, for the owner to overrule). The owner approved only sample 08's text. NASA's pages would need a download, a licence check per item and an HTML loader, which is GR-26, after v1. The fiction exercises every class, fact and quote type of the article ontology without putting words in real people's mouths. The sample reads any folder of Markdown articles.
Wave 4 members (25 October 2026).
- Ontology: GraphRagOntology (with Custom and WithLabelPrefix), GraphRagOntologyBuilder, GraphRagOntologies.Generic, .Novel and .Article, and the descriptions GraphRagOntologyClass, GraphRagOntologyRelationship and GraphRagOntologyQuote.
- Classes, in GraphBridge.GraphRag.Ontologies:
- things: GraphRagEntity with Person, Organization, Place, Event, Concept, Character, Group and Topic;
- facts: GraphRagFact with WorksFor, MemberOf<,>, LocatedIn<>, ParticipatesIn<>, PartOf<>, RelatedTo<,>, KinOf, TakesPlaceIn, Authored and About;
- mentions and quotes: GraphRagMention<T>, GraphRagQuote, Says and Quoted.
- Model schema (GR-32): GraphRagModelSchema with From(IModel), ToJsonSchema(), ToPromptText() and Fingerprint, with GraphRagSchemaClass, GraphRagSchemaPattern and GraphRagSchemaQuote.
- Extraction (GR-34):
- settings: GraphRagExtractionOptions (GraphRagOptions.Extraction) and GraphRagServiceKeys.ExtractionChatClient;
- the verifier: IGraphRagExtractionVerifier, GraphRagExtractionCandidate and GraphRagExtractionItem;
- the receipt: GraphRagExtractionSummary (GraphRagIngestionReceipt.Extraction), GraphRagPrunedItem and GraphRagQuarantinedReply.
- Changed:
- GraphRagSchema.Definitions/CreateSteps take an optional ontology, and EntityNamesIndexName is new;
- GraphRagSourceMetadata (GraphRagSource.Metadata) and the document's Published, Publisher and Url are new;
- GraphRagRemovalReport.Entities, and the health report's OrphanEntities and StaleFacts, are new.
- Gap list (GR-06):
- shared identified relationship types, for FROM_CHUNK and per-assertion facts;
- Count over identified relationships (GBQ170), which the toolkit and samples avoid by reading rows;
- string Contains in LINQ, which fact removal replaces with a bounded native query.
Review (25 October 2026). The proposal was checked against the design's ontology tables, the wave 4 brief, the provider rules and the wave 1 to 3 code. Its core choices hold: - association edges for facts, with the ontology's type names; - one identified mention type per class, as a named workaround; - exact-match resolution with placeholders kept apart; - extraction that prunes, with reasons.
The proposal left out what re-runs, removals and concurrent runs need, and these are now specified above:
- fact provenance (DocumentIds);
- deterministic mention and quote keys;
- placeholder IDs and IsPlaceholder;
- alias merging;
- the lock-then-read write pattern;
- the deletion order;
- the schema the ontology adds;
- retrying a failed extraction;
- per-chunk limits.
The open question whether facts should carry their own evidence spans is settled: no. Evidence spans would make facts identified relationships with one type per triple, which loses the ontology's type names. DocumentIds plus mentions recovers each fact's evidence instead.
8. Wave 5: serve and integrate (amendment, 25 October 2026)¶
Status: accepted on 25 October 2026, under the owner's instruction to decide what makes sense and keep working; the owner reviews it with the code. It settles spike (d) and adds the two adapter packages of section 1.
Agent tools (GR-13), in the core. GraphRagTools.Create<TContext>(services, options) returns AIFunctions, built with AIFunctionFactory, so any IChatClient or agent can call them.
- answer_from_documents(question): the toolkit's answer with checked citations, each with its document, chunk, byte span and quote, or a refusal with its reason.
- search_documents(query, limit): up to 20 passages, each with its document, chunk, byte span and text, labelled as evidence to read and quote, not as an answer.
- With an ontology:
- find_things(name, kind) returns up to 10 things from the names full-text index;
- related_facts(thingId) returns up to 50 facts that touch a thing, with the documents that state them.
- The evidence trail: every result carries IDs and byte spans. An optional IGraphRagToolTrail records each call: the tool, its arguments, and the returned IDs and spans, never document text. GraphRagToolTrail is the in-memory one.
DataIngestion adapter (GR-41), GraphBridge.GraphRag.DataIngestion. Spike (d) is settled for positioned chunks: every stored chunk keeps exact positions, and nothing is stored without them.
- The document's text is its reader's Markdown. That is the elements' GetMarkdown() joined by blank lines, stored as the document's UTF-8 bytes with media type text/markdown. A PDF read through MarkItDown is therefore cited by its Markdown, which is what the pipeline saw.
- GraphRagSpanChunker is an IngestionChunker<string> that chunks that text with the toolkit's own chunker. Each chunk's metadata carries its span (graphbridge.startByte, graphbridge.endByte).
- GraphBridgeChunkWriter<TContext> is an IngestionChunkWriter<string>. Spans come from that metadata when present. Otherwise it recovers them, in order:
1. it removes the heading-path prefix that header chunkers add (the chunk's Context and a line break);
2. it locates the rest byte-exact after the previous chunk;
3. failing that, it locates the rest with whitespace runs normalised, as quote location does. This covers spike (d)'s fifth chunk, where a blank line became one line break.
A chunk it can't place fails the document with position-not-recovered, and nothing of that document is written.
- It ingests through the core: the new IGraphRagIngestion.IngestChunkedAsync(documents, chunkerFingerprint) takes GraphRagChunkedDocuments and runs the same steps as file ingestion:
- lease, unchanged check and embeddings with the toolkit's own generator (embeddings the pipeline computed are not used);
- extraction when an ontology is mapped;
- one transaction per document.
The chunker fingerprint (the chunker's type and the adapter's version) joins the settings fingerprint. Each WriteAsync call is one document; its receipt is in GraphBridgeChunkWriter.Receipts.
Agent Framework adapter (GR-42), GraphBridge.GraphRag.AgentFramework. GraphRagContextProvider is an AIContextProvider. On each invocation it adds the GR-13 tools and instructions:
- answer questions about the documents only through answer_from_documents, and repeat its answer and citations as given;
- when it refuses, say the documents don't answer the question;
- treat search results as evidence to read, never as citations of their own.
The citations are checked by the toolkit, so an agent's answer cites only text that is stored. Tests run a ChatClientAgent (Microsoft.Agents.AI, test-only) with a scripted chat client.
Re-embedding as a reviewed plan (GR-37). A vector index covers one label and property, so the new embedding set gets a second property, and the application cuts over by renaming the property in its layout. GraphRagReembedding.Plan(layout, from, to, stagingProperty) gives:
- Preview(): the steps, and how many chunks are done and still to do.
- PrepareSteps: the new set's vector index on the staging property, as migration steps for the application's own plan.
- BackfillAsync: resumable and bounded. It embeds chunks whose staging vector isn't from the new set yet, in pages, with the new set's generator.
- It writes the staging vector and its set ID by key with native Cypher. This is a named workaround: EF maps one embedding property, and the staging one isn't mapped until cut-over.
- Cut-over: the application deploys AddGraphRag(to.Dimensions, o => o.Layout = plan.NewLayout) with the new set, then runs CompleteAsync. That records the new set ID on each chunk, so the health report's mixed-set check passes.
- Clean-up: CleanupSteps drop the old index, and CleanupAsync removes the old vectors in pages.
The next re-embedding swaps the two properties back. The health report flags mixed sets at every stage.
Verification:
- Offline: tool schemas and results, span recovery, including spike (d)'s case and an unplaceable chunk, and the plan's steps.
- Live:
- an agent answers through the provider with checked citations and refuses an unanswerable question;
- a DataIngestion pipeline with HeaderChunker, and one with GraphRagSpanChunker, writes positioned chunks whose text is the stored bytes;
- a re-embedding plan previews, prepares, backfills, cuts over, and cleans up, with search working on the new set and the health report clean.
Wave 5 members (25 October 2026).
- Core:
- tools: GraphRagTools (Create<TContext>, the four tool names), GraphRagToolOptions, IGraphRagToolTrail, GraphRagToolTrail, GraphRagToolCall and GraphRagToolEvidence;
- tool results: GraphRagToolAnswer, GraphRagToolCitation, GraphRagToolPassages, GraphRagToolPassage, GraphRagToolThing and GraphRagToolFact;
- chunked ingestion: GraphRagChunkedDocument.Create, IGraphRagIngestion.IngestChunkedAsync and GraphRagChunkers (Create, FingerprintOf);
- re-embedding: GraphRagReembedding.Plan, GraphRagReembeddingPlan (PrepareSteps, PreviewAsync, BackfillAsync, CompleteAsync, CleanupSteps, CleanupAsync, NewLayout) and GraphRagReembeddingPreview.
- DataIngestion adapter: GraphBridgeChunkWriter, GraphBridgeChunkWriterOptions, GraphRagSpanChunker, GraphRagIngestionText (Of, TitleOf, the metadata keys) and GraphRagChunkPlacementException.
- Agent Framework adapter: GraphRagContextProvider (Create<TContext>, DefaultInstructions, Tools).
9. Wave 6: trust, observability and the release review (amendment, 25 October 2026)¶
Status: accepted on 25 October 2026, under the owner's instruction to decide what makes sense and keep working; the owner reviews it with the code.
Tracing and receipts (GR-39).
- Activities: the toolkit has its own ActivitySource, GraphBridge.GraphRag, beside the provider's command tracing.
- GraphRag.Ingest covers one call, and GraphRag.Document each document within it, with child activities GraphRag.Embed, GraphRag.Extract and GraphRag.Write.
- GraphRag.Retrieve and GraphRag.Answer cover the read side.
- Tags are IDs, counts, outcomes and timings only: run and document IDs (hashes), statuses and error codes, chunk, call and token counts, routes and item counts, the answer's disposition and refusal reason. Never document text, questions, quotes, prompts or source URIs, which can hold private paths. A failure sets the activity's status with its error code, never an exception message.
- Receipts:
- runs already have one, with the extraction summary;
- an answer now carries GraphRagAnswerReceipt: its chat calls, input and output tokens when the client reports them, and the time spent retrieving and answering.
Evaluation harness (GR-23). GraphBridge.GraphRag.Evaluation is a test-only library in the repository, not a package of this release. It builds on AgentEval 0.43.0-beta (MIT, by the maintainer), pinned and test-only; the runtime packages never reference it. It has three deterministic metrics, as AgentEval IRAGMetrics, needing no model:
- citation validity: every citation's quote equals the evidence text at its byte span;
- abstention: an unanswerable question is refused, and an answerable one isn't;
- retrieval coverage: the share of a case's required evidence phrases that the retrieved items contain.
GraphRagEvaluator.RunAsync(answers, cases) runs a list of GraphRagEvaluationCases and reports each metric per case, with totals. Small fake clients let users run the same checks on their own corpus. The public corpus is the opening of Alice's Adventures in Wonderland from sample 08, with labelled questions marked as written by the agent and awaiting the owner's review. The semantic judge, recall matcher, labels and benchmark numbers stay private.
Docs, trust page and gap list (GR-22, GR-06).
- The docs site gains a GraphRAG section, marked experimental. It follows the design's ten pages, kept short:
- overview, quick start, ontologies, ingest, retrieve;
- answer with evidence ("GraphRAG you can trust": how citations are checked, when the answer refuses, and what isn't guaranteed: the truth of the sources, completeness, approximate scores at large limits);
- agents and APIs, maintain, test and evaluate, interop.
- The gap list is a section of the overview, ordered by what it would remove: shared identified relationship types, Count over identified relationships, string Contains in LINQ, a second mapped embedding property during re-embedding, several labels per entity type, search inside Fluent Cypher, and scoped search.
Workshop lesson. Lesson 31, "From documents to cited answers". Offline, it shows a chunk plan with exact positions and the model schema. Connected, it ingests two short texts through a migration-applied schema, answers with a checked citation, refuses an unrelated question, and removes everything it wrote.
Release review (GR-17). The checklist lives in this ADR's review section and is completed before PL-48:
- the API against ADR 0052 and its amendments;
- package metadata, icons, readmes and experimental markers, checked in the packed .nuspec files;
- the public snapshot's secret scan, in dry run;
- a file review of everything that came from the earlier prototype: no private names, prompts or paths.
Wave 6 members (25 October 2026).
- Core: GraphRagTracing (the activity source and activity names), GraphRagAnswerReceipt, and GraphRagAnswer.Receipt.
- Harness (tests/GraphBridge.GraphRag.Evaluation, test-only):
- the evaluator: GraphRagEvaluator (RunAsync, LoadCases, AliceCorpus, DefaultMetrics), GraphRagEvaluationCase, GraphRagEvaluationKeys, GraphRagEvaluationReport and GraphRagCaseResult;
- the metrics: GraphRagCitationValidityMetric, GraphRagAbstentionMetric and GraphRagRetrievalCoverageMetric;
- the stand-ins: GraphRagHashingEmbedder, GraphRagExtractiveChatClient, GraphRagScriptedChatClient and GraphRagInMemoryRetriever.
- Dependency: a test-only override of AgentEval's System.Security.Cryptography.Pkcs 8.0.1 (advisories GHSA-846f-f764-76mx and GHSA-2fmv-rv2c-59x7) to 10.0.13.
10. What the toolkit reuses¶
The contracts for source identity, positions, embedding-set validation, rank fusion, the evidence budget and quote location follow the maintainer's earlier prototype. They are re-implemented on GraphBridge's abstractions with fresh public tests. No prompt, evaluation method or domain model from that prototype is part of the toolkit. The release review (wave 6) reviews every ported file.
11. Wave 7: real models (amendment, 25 October 2026)¶
Status: accepted on 25 October 2026, under the owner's instruction to work through wave 7 autonomously and decide what makes sense; the owner reviews it with the code before RP1. Version 1 had run only with stand-ins; this wave runs the toolkit against a real model (GLM-5.3-Flash and bge-m3 on Bitdeer, decision D2) and changes what that showed.
Recordings and the real-model profile (GR-46).
- A recording is keyed by a hash of the request. For chat: the messages, the reply format and the strict flag. For embeddings: the texts and the dimensions. Each entry keeps the model's name, the date, the reply or the vectors (float32, exact), and the token counts the provider reported. It keeps no endpoint, no key, and no request text beyond an 80-character hint. The recorder refuses an entry that contains the value of any environment variable whose name says it holds a secret.
- It's sample and test code, not a package API: samples/GraphRagShared/ModelRecordings.cs, linked into the samples and tests. An application that wants the same wraps its own clients the same way.
- The samples have three modes:
- with AI_INFERENCE_PROVIDER set, they call the real model it names. The variables are the contract AgentEval and AgentMemory share: AI_INFERENCE_PROVIDER=bitdeer needs only BITDEER_API_KEY, with zai-org/GLM-5.3-Flash, BAAI/bge-m3 and Bitdeer's endpoint as defaults. A named provider with a variable missing fails closed, and the settings print without the key. Unlike those tools, the samples never pick a provider from keys that happen to be set, so a test run never calls a model by accident;
- with GRAPHBRIDGE_AI_MODE=replay, they replay their recordings, and a request with no recording fails;
- with neither, they run offline with stand-ins.
Offline, sample 08's extraction replays the recorded real replies for the passages they cover and finds nothing in other passages. The replies written by hand for sample 08 are gone. Sample 09 keeps its hand-written replies, labelled as such, until GR-58. Sample 07 now takes models from the environment too, and replays on its own copy of three documentation pages.
- The real-model tests are tests/GraphBridge.GraphRag.RealModelTests. They run in a process of their own, so the samples' settings resolve to the recorded models while the main integration suite keeps the stand-ins.
- With GRAPHBRIDGE_AI_RECORD=1 and a provider named, they record what's missing, within a call budget (GRAPHBRIDGE_AI_MAX_CALLS, 40 by default). GRAPHBRIDGE_AI_FRESH=1 records everything anew into a scratch file instead.
- Otherwise they replay at zero calls, even on a machine that names a provider for every tool. The live suite runs them this way, and the main integration suite clears the provider for its process, so the samples there always run on their stand-ins.
- RealModelFact marks tests that need a real model. They never run in the default suites or in CI.
- Known limit: a replay needs the request to be byte-identical. A changed prompt, schema or chunking, or a retrieval near-tie that reorders the evidence, needs a new recording.
Structured output (GR-48).
- The schema meets strict structured output's rules: every object is closed and lists all its fields as required, and the fields a reply may leave out are nullable. The schema's fingerprint changes, so stored documents are extracted again on their next ingestion.
- GraphRagExtractionOptions.StrictSchema (on by default) sets ChatOptions.AdditionalProperties["strict"] = true. That is the key Microsoft.Extensions.AI's OpenAI client reads: a test captures the request that client (10.11.0, test-only) sends for an extraction call, and finds "strict": true in its JSON schema format, and no flag when the option is off. A client that doesn't know the key ignores it, and the samples' own client sends "strict": true with the schema.
- Reasoning effort isn't a toolkit setting. An application that wants less reasoning registers the extraction client with ChatOptions.Reasoning, for example through ConfigureOptions; the same test shows Microsoft.Extensions.AI's OpenAI client sends it as reasoning_effort. On GLM-5.3-Flash, low cut the reasoning of a probe from about 150 tokens to 1. The extraction summary adds ReasoningTokens.
- RetryUnreadableReplies (on by default): a reply that isn't JSON for the schema is asked for once more, with what was wrong added as a message. The earlier reply isn't sent back, which saves tokens. A second unreadable reply is quarantined, with a new code: truncated, for a reply cut off at the output limit. The item checks are unchanged.
- The extraction summary adds Chunks, Repaired and OutputTokens. A reasoning model's output tokens include its reasoning.
- Quotes are one unbroken stretch (found on the whole book, 25 October). GLM-5.3-Flash joined speech interrupted by narration, as in “I can’t help it,” said Alice very meekly: “I’m growing.”, into one quote. That isn't a span of the passage, so 290 of 752 quotes were rightly pruned as quote-not-found. The novel and article ontologies now ask for one unbroken stretch, and a quote of its own for each part of interrupted speech. Changing the instructions changes the schema's fingerprint, so stored documents are extracted again.
- Emphasis marks are ignored when a quote is located (found on the whole book, 25 October). Project Gutenberg marks italics as _I_, and a model copying the words drops the marks; 25 more quotes failed for that alone. The locator now skips _ and * in both the quote and the text, so they match. The citation still records the stored text at its exact span, marks included. Nothing else is forgiven.
Resilience (GR-63).
- GraphRagOptions.Retry (GraphRagRetryOptions) replaces the two IsTransient settings, and covers embedding and extraction:
- 4 retries;
- backoff that starts at 1 second and doubles, with up to 25% jitter, up to 1 minute;
- a server's retry hint is honoured, read from a TimeSpan under GraphRagRetryOptions.RetryAfterKey in the exception's data, or through a RetryAfter delegate. A hint longer than the longest wait fails the call at once;
- by default, an HttpRequestException with 408, 429 or 5xx, a dropped connection, or a timeout is transient. Clients built on System.ClientModel retry on their own, and their exceptions aren't retried by default.
- A call that fails on a rate limit gives the new error code rate-limited.
- The ingestion receipt adds GraphRagRetrySummary: retries, how many answered a rate limit, and the time waited. The activity has graphrag.retries and graphrag.rate_limited.
- Parallelism and batches were already bounded (Lanes, EmbeddingBatchSize) and stay as they are.
- A lease is renewed while its document is worked on (found on the whole book, 25 October). Extracting all of Alice took longer than the 10-minute lease, so the write's renewal found it expired and the document failed with lease-lost after every model call had been paid for. The run now renews the lease every third of its duration, from taking it until the write. A renewal that finds the lease gone to another run stops the document's work early with lease-lost; one that fails for any other reason is tried again at the next beat. The write still renews the lease first in its transaction, so a takeover and a commit still can't both succeed.
- Re-ingesting unchanged text under new settings works (found on the whole book, 25 October). New instructions or a new embedding set re-ingest every document. The same text then chunks to the same IDs, so its chunks and mentions are deleted and written again under the same keys in one transaction. The provider's locks wrote each element's key to itself, and on Neo4j 2026.09 that kept the key taken for the rest of the transaction, so the write failed with write-failed. The provider now locks without touching the key: nodes and bulk writes since 8fac179 on main, and tracked saves of identified relationships since 29 October, when a provider test found the same conflict there. Tests cover it with two short documents and with the whole book.
- Cancellation keeps section 4's design. Each document is written in one transaction, and its lease is released. A cancelled run leaves each document whole or absent, and the next run completes it without duplicates. The extraction calls of the document in progress are lost; a caching client, such as the recorder in the tests, avoids paying for them twice.
- Retrieval and answers don't retry. They answer a person who is waiting, so the client's own policy applies.
Cost preview (GR-47).
- IGraphRagIngestion.PreviewAsync(sources) returns a GraphRagIngestionPreview, and nothing is written. For each source it:
- loads it, giving the same error codes as ingestion;
- checks whether it's unchanged against what's stored, a read only;
- chunks it.
- What it counts:
- embedding calls per document by batch size;
- extraction calls, one per chunk;
- input tokens, counted with the registered IGraphRagTokenCounter, or estimated at four bytes of UTF-8 per token (TokensCounted says which). The chat format adds 4 tokens per message and 3 per request.
It doesn't count the JSON schema, which some providers bill as input, the second asks, or output tokens. - Its test checks that the preview equals the receipt when the client counts tokens as the counter does. The whole book's preview is compared with the real receipt in GR-49.
Wave 7 members (25 October 2026).
- New:
- GraphRagRetryOptions (GraphRagOptions.Retry) and GraphRagRetrySummary (GraphRagIngestionReceipt.Retries);
- IGraphRagIngestion.PreviewAsync, with GraphRagIngestionPreview, GraphRagDocumentPreview and GraphRagPreviewStatus;
- GraphRagExtractionOptions.StrictSchema and RetryUnreadableReplies;
- GraphRagExtractionSummary.Chunks, Repaired, OutputTokens and ReasoningTokens.
- Removed: GraphRagExtractionOptions.IsTransient and GraphRagIngestionOptions.IsTransient, replaced by GraphRagOptions.Retry.
- Changed:
- GraphRagModelSchema.ToJsonSchema() is strict-compatible;
- the new error codes are rate-limited and truncated.
12. Wave 8: build apps on it (amendment, 26 October 2026)¶
Status: accepted on 26 October 2026, under the owner's instruction to run wave 8 straight after wave 7. The owner reviews it with the code at the end of the wave.
Streaming answers (GR-21), in the core. IGraphRagAnswers.AnswerStreamingAsync(question, options, cancellationToken) returns an IAsyncEnumerable<GraphRagAnswerUpdate>, in this order:
1. Retrieved, once, with the evidence.
2. Delta, zero or more times: the next piece of the model's answer text as it arrives. It is provisional: no citation has been checked yet.
3. Completed, once, with the same GraphRagAnswer that AnswerAsync returns for the same reply: checked citations, or a refusal with its reason and no text.
How it works:
- One streamed model call, the same request. The instructions, prompt and reply format are AnswerAsync's, so a recorded reply serves both, and both verify the reply the same way.
- The provisional text is the answer string of the JSON reply, decoded as it streams. A reply that starts with insufficient produces no deltas. A piece ends before an escape sequence that hasn't fully arrived.
- A refusal after deltas withdraws them. The deltas joined are the answer before trimming. A Completed that answers carries the final text, which replaces them.
- Cancellation stops the model call: the token flows into the streamed request, and nothing is written.
- The answer activity carries the same tags as AnswerAsync's, plus graphrag.deltas.
Endpoints (GR-21), GraphBridge.GraphRag.AspNetCore. app.MapGraphRag<TContext>(prefix, configure) maps three endpoints under prefix (default /graphrag), and returns their RouteGroupBuilder:
- POST {prefix}/ask, body {"question": "..."}, streams server-sent events (text/event-stream), one per update:
| Event | Data (JSON) |
|---|---|
retrieved |
items, contextItems, utf8Bytes, omittedItems, limitsReached, and the W3C traceId when tracing is on |
delta |
text, provisional |
verified |
answer, citations (document, chunk, byte span, quote) and receipt |
refused |
reason, withdraws (whether deltas were sent) and receipt |
A question that is empty or longer than MaxQuestionLength (2,000 characters by default) gets a 400 problem response before any work.
- POST {prefix}/search, body {"query": "...", "limit": n}, returns passages with their document, chunk, byte span and text, labelled as evidence. The limit is 1 to 20, as for the search_documents tool.
- GET {prefix}/documents/{id} returns a document's metadata (source, title, media type, published, publisher, URL, length, content hash, run) and its chunks' IDs and byte spans, in order, without their text. A document that doesn't exist gets a 404.
Boundaries:
- Authentication, authorization and rate limits are the application's. The returned group takes them, for example .RequireAuthorization() and .RequireRateLimiting("graphrag"). The README says so first.
- Errors: once a question is accepted, a failure ends the stream with an error event: retrieval-failed before the evidence, model-failed after it. The exception goes to the application's log, never into the event. Failures of search and documents are ASP.NET Core's 500 responses.
- JSON uses the web defaults (camel case). The request and event types are public records, so a .NET client can read them.
- Nothing is cached, and nothing is written. Every endpoint is a read.
Verification (GR-21):
- Offline:
- the streamed answer's deltas decode escapes split across pieces;
- insufficient gives no deltas;
- a refusal after deltas;
- the final answer equals AnswerAsync's for the same reply;
- cancelling stops the model call.
Against an in-process server on a loopback port: the event order, a refusal that withdraws provisional text, a cancelled request that stops the model call, and the 400s.
- Live: documents/{id} and an answer streamed from stored documents with checked citations.
Scoped search (GR-11). GraphRagRetrievalOptions.Scope keeps retrieval inside a GraphRagScope:
- Kinds: Documents(ids), Sections(ids) (the chunks wholly within each section's bytes), Matching(filter) (publisher, media type, source URI prefix, published from or before, compared as stored text), and FromCypher(cypher, parameters), a read-only query returning documentId and optionally startByte and endByte. At most 10,000 IDs; a metadata or Cypher scope that matches more keeps the first by ID and reports ScopeDocuments.
- Resolution: every scope becomes documents, each whole or limited to byte ranges, before any search.
- The spike, on Neo4j 2026.09.0:
- SEARCH ... WHERE filters inside a vector index before the top k, including IN lists, but only on properties declared when the index is created (WITH [c.p]); anything else is refused with 22ND3;
- the toolkit's index declares none, and a chunk's byte range can't be declared usefully for sections.
So wave 8 doesn't use it: that would need provider migration steps with filter properties and a reviewed index change on every existing graph. It is recorded as a future idea with the evidence.
- Strategies, chosen per route:
- vector, with at most ScopeExactChunks chunks in the scope (10,000 by default): exact, every chunk scored with the embedding set's similarity function, starting from the scope's documents through their unique key;
- vector, larger scopes, and full-text always: over-fetch, the index asked for about twice the limit inside the scope (at least the limit, at most 10,000), then filtered on the server. When it returns fewer than the limit and didn't ask for the whole graph, VectorScopeOverFetch or FullTextScopeOverFetch is reported.
- Defence in depth: every item and neighbour is checked against the resolved scope again in .NET before it becomes evidence; a Cypher expansion gets $scopeDocumentIds and stays the application's responsibility.
- The trace: GraphRagEvidence.Scope (GraphRagScopeTrace) gives the scope, its documents and chunks, and each route's strategy, candidates asked for and kept. The retrieve activity adds graphrag.scope, graphrag.scope_chunks and graphrag.scope_strategies.
- Endpoints: GraphRagEndpointOptions.ScopeFor(HttpContext) decides each request's scope; the caller can't. The retrieved event carries the trace.
- Verification: offline, the scope rules, the over-fetch sizes and the containment check; live, each kind of scope with exact scoring and with over-fetch keeps every item and neighbour inside it, an empty scope finds nothing and the answer is refused without a model call, and an expansion keeps to $scopeDocumentIds.
The NASA showcase (GR-58), sample 09. The owner approved five NASA Artemis II news releases (decision D8).
- What ships: each release's first six paragraphs, under data/nasa. fetch downloads the whole releases and keeps headings and paragraphs, without image captions or the media contacts after -end-. The six-paragraph cut also leaves out the one long quotation by a Canadian Space Agency astronaut, whose copyright status may differ from NASA's.
- Labelling: NASA's media guidelines forbid attributing AI output to NASA. The sample prints which model wrote each answer; only the checked quotations are NASA's words.
- The agent: agent runs a ChatClientAgent with GraphRagContextProvider. The samples' OpenAI-compatible client gained tool calling, and per-request ChatOptions.Instructions are sent as a leading system message.
- Recordings keep tool calls: a chat entry stores the reply's tool calls. A request's key covers its tool calls, tool results, tools and instructions only when it has them, so every earlier recording keeps its key.
- Recorded on 26 October 2026: 23 calls with GLM-5.3-Flash, replayed at zero calls. The numbers are in the sample's README.
Modest scale (GR-59). benchmarks/GraphBridge.GraphRag.Scale measures a generated corpus (deterministic, no download or model) and any Markdown folder, with real embeddings when a provider is named. It creates its schema by running the rendered steps directly, so it leaves no migration history, refuses to run below the 5 GB memory reserve, and removes everything it wrote. The results and every enforced bound are on the limits page. The measurement found removal the slowest operation, about a third of a second per document; a bulk path is a planned should row (GR-66).
13. Wave 9: one graph, two stacks (amendment, 26 October 2026)¶
Status: accepted on 26 October 2026, under the owner's instruction to continue through wave 9; the owner reviews it with the code. Adopt mode adds a grounding level, so it changes the trust contract of section 4.
What neo4j-graphrag 1.22.0 writes (read from its source and checked by the interop suite):
- documents labelled Document and __KGBuilder__, with path, createdAt and any metadata, and no ID;
- chunks labelled Chunk and __KGBuilder__, with text, index and embedding, and no ID; its writer removes the temporary ID it writes with;
- FROM_DOCUMENT and NEXT_CHUNK relationships, created with APOC.
GraphBridge reads such a graph's counts, and its reset refuses it, because no GraphBridge run wrote it. Retrieving and answering need what the graph lacks: a key per document and chunk, a chunk's document, its byte span, and the run that wrote it.
Adopt mode (GR-18). GraphRagAdoption.Plan(layout, embeddingSet, options) returns a GraphRagAdoptionPlan:
- PrepareSteps: the key constraints, and the indexes the layout doesn't name, as migration steps for the application's plan. An index the layout names is the other tool's: it is adopted as it is, and the dry run checks it, because the migration engine creates only absent names. They run before apply.
- PreviewAsync(db): the dry run, which only reads. It reports:
- the documents, chunks and links found;
- how many documents a source places, and how many chunks would be chunk-grounded;
- anything that blocks: a chunk without a document, two documents with one path, an embedding of other dimensions, or nodes a GraphBridge run already owns.
- ApplyAsync(db): one run that adds, and writes nothing else:
- on each document: Id, the SHA-256 of its source URI (the source file's URI when one is given, otherwise adopted: and its path), with SourceUri, MediaType, ContentHash, LengthBytes and RunId;
- on each chunk: the layout's chunk ID (the SHA-256 of its document ID and index), DocumentId, StartByte, EndByte, Grounding, EmbeddingSetId and RunId;
- RunId on the FROM_DOCUMENT and NEXT_CHUNK relationships, and the run node.
The text, index, embedding, the library's own properties and labels, and its indexes stay, so neo4j-graphrag reads the graph as before.
- UndoAsync(db, runId): removes exactly what that run added and what still carries its run ID. A document GraphBridge has since ingested again belongs to the later run, and stays.
Grounding. When options.SourceFiles gives a document's source file, its chunks are placed in it in order, byte-exact. Unlike the DataIngestion adapter, adoption doesn't accept a match with whitespace normalised, because it doesn't rewrite the chunk's text, and a chunk's text must be exactly its span. The adapter's span recovery moves into the core as a shared text locator. Then:
- Placed: the chunks get the real byte spans and are source-grounded, like ingested chunks.
- Not placed, or no source: the document's chunks are chunk-grounded. StartByte is 0 and EndByte the chunk's UTF-8 length, so a citation's span is inside the stored chunk's text, and Grounding is chunk.
Evidence items and citations carry Grounding (Source or Chunk), and the answer tools and endpoints pass it on. The trust contract becomes: every citation is located in stored bytes, of the source document or, for a chunk-grounded citation, of the stored chunk. A chunk-grounded citation proves the quote is in the passage, not where the passage sits in its source.
Index names. Neo4j allows one vector index per label and property, and neo4j-graphrag's users have named theirs. GraphRagLayout.VectorIndexName and FullTextIndexName name the indexes GraphBridge uses; by default they're derived, as before. An adopted graph keeps its vector index, and both stacks search it.
Verification: - Live: a graph of neo4j-graphrag's shape, built with Cypher: - the dry run writes nothing, checked by counts and by every node's and relationship's property keys before and after; - apply, then retrieval and answers with chunk-grounded citations; - with sources, source-grounded citations equal to the files' bytes; - undo restores the property keys exactly. - Interop suite: the same on a graph neo4j-graphrag itself built. Its vector retriever finds the same passages after apply and after undo.
Document identity. A document's ID hashes its source URI, and a file's URI was its full path. A moved folder was then a new set of documents, and the old ones stayed. Recorded model calls didn't replay in another clone either, because a tie in fusion is broken by chunk ID, and the agent tools return IDs. Found on 26 October 2026, when the tutorial ran sample 09 from its build output. GraphRagSources.File(path, uri) and Folder(path, baseUri) name files by a URI of the application's choosing, such as the address they're published at. The full path stays the default, because it is unique without one. The samples name their shipped files by their address in the public repository, so their recordings replay in any clone. Re-recording changed 8 calls, and the evaluation scores didn't change.
14. PDF with page-aware citations (amendment, 26 October 2026)¶
Status: accepted on 26 October 2026: the owner decided D4, a separate package on PdfPig, and approved the download of PdfPig 0.1.16 (Apache-2.0). GR-53.
The package. GraphBridge.GraphRag.Pdf, experimental like the toolkit (GBRAG001), depends on the core of the same version and on PdfPig. services.AddGraphRagPdf() registers GraphRagPdfLoader, an IGraphRagDocumentLoader for application/pdf. The core maps .pdf files to application/pdf and still refuses them with unsupported-media-type without the package, so the core takes no PDF dependency.
What is stored. A PDF's document is its text layer, extracted page by page in reading order: PdfPig's word extraction, then its layout analysis into text blocks, one paragraph per block. A blank line separates paragraphs and pages. The document's bytes are that UTF-8 text, so chunks are exact spans of it and citations are located in it, as for any document. The citation proves the quote is in the PDF's extracted text, not in the PDF file's bytes. The content hash is the hash of the extracted text, so a PDF that is re-saved with the same text is unchanged.
The page map. The loader returns where each page starts in the text (GraphRagLoadedDocument.PageStarts, UTF-8 byte offsets). Ingestion stores it on the document as GraphRagDocument.PageStarts. Retrieval gives each evidence item the pages it spans (GraphRagEvidenceItem.Pages), and the citation check gives each citation the page of its first byte and of its last (Page, EndPage). Documents without pages have none, and the tool output names pages only when there are some, so nothing changes for them.
Refusals, each a stable code in the document's outcome:
- pdf-no-text: no page has a text layer, as in a scan. It needs OCR first, which the toolkit doesn't do.
- pdf-unreadable: PdfPig can't open the file or read one of its pages, or it is encrypted.
- document-too-large: the file is over GraphRagPdfOptions.MaxFileBytes (64 MiB by default), it has more than MaxPages pages (5,000 by default), or its text is over the ingestion's MaxDocumentBytes.
Amendment, 29 October 2026 (GR-69): PdfPig parses a page's content only when the page is read, so a damaged page used to escape as an unclassified exception and end the whole ingestion run. A failure reading any page is now pdf-unreadable, naming the page. MaxPages bounds a crafted file whose pages are empty, and the loader's constructor checks its options as AddGraphRagPdf does.
Not adopted: - Positions in the PDF itself (the page's coordinates of a quote). Pages are what a reader needs to find a quote; coordinates would tie citations to one renderer. - OCR, and text from images. A scan is refused with a reason instead of yielding an empty document. - PDF in the core package. Most users don't need it, and PdfPig adds 16.7 MB to a restore.
Verification: tests build their PDFs with PdfPig's own builder, so no binary fixture is committed:
- offline: the text, the page map and the title of a three-page PDF; the refusals, including a page whose contents aren't a stream and a PDF over MaxPages;
- live: a PDF ingested and asked, with the citation's quote equal to the extracted text's bytes, on the right page.
15. Text2Cypher, experimental and off by default (amendment, 26 October 2026)¶
Status: accepted on 26 October 2026: the owner decided D5, experimental, off by default, behind read-only, EXPLAIN and row-cap gates. GR-15.
Why. Some questions are about the graph, not about a passage: how many releases name a person, who works where, which places one release mentions. Text answers can't count or list across documents; a query can.
The service. services.AddGraphRagText2Cypher<TContext>(options) registers IGraphRagGraphQuestions; nothing exists until it is registered. AskAsync(question) returns a GraphRagGraphAnswer: the query, its column names and its rows, or a refusal with a reason. A graph answer is never a text citation. It is marked as what it is: rows a query returned, which a reader or an agent phrases. The toolkit doesn't ask a model to turn rows into prose, so nothing unchecked is added.
The prompt. The model gets the question and a schema written from the EF model and the ontology: - the document and passage labels with their properties; - each ontology class's label and properties; - each fact type with its ends; - the mention and quotation types.
It replies with JSON, {"cypher": "..."} or {"insufficient": "..."}. The schema is generated deterministically, so recorded calls replay.
The gates, in order, each a refusal reason:
1. not-allowed, checked on the query's code: its text with comments blanked, string literals emptied and quoted names unquoted, so neither hides a keyword nor trips one. Refused:
- more than one statement;
- a first clause other than MATCH, OPTIONAL MATCH, WITH, UNWIND, RETURN or a CALL subquery, after an optional CYPHER 25, so administration commands such as SHOW and plan options such as EXPLAIN are refused;
- LOAD CSV, which reads files and URLs;
- a procedure call (CALL name(...); CALL { } and CALL (x) { } subqueries are fine);
- a function outside Neo4j's own namespaces (date, datetime, localdatetime, localtime, time, duration, point, vector.similarity, coll), because a plugin's function, such as APOC's apoc.cypher.*, can run a string as Cypher;
- USE, IN TRANSACTIONS;
- anything over MaxQueryLength.
2. invalid-query when EXPLAIN fails, and not-read-only unless EXPLAIN's query type is read-only. Then not-allowed again when the plan has a procedure call, a LoadCSV or a transaction operator: the server's plan decides what the query reaches, whatever the text gate let through.
3. The query runs in a read transaction, so the server refuses any write. It runs with Timeout (10 seconds by default), in explicit transactions, so the driver doesn't retry past it, and query-failed reports a server error. At most MaxRows rows (50 by default) are read. If there are more, the answer says so in LimitsReached, as every bound does.
Rows hold strings, numbers, booleans, dates as text, lists and maps. A node or relationship becomes the map of its properties without the chunk embedding. An embedding is left out anywhere it appears: a list of more than 32 numbers with a fractional one, and in properties any numeric list that long. A list keeps 1,000 items and a string 10,000 characters; what's cut is reported in LimitsReached (GraphQueryListItems, GraphQueryText). The driver still reads a row whole before it is cut, so a server memory limit for transactions bounds one huge row.
Amendment, 29 October 2026 (GR-69). The second review found the text gate could be passed by a comment straight after CALL or LOAD (CALL/**/db.labels(), LOAD/**/CSV), which EXPLAIN calls read-only, so a model's query could have called a read procedure or fetched a URL from the database server. Fixed by the code pre-pass, the first-clause and function-namespace rules, and the plan check above; the explicit transactions and the value bounds came from the same review. Text2Cypher doesn't take a retrieval scope: it reads the whole database, for single-tenant or trusted callers. A deployment can also give its connection a Neo4j user that may not run procedures or load files, where its edition allows that.
Not adopted: - Writing queries, with or without confirmation. - A model-written sentence over the rows, which no check could verify. - Running a query a model wrote outside a read transaction. - An agent tool, for now: tools change every recorded agent request. It can come later, opt-in like the service.
Verification:
- offline, with a scripted model: each gate refuses what it should; the schema text is stable; rows are bounded and embeddings left out;
- live, scripted: a read-only query answers; CREATE, LOAD CSV, a procedure call, both behind a comment, and a syntax error are refused by their gates; a keyword inside a string literal refuses nothing; the plan check refuses the server's plans of a procedure call and of LOAD CSV; the row cap is reported;
- real model: an evaluation of about 15 questions on samples 08 and 09, recorded once and replayed, with each answer checked against the graph through typed LINQ.
16. Entity resolution suggestions (amendment, 26 October 2026)¶
Status: accepted on 26 October 2026, under the owner's instruction to continue through the should rows. GR-55.
Why. Extraction resolves things by exact normalised name, so one character can arrive twice: the whole of Alice gave "Caterpillar" and "the Caterpillar", "Cheshire cat" and "the Cheshire Cat". Merging is a judgement, so the toolkit suggests, and a person decides.
Suggestions. GraphRagResolution.SuggestAsync(db) reads the things of each ontology class and pairs those of one class that are probably one:
- same-name: the names are equal once case, extra whitespace and a leading "the", "a" or "an" are ignored;
- alias: one's name, read the same way, is one of the other's aliases.
Placeholders are never suggested. Within a group, the thing with the most mentions is kept, so the most-cited name stays.
Embeddings are not used, and names that only contain another are not suggested. Things have no embeddings to compare. In the whole book, containment is mixed: "Mouse" and "Dormouse", "Alice" and "Alice's sister" are different characters, while "Knave" and "the Knave of Hearts" are one. Both are on the future list.
On the whole book (recorded extraction, replayed): 27 suggestions, 20 by name and 7 by alias. Read one by one, 26 are one character or place. One is not: "Mabel" into "Alice", because the model gave Alice the alias "Mabel", the girl she fears she has turned into. Suggestions inherit the extraction's mistakes, which is why a person decides. Merging all 27 kept every mention and quote the book's bytes, and undoing all 27 restored the graph exactly.
The merge plan. GraphRagResolution.Plan(accepted) takes the suggestions a person kept:
- PreviewAsync counts what would move and lists blockers: a thing merged twice, or kept in one merge and merged in another, and things gone since the suggestion.
- ApplyAsync merges each pair in one save:
- the merged thing's mentions and quotations move to the kept thing, keeping their bytes;
- its facts are re-pointed, and a fact the kept thing already has gets the union of the documents that state it;
- a fact that would join the kept thing to itself is dropped;
- its name and aliases become aliases of the kept thing.
Then the merged thing is deleted.
- Undo. Each merge is a run that records what it changed, so UndoAsync(runId) puts every mention, quotation, fact and property back and recreates the merged thing. It is refused once the merged thing or a removed row was written again, as re-ingesting a changed document does, because putting them back would duplicate them. Nothing is merged automatically.
Limitation. Extraction doesn't read aliases, so re-ingesting can bring a merged name back as a thing of its own; suggestions show it again. Resolving extraction through aliases is on the future list.
A merge that fails (amendment, 29 October 2026, GR-70). Each merge is its own save, so when one fails, nothing of it is written but the merges before it are. ApplyAsync throws GraphRagMergeException, with the failed merge (Failed), the runs applied before it (AppliedRunIds, in the plan's order) and the cause as the inner exception. Its message names the element the database refused when the save reports one: the entity type, its state and its key. The caller undoes the applied runs, last first, or keeps them. Cancellation still throws OperationCanceledException; the merges applied before it are the runs whose fingerprint starts with merge:. Found by the final review: the whole-book test's merge failed with a concurrency error in 2 of about 19 replays, at the plan's first merge, deleting a quotation of the merged character, and the caller had lost the runs of the merges already applied. The cause isn't known yet.
Undo after later writes (amendment, 29 October 2026, GR-69). The second review found that undo checked only the merged thing and the rows it removed, so it could lose what came after the merge: a document ingested later that stated a fact the merge moved, or a later merge into the same thing, whose alias an earlier run's undo dropped. A merge now records what it leaves on the facts it writes and on the kept thing, and undo is refused while any of it differs; undone last first, a plan's runs restore the graph. The record also holds the ontology's fingerprint, since it names rows by their place in the ontology, and an undo under another ontology is refused. A merge's run ID is chosen before its save, so a save whose commit outcome was lost is checked: if the run exists, it was written, and the exception lists it.
Verification: - offline: the suggestion rules on names, aliases, articles and placeholders; - replayed: the whole book's 27 suggestions merged and undone, with every node, relationship and property as before; - live: a graph with duplicates, previewed, merged and undone, with every node, relationship and property as before; an undo refused after re-ingesting, with the graph unchanged; a plan whose third save fails reports its first two runs, and undoing them restores the graph.
17. Answer export (amendment, 26 October 2026)¶
Status: accepted on 26 October 2026, under the owner's instruction to continue through the should rows. GR-14.
Why. An audit or a bug report needs the whole answer: what was asked, what the model saw, what it cited and what it cost.
Decision. GraphRagAnswerExport.ToJson(answer) writes one JSON document: format (graphbridge-graphrag-answer/1), exportedUtc, the toolkit's informational version, and the answer as System.Text.Json writes it with web defaults and enum names. FromJson refuses any other format. Check(answer) needs no database: each citation must name an evidence item of its chunk and document, lie inside its span and be its bytes; an answer needs a citation, a refusal a reason and no text.
Not included. No signing: Check finds an edited quote or span, not an edited passage with its quote edited to match. The export holds document text and inherits the documents' handling.
Amendment, 29 October 2026 (GR-69). Check also compares what a citation says of its bytes with what its evidence item says: the pages of its first and last byte, its grounding and its text origin, so an edit that drops TextOrigin or changes a page is found. It compares bytes, not decoded text, so a span that splits a character can't pass as the replacement character, and it reports a malformed answer (missing lists, an inverted span) instead of throwing. FromJson refuses an answer without its citations or evidence.
Verification: offline, a full answer and a refusal round-trip to the same document, and Check finds an edited quote, a citation outside its evidence, pages, grounding or an origin that aren't its evidence's, a split character, an inverted span and an uncited answer; live, the quick start's answer and refusal round-trip and check clean.
18. Follow-up questions (amendment, 26 October 2026)¶
Status: accepted on 26 October 2026, under the owner's instruction to continue through the should rows. GR-57. D7 decided against conversation memory in the toolkit; this adds only what a follow-up needs.
Why. Retrieval embeds and searches the question as written, so "Which organization does he work for?" finds nothing about the person meant.
Decision.
- GraphRagRetrievalOptions.History holds earlier turns (GraphRagTurn: question and optional answer), oldest first, passed by the caller each time. The toolkit stores none.
- With history, IGraphRagAnswers first asks the chat client, with JSON output, to rewrite the question so it stands alone. It then retrieves and answers the rewritten question. The evidence's Question is that question, and Evidence.Rewrite records the question asked, the standalone one, the turns used and the outcome: rewritten, unchanged or unreadable. An unreadable reply falls back to the question as asked.
- Bounds: the newest six turns, each earlier answer cut to 1,000 characters, a rewrite at most 2,000 characters on one line. More turns are reported in LimitsReached as HistoryTurns.
- The rewrite is one more chat call, on the receipt with its tokens, and a GraphRag.Rewrite activity carries the turns, the outcome and tokens. Following section 9, it never carries the question.
- IGraphRagRetriever refuses history, so nothing silently ignores it. The ask endpoint takes history from the client (at most 20 turns, each bounded by the question length), and its retrieved event carries the rewrite.
Not adopted. Rewriting inside retrieval (it has no chat client), and answering with the history in the answer prompt: citations must come from the evidence, and the earlier answers aren't evidence.
Verification: offline, the rewrite prompt, the fallback outcomes, the bounds, streaming, tracing and the endpoint; with a real model, recorded, follow-ups on sample 09 that retrieve the right release.
19. Removal in batches (amendment, 26 October 2026)¶
Status: accepted on 26 October 2026, under the owner's instruction to continue through the should rows. GR-66.
Why. GR-59 measured removal at about a third of a second per document: each document took about a dozen statements in its own transaction, and the facts listing it were found by a scan of every fact, once per document.
Decision. ResetAsync, RemoveSourceAsync and RemoveOrphansAsync remove documents in batches, one transaction each:
- A batch holds at most 100 documents and 8 MiB of their source text; a larger document goes alone.
- The same statements run once per batch, with the batch's IDs as a parameter: chunks, sections, mentions, quotes and structural edges by key, in pages of 1,000.
- Facts are read once per batch. A fact that lists only documents of the batch is deleted; the others lose those documents from their list. More than 100,000 facts listing one batch is refused, and nothing of that batch is removed, where before the extra facts were silently left listing a removed document. Then the batch is halved and each half tried again, so only a single document that more than 100,000 facts list is refused, with the batches before it removed (amendment, 29 October 2026, GR-69: a corpus of books, each listed by about 1,500 facts, could otherwise never be reset).
The guarantees stay: by key, never DETACH DELETE, only nodes with a toolkit run ID, orphans removed separately. Each document is still removed whole; what changes is that a failure undoes its whole batch, not one document.
Measured on the generated corpus, on the same workstation as the limits page: with 1,000 documents of five passages, 100 documents in 1.3 s instead of 23.5 s and the other 900 in 6.1 s instead of 121.3 s; with 4,000 documents (20,000 passages), 100 in 5.3 s instead of 68 s and the other 3,900 in 42 s instead of 23 minutes.
Verification: offline, the batch rules; live, two documents removed in one batch, keeping a fact a third states and dropping one only they state, and every GraphRAG live test.
20. neo4j-graphrag's entity shape (amendment, 26 October 2026)¶
Status: accepted on 26 October 2026, under the owner's decision D3 (entity-level interop). GR-16, on the provider's additional labels (ADR 0055) and shared relationship types (ADR 0056).
Why. neo4j-graphrag 1.22.0 writes an extracted entity as (:Type:__Entity__:__KGBuilder__ {name})-[:FROM_CHUNK]->(chunk), and its retrievers, resolver and examples read that shape. GraphBridge's things had one label, a mention type per class and Name.
Decision. GraphRagOntology.WithNeo4jGraphRagEntities() returns the same ontology stored in that shape:
- every class's nodes carry __Entity__ and __KGBuilder__ as additional labels;
- every class's mention is the identified type FROM_CHUNK, from the thing to its chunk, shared by all classes;
- Name is stored as name: the toolkit's native queries, its entity-names index and the Text2Cypher schema use the ontology's NameProperty.
What the model sees, the facts and the quotes don't change. The fingerprint changes, so a graph in the other shape is ingested again. Other ontologies keep their fingerprints and Cypher. A fact or quote type named FROM_CHUNK is refused. With GraphRagLayout.Neo4jGraphRag, the whole graph is in neo4j-graphrag's names.
Not adopted. Reading the entities neo4j-graphrag extracts: they have no stable key, byte spans or run, so citations and maintenance couldn't hold. Adoption stays at documents and chunks.
Verification:
- offline: the shape, the model's labels, the shared type and its one constraint, the name property, the refusal;
- live: extraction in the shape, LINQ reads, reset and orphan clean-up leaving nothing;
- interop: neo4j-graphrag 1.22.0 counts the same entities and mentions, and its VectorCypherRetriever returns each passage with exactly the things GraphBridge extracted from it.
21. Derived text (amendment, 26 October 2026)¶
Status: accepted on 26 October 2026, under D4 (PDF as its own package; OCR gets only a derived-text marker and recipes). GR-54.
Why. A scan has no text layer (pdf-no-text), and audio has no text at all. Text made from them by OCR or transcription can differ from the original, so a citation that is byte-exact in that text proves less than one in a born-digital document. The reader should know.
Decision.
- GraphRagSourceMetadata.TextOrigin names where the text came from, such as ocr: Tesseract 5.3; null means the source's own text.
- The document stores it. Retrieval reads it with the page map, and every evidence item, citation and tool citation of that document carries it. JSON writes it only when set, so output for other documents doesn't change.
- No OCR or speech engine ships. The docs give recipes: ocrmypdf then the PDF package, an OCR engine's plain text, or a transcript.
- The answer prompt is unchanged: the model sees the evidence as before, and the origin travels with the citations.
DataIngestion (GR-26, 26 October 2026). GraphBridgeChunkWriterOptions.TextOrigin gives the same origin to every document a DataIngestion pipeline writes, for readers that convert their sources: the recipe reads Office files with MarkItDownReader and markitdown 0.1.9. An opt-in test converts Word files it builds itself, ingests, retrieves and answers, with the origin on every citation.
Not adopted. Warning the model about OCR errors in the prompt: it would change every derived document's prompt, and checking a quote against the original image is beyond a text toolkit.
Verification: offline, a located citation and a tool citation carry the origin, JSON omits it otherwise, and an export keeps it; live, an ingested document stores it and every evidence item and citation names it, with the quote still the stored bytes.
Not adopted¶
- One relationship type for all mentions, as neo4j-graphrag's
FROM_CHUNK: refused by GraphBridge today (spike a). Each mention class gets its own type until a provider change allows several identified owners of one type with different endpoints. That change is on the gap list. - Mentions as association edges: an association edge is identified by its endpoint pair, so one entity mentioned twice in a chunk would need one edge holding several positions. Identified relationships keep one edge per mention.
- Document text on the document node: it would duplicate every byte the chunks hold, and large documents would make one very large property.
- Token budgets by default: a tokenizer is a model-specific dependency. Byte budgets are exact and always enforced; token budgets apply when the application supplies a counter.
- A second ingestion framework: the core has a small loader and chunker so it needs no preview package. PDF, Office and other readers come through the DataIngestion adapter.
Verification¶
Each wave's acceptance is in the implementation plan, and the evidence will be in feature evidence. For this ADR: - the spike tests pass and their answers are recorded above; - the public API of waves 1 to 3 is reviewed against this ADR at each wave's commit, and in the release review.
Consequences¶
- Wave 1 needs no workaround. The plan builds index steps in migrations (ADR 0053) and embeddings as EF properties (ADR 0054) first. The spike showed that the toolkit could also start without them, with a native vector write and reviewed DDL as named workarounds.
- Interop with neo4j-graphrag covers the document and chunk layer. Entities need several labels per type and one shared mention type, both later work.
- The lexical layer uses association edges, not native references. Native references point from the parent to the child and carry no properties, while the lexical layer needs child-to-parent links that carry a run ID. The chunk and section therefore store
DocumentIdas a property as well as the edge. - Native references with a chosen direction, several per node, and self-references would let the lexical layer use them. They go on the gap list; the toolkit doesn't need them.
Review¶
Reviewed on 24 October 2026 against the source, with an independent second reading, before acceptance. The review changed:
- the lexical links: association edges from child to parent, each with its run ID, because native references point from the parent to the child and carry no properties;
- embeddings: the dimensions are passed to AddGraphRag and the embedding set to the services, and one set is active at a time;
- schema: GraphRagSchema returns typed steps for the application's own migration plan, because the journal is one linear chain, instead of a provisioning service that applied its own plan;
- leases: inserted and never upserted, taken over and renewed with conditional updates that fence a document's commit;
- dependencies: no VectorData dependency in waves 1 to 3, minimum-version package dependencies, and Neo4j 2026.09 as the floor;
- smaller rules: the undefined Lexical route removed, a replaceable transient-failure test, a document size limit, cut evidence items keeping exact spans, one zero-vector rule, source URI normalisation and UTF-8 loading.
Release review (GR-17), 25 October 2026¶
The checklist of section 9, completed on the release candidate (commit 9317dfa, version 0.1.0-preview.5):
- API against this ADR and its amendments. All 120 public types of the three packages are named here; the one that wasn't, GraphRagExtractionItem, was added to the wave 4 members. No public member contradicts a section.
- Packages. All eight packages were packed in Release and checked, as release.yml now checks them:
- the MIT expression, the repository, the notices and the dependency inventory;
- the icon and the readme;
- an Experimental description on the three GraphRAG packages.
The dependencies are the ones section 1 fixes: the provider bounded below EF Core 11 and driver 7, and every other dependency a minimum. GraphBridge.GraphRag needs the provider of the same version and Microsoft.Extensions.AI.Abstractions 10.11.0; the adapters need GraphBridge.GraphRag, plus Microsoft.Extensions.DataIngestion 10.11.0-preview.1.26513.7 or Microsoft.Agents.AI.Abstractions 1.26.0.
- Secret scan. Publish-PublicSnapshot.ps1 -DryRun scanned the exact tree (562bfaf) with gitleaks 8.30.1 and found no leaks. Nothing was pushed.
- File review. No public GraphRAG file contains private names, paths, prompts or prototype terms. The contracts that follow the earlier prototype (section 10) are re-implemented, not copied.
- Gates.
- Offline, in Release: units 887/887, tooling 284/284, generators 47/47, GraphRAG 62/62 and the query gallery.
- Live on Neo4j 2026.09.0: Integration 339/339, which runs samples 07, 08 and 09 offline with replies written by hand; Tooling 24/24; Specification 81/81; Workshop 9/9 with no lesson skipped.
- The docs strict build, the public-documents check and the inventory.
- Left for the release itself (PL-48), with the owner's go:
- the public snapshot;
- the hosted release.yml, live.yml and security.yml runs on it;
- the nuget environment approval;
- the GitHub release and the private tag.
nuget.org will create the three new package IDs on the first push; confirm that the trusted-publishing policy covers them.
Release review (GR-61) for RP1, 25 October 2026¶
GR-17's checklist and an API review, on graphrag-v2 at f1c42b1, before the GraphRAG packages' first publish:
- API against this ADR. Wave 7's public changes are all in section 11's members:
- new: GraphRagRetryOptions, GraphRagRetrySummary, IGraphRagIngestion.PreviewAsync with GraphRagIngestionPreview, GraphRagDocumentPreview and GraphRagPreviewStatus, the two extraction switches and the four summary fields;
- removed: the two IsTransient settings;
- changed: what the quote locator forgives (section 11), the extraction schema and the ontologies' instructions, so stored documents are extracted again once.
The names follow the toolkit's conventions. No version of the toolkit has been published, so nothing breaks for users.
- The experimental marker (D9). The three packages keep GBRAG001 and an "Experimental" description. Recommended: keep, until people outside this repository have built on the API, or until 1.0. The owner decides at the release.
- Packages. All eight packed in Release and checked as release.yml checks them:
- the MIT expression, the repository, the notices and the dependency inventory;
- the icon and readme, and an "Experimental" description on the three GraphRAG packages.
The dependencies are section 1's: the provider bounded below EF Core 11 and driver 7, and minimums elsewhere.
- File review. The new public files hold no private names, paths or prompts. The recordings hold model replies about public-domain text and about three copies of GraphBridge's own documentation, plus embeddings. A test scans them for keys and addresses. The book is fetched and never committed.
- Claims. Every public GraphRAG claim is in the claims table with its evidence, or listed there as not verified.
- Gates, stabilization round 1:
- nothing reported: no open issues, no new comments on the discussions, no advisories or Dependabot alerts;
- in Release: the build with no warnings; units 887/887, tooling 284/284, generators 47/47, GraphRAG 72/72 and the query gallery; the eight packs and their checks; the inventory; no vulnerable packages;
- live on Neo4j 2026.09.0: Integration 346/346, Specification 81/81, Tooling 24/24, Workshop 9/9, and the real-model suite 18/18 replayed at zero calls. The GraphRAG suites also pass on 2026.10.1;
- the strict docs build and the public-documents check.
- Left for the release itself, with the owner's go:
- the owner's read of the pages (the owner reviewed the labels the same day and kept them all; the White Rabbit's first name stays unanswerable, so the model's "W." is its miss);
- the version;
- the merge into main;
- the snapshot's secret scan, the hosted runs and the nuget approval.
Release review (GR-61) for RP3, 26 October 2026¶
GR-17's checklist and an API review, on graphrag-v2 at 6bf2c4d, for the release that completes "one graph, two stacks":
- API against this ADR. Since RP1's review, 27 public types were added and none removed. Sections 12 and 13 describe the features; this list names every type:
- streaming answers: IGraphRagAnswers.AnswerStreamingAsync, GraphRagAnswerUpdate and GraphRagAnswerUpdateKind;
- scoped search: GraphRagScope, GraphRagDocumentFilter, GraphRagScopeTrace and GraphRagScopeRoute, with GraphRagRetrievalOptions.Scope and ScopeExactChunks, and GraphRagEvidence.Scope;
- the endpoints, in a fourth package, GraphBridge.GraphRag.AspNetCore:
- GraphRagEndpointRouteBuilderExtensions (MapGraphRag), GraphRagEndpointOptions and GraphRagEndpointReceipt;
- the requests and responses: GraphRagAskRequest, GraphRagSearchRequest, GraphRagSearchResponse, GraphRagSearchPassage, GraphRagDocumentResponse and GraphRagDocumentChunk;
- the streamed events: GraphRagRetrievedEvent, GraphRagDeltaEvent, GraphRagVerifiedEvent, GraphRagRefusedEvent and GraphRagErrorEvent;
- adopt mode: GraphRagAdoption, GraphRagAdoptionPlan, GraphRagAdoptionOptions, GraphRagAdoptionPreview, GraphRagAdoptionReceipt and GraphRagAdoptionUndo, and GraphRagGrounding, with Grounding on evidence items, citations, tool citations and GraphRagChunk;
- members of existing types: GraphRagLayout.VectorIndexName and FullTextIndexName, and GraphRagSources.File(path, uri) and Folder(path, baseUri).
One change isn't additive: IGraphRagAnswers gained AnswerStreamingAsync, so a class that implements the interface outside the toolkit must add it. No version of the toolkit has been published, so nothing breaks for users.
- The experimental marker (D9). The four packages keep GBRAG001 and an "Experimental" description. Recommended: keep, as at RP1. The owner decides at the release.
- Packages. All nine were packed in Release and checked as release.yml checks them: the MIT expression, the repository, the notices, the dependency inventory, the icon, and the readme and "Experimental" description of the four GraphRAG packages. GraphBridge.GraphRag.AspNetCore needs the core of the same version and the ASP.NET Core shared framework.
- File review. The public files changed since RP1 hold no private names, paths or prompts; the only address in them is the public repository's. The new recordings hold model replies about excerpts of NASA's news releases, which NASA's media guidelines allow, about two fictional articles, and about public-domain text. The interop suite pins neo4j-graphrag and its dependencies with hashes.
- Secret scan. gitleaks 8.30.1, with the snapshot's configuration, on this branch's exact tree. Its first run found 106 findings, all one false positive: the recordings' request hash was in a field named key, which the generic API-key rule matches. The field is now request, and the scan finds no leaks. Without this, the snapshot would have refused to publish. The scan of the merged tree runs again at the release.
- Claims. GR-60's full audit read every public GraphRAG page: about 120 claims, 15 findings, all closed. Each claim is in the claims table with its evidence, or listed there as not verified.
- Gates, stabilization round 3:
- in Release: the build with no warnings; units 887/887, tooling 284/284, generators 47/47, GraphRAG 118/118, benchmarks 90/90 and the query gallery; the nine packs and their checks;
- live on Neo4j 2026.09.0: Integration 356/356, Specification 81/81, Tooling 24/24, Workshop 9/9, the real-model suite 22/22 replayed at zero calls, and Interop 4/4 with neo4j-graphrag 1.22.0;
- on Neo4j 2026.10.1: the GraphRAG live tests 36/36 and the replays 22/22;
- the strict docs build and the public-documents check;
- the tutorial's commands, run in replay from sample 09's own build output.
- Left for the release itself, with the owner's go:
- the owner's read of the new pages: the tutorial, interop and adopt mode, and document identity;
- preview.5's publication first, then the version (0.1.0-preview.6) and its changelog section;
- the merge into main, the snapshot's secret scan, and the hosted release.yml, live.yml, security.yml and interop.yml runs. The hosted live run is the first replay of the recordings on another machine (GR-67), and the interop run closes GR-52;
- the nuget approval: nuget.org creates the four GraphRAG package IDs on the first push, so confirm that the trusted-publishing policy covers them.
Release review (GR-61) for the one release, 30 October 2026¶
The release of 29 October's decision, published as 0.2.0-preview.1 (the owner's choice at the go, 30 October: a new minor version for the toolkit; 0.1.0-preview.5 was never published), with the provider's features since preview.4 and every GraphRAG package, the should and could rows included. GR-17's checklist and an API review, on graphrag-v2 at 6fa1834:
- GraphRAG API against this ADR. Since RP3's review (cdb69d4), 20 public types were added and none removed. Sections 14 to 21 describe the features; this list names every type:
- PDF (section 14), a fifth package, GraphBridge.GraphRag.Pdf: GraphRagPdfLoader, GraphRagPdfOptions and GraphRagPdfServiceCollectionExtensions (AddGraphRagPdf); in the core, GraphRagPage, with Page and EndPage on citations and tool citations, Pages on evidence items and tool passages, and PageStarts on the source and the stored document;
- Text2Cypher (section 15): IGraphRagGraphQuestions, GraphRagGraphAnswer, GraphRagGraphAnswerDisposition, GraphRagGraphReceipt, GraphRagText2CypherOptions and GraphRagText2CypherServiceCollectionExtensions (AddGraphRagText2Cypher);
- merge suggestions (section 16): GraphRagResolution, GraphRagMergePlan, GraphRagMergeSuggestion, GraphRagMergePreview, GraphRagMergeReceipt, GraphRagMergeUndo and GraphRagMergeException;
- answer export (section 17): GraphRagAnswerExport;
- follow-up questions (section 18): GraphRagTurn and GraphRagRewrite, with GraphRagRetrievalOptions.History and GraphRagEvidence.Rewrite;
- derived text (section 21): TextOrigin on the source metadata, the stored document, evidence items, citations, tool citations, tool and search passages, and the endpoints' document response.
Sections 19 and 20 add no type. Every change is additive. No version of the toolkit has been published, so nothing breaks for users.
- Provider API since preview.4. 23 public members were added and none removed, all additive, so nothing breaks for preview.4's users:
- additional labels (ADR 0055): HasAdditionalGraphLabels and the AdditionalNodeLabels annotation;
- search inside Fluent Cypher (ADR 0057): SearchVector, SearchFullText and Score();
- vectors and index steps (ADRs 0053 and 0054, already in the preview.5 candidate of 25 October): IsGraphVector, VectorDimensions, GraphVectorSimilarity, VectorIndex and FullTextIndex steps and their schema fields;
- shared relationship types (ADR 0056) and the save diagnostics add no public member: the step and result are in Exception.Data.
- The experimental marker (D9). The five GraphRAG packages keep GBRAG001 and an "Experimental" description, as the owner decided on 26 October.
- Packages. Ten: release.yml packs, checks and pushes the five provider-side packages and the five GraphRAG ones, GraphBridge.GraphRag.Pdf included, which needs the core of the same version and PdfPig 0.1.16 (Apache-2.0). Packing and checking all ten in Release is stabilization round 4's.
- File review. The public changes since RP3 (219 files) hold no private names, paths, prompts or keys; the only address in them is the public repository's.
- Secret scan. gitleaks 8.30.1, with the snapshot's configuration, on this branch's exact tree at 6fa1834: no leaks. The scan of the merged tree runs again at the release.
- Claims. GR-69's second review extended the claims table to the should and could rows; each public claim is in the claims table with its evidence, or listed there as not verified.
- The second review (GR-69). Three independent read-only reviews found 65 findings, one a blocker: Text2Cypher's text gate could be passed by a comment after CALL or LOAD. All are fixed or skipped with a reason; section 15 records the gate as it is now. Verifying the fixes found a Tooling bug, fixed: a selected database-first inspection failed on a database with more than 64 labels, because Neo4j 2026 lists every label a database ever had.
- Open, GR-70. A whole-book merge plan failed twice in about 19 replays: its first merge found no relationship to delete for one quote, over the same graph as the passing runs. It hasn't recurred in 24 rounds since. A failed merge writes nothing, names the merge, the element and the provider's step, and lists the runs applied before it; running it again is safe. The cause isn't shown; the owner decides whether it ships as a known limitation in the release notes.
- Gates so far (stabilization round 4 repeats them in Release):
- on one Neo4j 2026.09.0 fixture, in live.yml's order: Integration 381/381, Specification 81/81, Tooling 25/25, Workshop 9/9, the real-model suite 23/23 replayed at zero calls, then Workshop and Tooling again after it;
- offline: units 914/914, GraphRAG 193/193, tooling 285/285; every test and sample project built with warnings as errors; the strict docs build;
- the quick start's code, from the provider and the toolkit packed at this commit, restored from that feed alone, on a fresh fixture: 97 documents and 880 chunks ingested, an answer with a cited span, all 97 unchanged on a second run, then removed.
- Left for the release itself, with the owner's go:
- stabilization round 4 (PL-52), including Neo4j 2026.10.1, then the docs' claims for it;
- the owner's read of the pages and the workshop track;
- the version text, the merge into main, the snapshot's secret scan, and the hosted release.yml, live.yml, security.yml and interop.yml runs; the interop run is GR-52's hosted run;
- the nuget approval: nuget.org creates the five GraphRAG package IDs on the first push, so confirm that the trusted-publishing policy covers them.