# Pre-registration — `runs/2026-09-10-viallo-metadata-survival`

```
run_id:        2026-09-10-viallo-metadata-survival
product:       viallo
tier:          micro
written_at:    2026-09-10T04:10:00Z
revision:      2 — adversary folded in before freezing
```

## 1. Question

Of the image files an anonymous visitor can obtain from a public page, what share still carries
location coordinates or camera identity — and does the derivative the platform offers for display
differ in that respect from the file it offers for download?

Population: image files served over plain HTTPS, with no account, cookie or referrer, by

- **frame W** — Wikimedia Commons file pages, and
- **frame S** — public posts with media on one named federated instance.

Two wordings in this field are narrowed from revision 1, both because of the adversary:

- "the copy the page displays" becomes **"the derivative the platform offers for display"**. The
  method reads the file the API designates for display; it does not fetch the file description page
  and read the URL the page actually names. A browser on a high-density display may fetch a `srcset`
  variant that this method never sees. Disposition of objection 3.2.
- "a public page" for frame W becomes the population the sampling rule actually reaches, which
  includes institutional scans, maps and satellite imagery, not only photographs taken by a person.
  Disposition of objection 1.9.

## 2. Method

A session that has never seen this run must be able to execute the text below. It is written for
that reader. The re-execution replicator receives this section and nothing else.

Every step below was revised after the adversarial pass. The revisions that change what is
executed, rather than only how it is worded, are marked `[A-n.n]` with the objection that caused
them; the dispositions are in field 7.

### 2.1 The unit, and the arms

One **fetch** of one image URL is one row. Frame W is measured twice per file for every accepted
file, and frame S three times.

| arm | frame | URL | when |
|---|---|---|---|
| `original` | W | `imageinfo.url` | every file |
| `display` | W | `imageinfo.thumburl` (`iiurlwidth=800`) | every file |
| `archive_oldest` | W | `imageinfo.archived[last].url` | pre-registered 30-file subsample |
| `display_w200` | W | `thumburl` at `iiurlwidth=200` | pre-registered 50-file subsample |
| `display_w1200` | W | `thumburl` at `iiurlwidth=1200` | same 50-file subsample |
| `s_full` | S | media `url` | every attachment |
| `s_thumb` | S | media `preview_url` | every attachment |
| `s_remote` | S | media `remote_url` | every attachment that carries one |

The pre-registered subsamples are taken by the rule "every tenth accepted file in acceptance order",
so they are fixed before any file is fetched and cannot be chosen after seeing a result.

`[A-1.3]` For files narrower than 800 pixels MediaWiki returns a `thumburl` whose **path is
identical to the original's** and differs only in a tracking query parameter. A pair in that state
is a guaranteed tie and proves nothing. Each frame-W pair records `display_is_original` (true when
the two URLs have the same path after the query string is stripped) and such pairs are excluded
from every paired statistic, counted and reported separately.

`[A-1.4]` For multi-page TIFF and PDF sources the display arm is a rendered page of a different
document — MediaWiki returns `lossy-page2-960px-<name>.tif.jpg` for a TIFF. Each row records
`container_original`, `container_display` and `display_page_number` (the `page<N>` segment of the
thumb path, or null). The paired difference is reported separately for same-container and
cross-container pairs and the two are never pooled.

### 2.2 Frame W — Wikimedia Commons

`[A-3.3]` `iilimit=max` is added to the `imageinfo` call so that superseded versions of a file are
visible; `[A-2.7]` `prop=coordinates` is added so that a page which publishes its own coordinates
can be told apart from a file that leaks them.

```
UA="article-research/1.0 (viallo metadata survival run; contact info@zava-solutions.com)"
curl -sS --max-time 30 -A "$UA" -G "https://commons.wikimedia.org/w/api.php" \
  --data-urlencode "action=query" --data-urlencode "list=random" \
  --data-urlencode "rnnamespace=6" --data-urlencode "rnlimit=50" \
  --data-urlencode "format=json" --data-urlencode "formatversion=2"
```

Repeated until 300 files are accepted, deduplicated by page id. For each title:

```
curl -sS --max-time 30 -A "$UA" -G "https://commons.wikimedia.org/w/api.php" \
  --data-urlencode "action=query" --data-urlencode "titles=File:<name>" \
  --data-urlencode "prop=imageinfo|coordinates" \
  --data-urlencode "iiprop=url|size|mime|sha1|timestamp|extmetadata" \
  --data-urlencode "iilimit=max" \
  --data-urlencode "iiurlwidth=800" \
  --data-urlencode "format=json" --data-urlencode "formatversion=2"
```

`[A-3.7]` Before the first fetch: `action=query&meta=siteinfo&siprop=general` is called once and its
`generator` string is written into `DATASET.json`.

### 2.3 Frame S — one federated instance

`[A-1.5]` **Instance: `mstdn.social`.** Revision 1 named `mastodon.social` as the primary and this
as the first fallback. Both were probed before the first measurement and `mastodon.social` refuses
anonymous reads:

```
mastodon.social     http=422  {"error":"This method requires an authenticated user"}
mstdn.social        http=200  40 statuses
fosstodon.org       http=200  40 statuses
```

The fallback is promoted because the primary cannot be measured at all. Chain:
`mstdn.social` → `fosstodon.org` → `mas.to`. `instances_refused` records the instances that
answered 422. **The instance is therefore one that permits anonymous timeline reads**, and the
article says so; the largest instances have closed this endpoint, so the frame selects for
instances that have not taken that step. `[A-3.7]` `GET /api/v1/instance` is called once and its
`version` is written into `DATASET.json`.

`[A-3.8]` The frame is drawn over **ten sequential pages** using `max_id` paging rather than one
call, accumulating until 300 image attachments or ten pages, whichever comes first. Window: the
measurement window of this run, one day. It is not spread across days.

```
curl -sS --max-time 30 -A "$UA" \
  "https://mstdn.social/api/v1/timelines/public?only_media=true&limit=40"
```

Media URLs come from each status's `media_attachments[]`. Posts are **not** selected by content,
language or author, and no post is fetched individually.

### 2.4 The fetch

`[A-1.1]` The fetch is made by the driver script with Node 22's global `fetch`, **not** by `curl -o`.
Two reasons, and the first is a defect the adversary found: `thumb.wikimedia.org` ignores the
`Range` header and returns the whole derivative (probed: `http=200`, 286 974 bytes) while
`upload.wikimedia.org` honours it (probed: `http=206`, `content-range: bytes 0-131071/2507196`). A
method that trusts the transport feeds the two arms different amounts of file. The driver therefore
requests `Range: bytes=0-131071` **and then hard-truncates the received buffer to 131 072 bytes**
before handing it to the instrument, so both arms are identical inputs regardless of what the
server chose to send. The second reason is field 7's disposition of objection 3.6: no image byte is
ever written to disk, because the bytes are parsed from memory.

Per request: `--max-time` equivalent `AbortSignal.timeout(30_000)`, one request per second per host,
at most one request in flight per host, and the User-Agent above. `range_honoured` records whether
the server answered 206 or 200, and `bytes_on_wire` records how many bytes it sent before the
truncation. The same URL is fetched twice in three places (tail, `archive_oldest`, width subsample)
and each fetch is its own row.

`[A-1.10]` Revision 1 promised to honour `robots.txt` for the HTML page leading to an image. The
method never fetches an HTML page, so the promise described a check that does not happen, and
Wikimedia's `robots.txt` disallows `/w/`, which is where `api.php` lives. The promise is withdrawn
and replaced by the rule that actually governs this traffic: a descriptive User-Agent carrying a
contact address, serial requests, one connection per host, and no request that a browser would not
make for a resource the page serves.

`[A-3.11]` Retry policy, split by class. On 429 or 503: honour `Retry-After` if present, retry up
to three times with increasing waits, record `throttled: true`. On 404, 403, 410 or a timeout:
retry once after 30 seconds, then record `fetch_failed: true`. The two counts are reported
separately, because only the second is a fact about the file.

### 2.5 The instrument

`runs/<run-id>/tools/parse-head.mjs`, Node 22, built-ins only (`node:fs`, `node:crypto`). It takes a
buffer and prints one JSON object. It walks the JPEG APP segments, the EXIF TIFF IFD, the GPS IFD,
the PNG chunks, the WebP RIFF chunks and the TIFF IFD0 and reports **presence, never values**:

```
has_exif, gps_ifd_present, gps_coord_tag_present, gps_coord_all_zero, gps_tag_count,
make_present, make_is_consumer_device, model_present, serial_tag_present,
datetime_original_present, software_present, icc_present, icc_desc_is_stock,
xmp_present, mpf_index_present, jpeg_dqt_present, width, height, container
```

**No coordinate value is decoded into any output and no coordinate value is written to any file in
this run.** Same rule for serial numbers. Three fields require reading values in memory to produce a
boolean or a count, and none of them discloses a position or a device, or is written to disk in any
other form:

- `[A-1.11]` `gps_coord_all_zero` — true when every latitude and longitude numerator in the GPS IFD
  is zero. A GPS IFD holding only `GPSVersionID`, and a coordinate tag holding `0/1, 0/1, 0/1`, are
  both common outputs of a camera that had no fix and of a partial stripping tool; without this
  field both are counted as "carries location coordinates".
- `gps_tag_count` — the number of tags in the GPS IFD. An integer count of tags, not a value.
- `[A-2.4]` `make_is_consumer_device` — the `Make` string matched in memory against a pre-registered
  vendor list: `Apple, samsung, Canon, NIKON, SONY, Google, Xiaomi, HUAWEI, OnePlus, FUJIFILM,
  Panasonic, OLYMPUS`. Without it "carries camera identity" is equally well explained by `Epson
  Expression 10000XL` on an institutional scan.
- `[A-3.10]` `icc_desc_is_stock` — true when the ICC profile description is one of `sRGB IEC61966-2.1,
  Display P3, Adobe RGB (1998), Generic RGB Profile, GRAY, Dot Gain`. **The description string
  itself is never written to disk.** Revision 1 put `icc_desc` in every row and in the published
  CSV; calibration software writes machine names and operator names into that string, which breaks
  this run's own presence-only rule in the one field nobody thought of as identifying.

`[A-1.12]` Failure must not look like absence. In the JPEG branch **all** APP1 segments are walked,
not only the first, because Adobe output writes XMP before EXIF. Three conditions are reported as
`parse_error` with a reason and never as `false`: a segment length that would run past the buffer, an
IFD entry count whose implied size exceeds the remaining segment, and a TIFF byte-order mark that is
neither `II` nor `MM`. Offsets are read with `readUInt16BE`/`readUInt32BE`, which throw on overrun,
never with `subarray` bounds arithmetic, which returns an empty buffer silently in Node.

`[A-1.7]` The WebP `EXIF` chunk sits **after** the image data — verified here on a synthetic file:
`VP8X offset=12 size=10`, `VP8 offset=30 size=3592`, `EXIF offset=3630 size=80`. For any WebP whose
image data exceeds the window the answer is structurally false, not measured. So in the WebP branch,
and in the TIFF branch when an IFD offset points past the buffer, a walk that reaches the end of the
buffer before the container's declared size is exhausted returns `parse_error: "truncated_riff"` or
`"truncated_tiff"` for every boolean.

`[A-1.13]` `second_image_present` is replaced by `mpf_index_present`, meaning "an MPF APP2 index
segment is present". The old name promised to locate a second image that lives at the end of the
file, which under a 131 072-byte window is false for every large original by construction, and which
would therefore have manufactured an arm difference in every large pair.

A format the script cannot parse is recorded as `parse_error` with the reason, never as `false`.

### 2.6 Calibration, before the first real fetch

`[A-3.1]` Revision 1 contained three internal consistency checks and no calibration, so a one-line
bug in the GPS IFD pointer walk would have produced `0 %` with every check green. Before the first
fetch, `/usr/bin/python3` with PIL builds **eight fixtures** in `data/fixtures/`: JPEG, PNG, WebP and
TIFF, each in two versions, one carrying a written GPS IFD and one stripped, plus one JPEG and one
PNG larger than 131 072 bytes with the EXIF header placed past the window. The instrument must score
all eight of the ordinary fixtures correctly on `gps_ifd_present` and must return `parse_error`, not
`false`, on the two displaced ones.

The result is written into `DATASET.json` as `instrument_calibration`. A run whose instrument cannot
detect a coordinate it planted itself **does not proceed**: it records `METHOD_BROKEN` with the
failing fixture named.

### 2.7 Head, tail and full — the validation

`[A-1.1, A-3.5]` Reading 131 072 bytes loses everything after the first end-of-image marker. That is
safe for metadata at the front of a file and is **not** assumed here. Two rules replace revision 1's
single binary gate, which the adversary showed would pass a broken method four times in five:

- **Tail.** Every unit whose API-reported `size` exceeds 131 072 bytes gets a second range request on
  the same URL for the **last** 131 072 bytes (`Range: bytes=-131072`), recorded as its own row with
  `arm = tail`. This catches a trailing PNG `eXIf` and the WebP `EXIF` chunk at one extra request
  instead of a full download, and turns "did we lose anything at the tail" into a per-row field
  rather than an assumption.
- **Full.** A pre-registered 10 % subsample (every tenth accepted file) is fetched completely and
  parsed whole, so the cross-check compares head against whole file rather than two parsers against
  the same truncated buffer — which is what revision 1's PIL cross-check did, and which the adversary
  correctly called not independent. `[A-2.8]`

`tail_disagrees` is set on the head row when head and tail disagree on `gps_ifd_present`.
`full_disagrees` is set when the head row and the full-file row disagree. If either rate exceeds
5 %, the head-based share is published as a **lower bound** and the article says so in the sentence
carrying the number. If the tail row finds a GPS IFD that the head row did not, the head verdict for
that unit is superseded by the union of the two.

The validation set is stratified: at least five files of each format present in the frame. The
adversary showed that "the first thirty accepted" is 28 JPEG, and JPEG is the one format where the
window cannot fail, so the old gate would have certified a near-certainty.

### 2.8 Repetitions

Every unit is fetched once, plus the tail fetch described in 2.7, plus the subsample arms in 2.1.

### 2.9 What is discarded, and on what rule

- The fetch's own `Content-Type` response header is not `image/jpeg`, `image/png`, `image/webp` or
  `image/tiff`. `[A-1.6]` For frame S the API reports `type` (`image`, `gifv`, `video`, `audio`), not
  a MIME type, so frame S additionally discards any attachment whose `type` is not `image` — in the
  probed payload 4 of 44 `url` values ended in `.mp4` and would otherwise have entered frame S as
  videos scored `false` on every boolean. **Counted and reported.**
- `display_is_original` pairs, from every paired statistic, counted separately. `[A-1.3]`
- Duplicate `sha256_head` within one arm.
- Units recorded as `parse_error` are reported separately and never folded into the "no" side.
- Files whose API-reported `size` exceeds 20 000 000 bytes are excluded from the **full-file**
  validation fetch and from the cross-check, and the exclusion count is reported. The head and tail
  fetches are unaffected: the probed draw contained a 1 127.53 MB, 32 001 × 32 001 radar TIFF, and
  whether the full-file set is cheap or ruinous is otherwise decided by draw order.
- Nothing else. Frame S is reported on its own and never pooled with frame W.

### 2.10 The numerator

`[A-1.11]` The headline share is the share of files where
**`gps_coord_tag_present AND NOT gps_coord_all_zero`**. This is fixed here, before any file is
fetched, because revision 1 did not say which of the two booleans was the numerator and the choice
was therefore available after seeing the data.

### 2.11 Statistics

`[A-3.8]` Shares carry Wilson 95 % intervals. Frame W's interval is computed over files; frame S's
is computed over **distinct accounts**, because one account posting five of forty statuses makes the
file count an overstatement of the sample — the probed draw had exactly that. The distinct-account
count is reported beside the file count. A paired difference is computed only over pairs where both
arms returned 2xx and neither is `display_is_original`, and is reported separately for
same-container and cross-container pairs.

`[A-2.2]` The frame-W paired difference is reported **twice**: over all eligible pairs, and over the
subset where both files are smaller than 131 072 bytes, so that head equals whole file on both
sides. If the two disagree in direction and the restricted set has at least twenty pairs, the
full-sample number is not published and the restricted one is.

### 2.12 Output

- `data/units.jsonl` — one JSON object per fetch, appended the moment it is produced, never
  rewritten. `unit_id`, `frame`, `arm`, `host`, `fetched_at`, `http_status`, `content_type`,
  `range_honoured`, `bytes_on_wire`, `bytes_read`, `sha256_head`, `read_complete`, `fetch_failed`,
  `throttled`, plus the instrument's fields, `tail_disagrees`, `full_disagrees`,
  `py_pil_full_gps_ifd_present`.
- `data/files.jsonl` — one row per accepted file, carrying the provenance the fetch rows inherit:
  `frame`, `file_id`, `title` (frame W only), `page_url`, `container_original`, `container_display`,
  `display_is_original`, `archived_version_count`, `location_on_page`, `provenance_class`,
  `make_is_consumer_device`, `size_bytes`.
- `[A-3.6]` **Frame-S rows carry no URL and no account.** The media URL, the status URL
  and `account.acct` are replaced at write time by a per-run salted SHA-256 prefix; the salt is
  generated at the start of the run and never leaves `data/`. The person who posted a photograph is
  not the subject of this study and is not exposed by it. Frame-W rows carry the file title, because
  a Commons file is a published work with a stable identifier.
- `data/calibration.json`, `DATASET.json` (created with `"state": "IN_PROGRESS"` before the first
  fetch), `DATASET.csv` (the published form).

## 3. Expectation

Direction, not a point:

- In frame W, between **a fifth and a half of the original files carry a GPS IFD** under the 2.10
  numerator, and the display arm carries **strictly fewer metadata fields than the original arm in
  the majority of eligible paired files** — the delivery path decides, not the uploader.
- The paired difference computed over pairs where both files are under the window points **the same
  way** as the full-sample difference. If it does not, the window is the cause and 2.11 says which
  number is published.
- `[A-1.2]` The display arm is **not** an 800-pixel file. Probed on three files before the first
  measurement: `iiurlwidth=800` returned a `960px-` path while the API reported `thumbwidth: 800`,
  and the fetched pixels were 960 wide. The method no longer asserts a width; it records
  `thumbwidth_reported` and `thumbwidth_actual` and the article describes the arm as "the derivative
  the API designates for `iiurlwidth=800`".
- In frame S, **fewer than one file in ten carries a GPS IFD in either arm**, because a public social
  timeline re-encodes on upload — and `[A-3.9]` frame S contributes a **single-arm** measurement
  only. The paired half of the question is answered on frame W alone, because on frame S both arms
  are re-encodes of one already-processed stored file, so a paired difference there is structurally
  zero and says nothing.
- `[A-2.5]` The `s_remote` arm carries a GPS IFD **at least as often** as the cached `s_full` arm:
  the sampling instance re-processes copies it receives from other instances, so the upstream file
  is the closer thing to an unprocessed upload.
- Overall, the share of files a visitor can download that still say where the photo was taken is
  **higher than a reader would guess**, and the copy offered for display is a poor guide to it.

## 4. Falsifier

Any one of these, evaluated by a script over `DATASET.json`:

- The two arms agree on `gps_ifd_present` in **95 % or more** of eligible paired files in frame W:
  the claim that the delivery path changes what a visitor can obtain is then false and the article is
  about something else.
- Fewer than **10 %** of frame-W originals carry a GPS IFD under the 2.10 numerator **and** fewer
  than 10 % of frame-S images do: the expectation of a meaningful leak is false, the honest article
  is the null one.
- Frame-S `s_full` and `s_thumb` are **byte-identical** (`sha256_head` equal) in every unit: then
  that platform is not re-encoding at all and the frame measures nothing.
- The window-restricted paired difference points the **opposite** way from the full-sample
  difference **and** the restricted set has at least twenty pairs: the full-sample difference is an
  artefact of the 131 072-byte window, 2.11 governs which number is published, and the expectation
  above is refuted.

## 5. Abandonment condition

- Calibration in 2.6 does not score eight of eight → `METHOD_BROKEN`, with the failing fixture named.
  This run uses `METHOD_BROKEN` for a method that cannot answer the question; `NOT_MEASURABLE` is
  reserved for a question the method could answer but the platforms would not let it.
- Wikimedia's API or upload host unreachable for 30 consecutive minutes → `NOT_MEASURABLE` for
  frame W; if frame S also fails, the run ends `NOT_MEASURABLE`.
- Fewer than 150 accepted frame-W units after 60 sampling rounds → `NOT_MEASURABLE`.
- Frame S unavailable anonymously on all three named instances → frame S dropped, run continues,
  article reports frame W alone.
- Tail or full disagreement above 5 % is **not** terminal: it lowers what the head-based share is
  allowed to claim, per 2.7.

## 6. Estimate

- **What the run needs from Jakub: nothing.** No account, no phone, no credential, no upload, no
  payment. Every input is fetched from a public URL by an anonymous client.
- Machine time: 5 hours, of which about 60 minutes is fetching at one request per second per host.
- Money: **0 USD.** No paid API is called. Zenodo cannot mint a DOI today (403 without a token), so
  publication is a file address and not a DOI, and the article says so.
- Data volume: under 1 GB. About 2 400 requests: 300 originals, 300 full display files (the thumbnail
  host ignores Range), about 600 tail requests, 60 archive and 100 width-subsample requests, about
  900 frame-S requests, and about 70 full-file validation fetches capped at 20 MB each. No image
  byte is written to disk.
- Claims expected in the ledger, by type: **M** — the shares, the paired difference, the tail and
  full disagreement rates, the failure counts (every one points at a row of `DATASET.json`).
  **S** — what the EXIF specification and each platform's own documentation say about metadata
  handling. **D** — none; this run uses no first-party data. **U** — none permitted.

## 7. Adversary dispositions

`ADVERSARY.md` raised 35 objections. Every one has a disposition and none was left without one, so
Gate B is not blocked.

- **Changed — 34.** The method was edited in response. Four of these also carry a part that was
  accepted as a limitation or rejected; the line for each names it.
- **Accepted as a stated limitation — 1** (`3.2`, frame W). The article carries it in its own
  sentence. The method was not changed there because the proposed change would have replaced one
  renderer-dependent reading with another.
- **Rejected with a reason — 0 in full, 2 in part** (`1.9`'s proposed second denominator and
  `3.6`'s demand to unversion the dataset directory). Both sit inside objections whose main
  disposition is *changed*, and both reasons are written out below.

That distribution is a statement about the draft rather than about the adversary's severity: the
method was written in one pass and attacked before anything was spent on it, which is the reason the
pass runs before the measurement.

**Section 1 — inputs that would make the conclusion false**

- `1.1` Display arm is not truncated and the original arm is → **changed**. Client-side truncation to
  131 072 bytes after the fetch; the defect was reproduced here before accepting it (thumb host
  `http=200`, 286 974 bytes; upload host `http=206`, 131 072). Method 2.4.
- `1.2` `iiurlwidth=800` does not produce an 800-pixel file → **changed**. Verified on three files.
  The width is no longer asserted; `thumbwidth_reported` and `thumbwidth_actual` are recorded and the
  arm is described as what the API designates. Expectation 3.
- `1.3` Seven of forty-five display URLs are the original under a different query string →
  **changed**. `display_is_original` by path comparison with the query stripped; those pairs leave
  every paired statistic and are counted. Method 2.1 and 2.9.
- `1.4` The display arm can be a different page of a different document → **changed**
  (`.container_original`, `.container_display`, `.display_page_number`; same-container and
  cross-container pairs never pooled). Method 2.1 and 2.11.
- `1.5` The named instance for frame S refuses anonymous access → **changed**. Reproduced here
  (`http=422`). `mstdn.social` is promoted to primary before the first measurement, the refusal is
  recorded, and the article states that the frame selects for instances that permit anonymous reads.
  Method 2.3.
- `1.6` Frame S has no MIME field → **changed**. Frame S discards on
  `media_attachments[].type != "image"`, and both frames discard on the response `Content-Type`.
  Method 2.9.
- `1.7` A WebP over the window size cannot have its EXIF seen at all → **changed**. Reproduced here
  on a synthetic file (EXIF chunk at offset 3630, after 3592 bytes of VP8 data). Truncated container
  → `parse_error`, never `false`. Method 2.5.
- `1.8` A 1.1 GB TIFF is in the frame and the method has no size ceiling → **changed**. A 20 MB
  ceiling applies to the full-file validation fetch only, the exclusion count is reported, and the
  head and tail fetches are unaffected. Method 2.9.
- `1.9` The composition of namespace 6 is not the population the sample section claims →
  **changed**, and its proposed remedy **rejected**. `provenance_class` is recorded per file and two
  denominators are published. The objection's own proposed second denominator — "`DateTimeOriginal`
  present with no `Software` tag" — is **rejected**: the absence of a `Software` tag is not evidence
  that a person took the picture, and many cameras write one, so that subset would be its own
  artefact. `make_is_consumer_device` from objection 2.4 is the instrument for the same purpose and
  is a better one, because it reads a value the camera wrote rather than one it omitted. Method 1,
  2.5 and 3.
- `1.10` The method's own robots.txt rule forbids its own API calls → **changed**. The promise is
  withdrawn as a description of a check that never ran and replaced with the API etiquette that
  actually governs the traffic. Method 2.4.
- `1.11` Values that produce a true boolean with nothing behind it → **changed**.
  `gps_coord_all_zero` and `gps_tag_count`, read in memory and written as a boolean and a count,
  and the numerator fixed in 2.10 before the first fetch.
- `1.12` Parser-desynchronising inputs that return an answer instead of an error → **changed**. All
  APP1 segments walked; three malformation classes reported as `parse_error`; throwing reads instead
  of silent `subarray`. Method 2.5.
- `1.13` `second_image_present` is false by construction in the truncated arm → **changed**. Renamed
  `mpf_index_present` and redefined as an index segment present in the head. Method 2.5.

**Section 2 — confounds**

- `2.1` The arms differ because of re-encoding, not a display policy → **changed**. A pre-registered
  50-file subsample is fetched at `iiurlwidth=200` and `1200`; `strip_is_width_invariant` records
  whether stripping is identical at every width. Method 2.1.
- `2.2` The head window is generating the arm difference → **changed**. The paired difference is
  reported twice, and the window-restricted number governs if the two disagree. Method 2.11 and
  falsifier 3.
- `2.3` The share is set by the upload channel → **changed in part**. `provenance_class` is recorded
  from file titles and `extmetadata.Credit`, which costs no extra request, and the share is
  stratified by it. The objection's proposed extra `prop=revisions` call per file is **accepted as a
  stated limitation** rather than implemented: it is a third instrument with its own parsing rules,
  it cannot be calibrated the way 2.6 calibrates the byte parser, and the title-and-credit classifier
  already separated six of forty-five files in the probe. The article states the limitation.
- `2.4` "Camera identity" conflates a phone, a scanner and an editor → **changed**.
  `make_is_consumer_device` against a pre-registered vendor list, cross-tabulated with
  `serial_tag_present`, which is already collected. Method 2.5.
- `2.5` Frame S measures a cache of other instances' processing → **changed**. A third arm,
  `s_remote`, fetched from `remote_url`; `account_is_remote` recorded. Method 2.1 and expectation 3.
- `2.6` Zero on frame S could be the client, not the server → **changed in part**.
  `dimensions_match_meta` compares `media_attachments[].meta.original` against the decoded
  dimensions, at no extra request; the `s_remote` arm is the other half. Method 2.1.
- `2.7` Coordinates present on Commons are frequently intentional → **changed**. `prop=coordinates`
  added to the existing call at no extra request; `location_on_page`; the leak share is reported as
  `gps present AND NOT page_declares_location`. Method 2.2.
- `2.8` The cross-check instrument is not independent → **changed**. PIL 9.5.0 now parses the
  **full** file for the subsample, which turns the cross-check into the head-versus-full measurement
  the method needs. Method 2.7.
- `2.9` A pair fetched at two moments is not a pair → **changed**. `sha1` and `timestamp` are
  re-queried after the second arm; a changed pair is dropped and `pair_fetch_gap_s` is recorded.
  Method 2.4.
- `2.10` Failure is not random across arms → **changed**. Failure and status are cross-tabulated by
  host and arm, and the paired analysis runs only over pairs where both arms returned 2xx. Method
  2.11.

**Section 3 — what the author was not thinking of**

- `3.1` There is no proof the instrument can detect anything → **changed**. Eight calibration
  fixtures, one of them displaced past the window, before the first real fetch, and a failure is
  `METHOD_BROKEN`. This is the objection that most changed the method. Method 2.6.
- `3.2` The display arm is a guess about the renderer, not a reading of the page → **accepted as a
  stated limitation** for frame W, and **changed** for frame S. Fetching the description page and
  parsing `srcset` would add an HTML parse whose result is itself renderer-dependent, and the method
  reads what the API designates; the arms are renamed to what they are, and field 1 says so. For
  frame S the arms are renamed from "display" and "download" to "timeline thumbnail" and "full-view
  image", because clicking an image opens the original and the pair is not a display-versus-download
  distinction as a reader experiences it.
- `3.3` The Commons privacy story lives in the file's old versions → **changed**. `iilimit=max` at no
  extra request, `archived_version_count` per file, and the oldest archived version fetched as a
  third arm for the 30-file subsample.
- `3.4` The file bytes are one of several channels, and the smallest one → **changed**. Two numbers
  recorded and never combined: `gps_in_file_bytes` and `location_on_page`.
- `3.5` The validation gate is a coin flip with a terminal outcome → **changed**. The binary gate is
  replaced by the per-row tail measurement and the stratified full-file subsample, and a disagreement
  rate above 5 % lowers the claim instead of ending the run. Method 2.7.
- `3.6` The run writes strangers' photographs and identifying URLs to a versioned directory →
  **changed in part, rejected in part**. Changed: frame-S URLs and account identifiers are replaced
  by a salted hash **at write time**, so the raw value never reaches disk, and no image byte is
  written anywhere because the driver fetches into memory. **Rejected**: adding `runs/*/data/` to
  `.gitignore`. The published dataset and the ledger live in that directory, and the run publishes
  them; making the artefact unversioned removes the record a citing reader needs in order to hide a
  value that the method can simply decline to write. The harm is removed at the source instead.
- `3.7` The answer is dated and the article will not be → **changed**. The Commons `generator` string
  and the instance `version` are recorded before the first fetch and the finding is stated as
  conditional on them.
- `3.8` Frame S has no meaningful denominator and no interval is planned → **changed in part**.
  Wilson intervals over **distinct accounts** for frame S, the distinct-account count published, and
  the frame drawn over ten `max_id` pages rather than one call. The objection's specific demand for
  "at least 24 calls spaced through a day" is **accepted as a stated limitation** in its spaced
  form: a micro-tier run does not span a day, so the article states that frame S is one window of
  one day.
- `3.9` One half of the question is not answerable on frame S as sampled → **changed**. Frame S
  contributes a single-arm measurement; the paired claim is pre-registered for frame W only. Field 3
  and expectation 3.
- `3.10` `icc_desc` is a value, and it can name a device or a person → **changed**. Replaced by
  `icc_desc_is_stock`; no description string reaches any file. Method 2.5.
- `3.11` A rate-limited response is recorded as a property of the file → **changed**. Failure
  handling split by class: 429 and 503 retry with `Retry-After` and are recorded as `throttled`;
  `fetch_failed` is reserved for 404, 403, 410 and timeouts. Method 2.4.
- `3.12` Frame S is mostly read in full and frame W mostly in part → **changed**. `read_complete` is
  a per-row boolean and the per-frame share of complete reads is published beside any cross-frame
  sentence. Method 2.12.

## 8. Amendments

Append-only. Each entry: timestamp, change, reason, `post_hoc`.

- `2026-09-10T05:10:00Z` — field 2 rewritten in place, and fields 1, 3, 4, 5, 6 revised, after
  `ADVERSARY.md` returned 35 objections. The pre-registration was not yet frozen, so this is the
  method being written rather than an amendment to a frozen one; recorded here so the revision is
  visible. No result of this run had been seen, so `post_hoc: false`.
- `2026-09-10T05:10:00Z` — field 2.3 promotes `mstdn.social` to primary in place of
  `mastodon.social`. Reason: `mastodon.social` returns 422 to the pre-registered call, reproduced
  here. This is a feasibility probe of the platform, not a measurement of the study's subject, and no
  result existed when it was taken. `post_hoc: false`.
