RFC 0033: Drop-in by default — no false stops on code WeaveC was never tuned on
- Status: Implemented (its build-cost gate D5 not met and carried forward, Implementation amendments 13)
- Authors: WeaveC authors
- Created: 2026-10-03
- Tracking issue: TBD
- Supersedes / superseded by: Amends RFC 0030: its rule that a
pointer converted from an integer is raw (§Soundness, Bugs deliberately
not caught; §2.1; §6.1; the
unsafe-operationrow of §3.4 and of Diagnostics), its rule that a lowered violation traps unconditionally (§3.4), its library table (§8: a portability rule and corrected rows), its link step (§13.2: what runs by default) and its command line (§16). Amends RFC 0032: the spatial clause of (G) for subscripts (§3, the limit “provenance is the pointer’s value”), the planning table of §6 (format arguments), the Darwin allocator of §2.5 (one runtime per process, the system’s allocations in the arena) and the failure path of §7. Amends RFC 0031 §7 (unit record format 30 becomes 31). Amends RFC 0005 and RFC 0030 §13.2 on what the link step analyses.
This RFC was drafted before implementation. On 2026-10-03 the project owner accepted the recommendation of the milestone analysis (make WeaveC drop-in by default before making it faster or extending its guarantee; measure on projects nobody tuned against; delete what that makes obsolete) and authorized drafting this RFC and implementing it end to end in one large change, with breaking changes and no compatibility layers. Accepted status records that authorization. The status becomes Implemented only when every gate in Acceptance gates passes on the final tree, or an Implementation amendment records what was measured instead and carries the gate forward.
Summary
Section titled “Summary”WeaveC passes every gate it has on the twenty projects it was built
against, and fails on the first four projects it was not. Built with
CC=weavec-cc and default flags, none of zstd, libuv, jq (with its
bundled oniguruma) and redis compiles: the build stops at errors that are
artefacts of the model, and when they are lowered, the lowered violations
trap in correct programs. Building costs 2.6 to 28 times Clang’s CPU, and
redis’s link step runs for 25 minutes and then fails with an error no flag
can lower.
This RFC makes one promise the default build keeps: a correct C program
built by weavec-cc with default flags compiles, links and runs as it
does with Clang, apart from run time. A false stop (an error, a failed
link or a trap on a correct program) is a WeaveC bug of the same severity
as a false proof. To keep the promise:
- A definite violation must be witnessed on every path by facts the
program states, never by a model artefact: a pointer converted from an
integer is no longer raw, only a pointer declared
WEAVEC_RAWis, so(void *)(uintptr_t)nstops being anunsafe-operationerror and its uses are guarded like any pointer of unknown provenance. - A lowered violation is guarded, not trapped unconditionally, when the build has a guard or check for it.
- The library table states what every supported C library accepts, not what the strictest reading of a standard requires.
- A guard asks about the bytes the access touches, so pointer
arithmetic that leaves an object and comes back (zstd’s
base + index) is not a trap. - The link step by default only reads records. Re-analysing the
program at link becomes
-fweavec-link=analyze, with a budget, and never fails a link except for a definite violation. - The analysis has a per-unit budget, so a large unit costs a bounded multiple of compiling it.
- Three silent misses close: fortified library calls, pointer
arguments of the
printffamily, and, on Darwin, memory the C library allocates. One runtime serves the whole process on Darwin. - The driver passes as Clang to configure scripts, links with
-fltoon macOS, and a failed check terminates even with signals blocked.
The measure is a new fresh corpus of eight projects WeaveC was never tuned on, plus a sealed set of five more that is built with WeaveC only once, at the end, to measure whether the fixes generalise.
Motivation
Section titled “Motivation”Measured on main at 6047863 (v0.13.0), release build, darwin-arm64,
CC=weavec-cc with each project’s own build and test commands
(-Wno-error=weavec where noted):
| zstd 1.5.7 | libuv 1.53.0 | jq 1.8.2 + oniguruma | redis 8.10.2 | |
|---|---|---|---|---|
| Default build | fails: 1 error | fails: 17 errors | fails: 65 errors | fails: 2,145 errors, link did not converge |
| Tests, errors lowered | zstd -13 -D dict traps |
319 of 473 pass | all oniguruma tests trap | server traps at start |
| Build CPU vs Clang | 11.5× (link step 85%) | 2.6× | 28× (link 64%) | link ran 25 min, 3.1 GB, then failed |
| Run time vs Clang (default) | 4–5× | 3× | 2× | 4–6× |
Run time, -fno-weavec-runtime |
1.0× | 1.05× | 1.1× | 1.2× |
Every error but one was false. The causes, each reproduced in a few lines:
- Raw pointers.
l->watchers[n + 1] = (void *)(uintptr_t)nfds;makes the elements ofwatchersraw, andstruct w *w = l->watchers[fd]; w->fdis thendereference of raw pointer 'w' outside an unsafe region. redis’s 2,145 errors, oniguruma’s 64 and 9 of libuv’s are this rule: hash tables keyed by integers cast to pointers, event loops that keep a count in a spare slot, thread arguments passed as(void *)42. - Summary arithmetic.
void f(int *p) { memset(p, 0, 16); } int a[4]; f(a);is'f' requires 17 bytes behind 'a', which has 16 bytes, an off-by-one forshortandintelements that stops zstd. - Library rows.
setgroups(0, NULL), the standard way to drop supplementary groups, is a definitenull-dereference;gettimeofday(NULL, &tz)traps redis at start;selectwithfd_sets sized fornfdstraps libuv. - Engine imprecision reported as definite. A pointer tested through a
function’s result (
if (listAddNodeTail(copy, v) == NULL)) is taken as null; a local initialised through a cast of its address ((processor_info_array_t *)&info) is taken as uninitialised;(void) tmp;is a use of an uninitialised value. - Lowered violations trap. RFC 0030 §3.4 traps a lowered temporal or
release violation unconditionally, so a false error lowered with
-Wno-errorbecomes a crash of a correct program. - The subscript rule of guards. zstd keeps
base = src - startIndexand readsmatch = base + matchIndex; match[matchLength]. The guard looks upmatch, which lies outside the buffer (in a dead slot or another object), while the bytes read lie inside it. - The link step. It re-parses every unit from its recorded command and re-runs the engine, up to 16 rounds per cycle of units, keeping one AST in memory; an indirect call whose type matches another unit’s function makes a cycle. zstd’s link takes 183 of 219 CPU seconds; redis’s does not converge, which is an error written past the diagnostic engine, so no flag lowers it. And it changes nothing the program runs: guards are fixed when each unit is compiled.
- Large units.
sqlite3.ctakes more than 10 minutes against Clang’s 5.6 seconds; the per-function budget bounds each run, but a unit runs its functions many times (summary rounds, contexts, invariant rounds). - The driver.
weavec-cc --versionprintsweavec-cc version …on its first line, and redis’s--version | head -1then takes the GCC path;-fltofails on macOS because the driver drops-lto_library; a check that fails while the program has blockedSIGTRAPspins at full CPU instead of terminating (a libuv test ran for nine minutes).
Three silent misses were found on the way, in very common code:
- with Darwin’s default
_FORTIFY_SOURCE,memcpy(d, s, h->len)into a heap block of unknown extent becomes__builtin___memcpy_chkand gets no guard; the same call with fortification off traps; printf("%s\n", s->user)afterfree(s)on some path gets no guard: a variadic row names no argument list, so RFC 0032’s planner leaves its temporal facet unresolved;- on Darwin, a use after free of memory from
strdup,getlineorasprintfruns silently: the C library allocates from the system’s zone, which the object table does not track. Each shared library built byweavec-ccalso carries an arena of its own, so a guard in one image does not see another’s heap.
None of this showed on the corpus, because each of its findings was fixed as it appeared. The corpus measures what WeaveC was tuned on; it cannot measure what it will do next. This RFC adds the measurement and fixes the classes, not the instances.
Soundness
Section titled “Soundness”The guarantee
Section titled “The guarantee”RFC 0030’s guarantee and RFC 0032’s clause (G) stand, with these changes.
(G) for subscripts. The spatial clause of (G) reads, for every guarded
access: if the bytes s accesses start inside a tracked object O, then O
is live and the bytes lie inside O, or the program traps at s; if they
start inside no tracked object but end inside one, or start in a dead
tracked object, the program traps at s. The address looked up is the
address of the access, p + i * step + offset, for a subscript as for a
dereference. RFC 0032’s subscript rule (the object containing p)
is withdrawn. Its first limit of (G) becomes: pointer arithmetic that
carries a pointer from one object into another live tracked object passes
a guard, at a dereference and at a subscript alike; the gap byte after
every heap object still catches a walk that leaves a heap object forwards,
one element at a time.
Raw pointers. A raw pointer is one whose declaration says
WEAVEC_RAW, or one loaded through or derived from such a pointer. A
pointer converted from an integer is not raw: its facets are decided like
any pointer the analysis knows nothing about, with the reason raw-cast,
and so are guarded (G) or checked (N) in an enforcing build. A forged
non-null pointer that lands in no tracked object passes its guards, under
assumption A6.
Lowered violations. (V) reads: a definite violation never reaches the
object unguarded. It fails the build; lowered with -Wno-error, the site
is checked or guarded, and when the build can do neither, it traps
unconditionally. A guard traps under (G) when the violation happens; a
false violation lowered runs as the program would.
Darwin. The C library’s own allocations are tracked heap objects (§6.2), and one object table serves every image of the process.
No proven, checked or violation outcome changes except as §1–§4 say, and none becomes proven.
Where this is more sound
Section titled “Where this is more sound”- Fortified library calls are guarded like their plain spellings.
- Pointer arguments of the
printfandscanffamilies are guarded: a%sargument withobject_s, every other pointer argument withlive. - On Darwin, memory from
strdup,getline,asprintf,realpath,qsort’s scratch and every other C library allocation is tracked, and a release of it is validated; a block freed by another image is the same block. - Pointers converted from integers are guarded where before they were
either an error that users lowered (and then, at run time, nothing) or
unresolved(raw-cast).
Where this is less sound
Section titled “Where this is less sound”- Definite errors that were not definite are gone. Most were false; a true one becomes a guarded facet that traps when the bug happens (when the runtime tracks the object) or an unresolved one. The rule in §1 says which facts may make a violation.
unsafe-operationno longer flags integer-to-pointer conversions. A program that relied on the error to find its pointer forgeries getsunresolved(raw-cast)rows in the ledger, guards in the build, and the error under-fweavec-require=checked(an unresolved or guarded facet is anunresolved-operationerror there).- Subscripts that jump into another live object pass (above). The
analysis still reports them where the extent is exact (
checkedor a violation); the loss is at guarded subscripts only. - Cross-unit findings are not reported by a default link. The default
link step reads records and verifies what records can verify (declared
contracts, exported requirements at cross-unit calls, slots), but it does
not re-run the engine, so a use after free whose free is in another unit
is caught by its guard at run time, not reported at link.
-fweavec-link=analyzeandweavec --whole-programreport it as before. - Leak warnings are off in a
weavec-ccbuild (§8); theweavectool and-Wweavec-leakkeep them. A leak was never part of the guarantee.
Bugs caught
Section titled “Bugs caught”Newly caught at run time (each has a case under
test/cases/semantics/dropin/):
memcpy(pk->payload, src, pk->len); // fortified, payload of unknown extent: objectstrcat(b->data, s); // fortified, b->data heap: objectmemcpy(m->data, p, strlen(p)); // flexible array member: objectif (ended) free(s); printf("%s\n", s->user); // live / object_schar *p = strdup(s); free(p); use(p[1]); // Darwin: live (was untracked)Bugs deliberately not caught
Section titled “Bugs deliberately not caught”Unchanged from RFC 0032, plus the subscript case above. Two classes the
milestone measurements found remain documented limits: a typed callback
that receives a released pointer (RFC 0032 amendment 17), and a pointer to
a local that outlives its scope (the object is untracked once it is dead,
so only the analysis’s lifetime-too-short warning reports it).
Accepted false positives and false traps
Section titled “Accepted false positives and false traps”The goal is none on correct code, measured by gates D1 and D2. What remains possible, and how it is handled:
- A correct program can still trap when it breaks C’s rules in a way
that happens to work (RFC 0032’s list): reading past a block’s
requested size, using a released block. Such code needs
WEAVEC_UNSAFE. Each trap on a fresh project is triaged; one that is not a bug in the program is a WeaveC bug. - Possible warnings can be false; they never stop a build (
-Werrorin project flags does not promote them) and §8 limits what aweavec-ccbuild prints. - A definite error can still be false where the engine’s value abstraction is wrong (an infeasible path it cannot refute). Gate D1 allows none on the fresh corpus.
Assumptions
Section titled “Assumptions”A1–A6 unchanged. A6 now also covers pointers converted from integers.
Detailed design
Section titled “Detailed design”1. What may make a violation
Section titled “1. What may make a violation”RFC 0030 §3 lists when each id is definite. This RFC adds one rule over all of them, the witness rule: a facet is a violation only when the fact that fails it is established, on every path, by one of
- an operation of the function itself whose meaning C defines: a release or reallocation through a heap-family row, an assignment of a null constant, the end of a local’s scope, a subscript or pointer offset by an index the facts bound;
- a callee’s summary effect that is unconditional and not
lossy(RFC 0030 §9.1), or a requirement that a callee’s summary states from a must-access rule (RFC 0030 §7.5) with a trivial guard; - a library row’s requirement, under the portability rule of §3;
- a declared contract (
WEAVEC_*annotations,[static N],nonnull), which may be broken only by the code that declared it.
and the object’s extent, when one is compared, is exact (RFC 0030 §7.1).
What is excluded is a fact made by a default (an unknown callee, an open
slot, an assumption about the entry state), by a join of summaries, or by
a value the engine conflated with another: in particular a test of a
call’s result never refines an argument of the call, unless the callee’s
summary says the result is that argument (RFC 0030 §8, arg(N)), and then
only on the outcome classes where it is.
The rule changes no data structure: it is how the engine’s existing
certainty bits are set. The implementation audits every place the engine
publishes Violation (EngineDecide.cpp, EngineLibrary.cpp,
EngineSummary.cpp, EngineLifetimes.cpp, LedgerAdapter.cpp) against
it, fixes the ones the fresh corpus shows wrong, and pins each with a case.
Known fixes:
- A store’s own width. A callee’s summary that stores through its
parameter at byte offsets (
memset(p, 0, 16)overint *pstores 16 one-byte cells#0…#15) is applied in the caller with the width each store has, not the width of the parameter’s element type at that offset (Transfer::storePastObjectoverfieldStepType, which takes#13as anintand computes an end of 17). The requirement is 16 bytes. - Results that test arguments. As above.
- Out-parameters of platform calls. A platform function handed the
address of a pointer-holding object may write a pointer there
(
host_processor_info(..., (processor_info_array_t *)&info, ...)):applyPlatformforgets the object’s pointer cells too, so a later read is notuse-of-uninitialized. - Discarded reads.
(void)xreads nothing: it is no use.
2. Raw pointers and unsafe-operation
Section titled “2. Raw pointers and unsafe-operation”RFC 0004’s raw kind is kept for what users declare and removed for what the analysis infers:
-
Kept. A parameter, field, global or result declared
WEAVEC_RAWis raw; a pointer loaded through, or derived by arithmetic from, a raw pointer is raw. Dereferencing a raw pointer outsideWEAVEC_UNSAFEis anunsafe-operationerror, as before. Inside a region it istrusted(unsafe). -
Removed. An integer-to-pointer conversion (
(T *)n,(T *)(uintptr_t) n, theIntToPtrsite) makes an ordinary pointer of unknown provenance. Its own site keeps its row,unresolved(raw-cast), which has no guard; the accesses through the pointer are decided like accesses through a pointer an unknown callee returned: nullchecked, spatial and temporalunresolved(raw-cast), henceguardedwith the runtime. A store of such a pointer into a cell makes nothing raw: the cell’s other values, and every later load of it, keep their own provenance. -
Removed. A call that passes an integer-derived pointer to a function that does not dereference it is no error (
same(p, p),low_bits(p)), and neither is passing one to a function that does: the callee’s accesses are guarded where its parameter is unknown. -
Must, not may. Rawness is a property of a value (
SymInfo::rawin the object domain), and joins and weak merges of cells today OR it (Heap::mergeWeak, the state join), so one raw value stored into one element of an array taints every element whose index the engine cannot tell apart. A merged value is raw only when every value merged is raw; otherwise it israwSome, whose accesses areunresolved(raw-cast)and guarded, never an error. This is the witness rule of §1 applied to rawness.
Everything in the engine, the site collector and the ledger adapter that
exists only to make rawness from integer conversions is deleted, not
disabled: the Cast raw origin and the raw flag of a non-zero pointer
constant, the syntactic raw origin of an integer-to-pointer cast in
SiteCollector (hasRawOrigin), the unsafe-operation forms for
arguments, releases and laundering that only such values reached. The site
kind IntToPtr and the reason raw-cast stay; RFC 0030’s union-punning,
byte-copy and va_arg rules (also raw-cast) are unchanged.
3. The library table
Section titled “3. The library table”Portability rule. A row states what every supported C library
(Darwin’s libSystem and glibc, on the targets RFC 0032 §2.6 supports)
does with an argument, not what the C or POSIX standard permits it to do.
Where they differ, the row takes the more permissive behaviour: null is
allowed if any of them accepts it (gettimeofday’s first argument), a
count of zero allows null if any of them reads nothing then (setgroups).
A row that requires more than a library does is a false-stop bug.
The audit. Every row with a non-null or extent requirement is checked against the Darwin and glibc manual pages and, where those are silent, their sources; the fresh corpus’s findings are fixed first:
| Row | Change |
|---|---|
setgroups(int, r:count(a0)) |
null-if-zero(a0) |
gettimeofday(w, w:null-ok) |
first argument null-ok |
select(int, rw:null-ok, …) |
each fd_set needs bytes(howmany(a0, NFDBITS) * sizeof(fd_mask)), not sizeof(fd_set) |
and the result of the audit is a table in the implementation amendments.
unittests/Core/LibrarySpecTest.cpp keeps one expectation per row.
4. Guards ask about the accessed bytes
Section titled “4. Guards ask about the accessed bytes”The object guard of a subscript or a *(p + i) (RFC 0032 §3, §6) looks
up p + i * step + offset, not p, and checks the width bytes from
there against the object found:
/* Passes when the access lies inside the live object containing its first * byte, or when that byte is in no tracked object and neither is its last. */int __weavec_rt_object(const void *p, long long index, unsigned long long step, unsigned long long offset, unsigned long long width);- The inline arena path decodes the slot of the accessed address; the
range-cache path keeps its entry for the accessed address (
[lo, lo + len)must contain the access, notp), so a loop that walks one block through an out-of-object base hits the cache. - The runtime’s
beforeflag (a pointer at the start of a stack or global object taken as one past the previous one) is deleted: no lookup is of a base pointer any more. - An access whose first byte is in no tracked object and whose last byte is in one traps (it straddles into an object from outside). An access whose first byte is in a dead object traps (temporal).
Index arithmetic that overflows still traps. Dereferences, library arguments and releases are unchanged: they already look up the address they use.
5. Fortified calls and format arguments
Section titled “5. Fortified calls and format arguments”Fortified calls. A call to a chk alias (__builtin___memcpy_chk,
__memcpy_chk, __builtin___strcat_chk, …) is planned exactly as the
row it aliases, with the alias’s argument mapping (RFC 0030 §8.2 chk):
requirement terms over the row’s arguments are expressed over the
corresponding call arguments, and the dropped object-size argument is
never named. Any difference between the plain and the fortified spelling
in a site’s ledger row is a bug; the cases pin both spellings of every
family the fresh corpus uses.
Format arguments. For a row with printf(f, v) or scanf(f, v)
whose format is a string literal, the planner guards each variadic
argument the format reads through: a %s (or %ls) argument with
object_s (spatial, which covers temporal, as RFC 0032 §6), a %n or
scanf destination with object_n of its conversion’s width, and any
other pointer conversion that dereferences with live. %p reads no
memory and gets nothing. A non-literal format leaves the variadic
arguments’ facets as they are (unresolved(inexpressible)), with a live
guard on each pointer-typed variadic argument. objectWitness gives
format arguments a witness like any other string argument.
6. The runtime
Section titled “6. The runtime”6.1 Failure terminates
Section titled “6.1 Failure terminates”A failed check or guard in trap or verify mode first restores the default
disposition of SIGTRAP and SIGILL and unblocks them in the calling
thread, then traps as before. The prelude emits this inline in the cold
path (a call to sigprocmask and two calls to signal, with the target’s
constants), so a unit still links without the runtime when it was
compiled with -fno-weavec-runtime. __weavec_rt_fatal does the same.
6.2 Darwin: one runtime per process, the system’s heap in the arena
Section titled “6.2 Darwin: one runtime per process, the system’s heap in the arena”- One runtime. Every image linked by
weavec-cccarries the runtime’s archives, as today. The first image to initialise its runtime becomes the process’s owner; it publishes a table of its entry points and state under a process-wide name (__weavec_rt_owner, found withdlsym(RTLD_DEFAULT, …)from images initialised later, and registered before any allocation is served). Every other image’s runtime copies the owner’s arena descriptor (__weavec_rt_heapis constant once the arena is reserved) and forwards every out-of-line entry point to the owner: allocation, release, lookup, stack and global registration, the epoch, reports and statistics. Inline guard paths need no change: they read their own image’s copy of the descriptor, which now describes the one arena. - The default zone. The owner registers its zone and promotes it to
the process’s default zone (unregistering and re-registering the
system’s zones until the arena’s zone is first, as jemalloc’s
zone_promotedoes), somallocfrom any image, the C library’s own calls included, is served by the arena. Blocks the system’s zones allocated before the promotion stay theirs: releasing or resizing one goes to the zone that owns it, as RFC 0032 §2.5’s next allocator does. - Opting out. A program that defines
mallocitself keeps its allocator (RFC 0032 §2.6) and no zone is promoted.
On ELF nothing changes: an executable’s definitions already serve every shared library, and the runtime’s state has default visibility.
6.3 Report mode
Section titled “6.3 Report mode”With WEAVEC_RT_REPORT_LOG set, reports go only to that file, so a test
that captures standard error sees the program’s own output.
7. The link step
Section titled “7. The link step”-fweavec-link= selects what weavec-cc does at a link, replacing
-f[no-]weavec-link:
| Value | What runs |
|---|---|
records (default) |
Steps 1–3, 5 and 6 of RFC 0030 §13.2 over the records alone: inputs and unanalyzed-input, the program’s slots, declarations against definitions (annotation-mismatch), exported requirements at cross-unit calls, the allocator, and, with -fweavec-ledger or -fweavec-summary, the program ledger composed from the records’ rows. No unit is parsed again and the engine does not run. |
analyze |
records, then step 4: the units analysed again with the program in view, as RFC 0030 §13.2 and RFC 0031 §7 describe, under a budget (below). Its temporal facets refine the program ledger and its definite violations are reported. |
none |
Nothing; the link proceeds as Clang’s. |
The budget of analyze. The re-analysis stops when its CPU time
exceeds -fweavec-link-budget=<seconds> (default: 4 times the CPU time
the records say the units’ analysis took at compile time, at least 30
seconds). A unit whose re-analysis did not finish keeps its compile-time
rows, the link prints one note naming the units, and the program ledger’s
config.link says "partial". A cycle of units that does not converge
within its rounds takes its widened summaries for the reporting run, as a
function over budget does, and is noted the same way. No re-analysis
outcome fails a link except a definite violation, and that is an error
through the diagnostic engine like any other, so -Wno-error=weavec-<id>
lowers it. The cannot re-analyse and did not converge errors are
deleted.
Records. Composing the program ledger without re-analysis needs every unit’s rows in full. The unit record gains them (format 31): the site rows it already carries become the unit ledger’s rows, with each facet’s outcome, reason, check, requirements and diagnostic index. The record grows; the header’s payload length is unchanged in meaning.
weavec --whole-program keeps today’s behaviour (it is the analysis
tool), with the same budget flag and the same non-failing fallback.
8. Diagnostics a build prints
Section titled “8. Diagnostics a build prints”leakis off by default inweavec-cc(enable with-Wweavec-leakor-Wweavec); theweavectool keeps it on.- RFC 0032 §9’s dropping of possible findings on guarded facets is unchanged; with §5 it now also drops those on format arguments.
unanalyzed-inputnames each input once per link, as today.
9. The per-unit budget
Section titled “9. The per-unit budget”-fweavec-budget=<n> keeps its meaning (block transfers per function
run). A second budget bounds a unit: -fweavec-unit-budget=<n>, block
transfers summed over every run in the unit (summary rounds, contexts,
invariant rounds, the authoritative runs), default 200 × n_blocks over
the unit’s CFG blocks with a floor of 2,000,000. When a unit exhausts it,
the functions not yet decided take the over-budget defaults
(unresolved(budget), guarded where a guard applies), their summaries are
the conservative ones, and the summary line says how many functions were
cut. The engine’s own per-pop costs found while measuring (an environment
lookup per block visit, the quadratic worklist scan, rebuilding the
unit’s kinds for every run) are fixed as ordinary bugs.
10. The driver
Section titled “10. The driver”weavec-cc --versionprints Clang’s version block first (… clang version …,Target:,Thread model:,InstalledDir:), thenweavec-cc version X (rev). Configure scripts that test the first line forclangtake the Clang path.weavec-ccloads the Clang configuration file the referenceclangwould load (<prefix>/etc/clang/<triple>.cfg, as Homebrew’s LLVM ships), so its default sysroot matches.- A link with
-fltoon Darwin passes-lto_librarynaming the libLTO of the LLVM WeaveC was built with, found from the recorded LLVM prefix, instead of dropping the flag.
11. Tests and the corpus
Section titled “11. Tests and the corpus”Fresh configs. test/corpus/manifest.json gains a set per config:
original (the eleven configs tuned since RFC 0030), heldOut (RFC
0031’s eleven), fresh (this RFC’s eight) and sealed (five). The fresh
configs are zstd, libuv, oniguruma, redis, expat, pcre2, libevent and
libsodium, each pinned by SHA with its own build and test commands. The
corpus gate’s --full runs original, heldOut and fresh; --sealed
runs the sealed set and is run once, for gate D2. The forbidden-word check
of scripts/check-hygiene.py gains every fresh and sealed project name.
Sealed configs. libxml2 2.14.5, libpng 1.6.50, mbedtls 3.6.4,
msgpack-c 6.1.0 and yyjson 0.11.1, pinned by SHA. Before D2 they may be
built with the reference compiler only (--reference-only), to write
their commands. After D2 they are fresh configs, and the next RFC chooses
a new sealed set.
Cases. test/cases/semantics/dropin/ holds one case per false stop
and per silent miss of Motivation, reduced from the project that showed
it, with a _ok twin where the defect is a false positive and a _bug
twin where it is a miss. The case runner builds every multi-unit case
with -fweavec-link=analyze, so the cross-unit pins keep testing the
analysis; the default link is tested by the corpus.
12. Deletions
Section titled “12. Deletions”- The inference of rawness from integer conversions (§2) and everything only it used.
- The unconditional trap of a lowered violation that has a guard (§3.4’s
Form::Violationstays for builds without the runtime). -fweavec-link/-fno-weavec-link(replaced by-fweavec-link=), the link step’scannot re-analyseanddid not convergeerrors, and the re-analysis of every unit when a ledger is composed.- The runtime’s
beforelookup. - Unit record format 30 readers (records are rebuilt by every compile).
13. Performance
Section titled “13. Performance”No run-time cost target changes in this RFC (the next one is about guard elimination); §4’s lookup of the accessed address and §5’s new guards must stay within RFC 0032’s amended G14 bounds. Build cost is gate D5.
Annotation surface
Section titled “Annotation surface”None changes. WEAVEC_RAW keeps its meaning; its documentation says it is
now the only source of raw pointers.
Diagnostics
Section titled “Diagnostics”No id is added or removed.
unsafe-operation: the forms for pointers converted from integers are removed; the message for declared raw pointers is unchanged.out-of-bounds,null-dereference,use-of-uninitializedand the temporal ids: fewer definite instances (§1).leak: off by default inweavec-cc(§8).- The link step’s two errors become a note,
weavec-cc: note: the whole-program analysis of '<unit>'[, …] stopped at its budget; their compile-time results stand(ordid not converge; …).
Implementation plan
Section titled “Implementation plan”The work lands on rfc0033-drop-in as stages, squashed into one change:
- S0 Tests first. Fresh and sealed configs in the manifest and the
gate; the
dropin/cases; the fresh corpus measured on the starting tree. - S1 Witnessed violations and raw pointers (§1, §2), with the lowered violations of (V).
- S2 The library table (§3).
- S3 Guards (§4, §5).
- S4 The runtime (§6).
- S5 The link step and records (§7), and the per-unit budget (§9).
- S6 The driver and diagnostics (§8, §10).
- S7 Fresh-corpus iterations: every remaining false stop reduced to a case and fixed by class.
- S8 Gates, the sealed run, documentation.
Acceptance gates
Section titled “Acceptance gates”Binaries are the release preset’s on the final tree; the reference
compiler is $WEAVEC_LLVM_PREFIX/bin/clang; darwin-arm64 unless named.
Drop-in
- D1. Fresh corpus. Every fresh config builds with its own build
commands and
CC=weavec-cc(default flags), and passes its own test suite with no trap in trap mode and no failure in report mode, except failures triaged true with source evidence. No definite error is triaged false. - D2. Sealed corpus. Run once on the final tree; the result is recorded as measured, whatever it is. A sealed config that fails is reduced to cases and fixed, and the amendment records that the set was no longer sealed for it.
- D3. No regression. The original and held-out configs meet RFC
0032’s gates as amended (R1–R5, R7) and the ratchet in
expected.json; the 39 injections are detected;run-cases.py --asanand--checks verifypass over every suite.
Coverage
- D4. Misses closed. The fortified, format-argument and (on Darwin)
C-library-allocation cases of
dropin/trap at the bug in the default mode.
Cost
- D5. Build. Each fresh config’s
weavec-ccbuild within 4 times the reference compiler’s CPU time;sqlite3.cand mujs’sone.ceach compiled within 120 CPU seconds; no link step longer than its budget. - D6. Run. RFC 0032’s amended G14 bounds hold.
Hygiene
- H1.
scripts/check-hygiene.pypasses with the line budget this RFC records; the library total does not grow by more than this RFC deletes plus 3,000 lines. - H2.
npm test && npm run buildindocs/; the README, the guarantees and CLI references,docs/architecture.md,docs/annotations.mdand the roadmap describe the change; this RFC is marked Implemented.
Implementation amendments
Section titled “Implementation amendments”The stages recorded the decisions below as they were implemented. Each amends the section it names; where the text above and an amendment disagree, the amendment holds.
-
The witness rule, as implemented (§1). Besides the fixes §1 lists, the fresh corpus showed five more classes of definite errors that rested on no fact the program states; each is fixed by class, with a case under
test/cases/semantics/dropin/:- A pointer passed to a function that uses only its value
(
free(p); forget(p);whereforgetcompares, hashes or printsp): no use of the released object. A call’s argument has no temporal use when every function it may reach has a complete summary and a body in which the parameter appears only as an operand of==or!=, converted to an integer, insidesizeof, as a variadic argument of a call whose literal format reads no string, or as an argument of a function of the unit that uses it only so (three levels down) (Transfer::usesValueOnly; redis’sxmalloc.c). - A local whose address is passed to a call (
f(&op_info)) may be written where no summary records it: reading it is never a definiteuse-of-uninitialized(pcre2’scompile_eclass_nested). - Storing a local’s address where the caller can reach it
(
req->data = &thread;) accesses no memory:lifetime-too-shortthere is a warning (may-dangle), and lowering it inserts nothing. Returning a local’s address stays a definite error. - A copy onto itself (
memcpy(h, f, n)withh == f, libsodium’sfe25519_copy(h, h)) is no overlap, statically or in thedisjointcheck. - Locals copied into each other in a loop sent the owning-slot
collector into endless recursion (a compiler crash, libevent’s
regress_buffer.c); each is followed once.
The sealed run (gate D2) showed four more, so those projects were no longer sealed for them (amendment 13):
- A release while a copy is held (
conflicting-borrow) accesses nothing through the copy; a later use of the copy is theuse-after-free. It is a warning, never an error (msgpack-c’smsgpack_vrefbuffer_initfrees its array on a failure path after storing copies in the caller’s struct). - A store of a value of no known type (read through a
void *, yyjson’sunsafe_yyjson_get_str) was left out of the callee’s summary, so the caller kept what its cell held before: a falsenull-dereferencein yyjson’s tests, and unsound in general. It is a store of an unknown value. - A member of a record a call returns (
f().x): Clang wraps the call in aMaterializeTemporaryExpr, which the engine did not see through, so the member was read from a copy of the call’s first word and the rest was never written (libxml2’shtmlParseHTMLName(ctxt, 0).name, a falseuse-of-uninitialized). - An inline-assembly operand taken in memory only
(
"+m" (*(uint64_t (*)[16]) d), mbedtls’sbn_mul.h) tells the compiler which memory the assembly may touch; it is passed by address, not read as a whole. Its lvalue is no access site (SiteCollector), so no guard checks 128 bytes behindd(a false trap in the RSA and X.509 suites). Register operands are loaded as before.
- A pointer passed to a function that uses only its value
(
-
Lowered violations (§2, (V)). Only a violation the analysis decided from its model of other code is guarded when lowered: a temporal one, or one of a call’s requirements (a callee summary’s or a library row’s). A spatial or null violation decided at the access from an exact extent keeps its static check, or the unconditional
violationtrap, which fires exactly when the access is reached and needs no tracked object. -
The portability rule (§3). A library accepts null when its manual, the standard it implements or a nullability annotation in its headers says what the call does with null. An undocumented test in one library for a call that crashes in another (glibc’s
EINVALfor a nullprintfformat,asctime(NULL),closedir(NULL)) is not acceptance; those rows keep their requirement. With the audit, the rows of 111 functions changed, of the 652 that carry a requirement: null after a count of zero (qsort,bsearch,readv,fgets,strftime, …), null documented as accepted (freeaddrinfo,puts,getenv,setenv,realpath(NULL, …), theexecfamily’sargv/envp,setitimer,pthread_cond_timedwait, …), and extents a library never reaches (memchrstops at the match:min(n, strlen + 1);getloadavgstores at most 3). Size arguments of written buffers keep their extent: both libraries’ fortified forms abort when the size exceeds the buffer. The term grammar gainst / N(rounded down) forselect’s sets,bytes((a0 + 7) / 8). -
Guards (§4). The accessed-address rule alone would let a subscript overflow from one global into the next pass, and a stack underflow into memory nothing tracks pass. The rule as implemented:
- the bytes accessed inside a live heap object pass, wherever the
base pointer points (zstd’s
base + index); - inside a live stack or global object they fail when reached forwards from inside another live stack or global object;
- outside every tracked object they pass unless their last byte is in
one, or the base pointer is inside a live tracked object (the access
left it for memory nothing tracks:
s[strlen(s) - 1]on an empty stack string traps).
The range cache keeps an arena block for the accessed bytes whatever the base; any other entry is used only while the base is inside it too, so a cached guard never passes what the uncached one fails. A pointer formed from untracked memory that lands inside a tracked object, and is subscripted back into untracked memory, traps: an accepted false trap none of the corpora shows. In verify mode a subscript whose spatial facet has a real
objectguard gets noliveverify check of its proven temporal facet: that check would ask about the base pointer, which may lie outside the object (--checks verifytrapped on thebase + indexcase), and the real guard already fails on a dead object. - the bytes accessed inside a live heap object pass, wherever the
base pointer points (zstd’s
-
Calls (§5). A fortified call’s
__builtin_object_sizeargument, and a read-only library call in an argument (strlen(p)), write nothing (rule 7 of RFC 0030 §10.3), which was what kept fortified calls unguarded. A call’s length argument that no term can repeat (memcpy(m->data, p, strlen(p))) is guarded where the call evaluates it:__weavec_chk_object_l(dst, n)wraps the argument, checksnbytes behind the pointer argument and returnsn(plan formLength). A requirement whose only witness is a static length check that could not be planned (the have is a flexible array member’s lower bound) is guarded with that witness’s need.%.Nsreads at mostNbytes and has no string requirement. A%sargument’s liveness is covered by itsobject_sguard; the other variadic arguments of a literal format are not dereferenced and have no guard (the RFC’sliveon every other pointer argument was not needed). A check term may name a global array alone (RFC 0030 §10.3 rule 1 allowed only parameters and locals): it stands for the array’s address, which no store changes, so a%sargument that is a global array (bzip2’sinName) gets the samestrnlencheck as a local one instead ofunresolved(inexpressible). A library function the C library’s header defines inline to call its checking builtin (glibc’s_FORTIFY_SOURCEwrappers, which Clang calls an inline builtin declaration) is still governed by its row: the program defines nothing (Darwin spells the same thing as macros). -
The runtime (§6).
- Failure terminates. The cold path calls
__weavec_rt_trapping()before the trap when the runtime is linked (the prelude cannot declaresigprocmaskportably). It unblocksSIGTRAPandSIGILLand resets an ignored disposition; a handler the program installed (a crash reporter, a test harness) still runs. - One runtime per process (Darwin). The owner is the copy
dlsym(RTLD_DEFAULT, "__weavec_rt_dispatch")finds, the same for every image; the others forward through its table and copy its arena’s descriptor. The flag that marks the resolving thread is compared withpthread_self(), not kept in a thread-local variable: Darwin allocates those on first use, throughmalloc, which recursed. The owner reserves its arena and promotes its zone in its constructor, sostrdupin the program’s first statement is tracked. Promotion moves every zone before the arena’s to the end, one at a time (the system’s default zone is not alone:objc-class_rw_tis registered before it). - The release preset compiled the runtime archives as ThinLTO bitcode
(the per-configuration IPO property outranked the target’s
OFF), so the driver could not read the allocator’s symbols and a program that definesmallocfailed to link. They are native objects now. - Lookups in signal handlers. The global objects’ table was sorted
lazily under a spin lock, so a guard in a signal handler that
interrupted another guard’s lookup waited forever (libuv’s
fs_partial_write). Adding a unit’s globals now merges them into the sorted table; lookups take no lock, and retry a read that overlapped an add (a sequence count). A grown table is not unmapped. - No
getenvafter start-up.freereadWEAVEC_RT_QUARANTINEon first use, and the C library frees with its environment lock held (unsetenv), so the secondgetenvaborted (libuv’senv_vars). The runtime reads its variables in constructors;WEAVEC_RT_ABORTandWEAVEC_RT_REPORT_LOGtoo, since a check can fail there.
- Failure terminates. The cold path calls
-
The link step (§7).
-fweavec-link-budgetdefaults to 120 seconds of wall-clock time, not four times the compile-time analysis, which the records do not carry. A program ledger composed from records needs the rows’ callees too (format 31 carries them), so that an exported requirement every caller meets is discharged without re-analysis.config.linkis not added to the ledger; the note says which units kept their compile-time results.weavec --whole-programkeeps failing on a unit it cannot parse at all (as on a compile error), and reports a group that does not converge as a note. G12’s link half (an injection must be reported by the tool and by the link step) links with-fweavec-link=analyze -fweavec-link-budget=0: a default link reads records only, so it finds no bug that spans units, and Lua’s program takes longer than the default budget. -
The per-unit budget (§9). A hard cap let the first few large functions spend it all (sqlite3.c: 1,622 functions cut). Each run gets instead a fair share of what is left, eight times over, and at least 500 transfers (
UnitRun::runShare); the unit total is then a soft bound. The default ismax(200000, 6 × sites). Onsqlite3.c(43,640 sites) it proves 11,909 sites in 114 CPU seconds; a budget of 1,000,000 proves 12,808 in 565 seconds, and no budget 12,870 in 724: the default gives up 7.5% of the proofs, which become checks and guards, for a build six times faster (D5 asks for 120 seconds). The quadratic worklist scan and the per-run kind tables were not changed; the environment lookup per block visit was hoisted. Two changes cut work without changing a result: a recursive component’s members run callees first (a postorder of the edges between them), so a round sees what its callees made in it (expat’s 14-member component: 33,900 block transfers to 25,200); and counted-field invariant inference (RFC 0030 §7.6) stops after a round that witnesses no candidate it keeps, and a round stops once every candidate is refuted (a witness is a value the writer stored, which fewer assumptions never adds; expat’sxmlparse.cspent 19,500 transfers there and kept none).xmlparse.cwent from 71,000 to 53,000 transfers with an identical ledger. A member of a recursive component whose summary round spends the per-function budget is not run again, in later rounds or in the authoritative pass: an over-budget run publishes nothing, so the pass would only spend the budget a third time (pcre2’sinternal_dfa_match: 90 to 36 seconds). A run cut short by its share of the unit’s budget is run again, since its share may grow.sqlite3.c’s outcomes are unchanged; two guarded rows changed reason, as the unit’s budget left more for later shares. The per-function budget stays at 50,000 (RFC 0030 §5.5’s rule): over the original, held-out and fresh corpus the largest function that finishes takes 22,244 transfers (pcre2’sparse_regex). -
The driver (§10).
weavec-ccalready picks the SDK itself when no sysroot is given, so Clang’s configuration file is not loaded.-fltonames the libLTO of the LLVM WeaveC was built with (beside theclangit records). -
Leak warnings (§8).
-Wweavec-leakturns them on in a build; the case runner passes it, as it passes-Wweavec-possible, so the cases keep pinning the analysis. -
Known limits found on the way, left as they are:
- A call-site requirement that holds only under a condition on an
argument with side effects (
scrub(p, atoi(argv[1])), a callee that writesnbytes whenn > 0) is neither checked nor guarded; the callee’s accesses rely on it. trusted(caller-contract)facets (RFC 0030 A1) are not guarded, as RFC 0032 amendment 17 says; an exported or address-taken function’s accesses rest on its callers.
- A call-site requirement that holds only under a condition on an
argument with side effects (
-
Code generation (§13). The helpers are inlined (
always_inline), and in a function with thousands of checks and guards (pcre2’smatch, 21,552 checked or guarded facets from macro expansion) the inlined copies kept the optimiser (the SLP vectorizer, then the rest of the function pipeline) on that function for over two minutes. The prelude now also defines<helper>_ool, a copy of each check and guard helper that is not inlined, and a function with more than 4,096 plan entries (CheckEmitterOptions::inlinedHelperCalls) calls those (pcre2_match.c: 160 to 27 seconds). Lua’sluaV_execute, the largest function the benchmarks run (1,868), keeps the inlined helpers. -
Gate results on the final tree (release preset, darwin-arm64, 2026-10-04).
- D1. Every fresh config builds with its own build and
CC=weavec-cc, and passes its own test suite in trap and in report mode with no trap and no definite error. libevent’s suite runs one test at a time and leaves outtest-ratelim__group_lim: underctest -j 8itsfdleakandratelimtests fail with the reference compiler too, andgroup_limfailed two of three serial runs of the reference compiler’s build. - D2. The one sealed run: libpng built and passed. libxml2,
msgpack-c and yyjson stopped at false definite errors (the last four
classes of amendment 1); mbedtls stopped at two true, deliberate
errors in
metatest.c(triaged true and lowered), and then trapped in its RSA and X.509 suites at inline-assembly operands (a false trap, amendment 1) and in its SSL suites at a true out-of-bounds read in its test helpers (triaged true). Each false stop was reduced to a case and fixed, so the set was no longer sealed for libxml2, msgpack-c, yyjson and mbedtls. On the final tree all five build and pass, mbedtls’s trap run stopping only at the triaged-true read; D1’s check now accepts a suite that traps only at triaged-true sites, as its text says. - D3.
run-cases.py,--asanand--checks verify: 574 of 574 cases each. 39 of 39 injections reported (G12’s link half asks for-fweavec-link=analyze, amendment 7). Unresolved shares, original configs: spatial 0.053, null 0.001, temporal 0.143; held-out: 0.100, 0.001, 0.185. G9, G10, G11, G13, G15 and RFC 0031’s G5, G6 and G12 pass. The ratchet was rewritten: proven counts fell by 1 to 36 per project where format arguments are now checked or guarded (§5) and where corrected library rows dropped facets (§3), and by 11% on sqlite (the unit budget, amendment 8); sqlite now builds (no raw pointer from an integer, §2). - D4. Met: the fortified, format-argument and C-library-allocation
cases of
dropin/trap at the bug in the default mode. - D5. Not met, carried forward. Build CPU over the reference
compiler’s: libsodium 1.2, msgpack-c 1.7, libevent 1.9, libuv 2.3–2.8,
mbedtls 3.6, zstd 3.9–5.1, redis 3.6–5.6 (the reference build’s own
CPU time varied from 38 to 59 seconds between runs; WeaveC’s did
not), libpng 4.6–4.7, yyjson 5.9, oniguruma 6.1, expat 8.1–8.7,
libxml2 12–16 and pcre2 32–36 (104 before amendment 12).
sqlite3.ctakes 157 CPU seconds (limit 120), mujs’sone.c15. No link step reached its budget. What remains is the analysis’s own cost: every join renumbers the whole state (Pairing), so a transfer costs time in proportion to everything the function holds. Making joins keep what both sides share is carried forward to the next RFC. - D6. RFC 0032’s amended G14: Lua 6.25 (limit 6.5), zlib 1.89 (2.0), cJSON 1.89 (2.0); without the runtime 1.12 (1.15), 1.00 (1.1) and 1.15 (1.15). cJSON’s runtime-less build measures 1.151, over its bound by 0.001; main’s binaries measure 1.148–1.150 on the same machine, so it is the bound’s noise, not this RFC’s cost. With the runtime, cJSON went from 1.76 to 1.89 (the accessed-address guards and the format-argument guards).
- H1.
check-hygiene.pypasses with the budgets this RFC records (21,500 engine and 70,000 library lines; measured 21,078 and 69,980, 1,079 library lines more than before it). - H2.
npm testandnpm run buildpass indocs/; the README, the guarantees, CLI and compatibility references,docs/architecture.md,docs/annotations.mdand the roadmap describe the change.
- D1. Every fresh config builds with its own build and
Drawbacks
Section titled “Drawbacks”- A weaker word for the same code. An error the user saw becomes a
guard they do not see. The ledger keeps every such row, and
-fweavec-require=checkedrestores fail-closed behaviour. - The default link reports less. Users who relied on the link step for
cross-unit findings must add
-fweavec-link=analyzeor runweavec --whole-programin CI. The cost it removes is most of the build overhead on multi-unit projects. - A process-wide runtime on Darwin is more machinery in the runtime, and promoting a zone to the default is platform lore that jemalloc maintains across macOS versions.
- Tuning on the fresh corpus turns it into a tuned corpus. The sealed set exists so that this RFC ends with one honest measurement; the next RFC needs a new one.
Alternatives
Section titled “Alternatives”- Keep the errors and add baselines (the old adoption RFC’s waivers). A false error a user must waive is still a false stop on first use, and a waiver file per project is not drop-in.
- Turn every definite finding into a warning. Simple, but it throws
away the errors that are true (the corpus’s three triaged-true
unsafe-operationerrors in sqlite are declared-raw misuse; RFC 0030’s probes catch real use after free at compile time). The witness rule keeps those. - Keep the link-time re-analysis by default and make it faster. The
re-analysis cannot change the program’s code, so its cost buys only
reports, which
weavec --whole-programgives in CI without slowing every developer’s build. Incremental re-analysis is still worth having foranalyzeand is future work. - A dynamic runtime library on Darwin (as AddressSanitizer ships). One copy of the state by construction, but every binary then depends on a dylib at an install path, which a drop-in compiler should not impose. The owner-forwarding scheme keeps static archives.
- Keep the subscript rule and exempt arithmetic bases (look up
punlesspwas formed by arithmetic in the function). It keeps catching jumps from a loaded base, but zstd also stores its out-of-object base in a struct and subscripts it after loading, so the exemption would have to follow the value; the accessed-address rule is the one without false traps.
Prior art
Section titled “Prior art”- AddressSanitizer checks the accessed address, never the base, which is why it runs zstd; its Darwin runtime promotes a zone and intercepts the C library’s allocations.
- jemalloc’s
zone.c: zone registration and promotion on Darwin. - Clang’s
-fsanitizedriver: passing the matching-lto_libraryand runtime libraries for the toolchain in use. - Coverity, Infer and the Clang static analyzer all report “may” findings only behind a configurable threshold and never fail a build on their own by default; their experience is that a false error at first contact ends adoption.
Unresolved questions
Section titled “Unresolved questions”- How many definite errors survive the witness rule on real code, and whether they are true; D1 measures it on eight projects and D2 on five.
- The default re-analysis budget (4× the compile-time analysis) is a
guess for
analyze. - The per-unit budget’s default trades proofs for build time on the largest units; D5 and the ratchet’s proven counts decide it.
- Zone promotion on macOS versions other than the reference machine’s.
Future work
Section titled “Future work”- RFC 0034, guard elimination (dominated guards, the hull of a block’s guards per pointer, loop hoisting, guards as optimiser-visible intrinsics), against RFC 0032’s carried cost gate.
- RFC 0035, adoption (formerly planned as 0033): records in object
sections so that archives and shared libraries carry them, incremental
analyze,weavec.toml,weavec suggest --apply, relocatable installs. - Guarding typed callbacks’ arguments (RFC 0032 amendment 17) and dead stack objects (use after scope).