Monday, 12 October 2026

Two graphs, not one: the decoupling under the placement result

The paper argues that an architecture does not change how much complexity a problem carries, only where that complexity is placed. The Discussion puts the reason in one line: the composition is the restriction, there is no version of add a rule that concentrates it. That is true. This blog is about why I believe it is true. The short answer is that in OfficeFloor control flow and data flow are two separate graphs.

Where I noticed it

I have been building a diagram tool for IntelliJ that opens an OfficeFloor endpoint and draws it. The surprising part, obvious in hindsight, is that it needs two overlays and not one. One overlay draws control flow, where the next function and the branches direct. The other draws data flow, the variables a function reads and writes and the value a function returns to the next one. They are different shapes over the same set of functions. Building a tool that had to keep them apart made concrete something the experiment had already measured without naming.

One graph or two

In a Spring endpoint the handler method is both a control node and a data node at once. The call graph and the data passing graph are largely the same edges. To get a value from the first step to the fourth you thread it through the second and the third even when they never use it. So control and data are one graph. When the data of the fourth step needs a change, it changes the first three.

Dependency injection softens this. Spring's @Service collaborators are decoupled objects, injected rather than constructed in place.  They provide partial separation and is likely part of why the Spring arm held up as well as it did. What dependency injection does not separate is the orchestration itself, the order the steps run in and the values handed between them, which still lives in the call sequence and its arguments. That is the coupling that remains, and it is the one continuation injection removes.

OfficeFloor keeps the two apart. Sequencing is continuations, the next and the flows, so you splice a step into the order by adding an edge and not by editing the caller. Data is variables, matched by type and qualifier through a shared namespace, so a function sources what it needs and publishes what it produces without any neighbour changing its signature. A rule is therefore a new function and a new edge, and nothing around it moves.

Why that is the mechanism and not a side detail

For add a rule to stay additive, which is the whole of the additive arm's behaviour, the new function has to do two things without touching anything already there. It has to take its place in the order, and it has to get its inputs and hand on its outputs. Those are exactly the two decouplings. Remove either one and add a rule stops being additive.

Two places the numbers already show it

I did not measure the decoupling directly, and the paper does not claim to. But two of its results are fingerprints of it.

The first is the escape from the call graph. Under the disclosed formula the Spring arm's count of container dispatched classes, the aspects, filters and listeners, rose from 3.0 to 11.2 per run. That is the agent reaching for Spring's own decoupling machinery to get complexity off a call graph that resists being spread out, because the call graph couples control and data and will not distribute cleanly. The OfficeFloor arm's count is exactly 1.0 in all five conditions. It never escapes, because the ordinary way it is written is already off the call graph. The flat 1.0 is the decoupling showing up as a relief valve that never has to open.

The second is the wiring the study deliberately does not score. OfficeFloor composes its endpoint in YAML, and the study keeps YAML in a separate pool so a wiring based design cannot dilute a per line metric. That guard is correct, and it cuts both ways, because the composition is where the additive arm puts the structure that makes it additive, and no complexity metric scores it. Read through the mechanism, that guard is the placement result stated at its most literal. The coordination of control and data has not left the system. It has moved out of the Java the call graph metrics can see and into the composition they cannot. The amount is conserved, as the paper shows. What the decoupling changes is where the coordination lives.

This is seen in looking an end point requiring more than one entity:



Decoupling is not the same as granularity

One honest sharpening. The decoupling is necessary for the additive behaviour but it is not the whole of it. You could decouple control from data and still write one large function that reads and writes many variables, which would be decoupled and concentrated at once. So there are two knobs, not one. The decoupling is the substrate, and the granularity, how finely the rules are spread across functions, is the placement that rides on it.

The experiment separates them cleanly, almost by accident. None of the four external instructions moved the additive arm's placement. The fifth lever, an independent AI reviewer reasoning about the code, did, and it did so by merging functions and collapsing edges while leaving coupling, file count and per node complexity untouched. In other words the one lever that moved the additive arm moved its granularity and never its decoupling. The decoupling held under all five conditions. That is the sense in which it is the substrate. It is the part nothing in the experiment could move.

Sunday, 11 October 2026

250 changes most of the damage was in the tests

Part of a series on how software architecture shapes AI driven code degradation. The earlier posts grew one REST endpoint through sixty change requests. This one grows a whole application from nothing through two hundred and fifty.

The question has not changed. When an AI agent keeps changing a codebase, does the code wear down, and does the architecture decide how fast? What changed is the size of the test. Instead of extending an existing server, the agent starts from an almost empty application and builds the whole thing: a database schema, a Spring Boot server and an Angular front end. Then it keeps going, one plain English change request at a time, two hundred and fifty times.

This post is about the first full chain. One architecture, one run, from the first request to the last. The headline numbers from it looked like a disaster. Almost none of that disaster was the agent's.

The setup

The application is a small billing system for a consultancy: clients, projects, invoices, payments, then taxes, discounts, credit notes, currencies, retention, recurring invoices and reports. Each change request is something a user would say, such as "Let me record what a client has paid on an invoice". About three quarters add something new. The rest change something that already exists.

Every request comes with a Playwright test that drives the running application through its UI. The agent sees only that one test. It never sees the earlier tests. After the agent finishes, the harness runs every test written so far, from the first request onwards, as the regression gate. The agent is told what it is being asked for and how its own change will be checked, and nothing about what it might have broken.

whatthis run
stackAngular front end, Spring Boot server, in memory H2 database
change requests250 (186 additions, 64 changes to existing behaviour)
conditiongated: a tool grades each change and asks for a refactor when it scores badly
modelClaude Opus 5.5, a fresh session for every request
cost$121.91 and 2,586 agent turns for the whole chain

The headline that lied

The strict pass rate asks the hardest question: after this change, is every test written so far still green? The run scored 33 out of 247 graded checkpoints. For the first twenty four requests it was perfect. Then it dropped to zero, recovered for ten checkpoints in the nineties, and from checkpoint 106 to the end it never passed again.

Strict pass is all or nothing, so one red test looks the same as a hundred. Counting the tests instead gives a truer feel for it:



The suite grows to 299 tests. At the end 287 pass and 12 fail, and at the median checkpoint only three are failing. The one tall red bar is checkpoint 68, and it is not what it looks like, as the next section shows.

The regression counter was no kinder. It recorded 71 true regressions, meaning tests that passed before a change and failed after it, not counting the ones a request deliberately replaced. Read at face value, the agent was quietly breaking the application as it went.

So every failure was traced back to its cause. Five independent reviews went through all 250 checkpoints: the request, the test, the agent's own transcript, and the code it changed. Every finding was then checked against the application as the agent actually left it.

Where the failures actually came from

At the end of the run twelve earlier tests were failing. This is what caused each one.

failing testscausewhose fault
4 levy testsRequest 244 said "remove the levy entirely", but only declared that it replaced one of the five tests that checked the levy.the test stream
1 stacked discount testRequest 248 went back to one discount per invoice. A test still expected two discount rows.the test stream
1 invoice lifecycle testA replacement test was copied from an old version and clicked a "mark paid" button that request 32 had deliberately removed.the test stream
1 early payment testRequest 164 abolished a fixed number of days. The updated test still seeded a fixed number of days.the test stream
1 top clients testRequest 176 ranked clients by their balance converted to the home currency. The updated test gave it no exchange rates.the test stream
1 savings testTwo discounts seeded into a world that now allowed one. Either reading of the request was defensible.ambiguous
2 settlement testsAt request 106 the agent put a "Credited" badge inside the invoice status cell, so the status read "PAID Credited" instead of "PAID".the agent
1 discount line testAt request 248 the agent removed a discount line from the invoice breakdown that nothing asked it to remove.the agent

Eight of the twelve were defects in the tests, one was a genuine ambiguity, and three were the agent. Those three come from two mistakes, one at request 106 and one at request 248.

The 71 true regressions collapsed the same way. Fifty nine of them came from a single moment at checkpoint 68, when the Java virtual machine crashed seventeen seconds into the gate. Every test after that failed to connect to an application that was no longer there, and each failure was counted against the agent. One more came from a crashed browser worker at checkpoint 146. At checkpoint 58 the test runner itself crashed before writing its report, and the harness quietly scored the previous checkpoint's report in its place.

Across two hundred and fifty changes, the agent made two mistakes that broke something earlier.

Why one small mistake looked like collapse

The strict pass rate did not fall because the agent kept breaking things. It fell because nothing that broke was ever repaired.

The agent is blind to the earlier tests by design, and it starts every request with a fresh session. When the badge went into the status cell at request 106, no later agent could know. The two tests it broke stayed broken for the remaining 144 checkpoints, and that one mistake on its own was enough to hold strict pass at zero to the end of the run.

That is worth saying plainly, because it changes what a long horizon measure measures. Over sixty requests, strict pass mostly tells you how often the agent breaks things. Over two hundred and fifty, it mostly tells you whether anything ever notices. A single unrepaired fault is permanent, and permanent faults accumulate whether or not the code is getting worse.

The tests had subtler problems too

The lasting failures were the obvious defects. The reviews found quieter ones that would have distorted any comparison between architectures.

Checkpoints that asked for nothing. Around fifteen requests asked for something the application already did. The agent ran its test before touching anything, it passed, and the agent changed nothing. Some were the test stream repeating itself: request 220 asked for ageing by due date, which requests 117 and 160 had already established. Others were side effects of earlier choices: Angular's currency pipe had been printing thousands separators since request 9, so request 40's "put commas in" was already done. Counted together with tests too weak to tell the old behaviour from the new, twenty nine checkpoints measured nothing at all.

Coverage that silently disappeared. When a request changes existing behaviour, it ships updated copies of the earlier tests it affects, and from then on the copy replaces the original. Forty three times, a copy left a check out with no request asking for that. "Adding a line item increases the invoice total" vanished from the gate at request 40. A statement that summed every client's invoices would have passed from request 48 on.

Test data the agent could not know about. Each test sets up its own data through a test endpoint that the agent writes and extends as it goes. That endpoint silently ignored any field it did not recognise. When a hidden test seeded a field no visible test had ever used, such as an archived flag, the field was dropped, and the test failed on data it never received. It looked exactly like broken behaviour.

Tests that disagreed with their own requests. Request 173 asked for ageing bands of current, 30, 60 and 90 days, and its test checked three bands. Request 21 said "I do not want to type one figure for an invoice", while request 10's test, still in force, required exactly that form. One test seeded a due date earlier than the issue date, and the agent dropped a database constraint to make it load.

The agent noticed most of it

Thinking blocks were not recorded, but the agent's own summaries were, and they read like a careful contractor's notes. When a request was ambiguous it said so, picked a reading, and named the alternative. At request 176 it wrote that a client whose currency had no exchange rate would no longer appear in the top clients list, and that "the other option would be to list such clients after the ranked ones". At request 201 it ended with "Tell me if it should follow that setting."

It also caught a mistake in the harness itself. The prompt told it to build "an OfficeFloor server" with "a database migration" for every change. The application was plain Spring Boot, and many changes needed no new tables. In roughly half of its two hundred and fifty summaries the agent pointed this out, followed the project's own instructions instead, and listed it as a departure from the request. It was right every time.

The application it built

With the noise removed, the code itself is the more interesting result. After 250 changes the application has around 10,200 lines of Java in 94 files and 12,400 lines of TypeScript in 84 files. The functions stay small, with a mean cyclomatic complexity of 1.5 per function touched. Money is handled properly: BigDecimal throughout, and the billing rules live on the invoice domain object rather than leaking into controllers.



Complexity grew in a straight line, to a total of 1,380 on the server. Where it sat did not run away. The share held by the five heaviest files fell from 0.86 at the start to about 0.43, and stayed there. Average coupling between classes rose quickly while the application took shape and then levelled out at around 8.

The wear is real, but it is concentrated rather than spread. The invoice entity reached 771 lines and 105 methods, because every billing rule from discounts to retention landed on it. The invoice page in the front end reached 1,527 lines. Every real regression in the run happened in that corner of the code. The single most edited function was not application code at all. It was the test data endpoint: a cyclomatic complexity of 102, 341 lines, and 106 edits, growing a little with every request because every test needs its data loaded.

Where the pressure actually fell

The two hundred and fifty requests are written in five acts. The first sets up the entities: clients, projects, invoices, payments. From the second act on, about four in five requests are aimed at one spine, the chain that turns invoice lines into what a client owes: lines, discounts, tax, rounding, the total, payments, the amount still due and the invoice's status. The idea was that change keeps landing on the same code, so pressure builds there and the later acts show how the application copes with it.

That was the plan. This is what the agent's changes actually touched, act by act.

actrequestschanges that touched the invoice corecomplexity added per requestfiles modified per changesettled lines rewrittenchange impact, median percentile
I: the entities1 to 6037%11.85.517%20
II: the calculation deepens61 to 12060%9.56.518%43
III: money over time121 to 18047%9.55.013%52
IV: reports181 to 24010%6.44.313%55
V: the finale241 to 25060%5.05.244%56

"The invoice core" means the invoice entity and its service, its line items and discounts, and the invoice page and service in the front end. "Complexity added per request" is the growth in total cyclomatic complexity across both server and front end, averaged over the act. "Settled lines rewritten" is the share of changed lines that replaced code which had already been there for at least one earlier request. Change impact is the server side grade from the impact tool, as a percentile against its reference distribution.

The pressure peaked in the second act, when six changes in ten went through the invoice core. It eased in the third. Then, for the sixty requests of the fourth act, it almost disappeared. The fourth act was written so that every report would have to re-read the invoice rules, either through the one calculation or by working it out again. In practice the agent built each report as new code that read the data alongside the calculation, not through it. Only one change in ten touched the core. The longest stretch of the run was testing how easily read-only pages could be added, not how a stressed core holds up.

The stress came back in the last ten requests. The two reversals, removing the levy and collapsing stacked discounts, rewrote settled code at more than twice the rate of any earlier act. But nothing came after them. The run ended at the moment it started to test what it was built to test, so the tail behaved like a short burst of change on an already complicated application. That is close to what the earlier experiment did with the Spring PetClinic, which started from a complex application, and not the long horizon this experiment set out to measure.

The amount column tells the same story from the other side. A requirement that adds to the problem should add to the amount of complexity, so across a run of steadily harder requests the amount should climb on a roughly straight line from the first request to the last. For the first three acts it did. Then it bent.


Through the first two acts the application gained about 10 points of cyclomatic complexity per request, and the third act held close to that line. The fourth act added 6.4 per request and the finale 5. The amount kept rising, but more and more slowly: it finished at 2,277 against the 2,604 a steady rate would have reached, 13% short, and nearly all of the gap opened after request 180. The problem stopped growing as fast as the requests kept coming. The earlier paper in this series found that the amount of complexity in a system is fixed by the problem, and that architecture only decides where it is placed. If the problem stops getting harder, there is nothing new to place, and so nothing to negotiate.

Amount is only half of it. The other half is where the complexity went. The point of a spine is concentration: a few entities on one screen, with what that screen does with them growing more and more complicated. Think of a search page or a social feed: one screen with an enormous amount behind it. Here the natural spine is the invoice page, with the invoice and payment entities and the handlers and services behind that page. That is 10 of the 94 server files and 2 of the 84 front end files. To really stress an architecture, most of the complexity should pile up there.


It did not. The spine's share of all complexity peaked at about 27% in the second act and then drifted down, to 20% by the end. In the fourth act only 4% of the new complexity landed on it. The front end shows it most clearly: the invoice page ended up holding 9% of the front end's complexity, because the agent gave almost every new feature a page of its own. The requests asked for "show me", "let me" and "on the home screen", and a new screen was the easiest place to put each one. Rather than piling up on one screen, the pressure spread across more than twenty feature areas.

One more thing in the last column. The change impact grade did climb steadily, from a median of 20 in the first act to 56 by the end, which is the pressure building. But the run was in the gated condition, which asks for a refactor when a change scores above the 98th percentile, and no change in any act scored higher than the 82nd. The gate never fired once. The gated run never asked for a single refactor, so in effect it was an ungated run.

What to aim for

The paper's result sets the target. Complexity cannot be removed from a problem. It can only be placed, and placement is what an architecture actually decides. So the stream has to keep adding to the problem for the whole run, so that new complexity keeps arriving and has to be placed somewhere. And every fourth request has to mutate complexity that is already in place, to test whether the earlier placement still allows change.

It also has to stop being kind to the architecture. The aim of a long horizon run is not to see whether an architecture survives two hundred and fifty requests. It is to find where it breaks, as early as possible, and then see how it behaves when the load keeps coming after that. Stress saved for the last ten requests shows neither.

That leads to four changes in how the stream is written.

One screen, one spine. All two hundred and fifty requests land on a single invoice workspace, with three entities behind it: the client, the invoice and the payment. No request adds a page. Every request makes that one screen, and the few handlers behind it, do more. Three screens would only split the run into three shorter horizons of about eighty requests each.

Overstress early. There is no gentle first act that sets up breadth. The first fifty requests build the spine and load it, so that by request fifty or sixty it is already at the edge. The remaining two hundred show what happens next.

Algorithms in every act. Most of the current requests are the same kind of arithmetic on money. The PetClinic stream also asked for concentrated algorithms, a Luhn check digit, identities derived from SHA-256 hashes, and Soundex matching, and those put dense logic in one place. Each act should add at least three: for example check digits and ISO 11649 payment references, largest-remainder allocation so the cents always add up, annuity amortisation, compound interest over a day count, tiered pricing, tax-inclusive back-calculation, payment matching by string similarity, and duplicate detection by fingerprint. Many of them should later come back as a revised version that changes the result.

Measure the break and what follows it. The amount should climb on a straight line or steeper for the whole run, and at least 80% of each act's new complexity should land on the spine. With one screen and no breadth act it should be well above that. The analysis should report when each break signal first fires, and the trajectory over the next fifty and hundred requests. The signals are: a change past the impact threshold, the spine's handler logic past a complexity limit, the first true regression, effort per request doubling, and rework climbing. The spine has to be measured neutrally about placement. Complexity counts as on the spine if it serves that screen and its entities, whichever classes it lives in, because an additive architecture will put it in new classes rather than old ones. And the impact gate's threshold has to be set where this kind of application can actually reach it, because a gate that never fires is not an intervention.

actrequestson the one workspacealgorithms added (at least 3)
I1 to 50Build the spine fast: lines, discounts, tax, totals, payments, status.Check-digit invoice numbers, ISO 11649 payment references, largest-remainder discount allocation.
II51 to 100Money over time: allocation, deposits, credits, retention, instalments, interest, currency.Annuity amortisation, compound interest over a day count, currency triangulation with per-currency rounding.
III101 to 150Interacting rules: tax by jurisdiction, tiered pricing, rate cards, minimums, cash rounding.Progressive tiered pricing, tax-inclusive back-calculation, cash rounding with distributed differences.
IV151 to 200Cross-cutting states: approvals, credit holds, disputes, recurring with proration, milestones.Calendar proration, remittance matching by string similarity, a risk score from weighted payment history.
V201 to 250Keep loading: credit and rebill, dunning, and revised versions of earlier rules and algorithms.Duplicate fingerprints, a business-day calendar, dunning escalation, revised check digits.

What changed for the next runs

None of these runs are worth comparing across architectures until the measurement is trustworthy, so the harness was repaired before going further. The test stream was repaired too, and those repairs now become rules that the rewritten single-screen stream is checked against before every run.

problemchange
crashed runs scored as resultsThe gate deletes old reports, checks that every test file reported and that the application is still alive afterwards, and retries a broken run once. A failure on a healthy application is never retried, so a real regression cannot be rolled again until it passes.
undeclared and contradictory test changesEvery request that removes behaviour now lists the tests it replaces. Stale and contradictory replacement tests were rebuilt from the version actually in force.
coverage silently droppedDropped checks were restored. A replacement test may now remove a check only if the request declares it retired, and a lint refuses the stream otherwise.
checkpoints that asked for nothingAll twenty nine were strengthened, so each now needs a real change on any reasonable implementation. Each new test was checked against the application as it stood just before that request, and later requests affected by the changed behaviour were updated to match. Every run now also lists any request whose test passed while the agent changed no code, so a request that asks for nothing is caught in the next stream too.
test data the agent never sawThe test endpoint must store every field or refuse it, and must read the data back. Before each test run, the data of every earlier test is replayed and read back, so an agent sees immediately when a change stops storing something an earlier test relies on. It sees the data only, never the earlier tests.
instructions duplicated in every stackThe harness owns one instruction file, AGENTS.md, identical for every architecture: the working rules and the full testing contract. Each stack keeps only its own design notes in DESIGN.md. Nothing in the stacks or the prompt is specific to one AI service.
wrong blame in the reportsA dropped connection or crashed browser is now labelled as infrastructure, not as broken test data or lost behaviour.

What this does not show

Four things, because a write up that only reports what went well is not worth reading.

It is one run. One chain, one architecture, one condition. Nothing here compares Angular with anything else, or the gated condition with an ungated one. The comparisons need ten chains per architecture, and they are still to come.

The numbers are from the old stream. Every figure in this post was measured before the repairs, on a stream that is now being rewritten around a single invoice workspace. The next runs will ask for different things, on one screen, from the first request onwards. Their results will not be comparable with this one, and should not be. This run, and the stream it used, stay in the project's git history for anyone who wants to look back.

The reviews were partly automated. AI reviewers read the transcripts and the tests. That is why every finding that mattered was checked against the real application at the checkpoint concerned: the new tests had to fail before the change they now require, and the checks they keep had to still pass on the application the agent built. It is still a review, not a proof.

A clean stream does not make the agent blameless. Two mistakes in 250 changes is a low rate. Both were the kind a careful human makes too: putting a badge where it changes the text beside it, and tidying away something nobody asked to remove. And one unrepaired mistake still left the application wrong for the rest of the run.

What it means

In a long horizon experiment the test suite is not neutral ground. It grows alongside the code, through the same kind of change requests, and it can wear down in the same ways: duplicated knowledge, stale copies, rules that contradict each other, coverage nobody notices is gone. Here, most of what looked like the agent degrading the application was the measurement degrading instead.

The second lesson is about repair. A blind agent cannot fix what it cannot see, so over a long enough run the dominant effect is not how often things break but how long they stay broken. That is a property of the process around the agent as much as of the agent. The replayed test data is a small first step: it lets the agent notice one class of breakage without seeing the hidden tests.

With the measurement repaired and the stream concentrated on one screen, the next runs can ask the original question properly: when the same two hundred and fifty requests pile onto a single spine in different architectures, where does each one break, how early, and what happens to it after that?

Friday, 9 October 2026

A framework I did not write: testing the plasticity gap across four full stacks

The backend study made a claim that invites one obvious objection. Over a long run of AI written changes it found that a mutative architecture, Spring MVC, lets change concentrate and its structure drift, while an additive architecture, OfficeFloor, holds its placement steady. The objection writes itself. OfficeFloor is mine. I designed it to be additive, so of course it behaves the way I say additive code behaves. A result about an architecture its own author built is a weak result, however clean the numbers are.

The new experiment is built to remove that objection. On the front end it introduces a second additive architecture that I did not write, did not design, and have no stake in: TanStack. Its router and its query layer are composition first. You add a route, a loader, a component, and you wire them in beside what is already there rather than reaching into a shared file that every feature edits. That is additive by someone else's hand. If additive code resists erosion only because I happened to build the additive thing, then TanStack should erode like anything else. If additive code resists erosion because of what additive composition is, then TanStack should hold too. That is the test.

It is worth being precise about who wrote what. On the front end both candidates are third party: TanStack is the additive one, Angular is the mutative one, and I wrote neither. On the back end OfficeFloor is mine and plain Spring Boot is the mutative comparison. So the additive claim no longer rests only on code I authored. The whole front end axis is independent of me, and the additive side of it is a popular framework a lot of teams already ship.

Four stacks, two layers

The study crosses two front ends with two back ends, so every combination of additive and mutative exists at each layer.

OfficeFloor back end (additive) Spring back end (mutative)
TanStack front (additive) additive both ends additive front, mutative back
Angular front (mutative) mutative front, additive back mutative both ends

The application is the same in every cell. It is one small business app, clients, jobs and invoices, grown from almost nothing by the same sequence of plain English change requests. Nothing about the domain or the requests changes between stacks. Only the architecture underneath changes, which is the whole point of treating architecture as the independent variable.

What the cross can show that a single pair cannot

A single additive against mutative pair tells you the two differ. It cannot tell you why. Is the difference a property of the architecture type, additive or mutative, wherever it sits in the stack, or do the front and the back influence each other so that the combination matters more than either layer alone? One pair cannot separate those. A two by two can.

Three questions fall straight out of the grid.

  • Does a mutative front end drift whether the back end is additive or mutative? That is the Angular row read across.
  • Does a mutative back end drift whether the front end is additive or mutative? That is the Spring column read down.
  • When additive meets mutative across the stack, do the two layers simply add up, or does one drag the other? That is the off diagonal.

If plasticity is purely a property of the type, the prediction is tidy. The additive cell stays flat, the mutative cell drifts, and the two mixed cells land in between in proportion to how much mutative code each carries. If instead the layers influence each other, the mixed cells will not sit where simple addition predicts. A mutative back end might pull an additive front out of shape as the front keeps reaching across to it, or a disciplined additive front might hold a messy back end partly in check. Either way the off diagonal is where that would show, and it is exactly the cell a single pair never gets to measure.

Why a whole stack and why 250 changes

The backend study changed one server behind a fixed API. This one grows a whole application, the database schema, the server and the user interface together, from a near empty start, one plain English request at a time. The tests are the only thing held steady through all of it. They drive the running app through named test anchors and assert on what a user sees, so the entire stack beneath them can be rewritten and the same test still judges it. The coding agent works blind, seeing only the current request and its own test, never the ones that came before, so it cannot pattern match its way through the sequence.

And it runs for 250 changes rather than 60. The backend study could not decide between two readings of its own result, that concentration genuinely compounds into damage over a long horizon, or that the effect is real but mild, because sixty changes on one endpoint may simply be too short to tell. A longer horizon is the way to settle that. The changes are also deliberately concentrated on one part of the app, the invoice and money logic, so pressure piles up in one place instead of spreading thinly across many screens where nothing ever gets hard. At the end of each act a fresh reader with no memory is asked to read the code cold and list every rule the money logic now applies, which gives a running measure of how completely and how cheaply the accumulated logic can still be understood.

What would prove me wrong

The experiment is set up to report any of several outcomes, not just the one I expect. If TanStack erodes like Angular, then the additive claim was really an OfficeFloor claim, and the general version does not survive contact with a framework I did not write. If the mixed stacks land exactly where simple addition predicts, then the layers are independent and there is nothing cross cutting to find. If nothing has pulled apart by 250 changes, then the horizon is still too short or the effect is mild after all. Each of those is a real result and each is one the harness will show plainly.

As with the rest of the series, the harness, the per checkpoint commits and the raw capture for every chain are public, and the whole study re-runs against any model. The stacks are built, the first full run is starting, and I will post the numbers as the chains finish.

Test Harness

The test harness is here: https://github.com/officefloor/ui-long-degradation-test

The four stacks are:

Wednesday, 7 October 2026

Speaking at ChurConf Sydney: explicit function orchestration and the data behind it

A short note. I am speaking at ChurConf in Sydney in November, and the talk rests on an experiment I have just finished and written up.



The talk

Code Your AI Agent Can Actually Read: Explicit Function Orchestration in Spring Boot.

The argument is that how you structure a Spring Boot application decides how legible it is, to a person and now to an AI agent, and that making the orchestration of functions explicit rather than leaving it implicit in a mutative controller changes what an agent can safely do to the code. The talk shows explicit function orchestration inside Spring Boot and why that placement of logic holds up when an agent starts changing it.

  • When: 18 to 19 November 2026
  • Where: Stone and Chalk Tech Central, Sydney
  • Conference: au.churconf.com

The data behind it

The talk is not an opinion piece. I have just finished an experiment and published the paper, so the claims come with empirical backing.

An AI agent implemented sixty accumulating change requests on one REST endpoint, under two architectures, a mutative Spring controller and an additive function injection pipeline, with the model held fixed and ten runs per cell. The question was simple. When you change what the agent is told, does the structure of the code move?

The finding is that the amount of complexity is conserved. The finished codebases carry roughly the same amount of complexity whichever architecture they use and whatever the agent was told. What moves is placement, where that complexity sits, and the two architectures do not move alike. Under every external lever I tried, from a bare change request up to handing the agent the disclosed structural cost function, the mutative controller's placement moved while the function injection pipeline's placement did not. On the placement metrics the mutative arm travelled between about three and ten times as far as the additive arm.

That is the point I want to land. The function injection architecture is not perturbed by prompting. Its placement of logic is set at the structural level, by the architecture, not at the level of whatever last prompted the agent. Prompting pushes a mutative controller around. It does not push an additive pipeline around, because there is nowhere for the pushing to land. Architecture is the lever, not the prompt.

The one thing that did move the additive arm was a second AI agent reviewing each change and reasoning about cohesion itself, and it moved it on one family of metrics only, with the cost reported in full. That is the other half of the paper's title.

Read it

  • The paper, Conserved amount, negotiable placement. Prompting perturbs one architecture, an AI reviewer both, is published with DOI 10.5281/zenodo.23190619.
  • It is the latest in a series on this blog about how software architecture shapes AI driven code degradation.
  • The harness, the per checkpoint commits, and the raw capture for every run are public, and the whole study re-runs against any model with one flag.

If you are at ChurConf, come and find me. This is the work the talk is built on, and I am happy to discuss any of it in person.

Tuesday, 6 October 2026

Metrics updated to include AI review

The fifth condition, the independent AI reviewer, is now folded into every metric figure in the series. I did not want to republish the whole metric gallery as a second wave of posts just for the AI review. As it's the same (or corrected data for the four conditions), the images in the earlier metric posts are updated to include this fifth condition.

See last month's blog posts for them.

Sunday, 4 October 2026

A wrong test is worse than no test

The paper is out as a preprint. Version two follows when the review arm finishes its run. That work answers a question about architecture. It does not answer the question that decides whether any of this is usable, which is whether the whole loop can run without a developer sitting in it.

So here is the loop with a column for where each part of it stands.

stepwhat it doeswhere it stands
ClarifyTurn what the user said into what the user meant, by asking them.I have not built it. Others are working on it.
SpecifyA refined change request, in English, that one person could hand to another.Held fixed in all my runs, as the thing being varied.
TestWrite the acceptance test that pins the requested behaviour, before any code exists.New repository. Instrument built, nothing graded yet.
ImplementMake the test pass without breaking the suite.Two harnesses, hundreds of runs, one preprint.
ReviewAn independent reader critiques the change; the author acts on it.Arm built, run in progress.

The gap in the middle of that table is what the rest of this is about.

What I have measured

The REST harness identified the amount of complexity is conserved and the placement of it is not. A mutative architecture ends up wherever the last prompt, tool or metric pushed it. An additive one stays put, because there is no slack for an instruction to take up.

The obvious objection is that this is one endpoint in one language. The full stack harness covers the other half. It starts from a near empty base (a front end shell, Spring with the OfficeFloor plugin, an in memory database with no tables) and grows a whole application through about sixty plain English change requests, each one spanning a schema migration, the server, the front end and its acceptance test. Erosion is tracked per layer, TypeScript as well as Java, and the finished chains run on OfficeFloor + TanStack and OfficeFloor + React, with Angular and HTMX stacks built to the same contract. The claim is about composing a whole application, so a result that held only for the server would not be worth much.

One decision in that harness carries everything else. The tests bind only to data-testid attributes and assert only through the running UI with Playwright. Data is arranged through a profile guarded seed endpoint, so a test arranges but never asserts through a back door. The whole stack underneath (schema, server, components) can be regenerated and the test still means what it meant before. Functionality is tracked as tests rather than as specifications, and sixty changes later the suite is the only artefact that survived untouched.

Which makes the suite the weak point

In both harnesses I wrote the tests. That is the right call for an experiment about architecture, since you do not let the thing under test grade itself. However, it quietly assumes away the step the pipeline depends on. In the real pipeline nobody hands the agent a known good acceptance test. The agent writes it before the feature exists.

And a wrong test is worse than no test. A missing test leaves a gap you can see. A test that passes without asserting the thing it was written for turns the gate green and lets the pipeline proceed. Every later change is then protected by something that is not protecting anything. Over three to five hundred unattended changes that is the failure that compounds.

So the new repository holds the same sixty checkpoints with the halves swapped: the application fixed and known correct and the agent writing the tests.

This is not new ground and that is useful

Measuring generated tests by breaking the program and seeing whether a test notices is the standard approach, and it has been done to LLM test generation plenty of times. Meta's mutation guided work puts a plain LLM prompt at roughly 53% mutation score on a Java benchmark and reaches about 89% by feeding the surviving mutants back in. There is a study specifically on coding agents writing over mocked tests, which is my failure mode arrived at from another direction. Deriving the mutations from requirements rather than from syntax is not mine either, since specification mutation dates to the late nineties.

Good. It means there are numbers to compare against instead of a blank page. What I could not find measured together is the particular shape I need:

  • Acceptance tests through a running UI, rather than unit tests on benchmark functions. Mutating a whole full stack application and grading it through a browser is rare, because it is expensive: every mutation needs a build, a serve and a Playwright run.
  • Written before the implementation exists. Most generated test work runs against code that is already there, so the test can be checked by running it. Here the agent sees the previous checkpoint and never the current one. It cannot confirm its new test passes. That asymmetry is the hard part of test first work and the main source of bad tests.
  • Sixty consecutive changes to one application. The long horizon benchmarks ask how the code degrades. This asks whether the suite stays honest and what it costs to keep.

It found four holes before any agent ran

Calibrating the instrument means running my known good suite against every mutation and checking each one dies. Four did not.

Four checkpoints asserted less than their request promised. The sharpest was checkpoint 21, where the request was "for each one give a description, how many, and the price each. Work out the total for me", and the spec asserted the line description and the invoice total but never a line's own quantity, unit price or amount. A line amount computed without its quantity, which is correct whenever the quantity happens to be one, was invisible to the suite that existed. Three others were the same shape.

Those were tests written with the requests available. The hole rate on careful human work is not zero and that is the bar the agent is measured against rather than perfection.

Thursday, 1 October 2026

Two chains in: can an AI reviewer move what the architecture will not?

Part of a series on how software architecture shapes AI driven code degradation. This is a progress note on two of ten chains on the review arm.

Two chains is not a result. It is a reading. The full study reports ten runs per cell with confidence intervals, and nothing below is a substitute for that. What two chains can do is tell me whether the arm is behaving, and whether a tidy story I could have told after one chain survives a second. This time it did not, and that is the interesting part.

The review arm, briefly

The fifth condition, reviewed, is the one endogenous signal in the set. The model's own architectural taste, wired up like a pull request. Each checkpoint runs three steps. The author implements the change with a neutral prompt. An independent, read-only reviewer in a fresh session critiques the structure metric-free and cannot run tests or touch the code. Then the original author is resumed with full memory of its own change and the reviewer's notes and it fixes what it agrees with and pushes back on the rest.

The question for this arm is narrow. Left to accumulate changes an AI author lets complexity concentrate. Can a second instance of the same model, looking only at structure, push that back? And does it hold on Spring but not on OfficeFloor the way every earlier lever did?

What two chains show

The two architectures behaved as differently as they do in the paper but the shape of the difference is the headline.

OfficeFloor barely noticed the reviewer and the two chains agree to the decimal.

OfficeFloor, final phase chain 0 chain 1
biggest-class WMC5555
PMD god classes00
impact added per change (additive)1521
cold-read recall0.580.63

That is the paper's durability result reproduced under a fifth previously untested lever. The additive architecture holds its shape whether the instruction is nothing, a prompt, a tool, a formula or a reviewer. Architecture not the review is what keeps it flat.

Spring is where the tidy story died. Here are the same two chains.

Spring, final phase chain 0 chain 1
biggest-class WMC78166
PMD god classes12
impact added per change (additive)27265
cold-read recall0.680.61

So at two chains the statement is not "AI review tames Spring." It is "AI review can and does not dependably." The mean of the two chains sits below the control, but the spread is enormous and two chains cannot separate it from the control at all. This is precisely the plasticity the paper describes, now visible from the inside. Point a stochastic reviewer at a plastic architecture and you get stochastic placement. Point it at a durable one and you get the same thing every time, because there was never much to move.

None of this bought safety. Spring logged 6 and 4 true regressions across the two chains, no better than the control's roughly 4, and OfficeFloor 2 and 4. Structural stability did not buy correctness in the paper and neither so far does review.

What it cost

The review arm is the most expensive in the study, because every checkpoint is now three model turns instead of one: author, reviewer, and a resumed fix. Per chain of sixty checkpoints.

per chain author review fix total
Spring reviewed~$85~$13~$35 to $43~$135 to $142
OfficeFloor reviewed~$93~$16~$47 to $57~$155 to $165

The control, author-only, runs around $88 on Spring and $83 on OfficeFloor for the same sixty checkpoints. So the loop adds roughly half again to the bill. It is doing real work for that money: pooled over both chains, the reviewer asked for changes and the author ran a fix on 40 of 120 Spring checkpoints and 52 of 120 OfficeFloor checkpoints, and passed the change on first look the rest of the time. This is not a rubber stamp. It is a negotiation that happens to be costly and, on the evidence so far, structurally unreliable on the architecture that needs it and unnecessary on the one that does not.