Skip to content

[Language design] Add explicit typed openings for digest-committed substate #13

Closed

Description

@a19q3

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"]
Loading

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

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);
. Phase 0 must decide whether its current type-name component is stable and canonical enough for a public commitment identity or whether a registered successor profile is required. In either case, the profile must bind CKB Blake2b-256, type identity, width, exact packed bytes, explicit preimage limits, metadata identity, and deterministic vectors shared by compiler, builder, simulator, CKB-VM, and checker.

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> and Opening<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

References

Activity

  1. a19q3 commented on Aug 27, 2026

    @a19q3
    CollaboratorAuthor

    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:

    • the domain tag CellScriptPackedHashV0 followed by a zero byte;
    • the source type name;
    • a length field;
    • the packed value bytes.

    See the lowering path:

    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);
    self.emit_sp_addi("t0", dest_offset);
    self.emit_stack_store("t0", dest.id * 8);
    return Ok(true);
    }
    if func == "__ckb_hash_data_packed" {
    let Some(arg) = args.first() else {
    self.emit("# cellscript abi: fail closed because hash_data_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_data_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_data_packed input is not materializable");
    self.emit_fail(CellScriptRuntimeError::PackedHashPreimageMaterializationUnresolved);
    return Ok(true);
    };
    self.emit_prepare_fixed_byte_source(&source, width, "hash_data_packed input");
    if !self.emit_fixed_byte_source_pointer_or_const_to("a0", &source) {
    self.emit("# cellscript abi: fail closed because hash_data_packed input pointer is not materializable");
    self.emit_fail(CellScriptRuntimeError::PackedHashPreimageMaterializationUnresolved);

    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:

    • nominal Commitment identity;
    • explicit witness-qualified Opening;
    • verification-before-use;
    • exactly-once decode/materialization;
    • successor recomputation and output binding;
    • ProofPlan/checker linkage.

    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:

    1. Reuse CellScriptPackedHashV0 exactly for fixed-width committed values.
    2. Register a new commitment profile with a different domain and canonical Molecule encoding.
    3. Define a versioned bridge only if byte-for-byte equivalence can be proven for a selected type subset.

    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:

    • stable parent Cell schema stores an explicit commitment;
    • witness carries an explicit opening;
    • current Script authenticates the opening;
    • source code explicitly computes and constrains the successor commitment.

    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:

    • UncheckedOpeningBytes<T, Profile>: source-qualified witness bytes;
    • AuthenticatedOpening<T, Profile>: value available only after the commitment comparison succeeds;
    • Commitment<T, Profile>: fixed-size digest with type/profile identity.

    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:

    • reading fields before authentication;
    • using the value on a failure path;
    • authenticating against one parent and binding the successor to another;
    • duplicating a state-producing decode/evaluation with different reads;
    • changing private state while preserving the old commitment.

    Source and role binding

    The opening identity must include more than T:

    • exact parent input role and source;
    • witness field and group/global placement;
    • commitment field/source span;
    • codec/hash profile;
    • maximum encoded size;
    • optional opening semantics;
    • successor output role when state changes.

    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

    • fixed-width, non-Cell, non-recursive T only;
    • one registered Blake2b profile;
    • one explicit witness field/source rule;
    • exact preimage golden vectors;
    • authenticate once, bind an immutable typed view;
    • optional no-open path only when private state is not read or changed;
    • explicit successor commit tied to one output field;
    • no nested commitments and no dynamic fields.

    Acceptance additions

    • Same packed bytes under a different source type name: reject.
    • Same T under a different commitment profile/domain: reject.
    • Valid opening bound to the wrong parent input role: reject.
    • Two identical commitments in the transaction with one opening and ambiguous role binding: reject.
    • Field read before comparison: type/checker failure.
    • Correct successor hash written to the wrong output: reject.
    • Opening decoded twice with one mutated witness read: impossible by lowering/mutation test.
    • hash_blake2b_packed fail-closed materialization path accidentally treated as a valid commitment: reject.
    • Cross-version profile migration without explicit old/new domain: interface compatibility failure.
    • Dynamic offset/count corruption once dynamic profiles are admitted: same strict decoder matrix as [Candidate][Consensus runtime] Implement executable bounded Cell-group consumption #7/[Candidate][Consensus runtime] Implement executable bounded output-plan correspondence #8.

    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:

    • exact preimage construction;
    • hash helper/profile;
    • compare/reject block;
    • authenticated-value use region;
    • successor hash and output comparison.

    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.

  2. a19q3 commented on Sep 11, 2026

    @a19q3
    CollaboratorAuthor

    0.30 implementation update: the bounded fixed-width committed-substate slice landed in 1e2f642.

    Implemented:

    • nominal 32-byte Commitment and explicit one-shot witness Opening;
    • the existing CellScriptPackedHashV0 domain, exact packed bytes, fixed-width and 512-byte preimage limits;
    • hash-and-compare before materializing T, with stable mismatch error 73;
    • successor commitment correspondence, ProofPlan/lowering/source-map records, generated-builder ABI parity, formatter/LSP coverage;
    • deterministic vectors, real CKB-VM pass/tamper cases, and an exact maximum-shape resource fixture.

    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.

  3. a19q3 commented on Sep 11, 2026

    @a19q3
    CollaboratorAuthor

    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.

  4. a19q3 commented on Sep 11, 2026

    @a19q3
    CollaboratorAuthor

    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_opening passed twice (8/8); full artifact-checker integration passed 36/36; checker crate passed 28/28. Independent review remains intentionally open.

  5. a19q3 commented on Sep 12, 2026

    @a19q3
    CollaboratorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions