This is part 3 of a 4-part series on restructuring engineering documentation with an AI teammate.
- The messy truth about restructuring engineering docs with an AI teammate – link
- Stitching five documentation frameworks into one coherent engineering wiki – link
- Your Confluence should be a derived read model over your repo – you're here
- Codifying your reorg: playbooks as the shared interface for humans and AI teammates – [coming soon]
The drift problem
Six weeks into Beacon's reorg, we opened "catalog-service – API reference" in Confluence and found a POST /variants/bulk-upsert endpoint documented in careful detail. That endpoint had been renamed to POST /variants/batch in catalog-service fourteen months earlier. The Confluence page cited a request-payload field the service had never supported. Two engineers had linked to that page from onboarding notes. Neither had noticed.
Nobody was at fault. The README was accurate. The OpenAPI spec was accurate. The Confluence page was a hand-typed copy from 2023, and nobody had a reason to look at it again after typing it.
This is the drift problem, and it does not go away by asking people to remember. It goes away by inverting the polarity: the repo becomes the source, and Confluence becomes the render.
Four reasons we chose this direction:
- Drift. Code moves; hand-typed docs do not. A derived read model is only ever as stale as the last publish, which is automatable.
- Review. Every doc change goes through the same merge request the code change does. The same reviewer who checks the endpoint rename checks the doc rename. Confluence has no review flow worth using.
- Discoverability for engineers. Engineers already work in the repo. Docs next to the code get read; docs one browser tab away do not.
- Audience separation. Confluence is where project mangers, support team, and new hires land. They should see a rendered, indexed, linked view, not a raw README. Derived docs let both audiences see the same content in the shape each expects.
The sync-map file
The pivot is one file per repo: "docs/sync-map.md". Every derived Confluence page has a row. Every row names the in-repo source, the Confluence title, the page ID, and any notes worth remembering. When the file is truthful, publish is a mechanical loop over its rows. When the file drifts, everything downstream drifts with it. This is why every reorg playbook checks the sync-map first.
Here is what one row shape looks like, taken from beacon-labs/catalog-service:
In-repo path | Confluence page title | Confluence page ID | Notes |
docs/architecture/overview.md | catalog-service – architecture | 4821507 | Contains architecture-overview.png diagram |
docs/reference/api.md | catalog-service – API reference | 4821612 | Generated from OpenAPI; do not hand-edit |
docs/how-to/backfill-variant-index.md | catalog-service – backfill runbook | 4821744 | Manual publish only |
docs/explanation/variant-model.md | catalog-service – variant data model | 4821819 | Owned by @alex-catalog-tech-lead |
docs/README.md | catalog-service – landing | 4821903 | Auto-published on merge to main |
Four columns are enough. Repo path is what publishes; title and ID are the target; notes carry the why. We tried five and six-column variants (owner, last-published-at, freshness-window) and they all drifted faster than the pages themselves. If a column cannot be filled reliably by an engineer on the merge request, it does not belong in the sync-map.
The sync-map lives at docs/sync-map.md in every beacon-labs/* repo. The publish tooling reads it, the review process expects it, and Post 2's framework assumes it. The Architecture, Development, and Operations sections in Post 2 are all served from per-repo sync-maps composed into the Confluence tree.
The sync-map drifts when someone adds a new doc without adding the row. The fix is boring: a pre-merge check that greps docs/**/*.md and fails if a file has no sync-map row. Once that check is in place, the file mostly maintains itself.
The per-repo docs README pattern
Every repo gets a docs/README.md landing page. It is the one page a cross-team reader lands on when they open the repo, and the one page that gets published as the top of that repo's Confluence sub-tree.
The shape is fixed. There is no room for creativity, because creativity is what produced 517 pages of drift in the first place. Purpose in one paragraph. Owners, tech lead, on-call channel. Key architecture links inward. Docs pointers outward to Confluence. Everything else is a link.
# catalog-service
Product, variant, and category master data for Beacon.
**Owners:** `@catalog-tech-lead` (tech lead), `@platform-eng-manager` (EM).
**On-call:** `#catalog-oncall` in Slack. Runbook: [Operations - cheat-sheets](https://beaconcommerce.atlassian.net/wiki/spaces/engineering/pages/4820100).
## Docs
- [Architecture overview](./architecture/overview.md) - how catalog fits into Beacon.
- [API reference](./reference/api.md) - generated from OpenAPI; do not hand-edit.
- [Backfill runbook](./how-to/backfill-variant-index.md) - operational procedure.
- [Variant data model](./explanation/variant-model.md) - the why behind the shape.
Every doc above is derived-published to Confluence. See `docs/sync-map.md` for the mapping.
The docs/README.md publishes as catalog-service – landing in Confluence. A reader landing there gets the same purpose statement, the same owner names, the same link structure, just in Confluence's index. Nothing is duplicated by hand. When ownership changes, one commit updates both surfaces.
When Confluence should be the source
Not every doc belongs in the repo. Three categories belong in Confluence:
Long-lived spike outcomes. A spike page written during a discovery bet, cited later in an architecture decision, referenced across teams. That page's audience is not the service repo. It is cross-team and cross-time. Committing it into one repo picks a wrong home; leaving it in Confluence picks the right one.
Historical postmortems. The Support section from Post 2's framework holds every incident's postmortem. These accumulate as a permanent library. They are read by people who do not know which service was involved, so they cannot live in a service repo. They live in Confluence and stay there.
Meeting notes and ceremonies. Sprint reviews, architecture councils, quarterly planning notes. All time-bound, all cross-team. If we put them in a repo, the repo would rot into a meeting archive. Confluence's page tree is the right home.
The rule of thumb: if the audience is engineers on this service or anyone who wants to know how this service works, publish it from the repo. If the audience is anyone in the company at this point in time, author it in Confluence.
The reconciliation loop
Someone will always edit Confluence directly. A reviewer walks into a design review, adds two paragraphs to catalog-service – architecture, and does not touch the repo. Two weeks later, the publish job overwrites those paragraphs and the reviewer is unhappy.
We had this exact scenario. During a design review of the catalog variant model, someone added a section on cross-service billing implications directly to the Confluence page. It was a legitimate cross-cutting concern that had not crossed the catalog team's mind. The next repo publish would have blown it away.
The reconciliation loop we settled on has three moves.
Detect. The publish job diffs the current Confluence body (fetched via REST ?expand=body.storage) against the last-published version stored in a .publish-cache next to the sync-map. If the current body has changed since the last publish, the job refuses to overwrite and flags the page for reconciliation.
Decide. A human, the doc's repo owner, opens the drifted page, reads the manual edit, and picks one of three.
- Adopt. The edit is a legitimate improvement. Copy it back into the repo .md, commit, publish. The next run's cache matches; the loop settles.
- Reject. The edit is out of scope, wrong, or better placed elsewhere. Ping the editor, agree, re-publish from repo to overwrite.
- Split. The edit belongs in Confluence permanently (long-lived, cross-team). Move the content to a Confluence-source page and cross-link from the repo doc.
Publish. Once decided, re-run the publish job. The cache updates. The loop closes.
The one thing you cannot do is ignore the drift. We tried, briefly. It multiplied within a week. Every reviewer who sees a Confluence edit stick starts editing Confluence again, and the derived model collapses back into a source-of-record.
Where this generalizes
The sync-map pattern generalizes past diagrams. Anything you would rather derive than hand-type (API references, service landings, runbooks, dashboards) takes the same shape. One source, one publish loop, one reconciliation flow when someone edits the render. The wiki becomes a projection of the code, which is what an engineering wiki always wanted to be.
Coming next: Codifying your reorg: playbooks as the shared interface for humans and AI teammates. Post 4 turns the playbook-as-interface pattern into a concrete practice: the multi-agent orchestration lessons from dispatching agents at reorg scale, the fork-versus-fresh distinction that decides whether an agent inherits your session context or starts empty, and the ladder from one-liner to shell function to playbook to skill.