Skip to content

chore(contract): align to c64-lib-contract v0.10.0 — §6.6 footprint asserts + pin-adaptive ZP spelling - #114

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

chore(contract): align to c64-lib-contract v0.10.0 — §6.6 footprint asserts + pin-adaptive ZP spelling#114
JC-000 merged 2 commits into
masterfrom
chore/contract-v0-10-alignment

Conversation

@JC-000

@JC-000JC-000 commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Contract SPEC.md on main moved v0.8.0 → v0.10.0 (eleven versions) since this repo was last aligned. This lane assessed the whole delta, implemented the parts that are small/safe/verifiable, and wrote up the rest rather than starting it.

Read main, not tags — the tag lag is real and is itself one of our filed issues (contract#71).

Delta assessment — what binds a consumer, and whether we comply

Measured, not assumed. main @ edf8dcb; prior alignment was 2d9429d.

VerSubjectBinds consumer?c64-https today
0.8.1§1 guards MUST be .assert/lderror; exports need : absyescompliant — this is our own report (#73/#74) landing
0.8.2every spec version taggedprocessn/a — also ours (#71)
0.8.3§4 placement diagnostics are shape-conditionalyescompliant; see G4
0.8.4§4 ZEROPAGE exemptnon/a
0.8.5§8.2 LIB_SHARED_REU_MUL_* MUST NOT be exportedyesnot compliant (upstream-side) — G3
0.8.6$-hex -D quoting; make $$$$→PID footgunyescompliant — verified
0.9.0§6 build-and-consume chapter; §2 ZP prefix registryyes, heavilypartly non-compliant — G1, G2
0.9.1§6.5 suppression gate; §6.2 ZP scopingyesG2
0.9.2§2 shortname prefixes; §8.2 bank_patchmarginaln/a at our pin
0.10.0§6.6 footprint asserts; §6.7 non-segment reservationsyes§6.6 absent → implemented here; §6.7 already covered

§13 (network backend ABI) is unchanged since v0.6.1 — no commit in 2d9429d..HEAD touches it. Recorded on #70 so nobody re-derives that plan against text that did not move.

Verified-compliant items worth naming because they were beliefs before:

  • §2 / v0.8.6 $-hex quoting. Both nistcurves wrappers pass -D values as single-quoted bash array elements straight to ca65 — no shell re-expansion, no make mediation, so the $$$$→PID footgun does not apply. build_x25519.sh passes no ZP overrides at all.
  • §1 version gate.src/crypto/ecdsa_verify.s:48-51 already carries the MAJOR/MINOR floor and lib_contract_asserts.s:145 the ABI generation gate, both .assert/lderror. Nothing to add.
  • §6.7. Our sqtab is a segment-resident .res 1024 (src/data.s:149), so §6.7 Rule 1 applies and ld65 enforces non-overlap natively. The residual bake-address risk is already guarded by Makefile:303/:312, correctly gated to the profiles where the library bakes the equate. Different mechanism from §6.7's __MAIN_LAST__ pattern, same coverage — no change needed.
  • USE_NISTCURVES_ONCHIP_COMB really does imply USE_NISTCURVES_ONCHIP (Makefile:86-88). Checked because CLAUDE.md asserts it; not a gap.

What this PR implements

1. §6.6 consumer footprint asserts — new src/contract_footprint_asserts.s

Zero-byte TU. No cfg change needed: every MEMORY area in both cfgs already carries define = yes, so __CRYPTO_HOT_SIZE__ / __CRYPTO_RESIDENT_SIZE__ are already published.

Stated plainly in the file, because a gate whose reach is overestimated is worse than no gate: this is not a tight region-pressure gate. It compares 9,130 against 16,384 while CRYPTO_HOT's real free space is 81 bytes (__CRYPTO_HOT_LAST__ = $9FAF, measured). A 100 B archive growth still overflows without tripping it.

What it is is a §6.4 per-variant-manifest regression gate, and that failure is live in this tree right now. od65 on the staged archives at the v0.9.1 pin:

profilemanifest memberRESIDENTCOLD
reulib_manifest_p256verify.o8700430
onchiplib_manifest_p256verify_onchip.o8700240
onchip-comblib_manifest_onchip.o270001650

27,000 B is the whole-library figure — the exact number that made §6.6 unadoptable before contract v0.9.0's §6.4, still live for comb. If a future bump regresses reu or onchip the same way, this names it instead of leaving an opaque segment overflow to bisect.

Evidence both directions:

  • links clean on reu + onchip, both backends;
  • PRG byte-identical with and without the TU (66e37037…, UCI) — costs nothing;
  • fires when forced on for comb, against the real manifest:
ld65: Error: src/contract_footprint_asserts.s(113): 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: ...

Comb is excluded with the reason recorded (see G1) and is outside make package, so no shipped artifact is affected.

Supporting Makefile change: -D BACKEND_UCI=1 / -D BACKEND_IP65=1 — the crypto code region is CRYPTO_HOT under UCI and CRYPTO_RESIDENT under ip65, so the __<AREA>_SIZE__ symbol an .import must spell differs by backend. The existing -I src/net/$(BACKEND) path cannot serve this: it carries net tuning, and region naming is a cfg property.

2. Pin-adaptive ZP slot spelling — both nistcurves wrappers

This one de-risks the concurrent wave bump (#112).

libs/nistcurves master (v0.10.1) has completed the §2 ZP-registry migration using §6.5's loud-break alias shape:

zp_ptr2 = nistcurves_zp_ptr2 ; unguarded, NO .ifndef

Measured against master's src/zp_config.s (ca65 V2.18):

-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 (61)

So the hardcoded bare spelling is a hard assemble failure at v0.10.1, while the canonical spelling at v0.9.1 defines an unused symbol and leaves the real slot at upstream's $fd. Neither spelling is safe across both pins, so both wrappers now probelibs/nistcurves/src/zp_config.s for the canonical name and follow it.

Correction to an earlier draft of this text: I called the v0.9.1 + canonical combination a silent no-op. It is not silent in either implementation — the check_zp_slot od65 guards catch it, as a $fd != $3d mismatch or as <absent> when the guard is aimed at the canonical name (which is how LaneWaveBump's branch behaved). 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 keep their .ifndef guards across the migration (verified on master).

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.

3. Doc refs

Folded LaneFollowups' tools/uci/test_*.pyrig_*.py rename into the two references in docs/library-ingestion-architecture.md (the file I own); coordinated with them by message so it is not done twice.

Assessed but NOT implemented

G1 — §6.1 "no ar65 member surgery": non-conformant, and now demonstrably harmful. §6.1 (v0.9.0) states it plainly: "an archive whose member set a consumer has edited is outside every §5/§8.0 manifest claim it ships." The comb manifest above is exactly that harm, in our tree — the comb profile builds from upstream's fulllib-onchip archive and our wrapper then rm -fs ~7 members, so upstream's lib_manifest_onchip.o survives describing the pre-surgery archive. Not fixed here because the fix is a pin move, not a code change: the sanctioned remedies (§6.2 CONTRACT_DEFINES/CONTRACT_ZP_DEFINES, §6.3 lib-app-owned) do not exist in the pinned v0.9.1 — measured, both absent from its Makefile — and do exist on master/v0.10.1. Retiring the surgery is unblocked by the wave bump and belongs with or after it. Sequenced on #70.

G2 — §8.0 APP_OWNED coverage assert still not writable. Unchanged and for the same reason src/lib_contract_asserts.s already records: we resolve double-ownership by deleting archive members, so the shipped mask describes the upstream archive, not ours. G1's fix is this one's fix.

G3 — §8.2 LIB_SHARED_REU_MUL_* are exported. v0.8.5 (#82) makes these MUST-NOT-export and names our pair as the live case. Measured: nistcurves-p256.a still exports _BANK, _OFFSET, _BANKS_USED. Harmless today only because USE_X25519_SIBLING=1 links on neither backend. Fixed upstream in c64-nist-curves#103 / c64-x25519#92, i.e. the wave bump closes it — re-check after.

G4 — §4 load-bearing cfg attributes. Neither pinned sibling declares align/type dependencies, and v0.8.3's measurement is that both ld65 diagnostics are silent in the common shapes. Our LIB_NISTCURVES_P256_BSS is type = bss; CLAUDE.md records that this was checked against data_p256.s being 32 .res directives with no .byte/.word. That check is a comment, not a gate — and it is a cfg concern, out of bounds for this lane. Flagged for the cfg owner.

G5 — tight CRYPTO_HOT slack tripwire. Declined: needs a hardcoded per-pin budget, maintained every bump, false-alarming on growth of our own code. Rationale recorded in the new TU rather than shipped.

Required change I could not make — for the wave-bump lane (#112)

src/lib_contract_asserts.s is owned by that lane, so this is a request, not a diff:

  1. src/lib_contract_asserts.s:145.assert LIB_ABI_VERSION = 1. Measured: libs/nistcurves master ships LIB_NISTCURVES_ABI_VERSION = 2. This assert will fire on the bump. That is the gate working; expected value becomes 2 after the export surface is re-checked (v0.10.x is a second ABI generation after v0.9.0's 17-symbol removal).
  2. The two-library case is no longer hypotheticalchore(libs): nistcurves v0.9.1 -> v0.10.1, x25519 v0.10.0 -> v0.11.0 (lib-contract phase-3 wave) #113 makes make BACKEND=uci USE_X25519_SIBLING=1 link. But do not reach for -D LIB_NO_BARE_EXPORTS=1 reflexively: LaneWaveBump measured that the link succeeds without it (the collision was the §8.2 canonical entry alias, not the bare-name family), and that adopting the gate would break the onchip profile, since mul_8x8.s:64 gates the sqtab_lo/sqtab_hi exports behind it and src/crypto/poly1305.s:14 imports both. The prefixed-import migration in lib_contract_asserts.s stands on its own merits; the -D gate is not currently the remedy it looks like.
  3. The ZP wrapper change in this PR means the bump does not additionally need to touch tools/integration/build_nistcurves_p256.sh / _p384.sh for the zp_ptr2 rename.

Upstream defects found and filed

Filed as JC-000/c64-lib-contract#107. Two more members of the copy-paste-facing class the contract already tracks (#41, #50, #58, #73, #82, #100), both reproduced standalone with working replacements:

  1. §7.if LIB_<X>_ABI_VERSION <> <expected>, which §7 calls "the load-bearing breakage gate", does not assemble. It is the exact form v0.8.1 fixed in §1. Error: Constant expression expected.
  2. §8.0 — the LIB_<X>_SHARED_CONSUMES construction snippet's .if ::X25519_ONCHIP_MUL does not assemble when the profile is not selected — i.e. the library's own standalone build, the majority case. Adopters copying it verbatim break their default build. Replacement is .ifdef.

Verification

Rebased onto merged master a6cf205 (nistcurves v0.10.1 / x25519 v0.11.0). Two conflict regions in build_nistcurves_p256.sh, resolved in favour of the probe per the agreed resolution; master's reu_mul_init.o drop from #113 is preserved. Everything below is re-run at the new pins — nothing is carried over from the pre-rebase run.

CheckResult
make clean && make (ip65)PASS — 47,105 B, d522e684…
make clean && make BACKEND=uciPASS — 62,977 B, 66e37037…
ip65 + USE_NISTCURVES_ONCHIP=1PASS — 47,105 B, b181ec08…
uci + USE_NISTCURVES_ONCHIP=1PASS — 62,977 B, 118241e9…
uci + onchip + combPASS — 62,977 B, 211ad1bd…
tools/test_ecdsa_kat_oracle.pyPASS 6/6
tools/test_x509.pyPASS 11/11
tools/test_ecdsa_kat_oracle.py on an onchip PRGPASS 6/6

The onchip KAT is run because CLAUDE.md requires it after any change touching the poly_prod_lo/hi rendezvous — v0.10.0 moved those bytes outside the ct_mul_8x8 gate, and the REU-profile build does not link mul_8x8_onchip.o at all, so it would pass regardless.

Both REU PRG hashes (d522e684…, 66e37037…) are byte-identical to my pre-rebase v0.9.1 measurements — independent confirmation of LaneWaveBump's finding that the REU profile is unchanged by the wave bump. The onchip/comb hashes moved, as expected.

§6.6 assert re-confirmed at the new pins

profileRESIDENTCOLDsumvs __CRYPTO_HOT_SIZE__ 16384
reu87004309130fits
onchip87002408940fits
onchip-comb27000165028650over — pre-existing, correctly excluded

__CRYPTO_HOT_LAST__ is still $9FAF, so CRYPTO_HOT free space is unchanged at 81 B and the new TU's comment stays accurate.

Answering the open question on onchip COLD_BYTES: it is 240 at v0.9.1 and 240 at v0.10.1 — a constant, not a change. I measured the v0.9.1 figure directly before rebasing (lib_manifest_p256verify_onchip.o, od65), which is the datapoint LaneWaveBump did not have. The 430 vs 240 split is between profiles (reu vs onchip), not between pins.

ZP probe resolved correctly at the new pin — build/labels.txt shows all four slots as intended, with the canonical name and its alias in agreement:

al C:0039 .fp_mul_i al C:003A .fp_mul_j
al C:003D .nistcurves_zp_ptr2 al C:003D .zp_ptr2

The check_zp_slot split (canonical hard, alias tolerated-when-present) was verified against objects built from v0.10.1's zp_config.s in three shapes: alias emitted → proceed; -D LIB_NO_BARE_EXPORTS=1, alias absent → proceed; alias forced to $fdstops the build.

ip65 blob provenance — stated explicitly

src/net/ip65/ip65_blob.s does .incbin "../../../ip65-build/ip65-c64.bin", and an agent worktree sits exactly three levels below the repo root, so that path can resolve to the parent checkout's blob. I tested this rather than assuming: with my worktree's blob moved aside, ca65 assembled the TU successfully (exit 0, 10,804 B object), proving it resolved to the parent's file.

So, precisely: the ip65 links above were not hermetic to this worktree. What that does and does not affect —

  • PRG hashes are unaffected. Both blobs are byte-identical (cf1a5ff7…, the documented deterministic hash), so whichever ca65 opened, the bytes were the same.
  • I did also rebuild the blob inside this worktree (make ip65-libs && make ip65-blob, and again via a plain make), reproducing cf1a5ff7… byte-for-byte — a further independent confirmation of the blob's determinism.
  • I am therefore not claiming "builds from scratch in an isolated tree". That claim would need the main checkout absent, which no worktree run can establish.

LaneFollowups is narrowing this trap in #115 with a build/net/ip65/ip65_blob.o: $(IP65_BIN) dependency edge.

Doc corrections owed (I do not own CLAUDE.md / README.md)

  • CLAUDE.md's "MEMORY requirements" bullet says contract main is v0.7.2 and that "c64-https imports no contract manifest equate today". Both stale: main is v0.10.0, and we import five today (LIB_ABI_VERSION, LIB_NISTCURVES_REU_BANKS_USED, LIB_NISTCURVES_SHARED_PRIMITIVES, LIB_NISTCURVES_VERSION_MAJOR/_MINOR), seven after this PR.
  • The same bullet's "sections added since v0.4.0" list should now name §6 (build-and-consume), §6.6 and §6.7.

🤖 Generated with Claude Code

JC-000and others added 2 commits August 15, 2026 08:25
…in-adaptive ZP spelling
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 __<AREA>_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) <noreply@anthropic.com>
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) <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