Repository navigation
[Language design] Add explicit typed openings for digest-committed substate #13
Description
Summary
Design an explicit, typed abstraction for opening, reading, updating, and recommitting digest-backed substate while keeping the parent Cell schema stable.
The target use case is an extensible capsule or plugin state: a shared base schema stores a commitment, while a concrete implementation supplies a typed preimage through a versioned witness, proves that the preimage matches the stored commitment, applies checked logic, and commits the successor substate.
Argent's virtual-slot state expansion demonstrates this application pattern. CellScript should preserve its stronger explicit-schema rule: the commitment field and witness opening must remain visible in source, ABI, ProofPlan, and metadata. The compiler must not inject hidden persistent fields.
Motivation
A stable external state shape is useful when independently developed implementations need private or implementation-specific memory:
Stable public capsule:
- controller identity
- implementation/interface handle
- strategy commitment
- energy / nonce / public counters
Implementation-private committed state:
- strategy parameters
- model memory
- plugin configuration
- bounded auxiliary state
Today an author can manually hash witness bytes and compare them to a Hash field, but the compiler cannot prove that:
- both sides use the same canonical schema;
- the hash has the correct domain separation;
- the preimage is decoded exactly once;
- every read comes from an authenticated opening;
- every mutation is reflected in the successor commitment;
- metadata and builders use the same witness codec;
- the checker can link source-level commitment semantics to machine code.
Explicit source model
The final syntax requires an RFC. A strawman illustrates the intended visibility:
struct AgentCapsule {
controller: Address
strategy_commitment: Commitment<StrategyState, CellScriptPackedHashV0>
energy: u64
}
struct StrategyState {
hunger: u64
mood: u64
}
action step(
before: AgentCapsule,
witness opening: Opening<StrategyState>
) -> after: AgentCapsule {
transition before -> after
verification
let strategy =
open(before.strategy_commitment, opening)
let next_strategy = StrategyState {
hunger: strategy.hunger + 1,
mood: strategy.mood
}
require after.strategy_commitment ==
commit<CellScriptPackedHashV0>(next_strategy)
require after.energy == before.energy - 1
}
The parent schema stores only the commitment. The opening is transaction evidence, not persistent hidden state.
flowchart LR
CELL["Input Cell data<br/>typed base schema"] --> DIGEST["Stored commitment"]
WIT["WitnessArgs opening<br/>versioned Molecule T"] --> DECODE["Decode T once"]
DECODE --> HASH["Domain-separated canonical hash"]
DIGEST --> MATCH{"hash == commitment?"}
HASH --> MATCH
MATCH -- "no" --> REJECT["Stable opening error"]
MATCH -- "yes" --> VALUE["Authenticated typed T"]
VALUE --> UPDATE["Compute next T"]
UPDATE --> COMMIT["Commit canonical next T"]
COMMIT --> OUT["Constrain output commitment"]
Required semantic properties
Typed commitment identity
Commitment<T, H, Domain> must include, directly or through canonical metadata identity:
- source type identity of
T; - registered serialization profile, type identity, and profile version;
- hash algorithm/profile;
- domain-separation identifier;
- fixed/dynamic size bounds;
- compatibility identity.
A commitment to StrategyStateV1 must not be interchangeable with the same bytes interpreted as another type.
Authenticated opening
An Opening<T> must:
- come from an explicit witness/source qualifier;
- use a versioned canonical codec;
- be decoded once with strict bounds;
- be hashed over the exact canonical bytes;
- become a typed value only after the digest comparison succeeds;
- never escape as an authenticated value on a failure path.
Successor correspondence
When source semantics claim an updated committed substate:
- the successor value must be evaluated once;
- the output commitment must be derived from the exact typed successor;
- no path may preserve the old digest while using mutated private state;
- no path may construct a digest from noncanonical bytes;
- linear Cell effects must remain explicit on the parent Cell.
No behavioral inference
Matching a base capsule or interface shape does not prove that an external implementation follows a desired behavior. This feature authenticates state openings and updates; it does not establish semantic equivalence between plugins.
Hash and codec requirements
The first fixed-width version should build on the existing CellScriptPackedHashV0\0 preimage contract used by hash_blake2b_packed instead of silently inventing a second encoding:
CellScriptPackedHashV0\0 || type_name || 0 || width_u32_le || exact packed bytes
The current implementation is visible at
CellScript/src/codegen/calls.rs
Lines 54 to 105 in 8ae6dc4
| if func == "__ckb_hash_blake2b_packed" { | |
| let Some(arg) = args.first() else { | |
| self.emit("# cellscript abi: fail closed because hash_blake2b_packed is missing input"); | |
| self.emit_fail(CellScriptRuntimeError::PackedHashPreimageMaterializationUnresolved); | |
| return Ok(true); | |
| }; | |
| let Some(width) = operand_fixed_byte_width(arg).or_else(|| match arg { | |
| IrOperand::Var(var) => self.fixed_byte_like_width(&var.ty), | |
| _ => None, | |
| }) else { | |
| self.emit("# cellscript abi: fail closed because hash_blake2b_packed input has no static packed width"); | |
| self.emit_fail(CellScriptRuntimeError::PackedHashPreimageMaterializationUnresolved); | |
| return Ok(true); | |
| }; | |
| let Some(source) = self.expected_fixed_byte_source(arg, width) else { | |
| self.emit("# cellscript abi: fail closed because hash_blake2b_packed input is not materializable"); | |
| self.emit_fail(CellScriptRuntimeError::PackedHashPreimageMaterializationUnresolved); | |
| return Ok(true); | |
| }; | |
| let type_name = match arg { | |
| IrOperand::Var(var) => named_type_name(&var.ty).map(str::to_string).unwrap_or_else(|| aggregate_type_label(&var.ty)), | |
| IrOperand::Const(_) => "const".to_string(), | |
| }; | |
| let mut header = b"CellScriptPackedHashV0\0".to_vec(); | |
| header.extend_from_slice(type_name.as_bytes()); | |
| header.push(0); | |
| header.extend_from_slice(&(width as u32).to_le_bytes()); | |
| let total_width = header.len() + width; | |
| if total_width > RUNTIME_SCRATCH_BUFFER_SIZE { | |
| self.emit("# cellscript abi: fail closed because hash_blake2b_packed preimage exceeds scratch buffer"); | |
| self.emit_fail(CellScriptRuntimeError::PackedHashPreimageMaterializationUnresolved); | |
| return Ok(true); | |
| } | |
| let buffer_offset = self.runtime_scratch_buffer_offset(); | |
| for (index, byte) in header.iter().enumerate() { | |
| self.emit(format!("li t0, {}", byte)); | |
| self.emit_stack_store_byte("t0", buffer_offset + index); | |
| } | |
| self.emit_prepare_fixed_byte_source(&source, width, "hash_blake2b_packed input"); | |
| if !self.emit_fixed_byte_source_pointer_or_const_to("a0", &source) { | |
| self.emit("# cellscript abi: fail closed because hash_blake2b_packed input pointer is not materializable"); | |
| self.emit_fail(CellScriptRuntimeError::PackedHashPreimageMaterializationUnresolved); | |
| return Ok(true); | |
| } | |
| self.emit_sp_addi("a1", buffer_offset + header.len()); | |
| self.emit(format!("li a2, {}", width)); | |
| self.emit("call __cellscript_memcpy_fixed"); | |
| self.emit_sp_addi("a0", buffer_offset); | |
| self.emit(format!("li a1, {}", total_width)); | |
| self.emit_sp_addi("a2", dest_offset); | |
| self.emit("call __ckb_hash_blake2b_var"); | |
| self.emit_return_on_syscall_error(CellScriptRuntimeError::SyscallFailed); |
Do not describe the fixed-width packed representation as Molecule. Bounded dynamic Molecule values require a separate registered codec/profile after the strict offset/count work in #7 and #8. Additional hash algorithms likewise require separate registered profiles; do not accept a free-form hash function parameter.
Representation options to decide
The RFC must choose and document:
| Question | Required decision |
|---|---|
| commitment bytes | raw 32 bytes versus a nominal fixed-width wrapper |
| domain separation | type-derived, declaration-derived, or explicit registered domain |
| witness placement | input_type, output_type, lock, or declared source |
| dynamic fields | initial fixed-width-only boundary versus bounded Molecule dynamics |
| nested commitments | reject initially or define depth and size budgets |
| optional openings | explicit enum/Option semantics; no empty-witness ambiguity |
| unchanged state | require opening, or permit digest-preserving paths that never read private fields |
| package upgrade | how compatible schema evolution affects commitment identity |
Implementation phases
Phase 0 — RFC and threat model
Cover:
- type/codec/hash/domain identity;
- witness source and size bounds;
- opening validity and evaluation order;
- unchanged versus updated paths;
- nested/dynamic limits;
- interface and package compatibility;
- error codes;
- ProofPlan and checker obligations.
Phase 1 — fixed-width committed values
- implement
Commitment<T>andOpening<T>for fixed-width non-Cell values; - add canonical commit/open operations;
- retain all source qualifiers and identities in typed semantics;
- generate exact CKB-VM hash and compare paths;
- add builder witness encoding.
Phase 2 — successor updates
- require explicit output commitment correspondence;
- add one-to-one source/update/output ProofPlan records;
- reject stale-digest and noncanonical-reencoding paths;
- add standalone checker mutations for missing opening and recommit blocks.
Phase 3 — bounded dynamic values
Only after fixed-width stabilization:
- permit selected bounded Molecule dynamic fields;
- enforce exact byte/count limits;
- extend cycle, stack, witness, and transaction-size evidence;
- keep nested or recursive shapes fail-closed unless separately specified.
Adversarial matrix
| Case | Expected |
|---|---|
| valid canonical opening | pass |
| one-byte preimage mutation | stable commitment mismatch |
| valid bytes under wrong type identity | reject |
| noncanonical bytes for the selected profile, including Molecule once a dynamic profile is admitted | reject |
| wrong hash/domain profile | reject |
| truncated or oversized witness | stable codec/size error |
| read private field before authentication | compile/checker rejection |
| update private value but preserve old digest | reject |
| compute correct digest but bind it to wrong output role | reject |
| evaluate state-producing expression twice with different reads | prevented by lowering and tested |
| remove hash comparison from ELF | standalone checker rejects |
| preserve digest without opening and without reading private state | allowed only if explicitly specified |
| nested opening beyond configured depth | production rejection |
Completion criteria
- An accepted RFC fixes type, codec, hash, domain, source, and update semantics.
- Commitment/opening types are explicit in source and public interfaces.
- Parent Cell schemas contain only author-declared fields; no hidden persistent field is injected.
- Authenticated openings cannot be used before verification or escape failure paths.
- Successor commitments are tied to exactly one evaluated typed successor.
- Typed semantics, ProofPlan, lowering records, source maps, and checker preserve the complete contract.
- Compiler, builder, simulator, CKB-VM, and independent checker share exact hash/codec vectors.
- Fixed-width positive and adversarial matrices pass before dynamic values are admitted.
- Cycle, memory, witness, transaction-size, and capacity bounds are recorded.
- LSP, formatter, VS Code, Playground summary, docs, and generated builders agree.
- Independent security review covers codec ambiguity, domain separation, stale commitments, and output correspondence.
Non-goals
- inheritance;
- hidden compiler-owned persistent state;
- arbitrary opaque witness bytes;
- generic recursive/dynamic allocation;
- behavioral equivalence of plugin implementations;
- automatically authorizing a runtime-selected Script;
- placing Cell-backed resources inside ordinary commitment values;
- replacing the explicit parent Cell lifecycle.
Relationship to other work
- [Language design] Model typed cross-Script transaction roles without runtime linkage #10 can use committed substate in open protocol roles, but matching the commitment shape alone does not prove peer behavior.
- [Language/ABI] Bind runtime Script values to verified package interfaces #11 can identify the selected Script implementation; this issue separately authenticates its committed state opening.
- [Candidate][Consensus runtime] Implement executable bounded output-plan correspondence #8 provides the output-correspondence principles that must also apply to a changed parent commitment.
Activity
a19q3 commentedon Aug 27, 2026
Research update: reuse the existing packed-hash profile unless the RFC proves it is incompatible
Comment audit
There were no prior comments on this issue at the time of review.
Existing CellScript capability
CellScript already has a typed packed-hash operation. The backend constructs a preimage containing:
See the lowering path:
CellScript/src/codegen/calls.rs
Lines 54 to 132 in 8ae6dc4
The operation fails closed with PackedHashPreimageMaterializationUnresolved when the value has no static packed width, cannot be materialized, or exceeds the bounded scratch contract.
Therefore the missing feature is not “CellScript cannot hash typed values.” It is the authenticated opening lifecycle:
Compatibility decision required
The original issue body proposed canonical Molecule encoding, while the existing hash_blake2b_packed profile hashes CellScript's packed representation. The issue body has now been corrected: fixed-width v1 must start from the existing profile contract or explicitly register a successor, while dynamic Molecule remains a later separate profile. The RFC must choose one of three honest paths:
It must not silently call both encodings “canonical.” A commitment created by one profile must never open under another.
The safest Phase 1 is likely fixed-width values under the already implemented packed-hash profile, with explicit profile identity in Commitment<T, Profile>. Dynamic Molecule values can remain a later profile after strict offset/count work from #7/#8 is available.
What Argent contributes
Argent PR #10 demonstrates the product pattern of digest-backed state expansion, and recent helper/function work such as PR #50 reinforces that implementation-private operations should remain within the actor/state boundary.
The useful adaptation for CellScript is:
Do not import hidden virtual persistent fields or treat a matching digest as authority to run another Script.
Type-system rule: authenticated values are capabilities
Opening should not be an ordinary user-constructible struct. The compiler should distinguish at least:
Only a checked open operation can produce AuthenticatedOpening. Ordinary casts, deserialization, test helpers, or raw hash equality must not create it.
Control-flow analysis must prevent:
Source and role binding
The opening identity must include more than T:
This prevents a valid opening for one input Cell from being replayed as the opening for another equal-looking commitment in the same transaction.
Recommended Phase 1
Acceptance additions
Checker boundary
The independent checker should not attempt to infer a commitment merely because it sees a Blake2b call and equality branch. Typed semantics and ProofPlan must emit a versioned commitment/open/update record, and lowering must bind that record to:
Mutation tests should remove each link separately.
Conclusion
This remains a valuable, use-case-driven feature, but the implementation should build on CellScript's existing typed packed hash rather than inventing a second encoding by accident. The RFC's first decision is the commitment profile and byte identity; the second is the authentication-before-use type/effect boundary.
a19q3 commentedon Sep 11, 2026
0.30 implementation update: the bounded fixed-width committed-substate slice landed in 1e2f642.
Implemented:
This does not complete the whole issue yet. Standalone machine-block mutations for removed/reordered opening and recommit sequences, plus independent review, are still pending. Bounded dynamic/Molecule values remain outside this first profile. The capability ledger therefore keeps #13 open and candidate, not release-complete.
a19q3 commentedon Sep 11, 2026
Follow-up for the pushed 0.30 commit 232e5c7: the previously reported repository-local machine-code mutation gap is now resolved.
The standalone artifact checker now binds the typed open/recommit operation order to the exact generated machine sequence: domain/type/width header stores, the 512-byte scratch boundary, exact opening-size guard, copy/hash/compare arguments and status paths, compare-before-materialize, stable mismatch error 73, and successor commitment destination. Rebound mutations cover marker removal/reordering plus header, hash length, comparison width/branch, and authenticated destination-store changes.
Validation on Rust 1.97.1: artifact checker 35/35; committed-state CKB-VM suite 7/7 (including repeated maximum-shape identity/resource checks); business anchor 3/3; artifact-checker and root clippy clean; WASM 11/11. Runtime ELF and transaction identities remain unchanged; only lowering/source-map/bundle-derived identities changed.
The issue remains open only for the recorded independent cryptographic/compiler review and release acceptance. No TAC, release, deployment, or signing flow was run.
a19q3 commentedon Sep 11, 2026
0.30 update: commit 12df6db is now pushed. The standalone checker now binds Commitment values reached through typed schema fields to the exact root stack slot plus accumulated fixed-layout offset, with a mutation test that changes the offset and is rejected as V2420. A new independently checked CKB-VM fixture pins the successor transaction and the stale, malformed, wrong-root, wrong-index, and wrong-successor cases, including artifact/lowering/source-map/bundle identities and distinct raw/serialized transaction hashes.
cargo +1.97.1 test --locked --test commitment_openingpassed twice (8/8); full artifact-checker integration passed 36/36; checker crate passed 28/28. Independent review remains intentionally open.a19q3 commentedon Sep 12, 2026
0.30 closure update: the bounded fixed-width committed-substate contract is complete. It includes nominal Commitment, one-shot typed Opening, exact CellScriptPackedHashV0/CKB Blake2b-256 identity, compare-before-materialize lowering, canonical shared entry-witness placement, successor-field correspondence, typed ProofPlan/checker records, root-slot plus fixed-layout offset binding, and mutation coverage.
Exact CKB-VM evidence covers the valid opening/successor flow and stale, malformed, wrong-root, wrong-index, and wrong-successor cases. The clean release replay at 3cf60a1 passed. Independent review was explicitly waived by the maintainer for this bounded 0.30 scope; it was not performed or claimed as passed. Dynamic openings remain outside the admitted contract. Closing as complete.