chore(contract): link-enforced c64-lib-contract asserts (#70) - #92
Conversation
Adds src/lib_contract_asserts.s, a zero-byte translation unit that turns four facts about libs/nistcurves that this project currently only *believes* into link-time errors: §1 LIB_ABI_VERSION = 0 — exported-surface generation gate. nistcurves bumped this 0 -> 1 at v0.9.0, the same release that removed 17 exported symbols (c64-nist-curves #90/#91), so this is the gate SPEC §7 calls load-bearing: pre-1.0 breakage rides MINOR bumps and MAJOR carries no signal. §3 (LIB_NISTCURVES_REU_BANKS_USED & $C0) = 0 — the library must not claim REU banks 6/7, which src/boot.s:840-841 uses for the P-384 overlay images. An upstream expansion into that pair would corrupt the overlays at first swap-in with no diagnostic at all. §8.0 LIB_NISTCURVES_SHARED_PRIMITIVES tripwire, pinning the measured $0007. The contract's own disjointness assert is not writable yet: c64-https is the APP_OWNED case and provides all three §8 primitives itself, while the library still claims all three, because we resolve the overlap by dropping archive members (tools/integration/build_nistcurves_p256.sh:130) rather than by the contract's SHARED_* deferral switches — surgery lib_manifest.o cannot see. Sequenced as plan item P2 on #70. §13.3 TCP_RECV_MASK is 2^n - 1 (assemble-time; it is a local equate). The PRG is byte-identical before and after — the TU emits nothing, the linker just gains four opinions. Deliberately NOT included: a §1 MAJOR/MINOR floor assert. The v0.6.0 pin exports those three without an address-size hint, so importing them emits two `ld65: Warning: Address size mismatch` per link — contract v0.7.4's §8.4 defect recurring in §1, fixed upstream at v0.9.0. Add the floor with the pin bump. Also not included: the §5 RESIDENT_BYTES fit check, which reports 27,000 B for the whole library while we link a §6 minimal variant archive into a 16,384 B CRYPTO_HOT. Measured, ca65/ld65 V2.18: make clean && make exit 0, 47,105 B, sha256 417c7085… (unchanged) make clean && make BACKEND=uci exit 0, 62,977 B, sha256 741f0e8c…, no warnings Negative controls (reverted): flipping the expected ABI value 0 -> 99 and the REU mask $C0 -> $04 each produce a named `ld65: Error:` with the source file and line, confirming the asserts bite rather than decorate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md said "Public net API is fronted by `src/net_abi.inc`. TLS/HTTP sources consume networking only through those symbols." That reads as a build-enforced boundary and is not one. Anyone sizing the §13 work needs to know the starting point is prose, not an interface — it changes the size of the job substantially. Measured 2026-08-14: - Nothing `.include`s the file. `grep -rn 'net_abi' src/ tools/ cfg/ Makefile` returns only comments, so none of its twelve `.import`s is checked by the assembler or the linker. - Declared and used surfaces overlap in 6 symbols out of 17. What TLS/HTTP/boot actually import is net_init, net_dhcp, net_poll, net_print_ip, net_dns_resolve, net_tcp_connect, net_tcp_close, net_tcp_send, net_send_len, net_recv_byte, net_banner_str (boot.s:107-114, http.s:61-67, tls_record_io.s:28-30, tls13.s:96). Five of those are absent from the header; six of the header's are imported by nobody. - The ip65 backend provides only six of the twelve. No net_dhcp_acquire (it exports net_dhcp — the exact name c64-lib-contract §13.1 tells c64-wireguard to rename away from), no net_tcp_set_recv_cb, net_local_ip, net_resolved_ip, net_last_error or net_tcp_state. net/ip65/net.s:28 calls them "deferred to Phase 7"; Phase 7 shipped. net_last_error exists only under UCI, so ip65 has no error channel at all. Corrects three places that repeat the claim: the CLAUDE.md section, the header block of net_abi.inc itself (the highest-leverage spot — it calls itself a "drop-in contract"), and src/net/ip65/README.md. The original intent is preserved as intent rather than deleted, and the symbol list now marks which six ip65 actually exports. Doc-only. Both PRGs byte-identical across the change: ip65 47,105 B sha256 417c7085…, UCI 62,977 B sha256 741f0e8c…. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scope note:
Both PRGs are byte-identical across that commit — ip65 47,105 B The three upstream contract defects described in this PR are now filed with their measurements and verified fixes: JC-000/c64-lib-contract#73 (§1 guard snippets do not assemble — both of them, including the ABI gate §7 calls load-bearing), JC-000/c64-lib-contract#74 (§1 Pattern block missing |
#92's §1 gate asserts `LIB_ABI_VERSION = 0`. nistcurves v0.9.0 bumped it to 1 (the same release that removed 31 exports), so merging the two PRs fires the gate by design: ld65: Error: src/lib_contract_asserts.s(135): libs/nistcurves: exported-surface generation changed (LIB_ABI_VERSION != 0) ... That is the assert working — a named error with file, line and the remedy, rather than a silent surface change. Flipped the expected value to 1 as the message instructs; the assert stays. Safety of the new generation is established by the link itself: an import of any of the 31 removed symbols would be an unresolved external. Both backends link, so we reference none of them. Verified after the flip: ip65 47,105 B sha d522e684…, uci 62,977 B, ECDSA KAT 6/6 including the three negative CAVP vectors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds
src/lib_contract_asserts.s, a zero-byte translation unit that converts fourfacts about
libs/nistcurvesthat this project currently only believes intolink-time errors. Nothing else changes: no cfg edit, no submodule bump, no
behavioural change, and the PRG is byte-identical before and after.
Part of the c64-lib-contract alignment tracked in #70. The rest of that intake —
including all of §13 — is not in this PR; it is written up as a measured,
sequenced plan in a comment on #70 instead.
What is now enforced
LIB_ABI_VERSION = 0— exported-surface generation gate(LIB_NISTCURVES_REU_BANKS_USED & $C0) = 0— the library must not claim REU banks 6/7, whichsrc/boot.s:840-841uses for the P-384 overlay imagesLIB_NISTCURVES_SHARED_PRIMITIVEStripwire, pinning the measured$0007TCP_RECV_MASKis2^n - 1(assemble-time — it is a local equate, not an import)The §1 gate is the load-bearing one. Contract §7 says so explicitly, and nistcurves
proves it:
LIB_ABI_VERSIONwent 0 → 1 at v0.9.0, the same release thatremoved 17 exported symbols (c64-nist-curves #90/#91) — on a MINOR bump, with
MAJOR still 0. A consumer gating on MAJOR would have seen nothing.
The §8.0 entry is a tripwire rather than the contract's own disjointness assert,
because that assert is not writable here yet and it is worth being precise about why:
c64-https is the
APP_OWNEDcase and provides all three §8 primitives itself(
sqtab_init/mul_8x8/ct_mul_8x8insrc/crypto/poly1305.s,mul_tables_initinsrc/crypto/shared/mul_tables.s,reu_mul_initinsrc/boot.s), yet the librarystill exports
$0007claiming all three. We resolve the overlap by deleting archivemembers (
tools/integration/build_nistcurves_p256.sh:130dropsmul_8x8.oanddata_shared.o) instead of using the contract'sSHARED_*deferral switches — andmember surgery is invisible to
lib_manifest.o. So the §5 manifest describes theupstream archive, not the one we link. Fixing that is plan item P2 on #70; until
then this pins the value we measured, so an upstream ownership change becomes a named
link error rather than a silent one.
What was deliberately left out
address-size hint, so importing them emits two
ld65: Warning: Address size mismatchon every link. That is contract v0.7.4's §8.4 defect recurring in §1;upstream fixed it at v0.9.0. The contract also rules out the obvious workaround
(
.import … : zeropagepins a constant to an address size that is an artifact ofits current value). **Add the floor together with the pin bump — it is free at
RESIDENT_BYTESfit check from SPEC §5's own worked example. It reports27,000 B for the whole library while we link a §6 minimal variant archive into a
16,384 B
CRYPTO_HOT. The spec's example fails against a conformant consumer doingthe conformant thing.
LIB_NISTCURVES_SHARED_CONSUMES(contract v0.5.0) — absent at this pin, so the§8.0 coverage assert has nothing to import.
One upstream defect worth flagging: SPEC §1's consumer-side snippet cannot
assemble. It gates with
.ifon an.imported symbol, which has no value untillink — ca65 V2.18 says
Error: Constant expression expected..assert …, lderroristhe working form, which the contract already uses in §13.0/§13.8 for exactly this
reason. §1 and §13 disagree, and §1 is the one that does not run. Detail in the #70
comment.
Evidence
ca65/ld65 V2.18. All four commands from the release evidence bar:
That hash is identical to the pre-change build, which is the point — the TU emits
no bytes, the linker just gains four opinions.
Beyond the bar: both onchip profiles
The evidence bar only covers the two default profiles, but the
$0007tripwire andthe REU mask are read from a different manifest object under
FP_ONCHIP_MUL(
lib_manifest_onchip.o, which reportsREU_BANKS_USED = $04). Both packaged onchipvariants were built to confirm the asserts hold there too — clean, no warnings:
Negative controls
An assert that never fires is decoration, so both link-time asserts were inverted and
restored:
Named message, source file, line number.
Note for whoever bumps
libs/nistcurvesA bump to v0.9.x will trip the §1 gate. That is the gate working, not a defect:
re-check the integration against the new export surface (17 symbols were removed at
v0.9.0), then change the expected value on
src/lib_contract_asserts.s:135from0to
1. Please do not delete the assert. Also re-verify the$0007tripwire beforechanging it — that value is what tells us whether the archive member-drops in
build_nistcurves_p256.share still the right ones.🤖 Generated with Claude Code