staysfixed 0.4.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,9 +4,173 @@ All notable changes to this project are recorded here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the version
5
5
  numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.6.1] — 2026-08-30
8
8
 
9
- Nothing yet.
9
+ Documentation only, and it is a correction rather than a polish. The README
10
+ described where journeys come from in the words of the design — read the code,
11
+ then harvest the project's own test suite, then recorded sessions — and only the
12
+ first of those is wired into `staysfixed check`. The suite harvest, session
13
+ recording and the flake register are written and tested in `src/v2/journeys/` and
14
+ nothing on the check path calls them. Both places that implied otherwise now say
15
+ so plainly. A feature that exists in the repository and not in the run is not a
16
+ feature you have.
17
+
18
+ ## [0.6.0] — 2026-08-30
19
+
20
+ The second sweep for silences, done the same way as the first: read all of
21
+ `src/v2` looking for anything that can drop something and then report nothing
22
+ found. Five more were there, and two of them were in the safety machinery
23
+ itself — the gate that decides what an agent may never wave through, and the
24
+ proof that decides whether a change explains a break.
25
+
26
+ Every fix below has a test that fails against yesterday's code and passes
27
+ against today's. That was checked by putting the old behaviour back, one line at
28
+ a time, in a copy of the repository, and watching each test go red.
29
+
30
+ ### Fixed — silences, second sweep
31
+
32
+ - **Only the first 80 differences of a finding were read when deciding whether it
33
+ was one of the five things nobody may wave through.** A cluster can hold
34
+ hundreds of addresses; a refund sitting at address 150 of 300 was classified
35
+ ordinary, which means an agent could waive it and it would never reach a
36
+ person. Every difference is read now. A cap on the one gate that cannot have
37
+ one is not a performance decision, it is a hole.
38
+ - **The causal proof re-checked five addresses and then spoke for all of them.**
39
+ A finding of three hundred where the first five went away came back "caused by
40
+ that change" — a machine-checked reason for an agent to close a break it had
41
+ only half explained. It re-checks every address now, and when a change explains
42
+ part of a finding and not the rest it says exactly that, with both numbers. It
43
+ costs nothing: the journeys were already re-walked and each extra address is one
44
+ lookup.
45
+ - **A wobble big enough to swallow the comparison was still subtracted, and the
46
+ run came back clean.** If the second run of the new build falls over, or the
47
+ product writes hash-named files, or stamps a fresh id on every line, most of its
48
+ addresses are unsteady, nearly every difference is dropped before anything is
49
+ compared, and the verdict reads "nothing that already worked has changed". A
50
+ build that disagrees with itself about more than half of its own addresses now
51
+ gets **no verdict**: the summary opens with `NO ANSWER FROM THIS RUN`, the
52
+ reason is a named hole in the coverage, and `ok` is false. No number here
53
+ decides whether any difference is real — it decides only whether the run has
54
+ earned the word clean.
55
+ - **Two facts written at one address lost one of them, silently.** Every index in
56
+ the engine keeps the first observation at a path, so a second one is never
57
+ compared with anything and a door that broke behind it is invisible. The
58
+ detector for this was written on the first day of v2 and nothing ever called it.
59
+ Every walk is checked now and each clash is named — which address, and what the
60
+ ignored answer was. Identical repeats are not reported, because they hide
61
+ nothing.
62
+ - **The route reader skipped a folder it could not open, and every route behind
63
+ it, without a word.** This is the same bug as the one fixed yesterday in the
64
+ file walk, in a second function that the first fix did not touch. It names the
65
+ folder now.
66
+
67
+ ### Added
68
+
69
+ - A twelfth case in the self-check corpus, and a third kind of expectation with
70
+ it: a product so unsteady that the comparison is thrown away before it happens,
71
+ where the only honest answer is that the run says nothing. Confirmed to fail
72
+ against 0.5.0 and pass against this. Measured after the change: twelve of
73
+ twelve, three times running, with the project's own suite in parallel and the
74
+ machine's load average between 208 and 343.
75
+ - Six more tests in `test/v2/silences.test.js`, one per fix above, each confirmed
76
+ red against the previous code.
77
+
78
+ ### Changed
79
+
80
+ - `readFileRoutes(root)` returns `{ doors, problems }` rather than an array. The
81
+ problems are folders it could not open, and they are reported as missing
82
+ coverage by every caller.
83
+
84
+ ### Known, and written down rather than fixed
85
+
86
+ - What a normalisation rule rubbed out is not itemised per run. The receipt
87
+ exists in the code and nothing calls it. The rules are in your repository and
88
+ the capture is stamped with which set it used, so a comparison across a rule
89
+ change is announced — but a broad rule is still how you go blind on purpose.
90
+ - Ranking reads up to 4,000 source files of up to 400KB. Past that a finding is
91
+ still reported and still counted; it may just not sort where it deserves to.
92
+ Nothing is dropped for being far from the change.
93
+
94
+ ## [0.5.0] — 2026-08-30
95
+
96
+ The release about the one way this tool can be catastrophically wrong: reporting
97
+ nothing while something is broken. Five separate places could do that, and one of
98
+ them could do it permanently.
99
+
100
+ ### Fixed — silences
101
+
102
+ Each of these produced a clean-looking run while something was invisible.
103
+
104
+ - **A `git diff` too big to read was treated as no diff at all**, so a large
105
+ uncommitted change was fingerprinted as a clean checkout of the commit it sat
106
+ on. If that commit was the reference, the check was comparing the build against
107
+ itself, and a comparison of a build with itself can only ever come back clean.
108
+ The diff is now streamed into a hash with no ceiling, and a git command that
109
+ fails is refused loudly rather than read as "nothing has changed".
110
+ - **A folder with no git in it** gave every build the same identity, with the same
111
+ consequence, quietly and for ever. It is now refused, with a sentence saying
112
+ why.
113
+ - **The two ends of a huge output were kept with only a rough size**, so a break
114
+ in the middle that was thrown away left a byte-identical record. The exact byte
115
+ count is kept now, and a truncated value is reported as missing coverage rather
116
+ than passed over.
117
+ - **An adapter that threw while listing what it would walk had its journeys
118
+ dropped in silence** — a whole surface disappearing from a run whose verdict
119
+ then read "nothing that worked has changed". It is recorded as a hole, by name.
120
+ - **A folder the source reader could not open was skipped without a word**, taking
121
+ every door behind it. It is named now, along with any file still too big to read.
122
+ - **The normalisation-rule stamp on a stored capture was written and never read.**
123
+ Comparing a record tidied up under one set of rules against a run tidied up
124
+ under another produces differences that are about the rules; the run now says so.
125
+
126
+ ### Changed — how long something took is recorded and never compared
127
+
128
+ A stopwatch on a shared machine measures how busy the machine is at least as much
129
+ as it measures the product. Measured on the corpus's own fixture: thirty runs of
130
+ the same one-line program on an idle Mac took 48ms to 96ms, against a bucket
131
+ boundary at 100ms — four milliseconds of headroom, so any load at all crossed it.
132
+ That is what made the self-check come back "1 of 9 wrong" one busy evening and
133
+ then pass five times in a row on a quiet machine.
134
+
135
+ Durations are still measured and still shown, in the sentence beside the address,
136
+ where a person can read them. They are no longer differenced, on any platform.
137
+ **Stays Fixed will not tell you your product got slower.** A build that hangs is
138
+ still caught, because it is stopped for taking too long and how it finished is
139
+ compared exactly, and every counter that comes from the product rather than from
140
+ the clock is compared exactly as before.
141
+
142
+ ### Added
143
+
144
+ - **Two more cases in the self-check corpus**, eleven in total: a break buried in
145
+ the middle of an output too big to store, and a build ten times slower that must
146
+ stay silent. Both were confirmed to fail against the previous code and pass
147
+ against this one.
148
+ - **The corpus re-runs a case before accusing the engine.** Fail twice and it is a
149
+ real failure. Behave the second time and it is reported as *could not tell* —
150
+ not a pass, not a failure, exit code 2 — with the machine's load beside it.
151
+ Measured after the change: eleven of eleven, three times running, with the
152
+ project's own test suite in parallel and load average between 227 and 334.
153
+ - `test/v2/silences.test.js`, holding each of the above shut.
154
+
155
+ ## [0.4.0] — 2026-08-30
156
+
157
+ The difference engine, published. Seven observation channels flattened into one
158
+ address space; the new build run twice so the product's own wobble is measured
159
+ and subtracted rather than guessed at; differences clustered, ranked by distance
160
+ from the changed code, and their cause proven by reverting the suspect change and
161
+ running again. The reference is cut by saying ship, and an agent may waive within
162
+ four gates but can never decide what "working" means. Adapters for processes,
163
+ HTTP, source reading, the web, Electron, Android, iOS and Windows, and a
164
+ self-check corpus of deliberately broken builds.
165
+
166
+ ## [0.3.0] — 2026-08-29
167
+
168
+ Checks tick off live as they run, and guards show what they assert.
169
+
170
+ ## [0.2.0] — 2026-08-29
171
+
172
+ Pictures you can look at, motion, and a report that says what was checked rather
173
+ than only how long it took.
10
174
 
11
175
  ## [0.1.0] — 2026-08-29
12
176
 
@@ -57,5 +221,8 @@ not been used by many people yet.
57
221
  - No phone or tablet simulators.
58
222
  - No hosted service, no dashboard, no accounts.
59
223
 
60
- [Unreleased]: https://github.com/asadev/staysfixed/compare/v0.1.0...HEAD
224
+ [0.5.0]: https://github.com/asadev/staysfixed/compare/v0.4.0...v0.5.0
225
+ [0.4.0]: https://github.com/asadev/staysfixed/compare/v0.3.1...v0.4.0
226
+ [0.3.0]: https://github.com/asadev/staysfixed/compare/v0.2.3...v0.3.0
227
+ [0.2.0]: https://github.com/asadev/staysfixed/compare/v0.1.1...v0.2.0
61
228
  [0.1.0]: https://github.com/asadev/staysfixed/releases/tag/v0.1.0
package/README.md CHANGED
@@ -148,8 +148,9 @@ pretend otherwise.
148
148
  | The reference cut when you ship, sealed intents, the waiver budget, and escalations in your closing summary | **Works.** This page describes what it actually does. |
149
149
  | The coverage ledger — every door counted, the unopened ones named, and the sentence saying so on every reply | **Works.** See [what it did not check](#what-it-did-not-check). |
150
150
  | Aiming a check at one kind of product, and refusing by name rather than checking something else | **Works.** |
151
+ | Steps taken from your own test suite, from a recorded session, or rejected at birth for not repeating twice | **Written, not wired.** The code is in `src/v2/journeys/` with tests around it, and nothing on the check path calls it yet. Journeys today come from what each adapter reads out of your source, plus any `--journeys` file you name. |
151
152
  | Android APKs on an emulator | **The adapter is here.** It reads everything the APK declares with nothing installed and no Java, and where there is an emulator it installs one build at a time and walks it. Whether *this* machine can run one is a separate question, and `doctor` asks the adapter itself rather than keeping a second opinion — most of what it wants installs with a command; accepting Google's licence, once, needs a person. Two emulator snapshots restoring byte-identically is unproven, so Android compares against the stored record and says which mode it used. |
152
- | The iOS simulator | Not yet. `doctor` says so rather than reporting a green run that never touched the phone. |
153
+ | The iOS simulator | **The adapter is here.** It reads what the app bundle declares with nothing running, and where Xcode and a simulator runtime are present it installs one build at a time, boots it and reads what is on the screen. It is new. Paired running costs two `xcodebuild` passes, so it is for before a release rather than for every edit, and like Android it compares against the stored record and says which mode it used. Ask `doctor` what it is actually covering on your machine before trusting a clean run. |
153
154
  | Native Windows GUI (a real Win32 app, not an Electron one) | **The probe is here**, driven over ssh to any machine that reaches a Windows desktop — a WSL shell on one counts, and nothing is installed on it. Windows shows one desktop, so two builds can never run at once: the comparison is genuinely weaker here than anywhere else. |
154
155
 
155
156
  `staysfixed check` is the front door for both. Version 1's flags still mean
@@ -210,7 +211,7 @@ evidence for something another channel already found.
210
211
  | `complaints` | What the product complained about: console messages, errors, crashes, the code it exited with. |
211
212
  | `results` | What the product gave back: what it printed, what it answered, what it offers other code. |
212
213
  | `contract` | The doors the source says exist: routes, exported functions, message channels. Read without running anything. Free, and exact. |
213
- | `counters` | Rough counts and rough timings. Deliberately rough precise timing is noise, not information. |
214
+ | `counters` | Rough counts files written, calls made, doors answered. Compared exactly. How long something took is **recorded and never compared**: see [what it will never do](#what-it-will-never-do). |
214
215
  | `pixels` | What it looked like. Used to show a person a problem another channel already found. |
215
216
 
216
217
  An address reads left to right, widest thing first:
@@ -232,6 +233,14 @@ under instrumentation → recorded real sessions → the agent exploring one nam
232
233
  gap and freezing it into a replayable file → never a person clicking through an
233
234
  app.
234
235
 
236
+ What is actually wired into `staysfixed check` today is the first of those and a
237
+ journeys file you point it at: each adapter reads your source and offers the
238
+ journeys it can walk — routes, commands, screens, message channels — and
239
+ `--journeys <file>` names steps by hand. The suite harvest, recorded sessions and
240
+ the flake register are written and tested in `src/v2/journeys/`, and **nothing on
241
+ the check path calls them yet**. Saying so is the point: a feature that exists in
242
+ the repository and not in the run is not a feature you have.
243
+
235
244
  ## Keeping it quiet
236
245
 
237
246
  Four layers before anything reaches the agent: **normalise** volatile shapes by
@@ -321,6 +330,27 @@ verdict reading *nothing that worked has changed*. It is arithmetically true and
321
330
  it would let a real regression through. That run comes back as **`NOTHING WAS
322
331
  ACTUALLY COMPARED`**, it is not a pass, and it exits non-zero.
323
332
 
333
+ ### The silences that were found and closed
334
+
335
+ Every one of these produced a clean-looking run while something was invisible.
336
+ They are listed because a tool like this earns trust by naming the ways it has
337
+ been wrong, not by claiming it never was. All five were found on 2026-08-29 and
338
+ 2026-08-30 by reading the whole engine looking for the same shape as the first
339
+ one, and each has a case in the corpus or a test holding it shut.
340
+
341
+ | It used to | Now |
342
+ | --- | --- |
343
+ | Skip any source file over 2MB **without a word**, then report that it had found no source at all — so a desktop app whose main process is one 3.5MB bundle had all 452 of its message channels silently unread | Reads up to 24MB, and names any file it still cannot open |
344
+ | Skip a folder it could not open, and every door behind it, silently | Names the folder and the reason, as missing coverage |
345
+ | Keep the two ends of a huge output and a **rough** size, so a break in the discarded middle left a byte-identical record | Keeps the exact byte count, and says out loud that only the ends were compared |
346
+ | Drop a whole adapter's journeys when it threw while listing them — a surface disappears and the verdict reads "nothing has changed" | Records it as a hole, by name, in the coverage |
347
+ | Treat a `git diff` too big to read as **no diff**, so a large uncommitted change was fingerprinted as a clean checkout — and if that commit was the reference, the check compared the build against itself and could only ever come back clean | Streams the diff into a hash with no ceiling, and refuses outright rather than guessing. A folder with no git in it is refused for the same reason |
348
+ | Read only the first **80** differences of a cluster when deciding whether it is one of the five things nobody may wave through — so a refund at address 150 of 300 was classified ordinary, and an agent could waive the lot | Reads every difference in the finding. There is no ceiling on the one gate that cannot have one |
349
+ | Re-check the first **five** addresses of a finding when proving whose change caused it, then say "caused by that change" about all three hundred — a machine-checked reason for an agent to close a break it had only half explained | Re-checks every address, and says in words when a change explains part of a finding and not the rest. It costs nothing: the walk already happened |
350
+ | Subtract the new build's own wobble even when the wobble had swallowed the comparison — a second run that fell over makes almost every address unsteady, everything is dropped before it is compared, and the run ends "nothing that already worked has changed" | A build that disagrees with itself about most of its own addresses gets **no verdict**: the run says NO ANSWER FROM THIS RUN, in those words, and is not a pass |
351
+ | Keep the **first** of two facts written at one address and ignore the second, so a door that broke behind a duplicated address could never be compared with anything. The detector for this was written on day one and never called | Every walk is checked for it, and each clash is named in the coverage: which address, and what the ignored answer was |
352
+ | Skip a folder it could not open **while looking for routes**, and every route behind it — the same bug as the one above, in a second place, still silent | Names the folder, and the routes behind it are reported as unread rather than as absent |
353
+
324
354
  ---
325
355
 
326
356
  ## Aiming a check at one thing
@@ -601,10 +631,32 @@ of it is in [docs/how-v2-works.md](docs/how-v2-works.md).
601
631
  A tool that reports "nothing changed" looks exactly like a tool that is broken,
602
632
  and there is no way to tell the two apart from the outside. So:
603
633
 
604
- **It has to prove it still catches things.** `staysfixed check --selfcheck` runs
605
- a corpus of deliberately broken builds and requires the engine to catch every
606
- one. If it misses any, it says so, and until that is fixed a clean check means
607
- nothing.
634
+ **It has to prove it still catches things.** `staysfixed check --selfcheck`
635
+ builds twelve tiny products each a real repository with a working commit and an
636
+ uncommitted change on top, which is the shape an agent actually points this tool
637
+ at — and requires the engine to behave on every one. Eight are breaks it must
638
+ catch. Three are the other half of the same promise: pairs that must produce **no
639
+ findings at all**, because a tool that cries wolf gets switched off, and a tool
640
+ that is switched off catches nothing. The twelfth is the third kind, added on
641
+ 2026-08-30: a product so unsteady that the comparison is thrown away before it
642
+ happens, where the only correct answer is that this run says **nothing** — and
643
+ saying "nothing changed" there is the worst thing the tool can do.
644
+
645
+ **And it has to be honest when it cannot tell.** A case that misbehaves is built
646
+ again from scratch and run again before that becomes an accusation. Fail twice
647
+ and it is reported as a real failure. Behave the second time and it is reported
648
+ as *could not tell* — not a pass, not a failure, exit code 2, with the machine's
649
+ load printed beside it. This exists because the corpus once came back "1 of 9
650
+ wrong" with a test suite running alongside it and then passed five times in a row
651
+ on a quiet machine, and a corpus that can be perturbed by a busy laptop is worth
652
+ nothing on a busy laptop. The cause was found and removed — see
653
+ [what it will never do](#what-it-will-never-do) — and the re-run stayed, so that
654
+ the next machine-shaped thing to creep in lands as "nobody knows" rather than as
655
+ a false accusation people learn to ignore. Measured on 2026-08-30: eleven of
656
+ eleven, three times running, with the project's own suite running in parallel and
657
+ the machine's load average between 227 and 334; and after the second sweep of
658
+ silences, twelve of twelve, three times running, with the suite in parallel again
659
+ and the load average between 208 and 343.
608
660
 
609
661
  **The unstable app.** `fixtures/unstable-app` is a page built to be impossible to
610
662
  observe consistently: a clock ticking ten times a second, an endless spinner, a
@@ -633,6 +685,40 @@ Honestly, so you know before you invest an afternoon.
633
685
  by design and permanently. A refusal is reported as a gap in coverage, never as
634
686
  a pass.
635
687
  - **A migration that destroys data is refused, not run twice.**
688
+ - **It will not tell you your product got slower.** How long something took is
689
+ recorded and shown to you, and it is never compared. A stopwatch on a shared
690
+ machine measures how busy the machine is at least as much as it measures the
691
+ product: measured here, thirty runs of the same one-line program on an idle Mac
692
+ took between 48ms and 96ms, against a bucket boundary at 100ms. Comparing that
693
+ invents a slowdown nobody caused every time the machine is busy, and a tool that
694
+ cries wolf gets switched off. A build that **hangs** is still caught — it gets
695
+ stopped for taking too long, and how it finished is compared exactly.
696
+ - **A change buried in the middle of a huge output can be missed.** Anything a
697
+ product prints over 64KB has its two ends kept and compared, plus the exact
698
+ number of bytes thrown away — so a middle that grew or shrank is caught. A
699
+ middle that changed without changing its length is not, the whole text is
700
+ written to the evidence folder either way, and the run says out loud that it
701
+ only compared the ends.
702
+ - **A build that will not answer the same way twice gets no verdict at all.**
703
+ Differences at addresses the new build cannot hold steady are dropped — that is
704
+ the whole design, and it has one failure shape. If the second run falls over,
705
+ or the product writes hash-named files, or stamps a fresh id on every line, then
706
+ most of its addresses are unsteady, almost everything is dropped before it is
707
+ compared, and what is left is not an answer. A run in that state now says **NO
708
+ ANSWER FROM THIS RUN**, in those words, and is not a pass. Fix it by writing a
709
+ normalisation rule for whatever is moving, not by trusting the clean-looking run
710
+ underneath it.
711
+ - **What normalisation rubbed out is not itemised on every run.** The rules are in
712
+ your repository, they are listed by `staysfixed rules`, and the capture is
713
+ stamped with which set was used — a run comparing against a record tidied by a
714
+ different set says so. What it does not yet do is print, per run, every value a
715
+ rule rewrote. Anything a rule covers is not being watched, and that is the point
716
+ of the rule; just know that adding a broad one is how you go blind on purpose.
717
+ - **Ranking reads your source, and it gives up on very large trees.** Distance
718
+ from the code you just changed is what sorts the list, and it reads up to 4,000
719
+ files of up to 400KB each to work it out. Past that a finding is still reported
720
+ and still counted — it just may not sort where it deserves to. Nothing is
721
+ dropped for being far away.
636
722
  - **A race that already existed will not show.** Subtracting the wobble floor
637
723
  actively hides intermittent bugs. Running the new build twice recovers half of
638
724
  this by flagging anything newly unstable. Only half. That is the sharpest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "staysfixed",
3
- "version": "0.4.0",
3
+ "version": "0.6.1",
4
4
  "description": "Prove that what already worked still works after an agent changed the code. Picture checks, guards for fixed bugs, a pre-release walkthrough, and known-good markers — as a CLI and as an MCP server.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1183,7 +1183,14 @@ export function looksIrreversible(method, url) {
1183
1183
  * in the background is filed separately, and this limit is stated in the adapter's own
1184
1184
  * description rather than hidden here.
1185
1185
  */
1186
- const DEVICE_HOSTS = /(^|\.)(google|googleapis|gstatic|android|gvt1|gvt2|doubleclick|crashlytics|firebaseinstallations)\.com$|(^|\.)google\.[a-z.]+$/i;
1186
+ /*
1187
+ * Every one of these was seen leaving a Google APIs emulator on 2026-08-30 while Terminal
1188
+ * Deck — an app that talks to one relay and nothing else — sat on its first screen. The
1189
+ * ones added that day are on the second line: `googleadservices` and `youtube` were being
1190
+ * filed as calls THE APP made, which reads as "your change made the app phone YouTube" and
1191
+ * is the most alarming false alarm this adapter is capable of producing.
1192
+ */
1193
+ const DEVICE_HOSTS = /(^|\.)(google|googleapis|gstatic|android|gvt1|gvt2|doubleclick|crashlytics|firebaseinstallations)\.com$|(^|\.)(googleadservices|googlesyndication|googletagmanager|googleusercontent|google-analytics|youtube|ytimg|ggpht|appspot)\.com$|(^|\.)google\.[a-z.]+$/i;
1187
1194
 
1188
1195
  /**
1189
1196
  * @param {string} host
@@ -953,22 +953,54 @@ export const androidAdapter = defineAdapter({
953
953
  const key = `${call.method} ${call.host}${call.route}`;
954
954
  byCall.set(key, (byCall.get(key) ?? 0) + 1);
955
955
  }
956
+ /**
957
+ * The phone's own background chatter is COUNTED, never given an address of its own.
958
+ *
959
+ * A device-wide proxy sees the whole phone, and on a Google APIs emulator most of what
960
+ * it sees is Play services: checking connectivity, fetching fonts, asking about digital
961
+ * asset links, phoning gmscompliance. None of it belongs to the app being checked, none
962
+ * of it is the same twice, and every one of those hosts given an address of its own is
963
+ * an address that appears in one run and is gone in the next.
964
+ *
965
+ * Measured on Terminal Deck on 2026-08-30, on a build with NOTHING changed in it: eight
966
+ * findings, seven of them a Google host that came or went. Running the build twice
967
+ * subtracted fifteen more and still could not subtract those, because this traffic is
968
+ * not merely unstable — it is episodic, so a run pair can agree with itself and still
969
+ * disagree with the run pair before it. Left as it was, an Android check reports noise
970
+ * on a build nobody touched, and the first thing anybody does with a tool that cries
971
+ * wolf is switch it off.
972
+ *
973
+ * So: the app's own calls keep one address each, which is the signal worth having, and
974
+ * the phone's own are reduced to a count that sits on one rung of a coarse ladder. What
975
+ * was seen is still said in plain English, and the ledger still records that nothing
976
+ * inside those requests was ever opened.
977
+ */
978
+ let fromThePhone = 0;
956
979
  for (const [key, times] of [...byCall.entries()].sort()) {
957
980
  const call = calls.find((c) => `${c.method} ${c.host}${c.route}` === key);
958
981
  if (!call) continue;
959
- const mine = !isDeviceHost(call.host);
960
- if (mine) fromTheApp += 1;
982
+ if (isDeviceHost(call.host)) {
983
+ fromThePhone += 1;
984
+ continue;
985
+ }
986
+ fromTheApp += 1;
961
987
  out.push(observation({
962
988
  channel: 'effects',
963
- path: joinPath('net', journey.name, mine ? 'the app' : 'the phone itself', key),
989
+ path: joinPath('net', journey.name, 'the app', key),
964
990
  value: { asked: countBucket(times), reached: call.allowed ? 'let through' : 'stopped here' },
965
- says: `${mine ? 'the app' : 'the phone, not the app,'} tried to call ${key}${times > 1 ? ` ${times} times` : ''} — ${call.why}`,
991
+ says: `the app tried to call ${key}${times > 1 ? ` ${times} times` : ''} — ${call.why}`,
966
992
  covered: call.how === 'encrypted' ? false : undefined,
967
993
  reason: call.how === 'encrypted' ? 'not supported here' : undefined,
968
994
  journey: journey.name,
969
995
  surface: 'android',
970
996
  }));
971
997
  }
998
+ out.push(notCovered({
999
+ channel: 'effects',
1000
+ path: joinPath('net', journey.name, 'the phone itself'),
1001
+ reason: 'not supported here',
1002
+ says: `${fromThePhone === 0 ? 'nothing else on the phone' : `${fromThePhone} other thing${fromThePhone === 1 ? '' : 's'} on the phone`} reached out while this ran — Android\'s own services, not this app. It is watched and stopped here like everything else, and it is not compared, because a device-wide proxy cannot tell which program made a call and Google\'s background traffic is different every single run.`,
1003
+ }));
972
1004
  out.push(observation({
973
1005
  channel: 'counters',
974
1006
  path: joinPath('count', journey.name, 'calls the app made'),
@@ -46,7 +46,7 @@ export { CHANNELS };
46
46
  * Why a thing was not observed. Short vocabulary on purpose: the engine counts these and
47
47
  * reports them as holes, and a free-text reason cannot be counted.
48
48
  *
49
- * @typedef {'irreversible'|'missing tool'|'refused'|'too big'|'timed out'|'not supported here'|'crashed'|'needs a sample'} NotCoveredReason
49
+ * @typedef {'irreversible'|'missing tool'|'refused'|'too big'|'timed out'|'not supported here'|'crashed'|'needs a sample'|'measures the machine'} NotCoveredReason
50
50
  */
51
51
 
52
52
  /** @type {Record<NotCoveredReason, string>} */
@@ -59,6 +59,8 @@ export const NOT_COVERED_MEANING = Object.freeze({
59
59
  'not supported here': 'this platform cannot be observed this way, and saying so is the honest answer',
60
60
  crashed: 'the thing being observed fell over before it could be read',
61
61
  'needs a sample': 'a real value has to be supplied before this can be tried at all',
62
+ 'measures the machine':
63
+ 'a stopwatch measures how busy this machine was at least as much as it measures the product, so the number is recorded and never compared',
62
64
  });
63
65
 
64
66
  // ---------------------------------------------------------------------------
@@ -465,6 +467,61 @@ export function timeBucket(ms) {
465
467
  return 'over five minutes';
466
468
  }
467
469
 
470
+ /**
471
+ * How long something took, recorded and DELIBERATELY NOT COMPARED.
472
+ *
473
+ * This used to be an ordinary observation whose value was the bucket the run landed in, and
474
+ * it was the single worst thing in the tool, for a reason that is arithmetic rather than
475
+ * theoretical. A wall clock on a shared machine measures how busy the machine is at least as
476
+ * much as it measures the product. Two runs of identical code, one while a test suite is
477
+ * running and one on a quiet laptop, land on different rungs of any ladder you care to draw
478
+ * — and the tool then reported a difference nobody caused, or worse, reported the address as
479
+ * "newly unpredictable", which is its sharpest accusation.
480
+ *
481
+ * Measured on this Mac on 2026-08-30, on the self-check corpus's own fixture: thirty runs of
482
+ * the same one-line program, machine idle, ran 48ms to 96ms — with the first rung boundary at
483
+ * 100ms. Four milliseconds of headroom. Anything at all happening on the machine crosses it,
484
+ * and that is exactly what happened the night the self-check came back "1 of 9 wrong" while
485
+ * the test suite ran alongside it, and passed five times in a row afterwards.
486
+ *
487
+ * The fix is not a wider bucket — every ladder has a boundary and every boundary has this
488
+ * problem — and it is certainly not a tolerance, which this tool does not have and will not
489
+ * grow. It is to stop claiming something a stopwatch cannot tell you. The number is still
490
+ * recorded, in the sentence, where a person can read it. It is never differenced.
491
+ *
492
+ * WHAT THIS GIVES UP, said plainly: Stays Fixed will not tell you your product got slower.
493
+ * WHAT IT DOES NOT GIVE UP: a build that hangs is still caught, because it gets killed for
494
+ * taking too long and how it finished IS compared; and every counter that comes from the
495
+ * product rather than from the clock — files written, calls made, doors answered — is still
496
+ * compared exactly.
497
+ *
498
+ * @param {object} spec
499
+ * @param {Channel} spec.channel
500
+ * @param {string|(string|number)[]} spec.path
501
+ * @param {number} spec.ms What it actually took, for the sentence.
502
+ * @param {string} spec.what What was being timed, in the reader's words.
503
+ * @param {string} [spec.andAlso] Anything else worth saying in the same breath.
504
+ * @param {string} [spec.journey]
505
+ * @returns {Observation}
506
+ */
507
+ export function howLongItTook(spec) {
508
+ return observation({
509
+ channel: spec.channel,
510
+ path: spec.path,
511
+ // One fixed string, so this address is identical in every capture of every build and can
512
+ // never become a difference. The measurement lives in the sentence, which is never compared.
513
+ value: `not compared — ${NOT_COVERED_MEANING['measures the machine']}`,
514
+ says:
515
+ `${spec.what} took ${timeBucket(spec.ms)}. That is recorded and NOT compared: a stopwatch on a shared machine ` +
516
+ `measures the machine as much as the product, so a busy laptop would otherwise invent a slowdown that nobody caused. ` +
517
+ `A build that hangs is still caught — it gets stopped for taking too long, and how it finished is compared.` +
518
+ (spec.andAlso ? ` ${spec.andAlso}` : ''),
519
+ covered: false,
520
+ reason: 'measures the machine',
521
+ journey: spec.journey,
522
+ });
523
+ }
524
+
468
525
  /**
469
526
  * Sizes, on the same principle as time. A response body that grew by two bytes is not news;
470
527
  * one that doubled is.
@@ -542,10 +599,12 @@ export function undoOurFootprint(text, footprint) {
542
599
  /**
543
600
  * Keep a piece of text at a size worth storing.
544
601
  *
545
- * Anything longer gets its head and tail kept — the two ends are where the interesting
546
- * lines are — plus a fingerprint of the whole, so a change in the middle still shows as a
547
- * difference even though the middle itself was never stored. The caller writes the full
548
- * text to the evidence folder and points at it.
602
+ * Anything longer gets its head and tail kept — the two ends are where the interesting lines
603
+ * are — plus the EXACT number of bytes left out, so a middle that grew or shrank still shows
604
+ * as a difference. A middle that changed without changing length does NOT, and that hole is
605
+ * stated rather than hidden: the caller marks the observation as not fully covered and writes
606
+ * the whole text to the evidence folder. See the comment in the body for why a digest of the
607
+ * whole text cannot be used here.
549
608
  *
550
609
  * @param {string} text
551
610
  * @param {number} [limit] bytes
@@ -557,8 +616,27 @@ export function trimForStorage(text, limit = 64 * 1024) {
557
616
  const keep = Math.floor(limit / 2);
558
617
  const head = text.slice(0, keep);
559
618
  const tail = text.slice(-keep);
619
+ // The marker used to carry a COARSE size bucket, and the doc above it claimed a fingerprint
620
+ // of the whole that was never actually computed. Both halves of that were wrong, and the
621
+ // result was the worst thing this tool can produce: a change that happened entirely in the
622
+ // discarded middle of a large output left a byte-identical stored value, so the comparison
623
+ // saw nothing and the run reported that nothing had changed. A silence that reads like an
624
+ // all-clear.
625
+ //
626
+ // The exact byte count goes in instead. A digest of the whole text would be strictly
627
+ // better AND IT CANNOT GO HERE: normalisation runs after the adapter, on the head and the
628
+ // tail, so a digest taken now would include every timestamp and every id the rules exist to
629
+ // rub out — the address would then disagree with itself on every run, be measured as wobble,
630
+ // and get subtracted, which would switch off the comparison of large outputs altogether.
631
+ // An exact length survives normalisation, because almost everything volatile (a timestamp,
632
+ // a uuid, a hex id) has a fixed width.
633
+ //
634
+ // What is left uncovered is real and it is named rather than hidden: a change confined to
635
+ // the middle that keeps the length identical is not seen. The caller marks the observation
636
+ // as not fully covered, the coverage ledger states the hole, and the whole text is written
637
+ // to the evidence folder so anybody can look.
560
638
  return {
561
- text: `${head}\n... ${sizeBucket(bytes - keep * 2)} left out of the middle ...\n${tail}`,
639
+ text: `${head}\n... exactly ${bytes - keep * 2} bytes left out of the middle of ${bytes} ...\n${tail}`,
562
640
  truncated: true,
563
641
  bytes,
564
642
  };
@@ -51,8 +51,8 @@ import { connect } from '../../drive/cdp.js';
51
51
  import { resolveElectronBinary } from '../../drive/find.js';
52
52
  import { splitPath } from '../observation.js';
53
53
  import {
54
- countBucket, defineAdapter, joinPath, notCovered, observation, sizeBucket, timeBucket,
55
- trimForStorage, undoOurFootprint,
54
+ countBucket, defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
55
+ timeBucket, trimForStorage, undoOurFootprint,
56
56
  } from './contract.js';
57
57
  import { compareTrees, snapshotTree } from './process.js';
58
58
  import {
@@ -1172,11 +1172,11 @@ export function describeApp(input) {
1172
1172
  : `What this journey did, in order: ${did.join('; ')}.`,
1173
1173
  journey: id,
1174
1174
  }));
1175
- out.push(observation({
1175
+ out.push(howLongItTook({
1176
1176
  channel: 'counters',
1177
1177
  path: joinPath('count', id, 'time to open'),
1178
- value: timeBucket(app.openedInMs),
1179
- says: `The app took ${timeBucket(app.openedInMs)} to open and show a window. Deliberately rough: exact timings differ every run.`,
1178
+ ms: app.openedInMs,
1179
+ what: 'Opening the app and getting a window on screen',
1180
1180
  journey: id,
1181
1181
  }));
1182
1182
  if (reading?.helpers) {
@@ -34,7 +34,7 @@ import path from 'node:path';
34
34
  import { spawn } from 'node:child_process';
35
35
  import {
36
36
  defineAdapter, joinPath, notCovered, observation, sizeBucket, stableValue,
37
- timeBucket, trimForStorage, undoOurFootprint,
37
+ howLongItTook, timeBucket, trimForStorage, undoOurFootprint,
38
38
  } from './contract.js';
39
39
  import {
40
40
  compareTrees, copyForScratch, frozenEnvironment, readWatcher, snapshotTree, watcherScript,
@@ -306,7 +306,7 @@ export const httpAdapter = defineAdapter({
306
306
  .find((name) => name in dependencies);
307
307
 
308
308
  const reading = await readContract({ root: project.root });
309
- const routes = [...reading.doors.filter((d) => d.kind === 'route'), ...await readFileRoutes(project.root)];
309
+ const routes = [...reading.doors.filter((d) => d.kind === 'route'), ...(await readFileRoutes(project.root)).doors];
310
310
 
311
311
  if (!config.start) {
312
312
  missing.push({
@@ -355,7 +355,7 @@ export const httpAdapter = defineAdapter({
355
355
  const config = project.config ?? {};
356
356
  const samples = config.samples ?? {};
357
357
  const reading = await readContract({ root: project.root });
358
- const routes = [...reading.doors.filter((d) => d.kind === 'route'), ...await readFileRoutes(project.root)];
358
+ const routes = [...reading.doors.filter((d) => d.kind === 'route'), ...(await readFileRoutes(project.root)).doors];
359
359
 
360
360
  /** @type {Map<string, import('./contract.js').Journey>} */
361
361
  const journeys = new Map();
@@ -722,11 +722,12 @@ export function describeRequest(input) {
722
722
  }));
723
723
  }
724
724
 
725
- out.push(observation({
725
+ out.push(howLongItTook({
726
726
  channel: 'counters',
727
727
  path: joinPath('count', id, 'duration'),
728
- value: timeBucket(ms),
729
- says: `${asked} took ${timeBucket(ms)}. Deliberately rough: exact timings differ on every run and would drown everything else.`,
728
+ ms,
729
+ what: asked,
730
+ journey: id,
730
731
  }));
731
732
 
732
733
  return out;