From af3acabdf1cf8c81d6dd2f5cf2cd32a8fec979fc Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:10:38 -0500 Subject: [PATCH 1/2] chore(contract): link-enforced c64-lib-contract asserts (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/lib_contract_asserts.s | 176 +++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 src/lib_contract_asserts.s diff --git a/src/lib_contract_asserts.s b/src/lib_contract_asserts.s new file mode 100644 index 0000000..7f65aee --- /dev/null +++ b/src/lib_contract_asserts.s @@ -0,0 +1,176 @@ +; src/lib_contract_asserts.s — c64-lib-contract conformance asserts. +; +; Assembled into every build, both backends, every profile. Emits no +; bytes: it exists purely so that a set of facts this project currently +; only *believes* about its vendored libraries become link-time errors +; when they stop being true. +; +; Contract: https://github.com/JC-000/c64-lib-contract — read SPEC.md on +; `main`, NOT the newest git tag (tags lag: newest tag v0.4.0, main is +; v0.8.0 as of 2026-08-14). Clauses referenced here: §1 (version +; identification), §3 (REU layout), §5 (aggregate manifest equates), +; §8.0 (shared-primitive ownership bitmask), §13.3 (rx ring shape). +; +; --------------------------------------------------------------------- +; WHY `.assert ..., lderror` AND NOT `.if ... .error` +; --------------------------------------------------------------------- +; SPEC §1's consumer-side snippet is written as +; +; .import LIB_X25519_VERSION_MAJOR +; .if LIB_X25519_VERSION_MAJOR < 1 .and LIB_X25519_VERSION_MINOR < 8 +; .error "this consumer needs c64-x25519 v0.8 or later" +; .endif +; +; That cannot assemble. `.if` needs a constant expression, and an +; `.import`ed symbol has no value until link. Measured on ca65 V2.18: +; +; t_if.s(3): Error: Constant expression expected +; +; The form that works is `.assert , lderror, ""`, which defers +; evaluation to ld65 — the same reasoning the contract itself applies in +; §13.0/§13.8 ("NET_BACKEND_FAMILIES is .import'ed, so its value is not +; known until link") — so §1 and §13 of the contract disagree, and §1 is +; the one that does not run. Written up in c64-https#70 for escalation +; upstream. Do not "fix" the asserts below back into `.if` form. +; +; --------------------------------------------------------------------- +; WHAT IS *NOT* ASSERTED HERE, AND WHY +; --------------------------------------------------------------------- +; §5 fit check (`LIB_NISTCURVES_RESIDENT_BYTES < __CRYPTO_HOT_SIZE__`): +; not usable. The manifest ships one RESIDENT_BYTES for the whole +; library (27,000 B at the v0.6.0 pin) while we link a minimal §6 +; variant archive (`lib-p256-verify`). 27,000 > CRYPTO_HOT's 16,384, +; so the contract's own worked example fails against every +; minimal-archive consumer. Flagged in c64-https#70. +; +; §8.0 disjointness/coverage (`(LIB_A & LIB_B) = 0`, `(CONSUMES & ~OWNED) = 0`): +; not writable today. c64-https is the APP_OWNED case — it provides +; all three §8 primitives itself (sqtab_init + mul_8x8 + ct_mul_8x8 in +; src/crypto/poly1305.s:21,24,213; mul_tables_init in +; src/crypto/shared/mul_tables.s:32; reu_mul_init in src/boot.s:31; +; mul_dma_lo/hi in src/data.s:123) — but the library's manifest still +; exports LIB_NISTCURVES_SHARED_PRIMITIVES = $0007, i.e. it claims to +; own all three. The disjointness assert would therefore FAIL, and +; correctly so: we resolve the double-ownership by *deleting archive +; members* (tools/integration/build_nistcurves_p256.sh drops +; mul_8x8.o and data_shared.o) rather than by the contract's +; `SHARED_*` deferral switches, so the shipped mask describes the +; upstream archive and not the one we link. The fix is to rebuild +; lib_manifest.o with those switches — the wrapper already does +; exactly this for zp_config.o — and is sequenced in c64-https#70. +; Until then the tripwire below pins the value we measured. +; +; §5 LIB_NISTCURVES_SHARED_CONSUMES (contract v0.5.0): absent at the +; v0.6.0 pin. Nothing to import. + +.include "constants.inc" + +; ===================================================================== +; §8.0 bit constants — copied verbatim from SPEC §8.0. +; +; These are plain assemble-time equates and MUST NOT be .export'ed +; (normative as of contract v0.7.3): they are unprefixed and identically +; valued in every adopter, so exporting them recreates the #43 +; duplicate-external collision on a symbol family the v0.7.0 prefixed +; forms do not cover. `.ifndef`-guarded so a -D define can override. +; ===================================================================== +.ifndef LIB_SHARED_PRIMITIVES_SQTAB + LIB_SHARED_PRIMITIVES_SQTAB = $0001 +.endif +.ifndef LIB_SHARED_PRIMITIVES_REU_MUL + LIB_SHARED_PRIMITIVES_REU_MUL = $0002 +.endif +.ifndef LIB_SHARED_PRIMITIVES_CT_MUL_8X8 + LIB_SHARED_PRIMITIVES_CT_MUL_8X8 = $0004 +.endif + +; Primitives provided by c64-https's own modules (SPEC §8.0 "APP_OWNED"). +APP_OWNED = LIB_SHARED_PRIMITIVES_SQTAB | LIB_SHARED_PRIMITIVES_REU_MUL | LIB_SHARED_PRIMITIVES_CT_MUL_8X8 + + +; ===================================================================== +; §1 — library version gate (libs/nistcurves) +; ===================================================================== +; The v0.6.0 pin predates contract v0.7.0, so it exports only the +; DEPRECATED bare `LIB_VERSION_*` / `LIB_ABI_VERSION` names — there is +; no `LIB_NISTCURVES_VERSION_MAJOR` to import yet. Importing the bare +; names is safe *only* because exactly one contract library is in the +; link: `USE_X25519_SIBLING=1` does not link on either backend, so the +; two-library #43 collision is unreachable in every shipping config. +; +; WHEN A SECOND CONTRACT LIBRARY EVER LINKS: build both with +; `ca65 -D LIB_NO_BARE_EXPORTS=1` and switch the import below to the +; `LIB_NISTCURVES_*` / `LIB_X25519_*` prefixed forms (contract v0.7.0). +; Both libraries emit the prefixed forms from v0.7.0 onward. +; +; ONLY the ABI generation counter is imported, deliberately. A MAJOR/ +; MINOR floor assert is the obvious companion and is NOT here because +; the v0.6.0 pin exports those three without an address-size hint +; (libs/nistcurves/src/lib_version.s:33-35 — only ABI_VERSION carries +; `:abs`). Their values fit in a byte, so ca65 infers `zeropage` while +; a consumer `.import` defaults to absolute, and every build then emits +; +; ld65: Warning: Address size mismatch for 'LIB_VERSION_MAJOR' +; +; twice. Measured on ld65 V2.18. This is the same defect contract +; v0.7.4 fixed for the §8.4 `_REGION`/`_SHARED` equates, recurring in +; §1; the contract's own note says the natural workaround (importing as +; `: zeropage`) is wrong, because it pins a manifest constant to an +; address size that is an artifact of its current value. Upstream fixed +; it at v0.9.0 (all four exports gained `:abs`, alongside the prefixed +; forms), so the floor assert becomes available warning-free at any pin +; >= v0.9.0 — add it then rather than eating two warnings per build now. + +; ABI generation counter (§1 as restated in contract v0.7.5: an +; independent monotonic counter, NOT a mirror of MAJOR). This is the +; load-bearing breakage gate — pre-1.0 libraries take breaking changes +; on MINOR bumps, so MAJOR carries no signal. +; +; nistcurves shipped 0 from v0.3.0 through v0.8.0 and bumped to 1 at +; v0.9.0, where it also REMOVED 17 exported symbols (c64-nist-curves +; #90/#91). If a submodule bump makes this assert fire, that is the gate +; working: re-check the integration against the new export surface, then +; update the expected value on the next line — do not delete the assert. +.import LIB_ABI_VERSION +.assert LIB_ABI_VERSION = 0, lderror, "libs/nistcurves: exported-surface generation changed (LIB_ABI_VERSION != 0) — re-check the integration, then bump the expected value in src/lib_contract_asserts.s" + + +; ===================================================================== +; §3 — REU bank budget +; ===================================================================== +; c64-https reserves REU banks 6 and 7 for the P-384 overlay images +; (src/boot.s:840-841 stashes the SHA-384 blob to bank 6 and the curve +; blob to bank 7). No vendored library may claim them. The library's own +; claim is $07 (banks 0/1 mul table + bank 2 Lim-Lee comb) under the REU +; profile and $04 under FP_ONCHIP_MUL, both of which pass; this assert +; exists to catch an upstream expansion into our reserved pair, which +; would otherwise corrupt the overlay images at first swap-in with no +; diagnostic at all. +.import LIB_NISTCURVES_REU_BANKS_USED +.assert (LIB_NISTCURVES_REU_BANKS_USED & $C0) = 0, lderror, "libs/nistcurves claims REU bank 6 and/or 7 — reserved by c64-https for the P-384 overlay images (src/boot.s:840)" + + +; ===================================================================== +; §8.0 — shared-primitive ownership tripwire +; ===================================================================== +; See the header note: the contract's disjointness assert is not +; writable here yet. What IS checkable is that upstream's ownership +; claim has not moved out from under the archive surgery in +; tools/integration/build_nistcurves_p256.sh. Measured $0007 on both +; the REU and FP_ONCHIP_MUL variants at the v0.6.0 pin. +; +; If this fires, upstream changed which §8 primitives it owns. Re-derive +; which archive members the wrapper must drop (or which `SHARED_*` +; deferral switches it must pass) BEFORE updating the expected value. +.import LIB_NISTCURVES_SHARED_PRIMITIVES +.assert LIB_NISTCURVES_SHARED_PRIMITIVES = APP_OWNED, lderror, "libs/nistcurves shared-primitive ownership claim moved — re-derive the archive member drops in tools/integration/build_nistcurves_p256.sh before touching this assert" + + +; ===================================================================== +; §13.3 — TCP rx ring shape +; ===================================================================== +; The ring mask must be 2^n - 1 or the backends' `and TCP_RECV_MASK` +; wrap arithmetic aliases addresses instead of wrapping. Assemble-time +; (`error`, not `lderror`) because TCP_RECV_MASK is a local equate from +; constants.inc, not an import. +.assert (TCP_RECV_MASK & (TCP_RECV_MASK + 1)) = 0, error, "TCP_RECV_MASK must be 2^n - 1 (c64-lib-contract SPEC §13.3)" From 986b22df647724c80e1f35a59856b339e40f2b3e Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:22:16 -0500 Subject: [PATCH 2/2] docs: net_abi.inc is documentation, not an enforced interface (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 46 ++++++++++++++++++++++++++++++++++-------- src/net/ip65/README.md | 18 ++++++++++++----- src/net_abi.inc | 34 ++++++++++++++++++++++++++----- 3 files changed, 80 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1f400f4..68df3dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,10 +261,38 @@ below for the post-W1 hot/cold split): ## Networking backend ABI -Public net API is fronted by `src/net_abi.inc`. TLS/HTTP sources consume -networking only through those symbols. Switching backend = picking a -different `cfg/c64-https-$(BACKEND).cfg` and linking different -`src/net//*.o` files. +Switching backend = picking a different `cfg/c64-https-$(BACKEND).cfg` +and linking different `src/net//*.o` files. + +**`src/net_abi.inc` is documentation, not an enforced interface.** This +section used to claim the net API was "fronted by" it and that TLS/HTTP +consume networking "only through those symbols". Both are false, and the +distinction is load-bearing for anyone sizing the §13 work (issue #70): +the starting point is prose, not an interface. Measured 2026-08-14: + + - **Nothing `.include`s it.** `grep -rn 'net_abi' src/ tools/ cfg/ + Makefile` returns only comments. It is not in the build, so none of + its twelve `.import`s is checked by anything. + - **Declared and used surfaces overlap in 6 symbols out of 17.** The + surface 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` (`src/boot.s:107-114`, `src/http.s:61-67`, + `src/tls_record_io.s:28-30`, `src/tls13.s:96`). Five of those are + absent from the header; six of the header's are imported by nobody. + - **The ip65 backend does not provide half of what the header + declares** — no `net_dhcp_acquire`, `net_tcp_set_recv_cb`, + `net_local_ip`, `net_resolved_ip`, `net_last_error` or + `net_tcp_state` (`src/net/ip65/net.s:28` calls them "deferred to + Phase 7"; Phase 7 shipped). It exports `net_dhcp` instead — the + exact name c64-lib-contract §13.1 tells c64-wireguard to rename + away from. The UCI adapter exports both, so the divergence §13 + exists to stop also runs between our own two backends. + - `net_last_error` in particular exists **only** under UCI, so ip65 + has no error channel at all. + +Consequence: the symbol list below describes an intent, not a contract +the linker checks. Treat it as a TODO list until #70's item P1 lands. Current backends: - `src/net/ip65/` — ip65/RR-Net (cs8900a driver). The ip65 blob is @@ -278,10 +306,12 @@ Current backends: produces a working PRG; `cfg/c64-https-uci.cfg` defines the UCI-specific memory map. -Public symbols (see `src/net_abi.inc`): - net_init, net_poll, net_dhcp_acquire - net_tcp_connect, net_tcp_send, net_tcp_close, net_tcp_set_recv_cb - net_dns_resolve +Symbols declared in `src/net_abi.inc` — aspirational, see the caveat +above. UCI exports all twelve; ip65 exports only the six marked `*`: + + net_init *, net_poll *, net_dhcp_acquire + net_tcp_connect *, net_tcp_send *, net_tcp_close *, net_tcp_set_recv_cb + net_dns_resolve * net_local_ip, net_resolved_ip, net_last_error, net_tcp_state ## UCI backend diff --git a/src/net/ip65/README.md b/src/net/ip65/README.md index 2769f0a..9af98e1 100644 --- a/src/net/ip65/README.md +++ b/src/net/ip65/README.md @@ -1,8 +1,16 @@ # src/net/ip65 — ip65 / RR-Net backend -The current networking backend for c64-https. Implements the `net_*` -ABI declared in `src/net_abi.inc` on top of the ip65 TCP/IP stack with -the RR-Net ethernet driver. +The default networking backend for c64-https. Provides `net_*` entry +points on top of the ip65 TCP/IP stack with the RR-Net ethernet driver. -Port of `src/net.asm` from ACME lands here in Phase 3 Batch D. Until -then this directory holds only this README. +**It does not implement all of `src/net_abi.inc`, and nothing checks +that it does.** That header is `.include`d by no translation unit, so +it is documentation rather than an enforced interface. This backend +exports six of its twelve symbols (`net_init`, `net_poll`, +`net_dns_resolve`, `net_tcp_connect`, `net_tcp_send`, `net_tcp_close`) +and omits `net_dhcp_acquire`, `net_tcp_set_recv_cb`, `net_local_ip`, +`net_resolved_ip`, `net_last_error` and `net_tcp_state` — see `net.s:28` +("deferred to Phase 7", which shipped). It exports `net_dhcp` in place +of `net_dhcp_acquire`. Alignment is tracked in issue #70; see the +"Networking backend ABI" section of `CLAUDE.md` for the measured +declared-vs-used surface. diff --git a/src/net_abi.inc b/src/net_abi.inc index bb62991..229e932 100644 --- a/src/net_abi.inc +++ b/src/net_abi.inc @@ -1,9 +1,33 @@ -; src/net_abi.inc — public networking API consumed by TLS/HTTP layers. +; src/net_abi.inc — INTENDED public networking API for TLS/HTTP. ; -; Drop-in contract: any backend (ip65/RR-Net today, UCI/U64E next) must -; export these exact symbols. Swapping backend = link-time choice via -; different ld65 cfg + different net//*.o files. No changes -; to TLS or HTTP sources. +; !! THIS FILE IS NOT IN THE BUILD. !! +; +; No translation unit `.include`s it (verified 2026-08-14: +; `grep -rn 'net_abi' src/ tools/ cfg/ Makefile` returns only comments), +; so not one of the `.import`s below is checked by the assembler or the +; linker. It reads like a drop-in contract and is not one. Three +; measured consequences, all of which someone planning work here needs: +; +; 1. The ip65 backend provides only six of the twelve. It has no +; `net_dhcp_acquire` (it exports `net_dhcp`), no +; `net_tcp_set_recv_cb`, no `net_local_ip`, no `net_resolved_ip`, +; no `net_last_error` and no `net_tcp_state` — see +; src/net/ip65/net.s:28. `net_last_error` exists ONLY under UCI, so +; ip65 has no error channel at all. +; 2. Five symbols TLS/HTTP genuinely depend on are missing here: +; `net_dhcp`, `net_print_ip`, `net_send_len`, `net_recv_byte`, +; `net_banner_str`. Declared and used surfaces overlap in 6 of 17. +; 3. `net_tcp_set_recv_cb` is an RTS stub with zero callers; +; c64-lib-contract SPEC §13.1 retires it outright. +; +; Making this real (or deleting it) is item P1 of the plan on issue #70. +; Until then: adding a symbol here changes nothing but this comment +; block's neighbours — put it in the backend adapters instead. +; +; Original intent, preserved: any backend (ip65/RR-Net, UCI/U64E) should +; export these exact symbols, so swapping backend is a link-time choice +; via a different ld65 cfg + different net//*.o files, with no +; changes to TLS or HTTP sources. .import net_init .import net_dhcp_acquire