Operator rulings: variants are a CultCache feature, stored as a delta from a
base and resolved by the cache. Ends, invariants and open forks for the
Imagination pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last report quoted the restored sha256 as though two runs agreeing on it
meant they had run the same source. They had not: `text=auto` gives a Windows
checkout carriage returns, so the win32-x64 run restored to b163cab7 and the
linux-x64 run to e3c6528a from the same commit. The digest is a restore check —
the file this run started with is the file it ended with — and it is said where
it is printed, so the next report does not borrow it for something it cannot do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The note beside the close's wake claimed the narrowing to `notify_one` was
equivalent, and gave the weaker half of the reason. The equivalence needs two
premises together: `closing` is set permanently under the gate before the notify
and appears in every waiter's predicate, so after a close begins no poller can
block at all and the set of waiters is fixed; and each exiting call's own scope
destructor takes the gate, decrements and wakes everyone, so one wake starts a
chain that reaches all of them. Neither carries it alone. Both are now written
where the claim is made, so it can be checked rather than believed.
Narrowing both wakes also survives, and that is not equivalent by any argument:
a wake consumed by the closer, waiting on the same variable for the count to
reach zero, leaves the chain stopped and the rest of the pollers sitting out
their timeouts. It passes on wait-queue ordering — the pollers are queued before
the closer, so a FIFO head is never the closer until it is the only waiter left.
Recorded as unreached, with the reason, and deliberately not as unreachable:
that claim has been wrong twice in this campaign. A scenario cannot force it
while the first premise holds, since the only way to put the closer ahead of a
poller in that queue is a poller that blocks after the close began. Reaching it
would need a seam that chooses which waiter a notify goes to. The standard does
not promise FIFO, so what survives here is the implementation's doing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last pass's only non-comment source change was held by nothing. Reverting the
hold's guard so it fires unconditionally survived all five scenarios, because no
scenario ever had a call in both states at once: the timeout scenario never arms
the hold, and the hold scenarios never let a timeout expire.
`holdtimeout` is the missing state. The hold is armed, the runtime is idle, and
one poll asks for 150 ms while the hold stays armed for 800. A call the wait woke
is the seam's to park; a call whose own timeout expired is on its way out, and
parking it would let `holdclose` report the bridge keeping a host call inside the
library when what kept it was the fixture. The measurement is which of those two
numbers the poll comes back at.
Two entries, and the loosening is the one worth reading: the same two conditions
in the other order. The hold blocks, so the order is the rule — asked first, it
parks the call and then decides whether it should have. Both die on
`holdtimeout` at about 800 ms and on nothing else; `holdclose` and `latecall`
stay green under both, which is the point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two bands ruled out a constant and ruled out nothing else. Any mapping that
is the identity at 200 and 900 passed for free, and the late tolerance was a flat
300 ms, so anything within that of the truth passed at every value forever.
Three ordinary ones survived all five scenarios on both targets: clamping the
wait to at most a second, adding 150 ms to it, and scaling it by six fifths.
The clamp is the one that matters. Its failure is verbatim the bug the last pass
claimed to fix — a host asking for five seconds gets one, forever, with nothing
reporting it — and "cap the wait so a shutdown gets noticed" is the most ordinary
spelling that line will ever be given.
So the long probe goes to 5000, above any ceiling a bridge would plausibly clamp
to, which kills the clamp and the scale on the early check and the late one. And
the late tolerance becomes a fraction of what was asked for — 60 ms plus an
eighth — which is what kills the offset: 150 ms is 75% late at the 200 ms probe
and there is no flat gap left to hide in. Measured overshoot on either target is
about 10 ms, against 85 ms allowed at the short probe and 685 at the long.
The table gets the clamp as an entry of its own, because the whole lesson here is
that a table of constants says nothing about a wait computed from the argument,
and a table of constants would go green again the moment these numbers drifted
back. Its honest limit is stated where the tolerance is defined: a scaling
smaller than an eighth survives, and nothing here claims otherwise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The container recipe carried a PowerShell backtick continuation in a snippet
whose next paragraph invites a POSIX shell to run it, where a backtick is not a
continuation. A continuation has to pick a shell, so the line does not have one.
The Dockerfile's own copy of the recipe still mounted one machine's checkout by
absolute path; it mounts whatever checkout is running it, like the other two.
And the requirement a newcomer meets by tripping over it: on win32-x64 the
harness needs the checkout somewhere short. MSBuild's file tracker gives out on
long paths, so a run from a deep temporary directory fails the build for every
mutant, the no-op control included. That is loud — the harness stops on a red
control and prints what the child said — but what the child says is MSBuild, not
the path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`* text=auto` and nothing else, so a Windows clone writes every shell script with
carriage returns. The documented Linux path is a container that runs one of them,
and it dies in under a second with a complaint about an interpreter named
`bash\r`. Nothing in that failure names line endings, and the same clone builds
and tests fine everywhere else, so it reads as a broken image.
`scripts/build-quic-native.sh` is the only shell script in the repository and the
only file on those paths that an interpreter reads by shebang; the mutation
runner is Node and handles carriage returns itself, and the Dockerfile builds
either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No constant sits in both bands, which is true and is not the rule. A wait that
caps itself at a second passes every scenario, and capping a wait so shutdown
gets noticed is the most ordinary way that line will ever be written. An offset
and a scale survive beside it.
A fresh Windows clone also cannot run the documented Linux path at all, because
nothing tells git to leave shell scripts alone and the container dies on a
carriage return in the interpreter line. The mount fix works; this sat behind
it.
The equivalence holds, but on two premises nobody had written down, and
narrowing both wakes survives while being equivalent to nothing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The blind spot was not where the hold sat. No scenario ever let a timeout
finish a poll, because both ended their waits with a close, so the duration
governed nothing anyone could see. A scenario that polls an idle runtime and
measures elapsed against what it asked kills all three constants, and it asks
twice at values whose bands do not overlap, because one value cannot tell a
bridge honouring the host from one waiting on that same number of its own.
The probe that reached the two-phase poll through a closed port is committed,
the wake finally has the entry the table had been claiming, and a surviving
mutant is argued equivalent and handed to Soul rather than swapped for an
easier one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three rules the bridge has always promised, none of which anything committed
could see.
The timeout was the structural one. Both scenarios end their waits with a close,
so the duration the bridge waits on governed nothing either of them observes: a
hard-coded 2000 ms survived the whole Linux matrix and Windows, and a host asking
for five seconds would have got two forever with nothing reporting it. The fix is
not another entry at another constant. A timeout's only observable is how long
the call takes, so `polltimeout` lets an idle runtime's poll be ended by its own
timeout and measures it, twice at different values, because no single constant
sits in both bands. The seam's hold moves behind that too: it parks only a call
the wait woke, so a poll whose own timeout expired is on its way out rather than
held there by the fixture and counted as the bridge's doing.
The refusal during a close (the header's section 4) had no scenario at all. A
call admitted after the closer has begun increments the in-flight count behind a
wait that has already read it. `latecall` holds the close open with the seam and
makes a call into it.
And the two-phase poll was written off as needing a listener, a credential and a
connection. It needs a closed loopback port: the failure always carries a reason,
which is an 87-byte payload, in about a millisecond. Moving the pop above the
copy crashes on both targets.
18 killed, 0 survived, 0 skipped on linux-x64; 16 killed, 0 survived on
win32-x64, where the two ThreadSanitizer entries are honestly skipped. Both runs
restored the source to the same sha256, b163cab7d48ebc93.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mount was this machine's own path, so the one documented way to build and
check the Linux bridge needed editing before it would run anywhere else. It is
the repository root now, named as the shell names it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A reference whose target contradicts its declaration is noticed on read in this
cut and refused by name. Writing the same check into the put path is intended
rather than optional: the declaration already names the target and the writer
already holds the record, so it is cheap, and leaving it to consumers means
every tool that writes records reimplements it and a tool that forgets looks
exactly like one that did not until something refuses.
The consumer story rests on less than it sounds. There is no Studio repository
and no project by that name; the tooling that enforces this today is one
console project in one game. That makes now the cheapest moment to move the
rule to its owner.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A generator runs against published schemas, and the published schema entry is
flat: the cache walks the base types to collect members and then throws the
hierarchy away. Fed that, a generator emits one flat class per leaf with the
inherited members copied into each, which is the opposite of building the
hierarchy up for you.
So the catalog carrying each leaf's base chain is a prerequisite of that
campaign rather than a nicety for naming families, and families fall out of it
once it exists. The campaign itself waits until this cut settles the shape it
would be generating against. No generator exists in the repository today.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A caller needing exact pairs asks once per schema. The limit is stated rather
than answered with a third addressing list.
Naming a family is parked, but not because there is nothing to name. In one
process a family is an assignability check; over the wire there is no type
system, and nothing publishes the hierarchy where a caller can see it. The fix
is a catalog entry naming each leaf's base chain, which is small work that
moves the catalog's shape, so it belongs in a cut already touching it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Floats are fragile, and every value in this vocabulary is already a string, so
decimal strings keep the wire to one value encoding rather than adding a
second. The recommendation this reverses had that backwards.
What it costs is a comparison rule, and that is where the danger sits: compared
as text, ten is less than nine. The cut owes a canonical decimal form and a
numeric comparison, both pinned, and it no longer owes anyone a sentence about
where exactness runs out.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A stranger can now rerun both targets from the committed container and the
README, which was the batch's point and holds. The rules are another matter.
The scenario holds its callers after the wait whose duration is the rule, so
a hard-coded two seconds walks through the whole matrix while the committed
entry only ever died because one constant was smaller than another.
The gap recorded as unreachable is reachable in fifteen lines against a closed
port, and the mutant crashes a plain optimised build on contact. Second gap
falsified today. The next batch fixes the cause once instead of adding another
entry at another constant.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The operator ruled ordinals, the two deletions and the leaf titles earlier
today, in the other campaign's map. This one still carried them as open, and
a stale live question is exactly what a later agent reads as a decision still
to make.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The cache already declares and persists what type each member is, so numbers
cost no migration. What they do cost is a public way to read a declared value
at all, because the evaluator today cannot reach one from outside the cache,
which is why the existing snapshot loop reflects. That is a hundred and fifty
lines, not sixty, and the ledger moves the wrong way to say so.
Three claims inherited from the earlier pass were wrong and were cut rather
than designed around. A fifth selector engine turned up in Rust. And the
cache's index maps keep one key per value, so an evaluator that reached for
them would answer a query about a whole family with a single row.
Watching a selection is mapped as its own cut, where nine overloads that are
a query language in disguise collapse into one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The map carried a one-line invocation that installed no JavaScript runtime,
so it could not run the mutation runner, and said nothing about disabling
randomisation or relaxing seccomp. That is how the Linux path came to be red
for everyone except the agent that had found the flags by hand. The recipe now
lives with the image and the bridge README, and the map points at them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The image the bridge is built and mutated in existed nowhere in the repository,
and the recipe that stood in for it installed a C++ toolchain and no JavaScript
runtime — so the container could not run the harness that checks the bridge it
had just built. It is a file now, pinned to the same debian:13 digest as the
release workflow, carrying ninja, Node, setarch and MsQuic's four runtime
dependencies.
The harness configures with `-G Ninja` on linux-x64, which is what
scripts/build-quic-native.sh uses. CMake's Linux default is Makefiles, the image
carries ninja rather than make, and the default configured nothing at all: the
first run in the committed container died in cmake. It said so, because the
control now prints what the child said, which is the whole point of that change.
The seccomp requirement is stated where someone hitting it will read it — the
bridge's README, beside the recipe — rather than being knowledge the runner
assumes its caller already has.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three selector engines evaluate the same two allowlists today, two of them
carrying identical copies of the same reflection, and projection is a delegate
no production code ever constructs. The map replaces all of it with one
selection a caller can state: any-of over the aliases the cache already
declares, a projection it asks for, an order, a cursor, and one hop along a
declared reference.
The audit is reported as measured rather than as hoped. About 326 of 21,720
lines collapse in CultMesh, and the repository grows by about 370, because the
Rust runtime gains a capability it never had.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Section 4 forbade any host call that begins after `cultmesh_quic_runtime_close`
starts, and promised in the same sentence that such a call is refused with -1;
section 6 repeated the promise for `cultmesh_quic_next_event`. The refusal lives
behind the runtime's own gate, so once the close has returned there is nothing
left to read it: the case the sentence describes is exactly the case where the
promise cannot hold.
The prohibition stands alone. The refusal is stated for what it is — a call that
races the start of a close, while the close is still running, is refused and
touches nothing — and no longer as a guarantee covering the call the section
forbids.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four entries the native target did not have. Two for the rule that a blocking
crossing is a counted host call: the revert deletes the call scope from
`cultmesh_quic_next_event`, and the loosening keeps the scope and its refusal but
drops it at the door, before the call blocks. Two for the rule that a positive
timeout blocks until an event or the close: the revert inverts the comparison,
and the loosening waits a token millisecond. All four were green before this,
because nothing checked that the pollers were inside the library at all.
The bounded-wait loosening goes from 1 ms to 50 ms. A 1 ms bound only ever lost
a race — the margin was a few hundred lock handoffs — and the same loosening at
50 ms survived on both targets. `holdclose` holds its pollers for 500 ms, so any
bound shorter than the hold now expires with calls still inside. A bound longer
than the hold is not distinguished, which is this entry's honest limit.
A run that skips entries covered those rules with nothing, and exiting 0 let a
win32-x64 run — where every ThreadSanitizer rule is skipped — be read as a green
gate. It exits 2 and says so, with a killed/survived/skipped line.
A red control arrived as a bare colour with the child's output thrown away,
which is how a configuration that could not start at all stayed invisible. The
control now prints what the child said. On linux-x64 the sanitizer
configurations are re-executed through `setarch -R`, because ThreadSanitizer
dies before `main` on the kernel's address-space randomisation; both that and
the seccomp denial behind `personality(ADDR_NO_RANDOMIZE)` stop the run with the
cause and the required invocation instead of being reported as kills.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The closerace scenario pinned the order of the count and the notify but never
that a host call is counted at all. Deleting the call scope from
`cultmesh_quic_next_event` outright leaves every configuration green — the
pollers finish before the teardown reaches them — and so does inverting the
comparison that makes a positive timeout block, which empties the library before
the close even begins. A scenario that only sleeps and hopes cannot tell any of
that apart from the real thing.
So the bridge says it itself, in development builds only (section 7 of the
header, behind CULTMESH_QUIC_DEBUG_ASSERTS, absent from every shipped binary):
the peak number of host calls counted inside the library, the number counted
when the close began its wait, and a hold that parks a call inside the library
until the scenario releases it. A held call returns the 0 an empty queue would
have and touches the runtime no further, so a bridge that frees the runtime
under it is reported by the scenario rather than crashing inside it.
`holdclose` drives the quiesce through that seam: the calls are counted, they
were still counted when the close began, and the close did not return while they
were held. `closerace` keeps hunting the race and now also refuses to pass when
a poller left before the close began, which is a scenario that raced nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lifetime fix holds under a thread sanitizer and the assertion kills the
deleted wait on both targets. The harness defending all of it does not hold.
Every native kill this cut had reported came from scratchpads that no longer
exist, the committed Linux path dies before its scenario starts and hides the
reason, and the scenario stays green when calls are not counted at all or
when nothing ever parks.
Two rules are defended by nothing committed. They are recorded as undefended
rather than given a number that would look better.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The mutation suite covered only the TypeScript rules. The bridge's two runtime
lifetime rules now have entries of their own, each with a revert that puts back
the wrong spelling and a loosening that keeps the shape and gives away the
guarantee: a notify moved outside the gate, a notify outside it that fires only
on the last call out, the quiesce wait deleted, and the quiesce wait made
bounded. Their killer is the scenario runner, built through CMake rather than
npm, so the runner now takes a check per target instead of assuming a workspace,
and a target without a dist/ keeps the verified restore without the sentinel.
Every native entry names the platforms it is honest on, and a run elsewhere
reports it as skipped rather than killed. There is no ThreadSanitizer for MSVC,
so the notify rule cannot be seen on win32-x64 at all. Soul's Cut 3 pass found
this the hard way, reporting two mutations killed from a win32-x64 release run
where both in fact survive.
Naming a target on the command line runs only that one, which is what lets the
native entries run inside the Debian 13 container where the TypeScript workspaces
have no installed dependencies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bridge's close path had no committed test. Whether a close is safe against
host threads already inside the library is the one thing about it a host cannot
establish by reading, and every check of it so far lived in a session scratchpad
and is gone.
The scenario runner under tests/ drives that path and nothing else: it parks
pollers in cultmesh_quic_next_event, closes the runtime under them, and asserts
that the close is what woke them. It consumes the bridge through the public
header, as a host does. CULTMESH_QUIC_BUILD_TESTS builds it, off by default,
because a delivery build has no business producing an executable.
CULTMESH_QUIC_DEBUG_ASSERTS, also off by default, makes the bridge state the
quiesce invariant itself rather than leaving it to whatever a sanitizer happens
to catch. Deleting the quiesce wait is otherwise falsifiable only by luck:
AddressSanitizer saw it 4 times in 48 attempts with pollers blocked mid-copy of
large frames, and never on release. With the assertion and pollers blocked it
fails on the first close.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README claimed a rebuild reproduces the committed Unity plugin DLL. It does
not. That plugin is 40,448 bytes, predates both the static CRT and the move off
Schannel, and still imports VCRUNTIME140.dll and MSVCP140.dll; the current source
builds to 302,080 bytes with no CRT import.
What /Brepro buys is that a build reproduces itself, and the README now says
that, along with the fact that the committed plugin stays the older build until
the Unity package build replaces it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 3 and cultmesh_quic_last_status both told a host that the portable answer
is the return code. What the mapping makes portable is the sign. The magnitude is
not: the same MsQuic failure carries different numbers on different platforms, a
TLS error being -12032 on win32-x64 and -1126 on linux-x64, and the 16-bit mask
gives QUIC_STATUS_USER_CANCELED and QUIC_STATUS_FILE_NOT_FOUND the same -1002 on
Windows.
The header now says the sign is what a host may branch on, that the magnitude is
diagnostic and platform-specific, and that switching on a particular code
identifies nothing reliably.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every error sentence widened its QUIC_STATUS straight to uint64_t. On Windows
that status is a signed HRESULT, so the sentence carried 0xFFFFFFFF80072B18 where
the status is 0x80072B18. The status is 32 bits on both platforms and now goes
through uint32_t on its way in.
Diagnostics only: the v1 managed connector matches on the wording of these
sentences, not on the hex.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A call scope used to drop the lock after decrementing the in-flight count and
notify afterwards. That left a window: the closer's predicate is already true
once the count reaches zero, so a scope preempted between the unlock and the
notify could come back to find the runtime torn down and signal a condition
variable that no longer exists. ThreadSanitizer reports it as a broadcast racing
the destroy.
The decrement and the notify now happen under one lock guard. The closer cannot
return from its wait until the scope releases the gate, so the condition
variable is alive for as long as anything can signal it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cut does not close. The lifetime model was right in shape and wrong by
one line: the last call notifies after it has released the lock, so the
runtime can be destroyed in between and the wake lands on freed memory. A
sanitizer found it on the unmutated bridge.
The mutation that survived by construction is ruled killable rather than
accepted. Under a sanitizer with pollers blocked mid-copy it fails four times
in forty-eight, so a dev-only assertion makes it fail every time instead. Two
further mutations are honest only on the sanitizer target and the table must
say so, because a mutation whose kill depends on the target is not killed
until it runs there.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The bridge keeps an object alive across the call that names it, closes
each handle exactly once, and quiesces before it frees. The contract is a
header hosts and tests are checked against. Return codes are negative on
every platform, one completion per frame, and every connection's end is
observable. One mutation survived by construction and is handed to Soul
to rule on.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Q9 ruled A. The Schannel MsQuic refuses QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12
with QUIC_STATUS_NOT_SUPPORTED, and PKCS12 is the only credential type a
provider has on both platforms, so a Windows provider could not open a
listener against the runtime this repo was shipping. The same bridge
binary passes all twenty smoke checks and the v1 gate against the OpenSSL
build, which is what Soul verified and what this now downloads.
Two digests, not one. The zip is pinned by FEE9A664 and 19,179,817 bytes;
the msquic.dll inside it by C17C6581 and 4,181,856 bytes. The earlier
report and the cut map's first record put the zip's digest on the DLL,
which is a pin that checks the archive and then ships a file nobody looked
at. Sizes ride along with both, because a digest that fails tells you less
than a digest and a length that fail together.
The CRT is linked statically. A Node or Unity host without the Visual C++
redistributable cannot load a DLL that imports vcruntime140.dll, and
Windows reports that as "the specified module could not be found" while
naming the module that was found. Confirmed on the rebuild: msquic.dll,
CRYPT32, ntdll, KERNEL32, and no CRT import at all. The DLL goes from
83,456 to 302,080 bytes, which is the price of the host not needing
anything installed.
The Unity plugin's committed msquic.dll is still the Schannel one and this
does not touch it; the next build-unity-package.ps1 run is what replaces
it, and that is not in this batch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Event 7 was not one per frame. The bridge writes the stream's kind byte
itself, that send completed as an anonymous event 7, and a host counting
completions saw six for five frames with nothing to tell them apart. The
send request now says which send it was; the kind byte's completion is the
bridge's own business and is dropped. A send MsQuic released without
putting on the wire reports it: event 7 carries a send result in `code`,
completed or canceled, so an aborted send is accounted for rather than
silently missing. Measured: 32 sends queued under an abort, 32 event 7, 28
canceled and 4 completed.
The initiator of a shutdown learned nothing. MsQuic gives it only
shutdown-complete, and the bridge swallowed that, so a host could end its
own connection and never see the id end. Event 4 now has one emitter —
shutdown-complete — and the two initiated-by callbacks record why rather
than announcing it. Each connection id gets exactly one event 4, whoever
ended it, with the code its side passed, and it is emitted after the id
has stopped resolving, so a host told a connection is gone cannot still
resolve it on the next line.
Return codes inverted by platform. QUIC_STATUS is a negative HRESULT on
Windows and a positive errno on POSIX, so a Linux host checking for a
negative code saw every failure as success, against a README that promised
negative. A MsQuic refusal is now -(1000 + (status & 0xffff)) on both, and
cultmesh_quic_last_status hands over the raw bits for diagnostics. On
win32-x64 a garbage PKCS12 returns -12032 with last_status 0x80072B18.
The v1 exports move with it; the managed connector tests the value against
zero and formats it, so the contract it was shipped with holds.
Smaller ones, from the same pass: AppendTrace is v1-only and bounded,
because a long-lived v2 connection grew that string once per MsQuic event
for nothing to read; a listener configuration failure takes the connection
out of the map instead of leaving it naming a handle MsQuic is about to
close; and next_event refuses a null payload with a non-zero capacity
rather than treating the lie as a discard.
Soul's C consumer now compiles against the header and asserts its own
struct and its eleven bound prototypes against it, and the koffi smoke
checks its declarations and the thirteen exports the same way: 20 of 20,
seq clean, cancel clean, the v1 gate 3 passed 1 skipped as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FindStream, FindConnection and FindListener took the lock, dropped it, and
handed back a raw pointer. MsQuic's worker freed the object on
shutdown-complete and the host thread dereferenced it on its next send.
One release run in three crashed on each target under an ordinary
peer-driven stream teardown; ASan caught it three times out of three, with
the free inside DestroyStream on a worker and the read inside
stream_send_frame on the host thread. A null check cannot fix that: the
pointer is not null, it is dead.
The maps hold shared_ptr now and a lookup returns a reference the caller
owns for the length of its call. The worker's destroy takes the object out
of its map and drops the map's reference only. The MsQuic handle follows
the object: the destructor closes it exactly once, under the exchange
guard, so the handle is alive for as long as any holder is. A stream holds
its connection, which orders every stream handle's close before its
connection's without a rule anywhere saying so.
runtime_close quiesces. It marks the runtime closing, wakes every blocked
poller, and waits for every in-flight call to return before it closes a
handle or frees a byte; a call that arrives after that is refused with -1.
Waking a poller without waiting for it was how close returned a poller
into freed memory while looking like it supported the pattern.
Two things fall out of the model rather than being fixed on top of it: a
listener that fails partway through opening leaves no map entry and no
handle, because every failure path just drops the local reference, and a
stream is in the map before StreamStart, because the shutdown-complete
that StreamStart can reach on a worker has to find it.
Cut with it: Listener::stopped, V1Client::connection (an unread raw
pointer into the same hazard), and FindListener.
Event 7 is still one too many and the shutdown initiator still learns
nothing; that is the next commit. The rest of Soul's C consumer passes on
win32-x64, including race at 400 streams, which crashed before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Soul wrote a C consumer of this ABI from the spec text, because there was
nothing to include. The struct layout, the exports and the conventions
lived in the .cpp and in prose, which is three places for one contract and
no way to notice when they part.
include/cultmesh_quic_native.h is now the contract: the 64-byte event with
every field offset asserted, the exports with their signatures, the id and
return-code conventions, the object lifetime rules, and the threading
rules. The .cpp includes it and keeps none of its own copy. The README
points at it rather than restating it.
The lifetime and return-code sections describe where the bridge is going,
not where it is; the two commits after this one make them true.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bridge's lifetimes are wrong: a lookup hands the host a pointer the
worker can free before the next call. Release crashes on both platforms
under an ordinary stream teardown. Three ABI gaps ride with it, and the
return-code contract inverts between Windows and POSIX. One digest in
this map was the zip's, not the DLL's. All in a fix batch before Cut 4.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
One credential type on both platforms, the provider role works, the ABI
is unchanged. The tree pays four megabytes more and the Unity plugin's
copy diverges until its rebuild. The map's claim that PKCS12 was one type
for both providers is withdrawn.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The bridge is a runtime-neutral two-role event queue, built and smoked
on both platforms with the Linux artifact named as development-loop only.
Two hangs the old shape never exercised are pinned. One fork stops the
Windows provider: the pinned Schannel flavour does not implement the
PKCS12 credential the spec assumed was one type everywhere, while the
same bridge passes beside the OpenSSL flavour. Recommended: pin OpenSSL
on Windows and pay the tree size.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There is no Linux MsQuic binary in any NuGet package, and the v2.5.9 GitHub
release carries only test bundles, so the Windows script's approach does not
transfer. What does exist is Microsoft's Debian 13 pool, which is the same
distribution Yggdrasil runs, and the msquic source tree at the matching tag for
the POSIX headers the NuGet omits.
`scripts/build-quic-native.sh` fetches the pinned `libmsquic_2.5.9_amd64.deb`
and the three headers `msquic.h`, `msquic_posix.h` and `quic_sal_stub.h`, and
verifies all four by SHA-256 the same way `build-quic-native.ps1:14` verifies
the NuGet. A digest that stops matching is a changed dependency, not a warning.
Two details the deb forced. It carries three library files and no licence text,
so the MIT licence is copied from the one this repo already committed for the
Windows plugin — the same text for both platforms. And it ships only
`libmsquic.so.2.5.9`, with no `libmsquic.so` for `-lmsquic` to resolve against,
so the script makes that link in the extracted tree rather than in the image.
The Linux CMake branch links `-fvisibility=hidden`, so only the twelve v2
exports leave the `.so` and the Windows-only v1 five are absent by construction
rather than by discipline, and sets `RPATH $ORIGIN` so the `libmsquic.so.2`
shipped beside the bridge resolves without being installed system-wide.
`build-quic-native.ps1`'s default output directory becomes
`artifacts/quic-native/win32-x64` so the two platforms' outputs sit beside each
other instead of on top of each other. Both existing callers —
`build-unity-package.ps1:80-82` and the Native.Tests csproj `:20-22` — pass an
explicit `-OutputDirectory`, so neither is affected.
Built and smoked in the workstation's Docker `debian:13` engine pinned by
digest: g++ 14.2.0, glibc 2.41, `libgamecult_mesh_quic_native.so` 85,456 bytes
with `NEEDED libmsquic.so.2` and `RUNPATH $ORIGIN`, and the same 15-check v2 FFI
smoke that runs on Windows passes under Node 24 on that image. That is a Linux
build on a Linux host and nothing more: Q6 ruled the release artifact comes from
the Actions job, and this one is the development loop.
A Linux host still needs MsQuic's own runtime dependencies present — libssl3t64,
libnuma1, libxdp1, libnl-route-3-200 — which is now written down, because the
first smoke run failed on a missing libxdp.so.1 and the reason was not obvious
from the error.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The bridge was one platform, one role and one host: Windows by construction
(`windows.h`, `__declspec`, `CryptHashCertificate2`), client-only, and shaped
around the managed connector that polls it every 2 ms. Nothing about MsQuic
required that; the shape came from having exactly one caller.
It now carries a C ABI v2 of twelve exports over a single per-runtime event
queue: open a runtime, open listeners and connections on it, open streams and
send frames, and take typed events out of one blocking wait. The event struct is
64 bytes described by layout rather than by a shared header, so a host declares
it in its own FFI and no header travels with the ABI. `next_event` keeps the v1
two-phase convention — return 2, leave the event at the head, report the size
the caller needs — because that is the one part of v1 that was already
host-agnostic.
The rule that shapes everything else is that nothing calls out. MsQuic's worker
threads only push onto the queue and wake that wait, so no host runtime is
entered from a thread it does not own, and the bridge needs to know nothing
about what is on the other side. That is what makes one binary serve both a
Node process through FFI and a Unity player through P/Invoke.
Trust moved out. A v2 client connection defers validation, emits the DER as
event 3 and answers MsQuic only when the host calls
`connection_certificate_complete`; if the host never answers, MsQuic's handshake
timeout closes the connection and event 4 says so. The C code no longer has an
opinion about certificates — except in the v1 shim, where it must.
The five v1 exports are unchanged in signature and behaviour and are now
`#ifdef _WIN32`, matching `CanConnect`'s own Windows gate. A v1 client is a
private v2 runtime holding one connection, and the pin comparison is the single
thing it still decides in C, because that is the contract the shipped Unity
package was built against. `windows.h`, `wincrypt.h` and `CryptHashCertificate2`
appear nowhere outside that block.
Two things the rewrite had to get right that the old shape never exercised.
The v1 pin must be attached before `ConnectionStart`, not after it returns: the
handshake runs on an MsQuic worker thread and reaches the certificate event
immediately on loopback, so a pin applied afterwards loses the race and the
connection silently takes the host path nobody is listening on. And teardown
must close the connection handles itself — `RegistrationClose` blocks until the
app has closed every child handle, and a silent shutdown delivers no shutdown
events at all, so leaning on the callbacks to do it deadlocks the caller
forever. Both were found by the tests hanging, and both now have a mutation that
reproduces them.
Verified: the four v1 tests in tests/GameCult.Mesh.Quic.Native.Tests pass
unchanged (3 passed, 1 skipped for want of an external endpoint), including the
wrong-pin refusal and the departed-client broadcast test. A 15-check FFI smoke
drives the whole v2 path — listener, deferred certificate, both sides connected,
latest-only stream, one frame with FIN, identical bytes out — and passes on
Windows x64 and on Linux x64. Ten source mutations, ten killed, two green
no-op controls.
One open fork, reported separately and not decided here: the pinned Windows
MsQuic is the Schannel build, which refuses
QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12 with QUIC_STATUS_NOT_SUPPORTED, so
`listener_open` cannot open a provider against it. The map assumed PKCS12 was
"one credential type for Schannel and OpenSSL"; the header declares it but the
Schannel provider does not implement it. The identical bridge binary passes the
full provider smoke against OpenSSL MsQuic 2.5.9, so this is a question about
which flavour Windows pins, not about the bridge.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both comments around `STRICTER_THAN_CSHARP` called the divergences host
repairs. Most are not. Of the eleven, three carry a backslash and two have no
authority for `System.Uri` to accept; `refusedByCSharpUriShape` refuses those
five on the raw string, before there is a host to repair. Four are genuine host
repairs `URL` would make and C# would not — two trailing dots, two fullwidth
hosts — and the last two throw in `URL` before any rule of ours runs.
The same slip sits in `isProtectedEndpoint`'s own doc comment, over a different
eleven: it says `URL` repairs eleven spellings `System.Uri` refuses, when only
the four carrying a tab or CR/LF are rewritten and the other seven are admitted
as written.
Wording only; no rule changes. It matters because the next reader deciding
whether the pre-check earns its keep will reach for the reason the comment
gives, and "host repairs" points at the wrong half of the list.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The codec's byte parity rests on two vector files written from opposite sides:
`realtime-frame-vectors.json` by the C# reference and decoded by TypeScript,
and `realtime-frame-vectors.ts-written.json` by the TypeScript codec and
decoded by C#. Neither half ran in CI. The TypeScript suite ran nowhere —
cultmesh-ts was not in any job — and the C# test ran nowhere either, because
`cultnet-interop.yml` is windows-latest and main-only and does not filter for
it. A codec that agreed only with itself would have gone green on every push,
which is the exact failure the two-file scheme exists to prevent.
Both now run in the `reactive-and-resource-lifetime` job beside the authority
steps, on windows-latest and ubuntu-latest, for main, codex/** and pull
requests. That job was already the one place the TypeScript side of CultMesh
gets exercised on Linux at all.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two mutants the cut-2 pass recorded as equivalent were not. Both notes were
reasoning, and both were wrong in the same way: they argued from the shape of
the code instead of trying the input that breaks it.
The decoder's length refusal reads `payloadLength < 0 || expected !== length`.
The note said the first clause could never fire on its own, because a negative
payload length makes the sum smaller than the frame. It does not: the sum also
carries the three identity lengths, and a negative payload cancels against
them. A 37-byte frame declaring `channelLength=65535, payloadLength=-65535`
sums to exactly 37, matches its own byte length, and without the clause decodes
to three blank identities and an empty payload where `CultMeshRealtimeWireProtocol`
refuses it. Two smaller spellings do the same, `ch=1, pl=-1` over 37 bytes and
`ch=2, pl=-1` over 38. No test covered that shape; one does now, and the
mutation is listed and killed rather than excused.
The payload copy was worse, because the fix and the bug looked identical. The
decoder used `bytes.slice(...)`, which copies a `Uint8Array` — and the existing
test passes one, so it was green — but Node's `Buffer` overrides `slice` with
an alias of `subarray`. A `Buffer` is what a socket read hands a Node caller,
which is exactly how the QUIC plane in cut 4 will reach this function, so every
decoded payload would have been a live view of a frame buffer the transport is
free to reuse.
`Uint8Array.prototype.slice.call(bytes, ...)` was the obvious repair and does
copy, but `%TypedArray%.prototype.slice` takes its result constructor from
`@@species`, and `Buffer`'s is `FastBuffer`: the payload then comes back a
`Buffer` from a `Buffer` frame and a `Uint8Array` from a `Uint8Array` one, so
the same frame compares equal or unequal under `deepStrictEqual` depending on
how it arrived. `new Uint8Array(bytes.subarray(...))` copies and is one type on
both. Three mutations now guard the line — return a view, return a `Buffer`
view, return a species-typed copy — and the first two die only on their own
input type.
Mutation runner: 75 mutations, 75 killed, 0 survivors, three green controls.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`npm run build:ts` and `test:ts` have been dead since 8cb3b72, not 8dd5a45 as
first recorded. That commit renamed the package to @gamecult/cultcache-ts and
left `run-typescript-workspaces.mjs` asking npm for a workspace called
`cultcache-ts`, which no longer exists; npm refused at the first entry in the
list, so neither script reached any workspace at all. npm resolves a workspace
by manifest name, not directory, so the list moves whenever a package is scoped
— said out loud in the source now, since nothing else will say it.
The mutation runner is renamed `scripts/mutate-cultmesh.mjs`. It stopped being
about the route verifier alone when cut 2 gave it a third target, the realtime
frame codec, and a file named for one of its three targets invites the reader to
assume the other two are somebody else's problem.
`docs/typescript-quic-realtime-cut.md:46-47` names the old path; the map is
Self's and is updated separately.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Byte parity held both ways under Soul's own frames. Three small things
did not: a mutant declared equivalent was not, the payload copy is a view
for a Node buffer, and the frame parity has no CI executor. The breakage
of the aggregate scripts dates from the scoped rename, not the version
bump this map first named.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>