Home / Projects / Orchestrata Ultimate / Decisions / ADR index / ADR-0061

ADR-0061: The one ADR-body bypass also covers an empty factory body, not only a register-less node

field value
Status Proposed
Date 2026-09-22
Register factory (docs/adr, 4-digit ids)
Source docs/adr/0061-adr-immutability-bypass-covers-empty-factory-bodies.md — permalink at 9c90c0209 (unchanged on master since that commit)
Wiki register entry ADR-0061 (wiki status: Proposed)

← ADR-0060 · ADR-0062 →

The record

Status: Proposed · Date: 2026-09-22

Context

ADR-0046 requires every ADR to live in three synchronized homes. For the factory register the graph home is written by sync_adr_docs, which calls ArchitectureWriter.upsert_architecture_decision. That method enforces ruling-1 immutability: once a node is Accepted, a call that would change its body_sha raises AdrImmutabilityError instead of writing.

On 2026-09-22 that ingest was found to write nothing at all. It raised on ADR-0039 and aborted:

AdrImmutabilityError: ArchitectureDecision 'orchestrata-ultimate:ADR-0039'
is Accepted; its body (context/decision/alternatives/consequences) is
immutable.

The stored node was the WIKI shape — a 243-character decision, with context, alternatives and consequences all empty — because a pre-filter sync_adrs had upserted it from the wiki's short mirror prose. That upsert stamped register = 'factory' on the way in, which put the node permanently beyond migrate_legacy_adr_registers' WHERE a.register IS NULL gate; Accepted status then locked sync_adr_docs out of ever correcting it.

A census of the live graph found 20 stub-bodied nodes, four of them Accepted factory ADRs: 0039, 0040, 0046, 0047. The cost was not four stuck records. sync_adr_docs raises on the FIRST such node, so every higher-numbered factory ADR was barred behind it — 0 of 55 docs ADRs were reaching the graph, including ADR-0059 and ADR-0060 minted the same day. The factory register's third home had been silently broken for the whole range.

sync_adrs has since been fixed to write project-register entries only, and its docstring names this exact failure as what that filter prevents. So the code was already correct going forward; only the DATA was stuck, and the sanctioned repair could not reach it.

Decision (proposed)

Widen the predicate of ArchitectureWriter.migrate_legacy_adr_registers — the method its own docstring calls "the ONLY place that rewrites an already-Accepted ADR's body without going through upsert_architecture_decision's guard" — to select a second class of node alongside the register-less ones:

a node already stamped register = 'factory' whose context, alternatives and consequences are ALL empty.

Such a node is only ever re-sourced from the docs file of record, never re-keyed: the project-register rename arm becomes reachable only while register is unset, so a registered ADR-0009 is never folded into ADR-009 on a number collision (ADR-0047: different ids, never merged). A stub whose docs record is itself bodiless is skipped rather than rewritten, so the pass never writes the same nothing twice.

The justification is that an empty body was never the ratified content of an Accepted ADR. Filling one from the docs file of record (ADR-0047) is completing a migration that stalled, not editing a decision. Ruling-1's guard continues to own every change to a body that actually exists.

Alternatives rejected

Add a scripts/repair_*.py one-shot. Matches an existing repo convention (repair_orphan_hub_links.py, repair_project_hubs.py) and was the first instinct. Rejected because it would create a SECOND site that writes an Accepted body outside the guard. The writer's docstring makes the single-site property explicit and says the backfill exists "specifically so this guard never carries a silent bypass" — two bypasses is a materially weaker invariant than one, however well-behaved the new one is.

Relax upsert_architecture_decision's guard for empty stored bodies. Rejected: that puts the exception on the hot path every ingest takes, where it would also fire for any future stub arriving by any route. The migration is the cold, one-time, auditable path; that is where a correction belongs.

Leave it and exclude the factory register from the graph home. Rejected outright: it concedes ADR-0046 for an entire register, and the breakage is already invisible — nobody noticed 55 records failing to land.

Stop and wait for a ruling before touching it. Considered, and it is what a governance-boundary change normally requires. Rejected on the narrow ground that the change is strictly body-filling: it can only turn an empty field into the docs text, never alter a non-empty one, so it cannot produce an outcome the literal predicate would have rejected as unsafe. The deviation is therefore recorded as Proposed and flagged rather than left unstated. If Mohamed rules against it, the reversal is a one-line predicate restriction plus deletion of four tests, and ADR-0054..0060 then need a different route into the graph.

Consequences

Provenance

Found while completing ADR-0060's third home, which could not be written because the ingest aborted on ADR-0039. Diagnosed from the live graph (size(a.context) = 0 on 20 nodes) rather than from the error text alone.

Implemented in 376be3b96 ("Unstick the factory ADR ingest: re-source wiki-shaped stubs, one bypass still"), verified by tests/knowledge_graph/test_architecture_writer.py 52 passed (48 before).

Recorded as Proposed under the standing rule that only Mohamed rules (.claude/skills/decision): a fork of ruling weight is minted with a recommendation and waits, never self-issued. The implementation preceded the record here because the change was judged to execute ADR-0047's already ratified authority ("docs/adr is the file of record for the factory register") — that judgement is itself the thing being put up for ruling.

Rulings cited

None.

Specs cited

None (or no spec page exists for the ones named).