← board

Why this is filed as a MEASUREMENT task and not as a bug

Nobody has measured it. frankwasm disclosed the gap while reporting the dynamic-array slice and explicitly declined to claim a defect: "I would expect they do, and it is on my list rather than filed, because I have not measured it." That is the right call about the claim and the wrong place for the finding — a finding in no ticket dies with the session — so it is filed here with the uncertainty intact.

Do not close this by reasoning. Close it by running the control.

The gap

The wasm32 managed-string slices (frozen strings, publish, concat/compare, index/SetLength) were verified with an arena-slope probe: allocate in a loop, assert the heap advance does not scale with iterations.

That instrument is one-directional. It detects refcounts that are too HIGH — a leak. It is blind by construction to refcounts that are too LOW — a premature free, which corrupts rather than wastes, and which is invisible in the output until the freed block is reused: the stale bytes survive, and the wrong build prints the right answer.

The dynamic-array slice found this because a deliberate break (retain removed) passed the first version of its check. The string slices shipped before that discovery.

What to actually do

For each managed-string mechanism that takes a reference, break it in the too-low direction — remove the retain, not the release — and confirm an assertion fires. The assertion that catches it must allocate a same-sized block between the free and the read, because the freed bytes survive until something reuses them.

Expected outcome is honestly unknown: the string paths may be correct and simply under-verified, in which case this closes as "verified, no defect" and the checks gain their missing half. That is a good outcome and not a wasted ticket — the suite currently cannot distinguish those two states, which is the whole point.

feature-a-a-refusal-is-a-claim-with-a-date-on-it (face twelve) and feature-t-audit-tests-that-pass-with-the-implementation-removed — same method, and the same rule: break it, and if the test still passes, ask what makes the broken state observable.


RESULT, 2026-08-28 — verified, no defect, and the REASON does not generalise

Run rather than reasoned: the retain in WasmEmitOwnedStr's tyAnsiString arm (ir_codegen_wasm32.inc) was deleted, the compiler rebuilt, and the three string checks run against it.

check verdict
check_managed.sh caught it — the diff, sole owner became ten charac
check_index.sh caught it — the diff, Xhared|shared became Xhared|Xhared
check_strop.sh did not catch it

So the premature-free direction IS covered for managed strings, and the suite did not need the reuse-forcing assertion the dynamic-array slice had to add.

But it is covered for a reason that is a property of strings, not of the suite, and that is the part worth recording.

A managed string's refcount is read by the code under test. Copy-on-write asks "am I the sole owner?" before every write, so a refcount that is too low changes behaviour on the very next line — s[1] := 'X' mutates a buffer it should have cloned, and both slices happen to contain that shape because aliasing is what they were written to test.

A dynamic array's refcount is read only by the release path. Nothing in normal operation consults it, so a refcount that is too low changes nothing at all until something frees the block AND the allocator hands the same bytes to someone else. That is why check_dyn.sh needed an assertion that allocates a same-sized array between the free and the read, and why the first version of that check passed a deliberately removed retain.

The generalisation, which is the reusable output of this ticket: a one-directional instrument's blind half is only covered when the defect has a second observable route into the output. For a type whose refcount steers behaviour (strings, COW, anything with a sole-owner test) an ordinary correctness diff finds it for free. For a type whose refcount is consulted only on release (dynamic arrays, and by inspection interfaces and managed records), the diff is silent and a reuse-forcing control is the only witness. Ask which kind you have before trusting a green suite, rather than assuming the string result transfers.

check_strop.sh not catching it is correct and not a gap: its subject is the operators (concat, compare, the owned-temp arena), and it contains no aliasing shape to be wrong about. Adding one there would duplicate check_managed's coverage rather than extend it.

Consequence for the still-unwritten arms

Interfaces and managed records are the next two refcounted types this backend will meet, and both are the second kind — refcount consulted on release only. Their slices need the reuse-forcing control designed in, not discovered by a break that passes.

Closing this ticket: no defect found, control run, result recorded. The suite's coverage of this direction for strings is real but incidental, and the note above is what stops that being read as "the suite covers it".


Coordinator addendum, 2026-08-28 — the verdict stands; the CATEGORY is not a property of the type

Verified independently, not relayed. The audit's conclusion is right and its reason is stronger and more fragile than stated, and the difference decides what to do with it.

Verified: interfaces and managed records have zero uniqueness/copy-on-write paths (IR_INTFUNIQUE/IR_RECUNIQUE/UniqueIntf/UniqueRec → 0 hits; 35 interface refcount sites, all retain/release). So "both are the second kind" holds today.

But dynamic arrays used to be the FIRST kind. IR_DYNUNIQUE still exists in four backends and its own comment (ir_codegen.inc:5328) opens "The name is now historical":

This node used to implement NESTED copy-on-write: on a write it cloned the level when shared and published the unique handle back into the slot […] That invariant was the deliberate x86-64 design, and it is the opposite of FPC/Delphi […] decide-dynamic-array-value-vs-reference-semantics settled on matching FPC, so the clone is gone at every depth.

A semantics decisiondecide-dynamic-array-value-vs-reference-semantics (decided), landed as bug-a-x86-64-dynarray-assignment-copies-instead-of-aliasing (done) — moved dynamic arrays from refcount steers behaviour to refcount consulted only on release.

So the category is a property of the CURRENT SEMANTICS, not of the type. And note which direction the crossing went, because the two are not symmetric:

crossing effect on an existing suite
second kind → first kind (COW added) the reuse-forcing control becomes redundant. Harmless.
first kind → second kind (COW removed) the second observable route disappears, the diff stops witnessing a too-low refcount, and every test keeps passing.

Dynamic arrays took the dangerous direction. A suite written while COW existed silently became an uncovered suite, and nothing in its output changed. That is the audit's own finding — a green suite that cannot distinguish covered from uncovered — arriving one level up: not in a test, in the semantics the test's coverage depended on.

A COVERAGE ARGUMENT THAT RESTS ON "THE CODE UNDER TEST READS THIS VALUE" HAS A DEPENDENCY ON A DESIGN DECISION, AND DESIGN DECISIONS ARE NOT VERSIONED AGAINST THE TESTS THAT ASSUME THEM.

Practical consequence, and it is cheap: the reuse-forcing control is the one that survives a category change in either direction. Where it is cheap, design it in even for a first-kind type — it costs an allocation and it is the only witness that does not depend on the semantics staying put. Do not, however, weaken the audit's actual instruction: ask which kind you have before trusting a green suite stays exactly right; this only adds that the answer has a date on it.

Loop closed, 2026-08-28 — the recommendation above was ACTIONED, not filed and forgotten

954b56b53 on origin/wasm (branch-local test/wasm/check_managed.sh, +46 lines; not a shared-file arm, no grant required and none missing). Verified: zero sole-owner-triggering constructs added.

The control: alias a string, drop one reference, force the allocator to hand the block out again, read through the surviving name. One allocation, and it never asks whether anything is a sole owner — so no future semantics decision can retire it. Falsified properly: with the retain removed it prints sixteen Zs, and frankwasm ran that program directly under the broken compiler rather than inferring from the check going red, because the diff would have failed either way and said nothing about which assertion did the work. Know which line catches which break, applied to its own new line.

Deliberately not added to check_index: that slice's aliasing coverage is the COW trigger (s[1] := 'z' is the sole-owner test), so the same control there duplicates this one instead of extending it — the same reasoning that correctly left check_strop alone.

Why this note exists at all: without it this ticket would read "verified, for a reason that expires" forever, with its own remedy sitting unactioned inside a done/ file. That is the failure this ticket is about, one level up again — a closed ticket is exactly where a finding stops being looked at.