Urban Renewal · Part 1 of 1

Urban Renewal #1: Structural Survey – Make the Agent Read Before It Writes

Before you hand a legacy project to a coding agent, don't let it write a line; make it read. "Read-only" means something different in every tool, so the survey runs on a disposable copy and ends with proof that the copy didn't change. Eleven findings from a real PHP repo, and an order to fix them in.

12 min read Software Architecture

This post is also available in Turkish →
A cutaway of an old apartment building with a surveyor's table in front holding a marked-up blueprint, tape measure and checklist; a dashed outline of an illegal extra floor on the roof.

In the Vibe Coder series we build on an empty lot: new project, clean schema, an agent starting from zero. This new series covers the other side. The lot is taken, people live in the building, the plumbing runs inside the walls and nobody can find the original drawings. AI-assisted refactoring of legacy code is usually exactly that: renovating an old project without tearing it down. Hence the name, Urban Renewal.

Here is the thesis up front. A coding agent is the fastest contractor I have ever seen. But if you don't hand it the structural survey, you end up with an illegal extra floor: changes you never asked for, scope that grew without a question, a diff nobody reads line by line. So in this first chapter the agent doesn't write a single line. It reads.

The demo building is fixed too: the Altınkaynak exchange rate library I wrote in 2020 (post in Turkish; code at github.com/HalitYesil/altinkaynak). It is a small MIT-licensed library that pulls currency and gold prices from a Turkish exchange bureau's SOAP service: ten PHP files, 679 lines. Last commit: 6 September 2020. Every file passes php -l on PHP 8.3.6, so the building stands; it is just old. Over this series we will actually renovate it.

Why read first?#

Tell an agent "refactor this project" and it starts fixing the first problem it sees. That isn't malice, it's the job description: you asked for work, it produces work. The catch is that while one problem gets fixed, the other nine stay invisible. Who set the priority? The order in which files happened to be read.

In a real renovation, a structural survey comes before the contractor, and the surveyor doesn't cut columns. Our equivalent: the agent takes an X-ray of the codebase, writes every finding with evidence, and touches nothing. A human reads the report and decides what gets fixed when.

Sounds simple. The hard part is "touches nothing", because "read-only" means something different in every tool.

"Read-only" is not one thing#

I read the planning and question modes of four tools from four ecosystems in their own documentation (15 October 2026):

  • Claude Code (Anthropic), plan mode. claude --permission-mode plan, or Shift+Tab inside a session. Per the docs, Claude reads files, runs shell commands to explore and writes a plan, but does not edit your source; edits stay blocked until you approve the plan. Shell commands go either through the auto mode classifier or through your approval, depending on the session.
  • Codex (OpenAI), --sandbox read-only. Per the docs, the agent can inspect files but can't edit files or run commands without approval. The difference is where the line is drawn: at the operating system level, with Seatbelt on macOS and bubblewrap on Linux and WSL2.
  • Cursor, Ask mode. File edits are blocked at the tool layer; shell commands partly rely on instructions given to the model. That's not my reading. On 8 May 2026 a user reported on the Cursor forum that an agent in Ask mode ran ALTER TABLE and UPDATE against a local Postgres database through docker exec (Cursor 3.2.21, model Kimi K2.5). The Cursor team's answer was candid: a known architectural limitation, they are working on enforcing Ask mode more strictly at the tool layer, no ETA.
  • OpenCode (open source), Plan agent. Switch with Tab. Per the docs, file edits and bash commands default to "ask", so each one waits for your approval.
Tool Mode File writes Running commands Who enforces it
Claude Code plan Blocked until plan approval Classifier or your approval The tool's permission system
Codex --sandbox read-only Blocked Needs approval OS-level sandbox
Cursor Ask Blocked at tool layer Partly up to the model Tool + model
OpenCode Plan Each one asks Each one asks The tool's permission system

The hard truth: blocking writes to the filesystem and blocking side effects are two different things. A database, a queue or a remote service the agent can reach may sit outside the mode entirely. That is precisely what happened in the Cursor report.

The rule I take from this doesn't depend on the mode: run the survey on a disposable copy, and prove at the end that the copy didn't change. The mode is the first line of defence, the proof is the second. The copy holds no .env, no real database credentials, no keys; the code alone is enough for an X-ray. The hooks from Vibe Coder 4.2 can be a third line, but not instead of the first two.

Bash
#!/usr/bin/env bash
# survey.sh: run the agent on a throwaway copy, then prove nothing changed.
set -euo pipefail
​
REPO=${1:?Usage: survey.sh <repo-url>}
COPY="$(mktemp -d)/building"
​
git clone --quiet "$REPO" "$COPY"
cd "$COPY"
START=$(git rev-parse HEAD)
​
echo "Copy: $COPY (HEAD ${START:0:7})"
echo "Start the agent here in its read-only mode; have it print the report, not save it. Press Enter when done."
read -r _
​
if [ -n "$(git status --porcelain --ignored)" ] || [ "$(git rev-parse HEAD)" != "$START" ]; then
  echo "Illegal floor: the copy changed during the survey." >&2
  git status --short --ignored >&2
  exit 1
fi
​
echo "Clean: the agent read, it didn't write."

I tried the script on altinkaynak with two scenarios. Pressing Enter without touching the copy printed "Clean" and exited 0. Appending a line to the README and dropping a report.md next to it while the script waited printed M README.md and ?? report.md and exited 1. The --ignored flag is there on purpose, so that a write into a folder git ignores, like vendor/, still gets caught. It's also why the report goes to the screen rather than a file: even the report file counts as a change.

The brief: we want a report, not a patch#

In Vibe Coder 4.1 we swapped the séance for a written spec. The survey spec is short, but it has to say one thing very clearly: the output is a report, not a patch.

Markdown
Task: Produce a risk report for this repository. Do not modify any file,
create files, install packages or make network requests. Write the report
only in this chat.
​
For each finding:
- Finding: one sentence
- Evidence: file:line (quote at most 3 lines; mask passwords and keys)
- Risk: what it can break, who it affects
- Cost of touching it: what else a fix might break
- Confidence: certain / suspected (if suspected, how to verify)
​
Do not write fix code. I will decide the order.
​
Final section: what you CANNOT see by reading this repo (runtime, external services, real data).

The last item earns its place. Asking for what can't be known from reading pulls the agent out of its "I've seen everything" voice and hands you the list of experiments.

X-raying altinkaynak#

I went through the repository with read-only commands only (git log, grep, php -l, git ls-files --eol). Findings, with evidence:

1. The service URL is http://. Line 33 of src/Altinkaynak.php: the WSDL address has no TLS. Every request, credentials included, travels in plain text.

2. Credentials in source. Lines 34 and 35 of the same file hold the service username and password as literals. They may look like public service details, but the principle stands: secrets don't go in the repo. I'm not repeating the values here either.

3. A misspelled option. One of the options passed to SoapClient silently does nothing:

PHP
<?php
// src/Altinkaynak.php, lines 167-170 (indentation simplified)
$this->Service = new \SoapClient($this->getServiceURL(), [
    "trace"     => 1,
    "exception" => 1, // No such option in PHP; the right one is "exceptions"
]);

The PHP manual has no exception option; the real one is exceptions and it defaults to true anyway. The code behaves correctly today, by accident. Hidden gem: a line that tells every reader "error handling is configured" while configuring nothing. One of the rare findings where the cost of touching it is zero.

4. The same method three times. GetCurrency (62), GetGold (92) and GetMain (122) are nearly identical: service call, XML parsing, the same loop. Only the remote method name differs. Even the gold list is stuffed into a GetCurrencyResponse object.

5. Dead code. The Response class has a decorateResponse() that maps the SOAP reply onto objects, but every response class is constructed with null, so the mapping never runs and the fields are filled by hand. AltinkaynakException is defined and never thrown.

6. Money as float. Buying and selling prices go through floatval() (lines 76, 106 and 136). Fine for showing a rate on screen; not fine once someone does arithmetic with it.

7. Unescaped output in the examples. Line 50 of all three example files prints the description from the remote service with <?= $Currency->desc; ?>, no escaping; the same files switch display_errors on at the top. External data, straight into HTML.

8. No types, no tests. No declare(strict_types=1), untyped properties and parameters, not a single test file.

9. A comment from another project. The doc comment of the hand-written autoloader reads "Register the autoloader for the Imflexi Gear SDK classes" (src/Autoload.php, line 8). Copied over from another project, name unchanged. The most human finding in any survey: the fingerprint of copy and paste.

10. CI could never have worked. .github/workflows/php.yml runs composer validate as its first step, but there is no composer.json in the repo. I ran the same command in the repo root: Composer 2.8.12 says ./composer.json not found. and exits with code 3. The repo's Actions page shows no recorded runs at all. So this building didn't live with an alarm flashing red; it lived with an alarm that was never wired up. The second is worse. You notice red. You don't notice silence.

11. Mixed line endings. The sneakiest finding of the lot. According to git ls-files --eol, all ten PHP files are stored with CRLF (Windows) line endings, while the workflow, README and LICENSE, created through GitHub's web UI, are LF. There is no .gitattributes.

I measured why that matters. On a copy, I made the one-word fix on line 169 (exception to exceptions) and saved the file with LF endings, the way an editor or an agent rewriting the file might. git diff --stat: 238 insertions, 238 deletions. The entire file. The same change viewed with git diff --ignore-cr-at-eol: 1 line.

A one-word fix turns into a 476-line review, and the real change disappears in the crowd. That's how illegal floors usually go up: not through malice, through noise.

What the survey can't see#

Two things couldn't be known by reading, so I checked both from the outside (15 October 2026).

First, the service itself. The data.altinkaynak.com SOAP address the library talks to opened neither over http:// nor over https://; the browser showed an error page. Altınkaynak's own web services page now lists only two addresses: https://static.altinkaynak.com/public/Currency and https://static.altinkaynak.com/public/Gold. I opened the currency one: no credentials, JSON over HTTPS. The grid this building was wired to has changed. Findings 1 and 2 are already solved on the service side; the library is simply still plugged into the old line.

Second, the shape of the response, and this is where finding 6 grew teeth. The new endpoint keeps the field names (Kod, Aciklama, Alis, Satis, GuncellenmeZamani), but it sends prices as strings with a decimal comma: "Alis":"49,350". In the 2020 post's sample output the same field used a dot (7.45). The library's floatval() stops reading at the comma:

PHP
<?php
var_dump(floatval("49,350")); // float(49)
var_dump(floatval("0,3086")); // float(0)

I ran it on PHP 8.3.6: the Japanese yen's buying price comes out as zero. No error, no warning. An agent that tries to hook the old building up to the new grid by "just changing the URL" delivers code that runs and prints wrong prices, and with no tests nobody notices. That is the survey's real job: saying what will break before anyone fixes anything.

From report to order#

An X-ray isn't a to-do list, it's a map. Sorted by the cost of touching each finding:

Finding Risk Cost of touching When
Mixed CRLF/LF, no .gitattributes Every diff drowns in noise Low, but needs its own single-purpose commit First
No tests Every change is blind None Scaffolding (#3)
Unescaped example output XSS Low Alongside the scaffolding
Duplicated methods, dead code Maintenance load Behaviour may change After the tests
No types, no strict_types Silent type juggling Medium (e.g. float money) Mechanical upgrade (#5)
http://, credentials in source, old SOAP address Service no longer answers New endpoint is HTTPS and credential-free, but the number format differs With the tear-down-or-retrofit decision (#2)
Broken CI Silent alarm Low Building inspection (#9)

An agent can propose this table; you sign it. One more caveat: in this chapter I did not run the four tools side by side. The mode behaviour above comes from the official docs and the Cursor team's forum reply. Running the same brief across different ecosystems and comparing the diffs is the job of chapter four, Illegal Floor.

When does this pay off, and when doesn't it?#

It pays off on code you didn't write or haven't opened in years. When a team hands a legacy repo to an agent for the first time. When you need to explain refactoring scope to a manager or a client; the report is raw material for the estimate.

It doesn't for a three-file script, where the report takes longer than the work. And if you won't read the report, don't ask for one. An unread X-ray is worse than none, because it gives you the feeling that someone looked.

Before anyone cuts a column#

We didn't drive a single nail in this chapter; we came out with eleven findings, an order and a gate. In the next chapter, Urban Renewal #2: Tear Down or Retrofit? – The Bill for a Rewrite, we take the first big decision this report puts on the table: strengthen the building, or knock it down and start over. All chapters live on the Urban Renewal series page.

Let the contractor be fast. Just make sure you're the one signing the survey. Stay with the technology.

Comments

0 comments

Leave a comment

Replying to

Comments appear after approval. Your email address is never shared. How is your personal data processed?