Shipping

Fixing it only in your app is how the template keeps the bug

A fix to a template-derived file is correct locally and leaves the defect in the template and every sibling project. Classify by provenance at edit time.

MobileByteSensei 10 min read
A pattern in the moulding sand, and the castings that will all carry whatever is on it
workflowmonorepoautomation

Projects here are scaffolded from a shared template. Some directories in every one of them are template-derived: they arrived with the project and the template still owns them.

Finding a bug in one of those files and fixing it is correct, complete, and the wrong level.

The case this post is about

One fix, to one file, that came from somewhere else:

at edit time, path is template-derived?
    human present      →  [P]ropose upstream  [L]ocal + waiver  [S]kip
    autonomous         →  apply locally, ENQUEUE the upstream change, carry on

The file was correct to change and the change was correct. It also lives under a path the project scaffolder owns, so three things follow that nobody intended: every project already scaffolded from that template still has the defect, every future one arrives with it, and the next sync may overwrite the fix with the broken original.

None of that is visible at the moment of the edit. The file looks like any other file in the repository.

This post follows that one fix, because the decision it forces — propose upstream or keep it local — is a human one, and the autonomous branch above has to do something other than ask.

The symptom

Nothing looks wrong at first, which is the whole difficulty. The bug is real, the fix is right, the tests pass, and the application works. There is no error anywhere and no reason for anybody to look further.

The second thing happens weeks later, in a different repository. A new project is scaffolded from the template and arrives with the same defect — not a regression, not a new bug, the original one, which was never fixed where it lived. Whoever hits it debugs it from scratch, because there is nothing connecting their symptom to a fix that exists in somebody else's repo.

The casting corrected on the bench while the pattern keeps its flaw

The third is the one that actually stings. The template syncs, and because that file is template-owned, the sync restores it wholesale — your fix is gone. And the diff that removed it shows no fork-owned change at all, because from the repository's point of view a template-derived file was simply brought back into line with its source. You cannot find the deletion by looking at your own work, because it is not in your own work.

The fourth only appears with several projects. Four applications hit the same template bug at different times, each fixes it locally, and each fixes it slightly differently — a different guard, a different default, a different place in the call chain. Now four repositories have four variants of a file that is supposed to be identical, the template matches none of them, and the next sync produces four different conflicts that each look like somebody else's mess.

Why the obvious fix failed

Remember to upstream it. The rule everybody writes down, and it asks for the one thing nobody has at that moment: the knowledge that this particular file is template-derived. The file is sitting in your repository, in a normal-looking directory, and nothing about opening it says where it came from.

Upstream it automatically. Tempting, and it is worse than doing nothing. Opening and merging a pull request into a shared template — one that scaffolds every other project — without a person looking is exactly the irreversible, wide-blast-radius action that should never be automated. The template is not your repository, and a change to it lands in projects that do not exist yet.

Stop using a template. Vendor everything, own it all, no shared source. This removes the problem by removing the benefit: the reason those directories are shared is that the build wiring, the module conventions and the deployment scaffolding are genuinely the same across projects and genuinely expensive to re-derive. Trading that away to avoid an upstreaming question is a bad exchange.

Make the files read-only. The instinct is right — mark them so people stop editing them — and it answers the wrong half. The bug in those files is real and does need fixing. The question was never may this be changed; it was where should the change land, and a read-only flag has no opinion about that.

The actual mechanism

Classify by provenance at the moment of the edit, before the change is written rather than after it has shipped.

A rack where every pattern carries its ownership mark, readable before you pick one up

Which directories are template-derived is a fact that already exists and does not change: it is a property of the path, fixed when the project was scaffolded. So the edit can be intercepted and the question asked at the one moment when all the context is present — you know what the bug is, you know what the fix is, and you have not yet moved on.

Two moulds from one pattern, and the one correction that reaches both

What happens next depends on whether a person is there, and this is the part that took a while to get right.

With a human present, ask — in one line, with letters. Propose it upstream, keep it local with a waiver, or skip. Three options, one keystroke, no interruption worth resenting. The person editing the file is the person best placed to judge whether the fix generalises, and they will never be better placed than right now.

With no human present, do not stop and do not ask. An autonomous run that halts to request approval has failed at the thing it was for. So the local fix proceeds — the project gets its correction immediately — and the upstream half is enqueued: written to a queue with the file, the diff and the reasoning, to be surfaced the next time a person is in the loop. Never auto-opened, never dropped.

That split is the whole design. It separates fixing the project, which is urgent and safe and can happen unattended, from changing the template, which is neither, and it does so without losing the finding in between.

And when the upstream change is eventually opened, it opens as a draft. A pull request into a shared template is precisely the case where a person must release it, and a draft is the state that makes that structural rather than procedural.

The sub-case that decides whether this stays honest: the local-only option needs a dated, reasoned waiver, recorded in the project. Without that, "keep it local" is a frictionless escape and becomes the default within a month — it is always the cheapest answer in the moment. With it, choosing local costs a sentence explaining why this fix does not generalise, which is both a small tax and the record somebody will need when the next sync conflicts.

There is a second question the mechanism has to answer and it is easy to miss: which upstream? A project scaffolded from a template usually cannot push to that template directly — it is somebody else's repository, with its own permissions — so the change goes through a fork, and the fork has to exist, be current, and belong to the right account. That is three preconditions that are all fine until the first time one of them is not, at which point the upstream step fails at the end of the process rather than the beginning.

Resolving the target is therefore part of the mechanism rather than a detail of it. Where does this project's template live, is there a fork, is it behind — asked and answered before anything is queued, so a queued item is one that can actually be opened rather than one that will fail when somebody finally gets to it.

The fix

at edit time, path is template-derived?
    human present      →  [P]ropose upstream  [L]ocal + waiver  [S]kip
    autonomous         →  apply locally, ENQUEUE the upstream change, carry on
at the next human touchpoint
    drain the queue    →  open each as a DRAFT pull request, never merged by automation

The branch on "is a human present" is the design, and the autonomous path is the one that has to be right — it must not block the drive, and it must not lose the upstream change either:

const TEMPLATE_PREFIXES = ["core/", "core-base/", "deployment/"];

export async function onTemplateEdit(path: string, diff: string, ctx: Ctx) {
  if (!TEMPLATE_PREFIXES.some((p) => path.startsWith(p))) return { kind: "local" as const };

  if (ctx.humanPresent) {
    const choice = await ask(`${path} is template-derived.`, ["Propose", "Local+waiver", "Skip"]);
    if (choice === "Propose") return { kind: "propose" as const };
    if (choice === "Local+waiver") return { kind: "waiver" as const, requires: "dated entry" };
    return { kind: "skip" as const };
  }

  // AUTONOMOUS: apply locally and ENQUEUE. Halting here would stall the drive for
  // a decision nobody is present to make; dropping it would silently fork the
  // template. Neither is acceptable, so the change is recorded and surfaced later.
  await appendQueue(ctx.queuePath, { path, diff, at: isoNow(), run: ctx.runId });
  return { kind: "enqueued" as const };
}

Both paths on the same edit:

$ fix core/designsystem/Spacing.kt          # human present
  core/designsystem/Spacing.kt is template-derived.
  [P]ropose upstream  [L]ocal + waiver  [S]kip

$ fix core/designsystem/Spacing.kt --auto   # autonomous drive
  applied locally · enqueued for upstream (queue: 3 pending)

The queue is what stops the autonomous path being a quiet fork. Three pending entries is a question somebody will be asked at the next touchpoint; a missing entry is a divergence nobody finds until the next template sync overwrites the fix.


The queue is the component that looks like bookkeeping and is doing the real work. Without it there
are only two options, and both are bad: block the unattended run, or lose the finding. With it, the
drive keeps moving and the upstream change waits in a form that still contains the reasoning — which
matters, because the hard part of an upstream fix is never the diff, it is reconstructing why it was
needed.

## How we proved it

**The sync overwrite is the demonstrable part**, and it is what makes this more than a tidiness
argument. A template-owned file carrying a local fix, after a sync, is back to its template content.
That is observable, repeatable, and it establishes the thing people doubt: that a local fix to a
template-derived file is not merely incomplete, it is **temporary**.

**The provenance classification is mechanical.** Which directories are template-derived is recorded,
and the check is a path comparison. It needs no judgement, which is why it can run on every edit
without being tuned.

**What I cannot show you is the queue being drained at scale.** The mechanism is in place, items go
into it, and the number of upstream pull requests I have actually opened from it is small. A queue
with few items is consistent with "the system works and template bugs are rare" and equally
consistent with "nobody is draining it", and I do not have enough history to tell those apart. That
is the same gap I have written about elsewhere and I have not closed it here either.

**What I have not solved:** the fix that is already shipped. Everything above intercepts edits from
here on. Template-derived files that were corrected locally before any of this existed are still
sitting there, indistinguishable from fork-owned work until the next sync removes them, and nothing
sweeps for them. Finding them means diffing every template-derived path against the template, which
is a straightforward thing to build and which I have not built.

![The correction applied to the casting now, and queued for the pattern](/posts/template-fixes-flow-upstream/anim-queue.gif)

**And a limitation in the model itself:** provenance is recorded per directory, not per line. A file
that started as template-derived and has since accumulated genuinely fork-specific logic is still
classified as template-derived in full, so a fix to the fork-specific part gets asked about
unnecessarily. That is a false positive rather than a miss, which is the right direction for the
error to go, and it is still friction in the place friction is least welcome.

## What this generalizes to

Any artifact in your repository that **came from somewhere else** carries this property: a fix to it
has a level, and applying it at the wrong level produces a correct result with a half-life.

Vendored dependencies, generated scaffolding, copied configuration, forked libraries, shared CI
workflows — all of them look like your files, sit in your directories, and get edited by people who
have no particular reason to know their origin. The information that makes the difference is not in
the file and is not in the diff; it is in the provenance, which is usually recorded nowhere that the
editor will encounter.

So record it somewhere the edit path can read, and ask the question at edit time rather than at sync
time. And when the upstream half needs a human — because it almost always does, since it affects
people and projects you cannot see — do not let that requirement block the local work. Fix the thing
in front of you, queue the thing that needs a decision, and make sure the queue is somewhere a person
will actually look.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS