Key succession, the pin tool's half: elf/prior/ and the manifest's prior and succession blocks, the host embedding both pairs, the release-manifest check comparing both

docs/design/key-succession.md (the enforced-proving lane, 8 October 2026): the
object names the prior pair, a next pair, an activation height H and a window
W; the node embeds both pairs and verifies under the pair a proof claims when
that pair is accepted at the carrier's DAA (the node branch key-succession-node
on the fork carries that side).

The pin tool (igneum-prove-pin): a pin over an existing elf/ moves the four
files it holds to elf/prior/ and writes the manifest's `prior` block (the same
per-program fields plus the pinned_at of that pair) and a `succession` block
naming both pairs' ids; the top-level shard and aggregator stay the newest
pair; at most two pairs live (a pin over an elf/ that already carries prior/
replaces prior/); `--no-prior` keeps today's shape. The host (pinned.rs)
embeds elf/prior/'s four files, checks them against the `prior` block when the
manifest carries one and derives the prior ids from the keys (Pinned::prior,
PriorKeys), checks the `succession` block's four ids against both pairs, and
builds unchanged on a tree whose manifest names no `prior` (every tree now
carries elf/prior/ as a copy of the current pair, so the include_bytes
compile; the in-process verifier reads `prior` as absent). The release
manifest check refuses a served manifest whose `prior`, `succession`,
succession_daa or succession_window_daa differ from elf/manifest.json's, and
a `prior` present on one side only.

The node reads H, W and the next ids from its object, never from the manifest;
`succession` is for the served manifest and its check. The first real pin (the
live pair 0x2b1a81cb…/0x474678f3… into prior/, the re-pinned
0x282dcfce…/0x3fd721e8… on top) runs when main names H.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-08 17:34:55 +00:00
parent ecb70db5bb
commit 07c8380002
7 changed files with 128 additions and 2 deletions

Binary file not shown.

View file

@ -70,10 +70,32 @@ fn main() -> Result<()> {
let out = arg("--out").map(PathBuf::from).unwrap_or_else(|| here.join("elf"));
std::fs::create_dir_all(&out)?;
let host = std::process::Command::new("uname").arg("-a").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_else(|| "unknown".into());
// Key succession (docs/design/key-succession.md, 8 October 2026): a pin over an existing elf/ moves the pair it
// holds to elf/prior/ and writes the manifest's `prior` block (the same per-program fields) and a `succession`
// block naming both pairs' ids; the top-level shard and aggregator stay the newest pair. At most two pairs live:
// a pin over an elf/ that already carries prior/ replaces prior/ with the current pair. The node reads H, W and
// the next ids from its object, never from the manifest; `succession` is for the served manifest and its check.
// `--no-prior` keeps today's shape (the bootstrap, or a pin that replaces a pair never served).
let keep_prior = !args.iter().any(|a| a == "--no-prior");
let prior = if keep_prior && out.join("manifest.json").exists() {
let current: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(out.join("manifest.json"))?)?;
let prior_dir = out.join("prior");
std::fs::create_dir_all(&prior_dir)?;
for name in ["igneum-prove-program", "igneum-prove-aggregator"] {
for ext in ["elf", "vk"] {
let f = format!("{name}.{ext}");
std::fs::copy(out.join(&f), prior_dir.join(&f)).with_context(|| format!("move {f} to prior/"))?;
}
}
println!("RESULT pin: the pair pinned at {} moved to {} (prior)", current["pinned_at"].as_str().unwrap_or("?"), prior_dir.display());
Some(serde_json::json!({ "shard": current["shard"], "aggregator": current["aggregator"], "pinned_at": current["pinned_at"] }))
} else {
None
};
let light = LightProver::new();
let shard = pin_one(&light, &from, &out, "igneum-prove-program")?;
let aggregator = pin_one(&light, &from, &out, "igneum-prove-aggregator")?;
let manifest = serde_json::json!({
let mut manifest = serde_json::json!({
"format": "igneum-prove-elf-manifest-v1",
"sp1_crate_version": "6.8.1",
"sp1_circuit_version": sp1_sdk::SP1_CIRCUIT_VERSION,
@ -82,6 +104,26 @@ fn main() -> Result<()> {
"shard": shard,
"aggregator": aggregator,
});
if let Some(p) = prior {
manifest["succession"] = serde_json::json!({
"prior_shard_program_id": p["shard"]["program_id"],
"prior_aggregator_id": p["aggregator"]["program_id"],
"next_shard_program_id": manifest["shard"]["program_id"],
"next_aggregator_id": manifest["aggregator"]["program_id"],
});
manifest["prior"] = p;
} else {
// no pair to carry: prior/ is a copy of the current pair so the host's include_bytes compile, the manifest
// names no `prior` (the in-process verifier reads it as absent)
let prior_dir = out.join("prior");
std::fs::create_dir_all(&prior_dir)?;
for name in ["igneum-prove-program", "igneum-prove-aggregator"] {
for ext in ["elf", "vk"] {
let f = format!("{name}.{ext}");
std::fs::copy(out.join(&f), prior_dir.join(&f))?;
}
}
}
std::fs::write(out.join("manifest.json"), format!("{}\n", serde_json::to_string_pretty(&manifest)?))?;
println!("RESULT pin: manifest written to {}; rebuild the host so it embeds these files", out.join("manifest.json").display());
Ok(())

View file

@ -26,6 +26,13 @@ pub const SHARD_VK_BYTES: &[u8] = include_bytes!("../../elf/igneum-prove-program
pub const AGG_ELF_BYTES: &[u8] = include_bytes!("../../elf/igneum-prove-aggregator.elf");
pub const AGG_VK_BYTES: &[u8] = include_bytes!("../../elf/igneum-prove-aggregator.vk");
pub const MANIFEST_JSON: &str = include_str!("../../elf/manifest.json");
/// Key succession (docs/design/key-succession.md, 8 October 2026): the prior pair's files under `elf/prior/` (the same
/// names), present in every tree (a copy of the current pair until a pin moves one there, the manifest then naming no
/// `prior`); the host embeds them so a node carries both pairs across a succession window.
pub const PRIOR_SHARD_ELF_BYTES: &[u8] = include_bytes!("../../elf/prior/igneum-prove-program.elf");
pub const PRIOR_SHARD_VK_BYTES: &[u8] = include_bytes!("../../elf/prior/igneum-prove-program.vk");
pub const PRIOR_AGG_ELF_BYTES: &[u8] = include_bytes!("../../elf/prior/igneum-prove-aggregator.elf");
pub const PRIOR_AGG_VK_BYTES: &[u8] = include_bytes!("../../elf/prior/igneum-prove-aggregator.vk");
/// One pinned program: file names, hashes and the program id (`agg::vk_bytes` of the verifying key's `hash_u32`).
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@ -47,6 +54,29 @@ pub struct Manifest {
pub pinned_on: String,
pub shard: PinnedProgram,
pub aggregator: PinnedProgram,
/// Key succession: the pair pinned before this one (`elf/prior/`), present only once a pin has moved one there.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prior: Option<PriorPair>,
/// Key succession: both pairs' ids as the served manifest carries them (the node reads H, W and the next ids
/// from its object, never from here).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub succession: Option<Succession>,
}
/// The prior pair's block: the same per-program fields as the top level and the time it was pinned.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct PriorPair {
pub shard: PinnedProgram,
pub aggregator: PinnedProgram,
pub pinned_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Succession {
pub prior_shard_program_id: String,
pub prior_aggregator_id: String,
pub next_shard_program_id: String,
pub next_aggregator_id: String,
}
impl Manifest {
@ -78,6 +108,17 @@ pub struct Pinned {
pub agg_vk: SP1VerifyingKey,
pub shard_id: B256,
pub agg_id: B256,
/// Key succession: the prior pair (its two verifying keys and ids) when the manifest carries a `prior` block and
/// the embedded prior files hash to it; `None` on today's shape.
pub prior: Option<PriorKeys>,
}
/// The prior pair as loaded: the keys and the ids derived from them.
pub struct PriorKeys {
pub shard_vk: SP1VerifyingKey,
pub agg_vk: SP1VerifyingKey,
pub shard_id: B256,
pub agg_id: B256,
}
impl Pinned {
@ -95,7 +136,35 @@ impl Pinned {
let agg_id = program_id_of(&agg_vk);
check_id("shard", shard_id, &manifest.shard.program_id)?;
check_id("aggregator", agg_id, &manifest.aggregator.program_id)?;
Ok(Self { manifest, shard_vk, agg_vk, shard_id, agg_id })
let prior = match &manifest.prior {
Some(p) => {
check_file("prior shard ELF", PRIOR_SHARD_ELF_BYTES, &p.shard.elf_sha256)?;
check_file("prior shard verifying key", PRIOR_SHARD_VK_BYTES, &p.shard.vk_sha256)?;
check_file("prior aggregator ELF", PRIOR_AGG_ELF_BYTES, &p.aggregator.elf_sha256)?;
check_file("prior aggregator verifying key", PRIOR_AGG_VK_BYTES, &p.aggregator.vk_sha256)?;
let pvk: SP1VerifyingKey = bincode::deserialize(PRIOR_SHARD_VK_BYTES).context("the embedded prior shard verifying key does not deserialize")?;
let pavk: SP1VerifyingKey = bincode::deserialize(PRIOR_AGG_VK_BYTES).context("the embedded prior aggregator verifying key does not deserialize")?;
let (psid, paid) = (program_id_of(&pvk), program_id_of(&pavk));
check_id("prior shard", psid, &p.shard.program_id)?;
check_id("prior aggregator", paid, &p.aggregator.program_id)?;
if let Some(s) = &manifest.succession {
check_id("succession prior shard", psid, &s.prior_shard_program_id)?;
check_id("succession prior aggregator", paid, &s.prior_aggregator_id)?;
check_id("succession next shard", shard_id, &s.next_shard_program_id)?;
check_id("succession next aggregator", agg_id, &s.next_aggregator_id)?;
}
Some(PriorKeys { shard_vk: pvk, agg_vk: pavk, shard_id: psid, agg_id: paid })
}
None => None,
};
Ok(Self { manifest, shard_vk, agg_vk, shard_id, agg_id, prior })
}
pub fn prior_shard_elf(&self) -> Option<Elf> {
self.prior.as_ref().map(|_| Elf::Static(PRIOR_SHARD_ELF_BYTES))
}
pub fn prior_agg_elf(&self) -> Option<Elf> {
self.prior.as_ref().map(|_| Elf::Static(PRIOR_AGG_ELF_BYTES))
}
pub fn shard_elf(&self) -> Elf {

View file

@ -28,6 +28,21 @@ if (existsSync(elfp)) {
const e = JSON.parse(readFileSync(elfp, 'utf8'));
for (const [a, b] of [['shard_program_id', e.shard.program_id], ['shard_elf_sha256', e.shard.elf_sha256], ['shard_vk_sha256', e.shard.vk_sha256], ['aggregator_program_id', e.aggregator.program_id], ['aggregator_elf_sha256', e.aggregator.elf_sha256], ['aggregator_vk_sha256', e.aggregator.vk_sha256], ['sp1_circuit_version', e.sp1_circuit_version], ['sp1_crate_version', e.sp1_crate_version], ['pinned_at', e.pinned_at]])
if (m.proving[a] !== b) fails.push(`manifest: proving.${a} is ${m.proving[a]}, the ELF manifest says ${b}`);
// key succession (docs/design/key-succession.md, 8 October 2026): the served manifest carries the prior pair and the
// succession block exactly as the ELF manifest does; a `prior` present on one side only is a refusal, absent on both is
// today's shape
const ep = e.prior && typeof e.prior === 'object' ? e.prior : null;
const mp = m.proving.prior && typeof m.proving.prior === 'object' ? m.proving.prior : null;
if (!!ep !== !!mp) fails.push(`manifest: proving.prior is ${mp ? 'present' : 'absent'} but the ELF manifest's prior is ${ep ? 'present' : 'absent'}`);
if (ep && mp) {
for (const [a, b] of [['shard_program_id', ep.shard?.program_id], ['shard_elf_sha256', ep.shard?.elf_sha256], ['shard_vk_sha256', ep.shard?.vk_sha256], ['aggregator_program_id', ep.aggregator?.program_id], ['aggregator_elf_sha256', ep.aggregator?.elf_sha256], ['aggregator_vk_sha256', ep.aggregator?.vk_sha256], ['pinned_at', ep.pinned_at]])
if (mp[a] !== b) fails.push(`manifest: proving.prior.${a} is ${mp[a]}, the ELF manifest's prior says ${b}`);
const es = e.succession || {}, ms = m.proving.succession || {};
for (const k of ['prior_shard_program_id', 'prior_aggregator_id', 'next_shard_program_id', 'next_aggregator_id'])
if (ms[k] !== es[k]) fails.push(`manifest: proving.succession.${k} is ${ms[k]}, the ELF manifest says ${es[k]}`);
for (const k of ['succession_daa', 'succession_window_daa'])
if (!(k in m.proving) || !(Number.isInteger(m.proving[k]) || m.proving[k] === 'never')) fails.push(`manifest: proving.${k} must be an integer DAA or "never" when a prior pair is carried`);
}
}
const vercel = JSON.parse(readFileSync(join(site, 'vercel.json'), 'utf8'));
if (!(vercel.rewrites || []).some(r => r.source === '/release.json' && r.destination === '/release-manifest.json')) fails.push('vercel.json: no rewrite /release.json -> /release-manifest.json');