Skip to content

chore(contract): link-enforced c64-lib-contract asserts (#70) - #92

Merged
JC-000 merged 2 commits into
masterfrom
chore/contract-alignment
Aug 14, 2026
Merged

chore(contract): link-enforced c64-lib-contract asserts (#70)#92
JC-000 merged 2 commits into
masterfrom
chore/contract-alignment

Conversation

@JC-000

Copy link
Copy Markdown
Owner

Adds src/lib_contract_asserts.s, a zero-byte translation unit that converts four
facts about libs/nistcurves that this project currently only believes into
link-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

clauseassert
§1LIB_ABI_VERSION = 0 — exported-surface generation gate
§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
§8.0LIB_NISTCURVES_SHARED_PRIMITIVES tripwire, pinning the measured $0007
§13.3TCP_RECV_MASK is 2^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_VERSION went 0 → 1 at v0.9.0, the same release that
removed 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_OWNED case and provides all three §8 primitives itself
(sqtab_init/mul_8x8/ct_mul_8x8 in src/crypto/poly1305.s, mul_tables_init in
src/crypto/shared/mul_tables.s, reu_mul_init in src/boot.s), yet the library
still exports $0007 claiming all three. We resolve the overlap by deleting archive
members
(tools/integration/build_nistcurves_p256.sh:130 drops mul_8x8.o and
data_shared.o) instead of using the contract's SHARED_* deferral switches — and
member surgery is invisible to lib_manifest.o. So the §5 manifest describes the
upstream 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

  • 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 on 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 … : zeropage pins a constant to an address size that is an artifact of
    its current value). **Add the floor together with the pin bump — it is free at

    = v0.9.0.**

  • The §5 RESIDENT_BYTES fit check from SPEC §5's own worked example. It reports
    27,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 doing
    the 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 .if on an .imported symbol, which has no value until
link — ca65 V2.18 says Error: Constant expression expected. .assert …, lderror is
the 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:

$ make clean && make
exit 0 build/c64-https.prg 47,105 B
sha256 417c70859411e8b5b9d2ef7a89575c2122d263cb0a622b8fc336693a907f2444

That hash is identical to the pre-change build, which is the point — the TU emits
no bytes, the linker just gains four opinions.

$ make clean && make BACKEND=uci
exit 0 build/c64-https.prg 62,977 B
sha256 741f0e8cd99470a2d2f76b21d0603235eb625cded32f9ad80b9f7c93f7462e70
no ld65 warnings
$ C64_SKIP_BUILD=1 python3 tools/test_ecdsa_kat_oracle.py
Passed: 6/6
Failed: 0/6
[+] ECDSA KAT oracle: ALL 6 VECTORS PASSED (3 valid accepted, 3 invalid rejected)
$ C64_SKIP_BUILD=1 python3 tools/test_x509.py
Passed: 11/11
Failed: 0/11
[+] X.509/ECDSA: ALL 11 TESTS PASSED

Beyond the bar: both onchip profiles

The evidence bar only covers the two default profiles, but the $0007 tripwire and
the REU mask are read from a different manifest object under FP_ONCHIP_MUL
(lib_manifest_onchip.o, which reports REU_BANKS_USED = $04). Both packaged onchip
variants were built to confirm the asserts hold there too — clean, no warnings:

$ make clean && make BACKEND=uci USE_NISTCURVES_ONCHIP=1
exit 0 62,977 B sha256 fe959dad5edc1dc7f03f024541fb84e7095cb3434bca4b296625c3cd51de9df9
$ make clean && make USE_NISTCURVES_ONCHIP=1
exit 0 47,105 B sha256 573561dab4af9e3776baa11e29b61702e89a365645b6f8a3d7f83ed02f2e861b

Negative controls

An assert that never fires is decoration, so both link-time asserts were inverted and
restored:

# expected ABI value 0 -> 99
ld65: Error: src/lib_contract_asserts.s(135): 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
# REU reserved mask $C0 -> $04
ld65: Error: src/lib_contract_asserts.s(150): libs/nistcurves claims REU bank 6
and/or 7 — reserved by c64-https for the P-384 overlay images (src/boot.s:840)

Named message, source file, line number.

Note for whoever bumps libs/nistcurves

A 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:135 from 0
to 1. Please do not delete the assert. Also re-verify the $0007 tripwire before
changing it — that value is what tells us whether the archive member-drops in
build_nistcurves_p256.sh are still the right ones.

🤖 Generated with Claude Code

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>
@JC-000

JC-000 commented Aug 14, 2026

Copy link
Copy Markdown
OwnerAuthor

Scope note: 986b22d adds a doc-only second commit correcting the src/net_abi.inc claim in CLAUDE.md, src/net_abi.inc's own header, and src/net/ip65/README.md.

CLAUDE.md described the file as an enforced boundary ("TLS/HTTP sources consume networking only through those symbols"). Nothing .includes it — grep -rn 'net_abi' src/ tools/ cfg/ Makefile returns only comments — so none of its twelve .imports is checked by ca65 or ld65. Declared and used surfaces overlap in 6 symbols out of 17, and the ip65 backend provides only 6 of the 12 (no net_last_error at all, and net_dhcp in place of net_dhcp_acquire). That materially changes the size of the §13 work tracked in #70, so it belongs in the tree rather than only in an issue comment.

Both PRGs are byte-identical across that commit — ip65 47,105 B 417c7085…, UCI 62,977 B 741f0e8c…, i.e. unchanged from the evidence in the PR body above.

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 : abs, which is why this PR ships no MINOR floor assert), and a comment on JC-000/c64-lib-contract#62 for the §5 RESIDENT_BYTES case.

@JC-000
JC-000 merged commit bdf4ee0 into masterAug 14, 2026
JC-000 added a commit that referenced this pull request Aug 14, 2026
#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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@JC-000