From 9dfe1783a85b47231cdb9688365e387b75b86d3d Mon Sep 17 00:00:00 2001
From: JC-000 <3798556+JC-000@users.noreply.github.com>
Date: Sat, 15 Aug 2026 08:13:57 -0500
Subject: [PATCH 1/2] =?UTF-8?q?chore(contract):=20align=20to=20c64-lib-con?=
=?UTF-8?q?tract=20v0.10.0=20=E2=80=94=20=C2=A76.6=20asserts=20+=20pin-ada?=
=?UTF-8?q?ptive=20ZP=20spelling?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Contract SPEC.md on `main` moved v0.8.0 -> v0.10.0 (eleven versions). Two of
the new clauses bind a consumer and were unimplemented here; one upcoming
upstream change would have broken the build silently at the next pin move.
§6.6 consumer footprint asserts (new, contract v0.10.0)
-------------------------------------------------------
New zero-byte TU `src/contract_footprint_asserts.s` imports
LIB_NISTCURVES_RESIDENT_BYTES + _COLD_BYTES and asserts them against the
ld65-published region size. No cfg change was needed: every MEMORY area in
both cfgs already carries `define = yes`.
Stated honestly in the file: against a $4000 region this carries ~7 KB of
slack while the region's real free space is 81 B (__CRYPTO_HOT_LAST__ =
$9FAF), so it is NOT a tight region-pressure gate. What it is is a §6.4
per-variant-manifest regression gate, and that failure is live in this tree
today. Measured with od65 on the staged archives at the v0.9.1 pin:
profile manifest member RESIDENT COLD
reu lib_manifest_p256verify.o 8700 430
onchip lib_manifest_p256verify_onchip.o 8700 240
onchip-comb lib_manifest_onchip.o 27000 1650 <- whole library
The comb profile is excluded, with the reason recorded: it builds from
upstream's FULL lib-onchip archive and our wrapper then rm -f's ~7 members,
so upstream's manifest survives describing the pre-surgery archive. That is
exactly the harm §6.1 names ("an archive whose member set a consumer has
edited is outside every §5/§8.0 manifest claim it ships"). Comb is outside
`make package`, so no shipped artifact is affected.
Verified both directions: PRG byte-identical with and without the TU
(66e37037… UCI) so it costs nothing, and forced on for comb it fires with
the intended ld65 message against the real 27000 B manifest.
Supporting Makefile change: -D BACKEND_UCI=1 / -D BACKEND_IP65=1, because
the crypto code region is CRYPTO_HOT under UCI and CRYPTO_RESIDENT under
ip65, so the ___SIZE__ symbol to import differs by backend.
Pin-adaptive ZP slot spelling (both nistcurves wrappers)
--------------------------------------------------------
libs/nistcurves master (v0.10.1) has completed the contract §2 ZP-registry
migration using §6.5's loud-break alias shape:
zp_ptr2 = nistcurves_zp_ptr2 ; unguarded, NO .ifndef
so the wrappers' hardcoded bare spelling is a hard assemble failure the
moment the pin moves. Measured against master's zp_config.s:
-D 'zp_ptr2=$3d' -> zp_config.s(56): Error: Symbol 'zp_ptr2'
is already defined
-D 'nistcurves_zp_ptr2=$3d' -> assembles; od65 shows BOTH
nistcurves_zp_ptr2 AND zp_ptr2 = $3D
The canonical spelling would conversely be a silent no-op at the pinned
v0.9.1, leaving the slot at upstream's $fd. Both wrappers now probe
libs/nistcurves/src/zp_config.s for the canonical name and follow it, so
they are correct at both pins and the wave bump does not need to touch them.
fp_mul_i/fp_mul_j need no probe — `fp_` is a registered §2 prefix, so those
keep their .ifndef guards across the migration.
check_zp_slot now runs against both spellings, which additionally proves the
alias tracks the override rather than splitting one slot across two
addresses — the outcome §6.5 explicitly forbids.
Also: fold LaneFollowups' tools/uci/test_*.py -> rig_*.py rename into the
two references in docs/library-ingestion-architecture.md.
Verification
------------
make clean && make PASS 47,105 B
make clean && make BACKEND=uci PASS 62,977 B
+ USE_NISTCURVES_ONCHIP=1, both backends PASS
+ USE_NISTCURVES_ONCHIP_COMB=1 (uci) PASS
tools/test_ecdsa_kat_oracle.py PASS 6/6
tools/test_x509.py PASS 11/11
ip65 blob rebuild 6,951 B, cf1a5ff7… (documented hash)
Co-Authored-By: Claude Opus 5 (1M context)
---
Makefile | 13 +++
docs/library-ingestion-architecture.md | 4 +-
src/contract_footprint_asserts.s | 119 +++++++++++++++++++++
tools/integration/build_nistcurves_p256.sh | 64 +++++++----
tools/integration/build_nistcurves_p384.sh | 14 ++-
5 files changed, 192 insertions(+), 22 deletions(-)
create mode 100644 src/contract_footprint_asserts.s
diff --git a/Makefile b/Makefile
index 1e72760..b04ea76 100644
--- a/Makefile
+++ b/Makefile
@@ -36,6 +36,19 @@ IP65_BUILD := ip65-build
IP65_BIN := $(IP65_BUILD)/ip65-c64.bin
CA65FLAGS := -I src -I src/inc -I src/crypto/shared -I src/net/$(BACKEND) -I build --debug-info
+# Which backend is selected, as a ca65 define. Sources that must name a
+# cfg-level symbol need this, because the two cfgs give the same region
+# different names: the crypto code+rodata region is CRYPTO_HOT under UCI and
+# CRYPTO_RESIDENT under ip65, so the ld65-published ___SIZE__ symbol an
+# `.import` has to spell differs by backend (src/contract_footprint_asserts.s
+# is the current consumer). The `-I src/net/$(BACKEND)` path above cannot
+# serve this: it carries net tuning, and region naming is a cfg property, not
+# a networking one.
+ifeq ($(BACKEND),uci)
+CA65FLAGS += -D BACKEND_UCI=1
+else
+CA65FLAGS += -D BACKEND_IP65=1
+endif
# Optional HTTPS target-port override (src/boot.s defaults to 443).
# `make HTTPS_PORT=4433` lets a test rig's TLS listener bind unprivileged.
ifdef HTTPS_PORT
diff --git a/docs/library-ingestion-architecture.md b/docs/library-ingestion-architecture.md
index 2405544..7c789dd 100644
--- a/docs/library-ingestion-architecture.md
+++ b/docs/library-ingestion-architecture.md
@@ -351,7 +351,7 @@ Suitable to file as GitHub issues against `JC-000/c64-https`. Each is sized for
**Scope:** Split `cfg/c64-https-uci.cfg`'s `CRYPTO_RESIDENT` ($6000-$BFFF, 24 KB) into `CRYPTO_HOT` ($6000-$9FFF, 16 KB) and `CRYPTO_COLD_SHADOW` ($A000-$BFFF, 8 KB, under-BASIC-ROM RAM). Move ChaCha20-Poly1305 + SHA-256 + HKDF + TLS state machine + HTTP + record I/O into CRYPTO_HOT; move ECDSA-verify dispatcher, X25519 ECDH glue, per-curve scratch BSS into CRYPTO_COLD_SHADOW. CRYPTO_OVERLAY ($4200-$5FFF, 7.5 KB) absorbs all cold-path *code* via REU-paged overlays.
-**Expected output:** `build/c64-https.prg` builds clean under `BACKEND=uci`. Smoke tests (`test_https_local.py`, `test_https_local_p384.py`) PASS. `build/labels.txt` shows CRYPTO_HOT under-allocated by ≥1 KB.
+**Expected output:** `build/c64-https.prg` builds clean under `BACKEND=uci`. Smoke tests (`rig_https_local.py`, `rig_https_local_p384.py`) PASS. `build/labels.txt` shows CRYPTO_HOT under-allocated by ≥1 KB.
**Dependencies:** None; this work is c64-https-internal.
@@ -442,7 +442,7 @@ After all four cells build clean:
**Tier 2 — U64E (slow, gated, ~10-90 minutes):**
- Runs on self-hosted runner with LAN access to the U64E (probably `192.168.1.81` per the project memory).
- Acquires DeviceLock via `c64-test-harness` (queue-aware). If queue depth exceeds threshold (W7), skip with `:warning: queue busy, skipping U64E tier; will retry in 6h`.
-- `tools/uci/test_https_local.py`, `test_https_local_p384.py`, `phase3_tcp_echo.py`, `boot_check.py`.
+- `tools/uci/rig_https_local.py`, `rig_https_local_p384.py`, `phase3_tcp_echo.py`, `boot_check.py`.
- Wall-clock budgets per CLAUDE.md: ~82 s P-256 handshake, ~7 min P-384 handshake.
### 5.4 U64E hardware contention
diff --git a/src/contract_footprint_asserts.s b/src/contract_footprint_asserts.s
new file mode 100644
index 0000000..eb6d818
--- /dev/null
+++ b/src/contract_footprint_asserts.s
@@ -0,0 +1,119 @@
+; src/contract_footprint_asserts.s — c64-lib-contract SPEC §6.6 consumer
+; footprint asserts.
+;
+; Emits no bytes. Companion to src/lib_contract_asserts.s, kept as a
+; separate TU because §6.6 landed at contract v0.10.0 and its gating is
+; profile-dependent in a way the §1/§3/§8.0/§13.3 gates are not (see the
+; comb exclusion below).
+;
+; Contract: https://github.com/JC-000/c64-lib-contract — read SPEC.md on
+; `main`, NOT the newest git tag (tags lag; main is v0.10.0 as of
+; 2026-08-15). Clause referenced here: §6.6 (consumer footprint asserts).
+;
+; ---------------------------------------------------------------------
+; WHAT §6.6 IS FOR
+; ---------------------------------------------------------------------
+; SPEC §6.6 exists because of a failure measured in THIS repo and filed
+; upstream as c64-lib-contract#69: a MINOR library bump grows resident
+; code or rodata, the consumer's region overflows at link time, and there
+; is no advance signal that the bump was a spatial event. c64-https hit it
+; twice — most recently v0.7.0's +208 B validation gate against CRYPTO_HOT's
+; ~1 byte of slack, bisected across three tags to find.
+;
+; The clause's consumer pattern binds the library's declared footprint to
+; a region budget:
+;
+; .import LIB_NISTCURVES_RESIDENT_BYTES
+; .import LIB_NISTCURVES_COLD_BYTES
+; .import __MAIN_SIZE__ ; cfg: MAIN: ... define = yes;
+; .assert LIB_NISTCURVES_RESIDENT_BYTES + LIB_NISTCURVES_COLD_BYTES \
+; <= __MAIN_SIZE__, lderror, "..."
+;
+; No cfg change was needed to adopt it: every MEMORY area in both
+; cfg/c64-https-uci.cfg and cfg/c64-https-ip65.cfg already carries
+; `define = yes`, so ld65 publishes __CRYPTO_HOT_SIZE__ /
+; __CRYPTO_RESIDENT_SIZE__ already (verified in build/labels.txt).
+;
+; ---------------------------------------------------------------------
+; WHAT THIS ASSERT ACTUALLY CATCHES — AND WHAT IT DOES NOT
+; ---------------------------------------------------------------------
+; Stated plainly, because a gate whose reach is overestimated is worse
+; than no gate.
+;
+; It does NOT tightly bound region pressure. CRYPTO_HOT is $4000 (16,384 B)
+; and holds c64-https's own crypto as well as the library, so at the v0.9.1
+; pin the assert compares 8,700 + 430 = 9,130 against 16,384 — roughly
+; 7 KB of slack, while the region's REAL free space is 81 bytes
+; (__CRYPTO_HOT_LAST__ = $9FAF against a $A000 end, measured). A bump that
+; grows the archive by 100 B still overflows the region without tripping
+; this assert. Making it tight would require hardcoding a per-pin budget
+; constant, which then needs maintenance on every bump and fires as a
+; false alarm on any growth of c64-https's own code; that trade was
+; considered and declined.
+;
+; What it DOES catch is a §6.4 per-variant-manifest regression, and that
+; is not hypothetical — it is measured live in this tree on a sibling
+; profile. §6.6's whole implication ("declared <= budget implies actual <=
+; budget") rests on the manifest describing the archive we link. When it
+; does not, the number is not conservative, it is meaningless. At the
+; v0.9.1 pin, measured with od65 on the staged archives:
+;
+; profile manifest member RESIDENT COLD
+; reu lib_manifest_p256verify.o 8700 430
+; onchip lib_manifest_p256verify_onchip.o 8700 240
+; onchip-comb lib_manifest_onchip.o 27000 1650 <- whole library
+;
+; 27,000 B is the whole-library figure against a 16,384 B region. That is
+; the exact number that made §6.6 unadoptable before contract v0.9.0's
+; §6.4, and it is still live for the comb profile — so if a future bump
+; regresses `reu` or `onchip` the same way, this assert names it instead of
+; leaving an opaque segment overflow to be bisected.
+;
+; ---------------------------------------------------------------------
+; WHY THE COMB PROFILE IS EXCLUDED
+; ---------------------------------------------------------------------
+; The comb archive would FAIL this assert today (27,000 + 1,650 = 28,650
+; against 16,384) and the failure would be a false alarm: the comb PRG
+; links and runs, so the declared number is wrong, not the build.
+;
+; The cause is ours, not upstream's. `tools/integration/build_nistcurves_p256.sh`
+; builds the comb profile from upstream's FULL `lib-onchip` archive and
+; then `rm -f`s ~7 members (fp384, mod384, curve384, points384_*, ...) to
+; narrow it to the P-256 comb set. Upstream's `lib_manifest_onchip.o`
+; legitimately describes the archive upstream shipped; it survives our
+; member surgery still describing the pre-surgery set. The `reu` and
+; `onchip` profiles are unaffected because they build from upstream's
+; already-minimal `lib-p256-verify` / `lib-p256-verify-onchip` targets,
+; which carry per-variant manifests.
+;
+; This is precisely the harm SPEC §6.1 names: "an archive whose member set
+; a consumer has edited is outside every §5/§8.0 manifest claim it ships."
+; The sanctioned remedy is §6.2 `CONTRACT_DEFINES` / `CONTRACT_ZP_DEFINES`
+; plus §6.3's `lib-app-owned` target, so the configuration is reachable
+; without surgery. Neither exists in the pinned v0.9.1; both ARE implemented
+; on libs/nistcurves master (v0.10.1, measured). Retiring the surgery is
+; therefore unblocked by the wave bump and is sequenced in c64-https#70 —
+; when it lands, delete the .ifdef below and the comb profile is covered
+; by the same assert as the other two.
+;
+; The comb profile is deliberately excluded from `make package`, so no
+; shipped artifact is affected by the gap this exclusion leaves open.
+
+.ifndef USE_NISTCURVES_COMB
+
+.import LIB_NISTCURVES_RESIDENT_BYTES
+.import LIB_NISTCURVES_COLD_BYTES
+
+; The code+rodata region differs by backend: CRYPTO_HOT under UCI,
+; CRYPTO_RESIDENT under ip65. Both are $6000-$9FFF ($4000 bytes) and both
+; are declared `define = yes`, so the published size symbol is the only
+; thing that differs. BACKEND_UCI / BACKEND_IP65 come from the Makefile.
+.ifdef BACKEND_UCI
+ .import __CRYPTO_HOT_SIZE__
+ .assert LIB_NISTCURVES_RESIDENT_BYTES + LIB_NISTCURVES_COLD_BYTES <= __CRYPTO_HOT_SIZE__, lderror, "libs/nistcurves declared footprint (LIB_NISTCURVES_RESIDENT_BYTES + _COLD_BYTES) exceeds CRYPTO_HOT. Contract SPEC 6.6. Most likely cause is a 6.4 regression: the archive ships a whole-library manifest instead of a per-variant one (27000 B is the whole-library value). Check with od65 --dump-exports on the staged lib_manifest*.o before assuming the library really grew."
+.else
+ .import __CRYPTO_RESIDENT_SIZE__
+ .assert LIB_NISTCURVES_RESIDENT_BYTES + LIB_NISTCURVES_COLD_BYTES <= __CRYPTO_RESIDENT_SIZE__, lderror, "libs/nistcurves declared footprint (LIB_NISTCURVES_RESIDENT_BYTES + _COLD_BYTES) exceeds CRYPTO_RESIDENT. Contract SPEC 6.6. Most likely cause is a 6.4 regression: the archive ships a whole-library manifest instead of a per-variant one (27000 B is the whole-library value). Check with od65 --dump-exports on the staged lib_manifest*.o before assuming the library really grew."
+.endif
+
+.endif ; USE_NISTCURVES_COMB
diff --git a/tools/integration/build_nistcurves_p256.sh b/tools/integration/build_nistcurves_p256.sh
index 1ddcebc..61e59d5 100755
--- a/tools/integration/build_nistcurves_p256.sh
+++ b/tools/integration/build_nistcurves_p256.sh
@@ -95,24 +95,49 @@ AR65="${AR65:-ar65}"
# unused inside ZP_CRYPTO.
# Other slots match upstream defaults — see libs/nistcurves/src/zp_config.s.
#
-# THE POINTER SLOT IS SPELLED `nistcurves_zp_ptr2` FROM v0.10.0, AND THE
-# OLD SPELLING NOW HARD-ERRORS. The §6.5 rename window (upstream #107)
-# made the four general-purpose scratch slots canonically
-# `nistcurves_zp_{tmp1,tmp2,ptr1,ptr2}` and left the bare `zp_*` names as
-# unconditional same-address aliases:
+# THE POINTER SLOT IS SPELLED `nistcurves_zp_ptr2` FROM v0.10.0. The
+# spelling is PROBED, never hardcoded, because both spellings are wrong at
+# some pin this script has to build.
+#
+# c64-lib-contract SPEC §2 gained a ZP prefix registry at v0.9.0: a bare
+# `zp_ptr2` is unregistered (three adopters had independently converged on
+# bare zp_tmp1/zp_ptr1 — contract #83), so c64-nist-curves renamed its four
+# general-scratch slots to the registered `nistcurves_zp_*` family and left
+# the bare names as aliases for the §6.5 rename window, in the "loud-break"
+# shape that clause ratifies:
#
# .ifndef nistcurves_zp_ptr2
# nistcurves_zp_ptr2 = $fd
# .endif
# zp_ptr2 = nistcurves_zp_ptr2 <- NOT .ifndef-guarded
#
-# so `-D zp_ptr2=$3d` no longer suppresses a guarded definition; it
-# collides with the alias assignment and stops the build with
-# `zp_config.s(56): Error: Symbol 'zp_ptr2' is already defined`. That is
-# the good case — a loud failure, not a silently-dropped override. The
-# `fp_*` names are documented override knobs and were not renamed.
+# Measured, ca65 V2.18, against each pin's own zp_config.s:
+#
+# spelling v0.9.1 v0.10.1
+# -D 'zp_ptr2=$3d' overrides correctly Error: Symbol
+# 'zp_ptr2' is
+# already defined
+# -D 'nistcurves_zp_ptr2=$3d' defines an unused overrides both
+# symbol; real slot names to $3D
+# stays at $fd
+#
+# Neither spelling is safe across both, so probe the library source and
+# follow it. Note the v0.9.1 + canonical cell is a *wrong value*, not a
+# silent one: the `check_zp_slot` guards below read the emitted object with
+# od65, so that combination stops the build (as `$fd` != `$3d`, or as
+# `` if the guard is aimed at the canonical name). The probe's value
+# is being loud AND correct at both pins, rather than merely loud at one.
+#
+# fp_mul_i / fp_mul_j need no probe: `fp_` is a registered §2 prefix for
+# c64-nist-curves, so those names are canonical already and keep their
+# `.ifndef` guards across the migration (verified on v0.10.1).
+if grep -qE '^[[:space:]]*\.ifndef[[:space:]]+nistcurves_zp_ptr2[[:space:]]*$' "$LIB_SRC/zp_config.s"; then
+ ZP_PTR2_SLOT='nistcurves_zp_ptr2'
+else
+ ZP_PTR2_SLOT='zp_ptr2'
+fi
ZP_OVERRIDES=(
- '-D' 'nistcurves_zp_ptr2=$3d'
+ '-D' "$ZP_PTR2_SLOT=\$3d"
'-D' 'fp_mul_i=$39'
'-D' 'fp_mul_j=$3a'
)
@@ -189,14 +214,15 @@ check_zp_slot() {
exit 1
fi
}
-# Check the CANONICAL name. The bare `zp_ptr2` alias is also exported in a
-# default build and carries the same value, but it disappears under
-# `-D LIB_NO_BARE_EXPORTS=1` (SPEC §6.5) and is removed outright at the
-# next MAJOR — checking it would make this guard silently vacuous exactly
-# when a consumer tightens the build.
-check_zp_slot nistcurves_zp_ptr2 61 # $3d
-check_zp_slot fp_mul_i 57 # $39
-check_zp_slot fp_mul_j 58 # $3a
+# Both spellings are checked. On the pinned v0.9.1 these are the same symbol
+# and the second call is a no-op restatement; on a §2-migrated pin they are
+# the canonical slot and its deprecated alias, and checking both proves the
+# alias tracks the override rather than splitting one slot across two
+# addresses — the silent outcome contract §6.5 forbids.
+check_zp_slot "$ZP_PTR2_SLOT" 61 # $3d — canonical spelling for this pin
+check_zp_slot zp_ptr2 61 # $3d
+check_zp_slot fp_mul_i 57 # $39
+check_zp_slot fp_mul_j 58 # $3a
# --- 4. Drop conflicting members / rebuild the onchip mul object ---
# `reu_mul_init.o` is the SPEC §8.2 `reu_mul` provider. c64-https is the
diff --git a/tools/integration/build_nistcurves_p384.sh b/tools/integration/build_nistcurves_p384.sh
index 6a9eed4..a2a9d1d 100755
--- a/tools/integration/build_nistcurves_p384.sh
+++ b/tools/integration/build_nistcurves_p384.sh
@@ -54,8 +54,20 @@ CA65="${CA65:-ca65}"
AR65="${AR65:-ar65}"
# --- ZP-slot overrides (c64-https canonical map + SHA-384 isolated window) ---
+# zp_ptr2's SPELLING is pin-dependent and probed, never hardcoded — see the
+# long rationale in build_nistcurves_p256.sh (contract SPEC §2 ZP prefix
+# registry + §6.5's loud-break alias shape: on a migrated pin the bare
+# spelling is an unguarded alias and `-D zp_ptr2=...` dies with
+# "Symbol 'zp_ptr2' is already defined"). `fp_`/`sha_` are registered §2
+# prefixes for c64-nist-curves and keep their `.ifndef` guards, so only this
+# one slot needs the probe.
+if grep -qE '^[[:space:]]*\.ifndef[[:space:]]+nistcurves_zp_ptr2[[:space:]]*$' "$LIB_SRC/zp_config.s"; then
+ ZP_PTR2_SLOT='nistcurves_zp_ptr2'
+else
+ ZP_PTR2_SLOT='zp_ptr2'
+fi
ZP_OVERRIDES=(
- '-D' 'zp_ptr2=$3d'
+ '-D' "$ZP_PTR2_SLOT=\$3d"
'-D' 'fp_mul_i=$39'
'-D' 'fp_mul_j=$3a'
# SHA-384 streaming pointer slots (moved out of $04-$0b defaults
From 6ff25839a8d2c3c885cf68a7a6192b651ef86532 Mon Sep 17 00:00:00 2001
From: JC-000 <3798556+JC-000@users.noreply.github.com>
Date: Sat, 15 Aug 2026 08:23:53 -0500
Subject: [PATCH 2/2] fix(integration): point the ZP post-check at the
canonical slot name
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review from the wave-bump lane: the deprecated bare `zp_ptr2` alias
disappears under `-D LIB_NO_BARE_EXPORTS=1` (contract §1/§6.5), so a guard
aimed at it stops guarding exactly when a consumer tightens the build.
Their framing was "goes vacuous"; measured, it is worse than that in this
script — `check_zp_slot` treats an absent symbol as `got=""`, which is a
mismatch, so the bare check would have *failed the build* over a symbol the
contract expects to be gone.
Split accordingly:
- canonical `$ZP_PTR2_SLOT` is checked HARD and unconditionally — it is the
name the library's own code reads, so absence or disagreement is always a
defect;
- the bare alias is checked only when present AND when it is a distinct
symbol, which still catches a split alias (one slot at two addresses, the
outcome §6.5 forbids) without failing on its legitimate removal.
Verified against objects built from libs/nistcurves master's zp_config.s in
three shapes:
A. migrated pin, alias emitted -> canonical 61, alias 61 -> proceed
B. migrated pin + LIB_NO_BARE_EXPORTS -> canonical 61, alias absent -> proceed
C. alias forced to $fd (split) -> canonical 61, alias 253 -> STOP
The p384 wrapper gets the same canonical-first ordering. Every check there
stays presence-tolerant because its sha and curve trees export disjoint slot
subsets (the sha tree has no zp_ptr2 at all), so absence is legitimate
per-tree and cannot be asserted away.
Co-Authored-By: Claude Opus 5 (1M context)
---
tools/integration/build_nistcurves_p256.sh | 35 ++++++++++++++++++----
tools/integration/build_nistcurves_p384.sh | 10 ++++++-
2 files changed, 38 insertions(+), 7 deletions(-)
diff --git a/tools/integration/build_nistcurves_p256.sh b/tools/integration/build_nistcurves_p256.sh
index 61e59d5..d349cf8 100755
--- a/tools/integration/build_nistcurves_p256.sh
+++ b/tools/integration/build_nistcurves_p256.sh
@@ -214,13 +214,36 @@ check_zp_slot() {
exit 1
fi
}
-# Both spellings are checked. On the pinned v0.9.1 these are the same symbol
-# and the second call is a no-op restatement; on a §2-migrated pin they are
-# the canonical slot and its deprecated alias, and checking both proves the
-# alias tracks the override rather than splitting one slot across two
-# addresses — the silent outcome contract §6.5 forbids.
+# Same as check_zp_slot, but tolerates the symbol being absent. Only correct
+# for a slot whose ABSENCE is legitimate — see the alias check below.
+check_zp_slot_if_present() {
+ local name="$1" want="$2" got
+ got=$("${OD65:-od65}" --dump-exports "$STAGING/$ZP_MEMBER" \
+ | awk -v n="\"$name\"" '$1=="Name:" && $2==n {f=1; next} f && $1=="Value:" {gsub(/[()]/,"",$3); print $3; exit}')
+ [ -z "$got" ] && return 0
+ if [ "$got" != "$want" ]; then
+ echo "ERROR: $ZP_MEMBER exports $name = $got, expected $want (c64-https ZP override did not take)" >&2
+ exit 1
+ fi
+}
+
+# The CANONICAL spelling is checked unconditionally — it is the name the
+# library's own code reads, so its absence or disagreement is always a defect.
check_zp_slot "$ZP_PTR2_SLOT" 61 # $3d — canonical spelling for this pin
-check_zp_slot zp_ptr2 61 # $3d
+
+# The deprecated bare alias is checked only WHEN PRESENT, and only when it is
+# a distinct symbol. On a §2-migrated pin this proves the alias tracks the
+# override rather than splitting one slot across two addresses (the silent
+# outcome contract §6.5 forbids) — but the alias legitimately disappears under
+# `-D LIB_NO_BARE_EXPORTS=1` (§1), and a hard check would then fail the build
+# over a symbol the contract expects to be gone. Checking the bare name
+# *instead* of the canonical one would be the worse error in the other
+# direction: it is the spelling that goes away, so the guard would stop
+# guarding exactly when a consumer tightens the build.
+if [ "$ZP_PTR2_SLOT" != "zp_ptr2" ]; then
+ check_zp_slot_if_present zp_ptr2 61 # $3d — deprecated alias, if still emitted
+fi
+
check_zp_slot fp_mul_i 57 # $39
check_zp_slot fp_mul_j 58 # $3a
diff --git a/tools/integration/build_nistcurves_p384.sh b/tools/integration/build_nistcurves_p384.sh
index a2a9d1d..5b562b6 100755
--- a/tools/integration/build_nistcurves_p384.sh
+++ b/tools/integration/build_nistcurves_p384.sh
@@ -180,7 +180,15 @@ for tree in sha curve; do
"${ZP_OVERRIDES[@]}" \
-o "$STAGING/$tree/$zp_member" \
"$LIB_SRC/zp_config.s"
- check_zp_slot_if_present "$STAGING/$tree/$zp_member" zp_ptr2 61 # $3d
+ # Canonical spelling first (see build_nistcurves_p256.sh). Every check here
+ # is presence-tolerant because the sha and curve trees export disjoint slot
+ # subsets — the sha tree has no zp_ptr2 at all — so absence is legitimate
+ # per-tree and cannot be asserted away. The bare alias is additionally
+ # optional because `-D LIB_NO_BARE_EXPORTS=1` removes it (§1/§6.5).
+ check_zp_slot_if_present "$STAGING/$tree/$zp_member" "$ZP_PTR2_SLOT" 61 # $3d
+ if [ "$ZP_PTR2_SLOT" != "zp_ptr2" ]; then
+ check_zp_slot_if_present "$STAGING/$tree/$zp_member" zp_ptr2 61 # $3d — deprecated alias
+ fi
check_zp_slot_if_present "$STAGING/$tree/$zp_member" fp_mul_i 57 # $39
check_zp_slot_if_present "$STAGING/$tree/$zp_member" fp_mul_j 58 # $3a
done