
A customer renames a Firestore collection in their login service and pushes the branch. The deploy succeeds, but the architecture graph still describes the old collection name. The next generation reads that stale text and builds against a collection that no longer exists.
On Genesis, the multi-tenant platform I work on, every service has a description in the architecture graph, and the code generator reads that graph when it reasons about what to build. The forward path writes those descriptions from node data. The return path keeps them alive after code changes. This post is about the return path.
The graph rots unless code writes back to it
The forward job takes one node of the graph and turns it into a spec doc. It sends the service name, the environment, the resource type, and the cloud project id as labeled blocks. It lists related infrastructure underneath, reading each relation through a small helper that looks inside properties and config, with a fallback from table_name to name_of_service so a database entry keeps both the database name and the collection it works with. The model returns JSON with one key, desc, and the job writes that text into desc and desc_final. Each run marks its progress in realtime state as COMPLETED or FAIL. That text becomes the input the next generation reads.
That is why a change in code breaks more than the service. Rename a collection, add a topic, move auth from one middleware to another, and the stored description now disagrees with the branch. There is no vector store or ranking step that heals that gap here. Retrieval on this platform is Firestore and GCS assembly plus an edge walk that scopes the graph to the service being built. When the stored text is wrong, the assembled context is wrong. Freshness here is write back, not retrieval ranking.
The job reads only the files that changed
A service_changed event starts the return path. It names the client, the project, the repo, the branch, the service, and the list of files that changed. The job resolves the git credential from Firestore, reads the token from Vault over a call capped at fifteen seconds, then reads each file back from the branch over calls capped at thirty seconds. Timeouts that short keep one slow dependency from holding a job slot open.
It does not read everything. A change set can include a lockfile, a vendored tree, or an env file, and none of those tell the model what the service does. A filter drops env paths, vendor and module directories, and lockfiles before any fetch happens. Each remaining file is capped at sixty kilobytes, and the whole code context is capped at two hundred forty kilobytes. Those caps are cost and correctness controls. Without them one large commit gets the completion rejected for length and the run is wasted. Files that cannot be read are logged and skipped. A deleted path is a normal part of a change set, and the files that remain still describe the service.
In shape, the loop is this:
budget := maxPromptBytes // 240KB for the whole run
for _, path := range files {
if !isReadableSource(path) {
continue
}
if budget <= 0 {
break
}
content, err := fetchFile(ctx, cred, token, repo, branch, path)
if err != nil {
continue // deleted file, skip it
}
if len(content) > maxFileBytes { // 60KB per file
content = content[:maxFileBytes]
}
if len(content) > budget {
content = content[:budget]
}
budget -= len(content)
out = append(out, sourceFile{Path: path, Content: content})
}

The old text is the base and the diff is evidence
The model never gets the diff alone. The prompt sends the service name, the environment, the repo and branch, then the description the graph holds today, then the changed files with paths. When the graph has no text for the service, the prompt says so plainly instead of leaving the slot empty. The instruction around that context is direct. The current text is the base document. Reproduce every section the changed files say nothing about. Rewrite only where the code disagrees or adds something. Keep confirmed wording. Add a missing section only when the files establish it.
Two rules inside that instruction carry most of the weight. Firestore names are read from the code, not guessed. The database comes from the client builder and the env that feeds it, with an environment prefix on the name. The collection comes from the collection call argument or the constant behind it. A database name is never described as a collection. The second rule is about scope. The text must describe what the service does for a reader who never saw the commit, with concrete routes, collections, topics, buckets, and status codes, and it must leave deployment fields alone because the graph already holds them.
The completion limit is set to 8192 tokens because the output is a structured doc of several hundred words and the provider default can cut it mid string. The response must parse as one JSON object with a desc key, pulled out of any fence or surrounding prose by a small extractor. A run that cannot parse is reported as failed. A single model writes the text, and parse or fail decides whether the run continues. That strictness is deliberate. A half parsed description written into the graph would read as truth on the next generation.
The user prompt is assembled in this shape:
fmt.Fprintf(&b, "SERVICE: %s\n", req.ServiceName)
fmt.Fprintf(&b, "REPOSITORY: %s (branch %s)\n", req.Repo, req.Branch)
if currentDesc != "" {
fmt.Fprintf(&b, "\nCURRENT DESCRIPTION IN THE GRAPH:\n%s\n", currentDesc)
}
fmt.Fprintf(&b, "\nCHANGED FILES (%d):\n", len(files))
for _, f := range files {
fmt.Fprintf(&b, "\n--- %s ---\n%s\n", f.Path, f.Content)
}

The same text lands in both stored copies
The new text is produced once, then written twice. The platform keeps the architecture graph in two objects in GCS, the working copy the frontend edits and the backup it falls back to, infra.json and infra_backup.json. The job resolves a path for each, reads each object, and patches every node that carries the service name. It tries the exact name first, then the bare name without the environment prefix, so d-ht-login matches ht-login when the graph stores bare names. It walks both the flat node list and the grouped view, and it counts each node it touches.
The write respects fields that already exist. It always sets the base desc field. Where a desc_final field already exists beside it, it rewrites that too, because desc_final wins when the platform resolves what to show, and leaving the old string there would keep stale text in front. Where no desc_final exists, none is created. A service legitimately absent from one artifact is left untouched instead of written back unchanged. The run reports nodes_updated and the paths written. When nothing matched in either object, the run fails loudly. A retry simply rewrites both copies with the same text, so a partial write converges instead of drifting.
The patch loop has this shape:
for _, artifact := range []string{"infra.json", "infra_backup.json"} {
path := artifactPath(projectData, artifact, envName)
doc, err := readInfraObject(ctx, path)
if err != nil {
continue
}
updated := patchServiceDescription(doc, req.ServiceName, bareName, description)
if updated == 0 {
continue
}
if err := writeInfraObject(ctx, path, doc); err != nil {
return res, err
}
res.NodesUpdated += updated
}

What I would tell someone building this
Merge, do not regenerate. The diff alone deletes correct docs and the base alone preserves stale ones, so send both and require the model to keep what the code still confirms.
Cap input as a correctness rule. A per file cap at sixty kilobytes and a whole prompt cap at two hundred forty kilobytes stop one large change from wasting the run.
Patch every copy in the same run. Two stored objects that drift become two truths, so write the same text to both and count the nodes touched.
Fail instead of writing a guess. A response that does not parse, an empty string, or zero matched nodes means the graph stays as it was and the failure stays visible.