Bindings need to execute semantic store requests without retaining a
machine borrow across a thread or await. This note defines the shared
binding layer used by C/WASM and PyO3 for the operations owned by store-record-fact-access.md.
Each endpoint machine owns at most one active StoreCall.
It contains the suspended continuation, ordered StoreOp,
and an empty or filled complete response. Submission consumes the active
call exactly once. A connected interlace therefore has at most one; a
direct pair may have one per side concurrently.
Native Rust code may borrow StoreCall within one machine turn. No foreign API exposes its address. A raw StoreCall pointer is not a durable identity: a borrow expires on machine mutation and an allocator could later reuse its address.
The shared binding layer in the lace-ffi rlib provides
safe typed Rust APIs and explicitly unsafe trusted-record byte commits.
Thin C/WASM exports and PyO3 wrappers use that same layer. It owns value
projection and identity checks only—never a store, Lace, mutation gate,
watch, future, worker, callback, or runtime lifecycle.
A binding issues one move-only execution value for an active side of an interlace machine or for a retention sweep:
StoreWork
├── hidden issuer: interlace-machine identity and side, or sweep identity
├── hidden nonzero StoreCall id
├── owned StoreOp
└── empty or complete owned Result<StoreResult,StoreError>
The C/WASM spelling is an opaque owned allocation:
typedef struct LaceStoreWork LaceStoreWork;
LaceStoreWork *lace_interlace_machine_issue_store_work(
LaceInterlaceMachine *machine,
uint32_t side,
LaceErrorOut *error);
uint32_t lace_store_work_side(const LaceStoreWork *work);
uint64_t lace_store_work_id(const LaceStoreWork *work);
uint32_t lace_store_work_op_kind(const LaceStoreWork *work);
int32_t lace_interlace_machine_submit_store_work(
LaceInterlaceMachine *machine,
LaceStoreWork *work,
LaceErrorOut *error); /* consumes work */
void lace_store_work_free(LaceStoreWork *work);Normal routing uses protocol side; sweep work reports side 0. The
sweep’s
lace_retention_sweep_issue_store_work/_submit_store_work
pair follows the same rules (ABI.md).
The id is available for diagnostics and tests; callers do not
reconstruct a ticket. The binding issues at most one work value for one
active ticket. A second issue is an integration error even if the first
value was freed. Abandoning work while the operation remains live
requires terminating that operation.
StoreWork is immutable while another thread executes it except
through its exclusive result-construction calls. It may be moved across
one thread boundary or retained as an opaque WASM pointer across
await, but callers must not access it concurrently. PyO3
exposes the same move-only Rust value; its machine and codec wrappers
may move between threads, and PyO3 borrow checking rejects concurrent
calls.
In raw WASM the numeric pointer denotes the owned Rust allocation,
not StoreCall. It remains opaque across memory.grow. After
a result-byte reservation, the host reacquires
memory.buffer, fills exactly the reservation, and makes
commit the next call on that work value.
StoreWork accessors need no machine argument and expose one owned operation:
CurrentStorePosition
ResolveStoreRecordIds
FindRecords
LoadRecordsByHash
LoadRecordsByStoreId
StoreRecords
They are positional and operation-neutral: ordered request entries
(hash, store id, or record bytes), read_through,
lower_exclusive, resume cursor, row bound, and one ordered
sequence of exact-hash, field, field-prefix, and record-link constraints
read by slot and text part. No accessor or result builder is named after
an operation; the operation StoreWork holds selects the meaning. Actual
StoreRecordId inputs reject zero.
PyO3 projects a FindRecords query once into owned Python values used
directly by the host’s StoreQuery. Constraint order,
duplicates, and absent versus empty values survive projection and
StoreWork consumption; C/WASM returns an absent text part as a null
pointer and empty text as a non-null pointer with zero length.
Containment, exposure, Datalog, and policy do not cross this boundary. Rust projects facts and applies those constraints above storage.
Foreign result calls mutate only StoreWork. They never incrementally mutate the live machine. Their families are:
CurrentStorePosition
ResolvedStoreRecordIds
FoundRecords
LoadedRecords
StoredRecords
StoreError
Exact outcomes are positional against the StoreOp retained by StoreWork:
ResolveStoreRecordIds[i] -> StoreRecordId | Missing
LoadRecordsByHash[i] -> Loaded(record bytes) | Missing
LoadRecordsByStoreId[i] -> Loaded(record bytes) | Missing
StoreRecords[i] -> Stored(StoreRecordId) | Duplicate(StoreRecordId)
Outcomes do not echo requested hashes, ids, input hashes, or response slots. Construction calls use a slot only to fill the corresponding position. The complete result retains no slot value.
C/WASM builders set a position or completion flag, add an id-only
entry outcome, or reserve and commit one entry’s record bytes.
Full-record reserve/commit buffers belong to StoreWork until it is
consumed or freed. Their trusted-byte preconditions are defined in lace-ffi/ABI.md.
The Rust StoreWork raw-byte commit methods are explicitly
unsafe under those same preconditions; request/hash checks
do not replace prior record validation. The safe typed entry-record
method, used by PyO3, instead accepts validated Record
owners; found hashes come from those records, while exact loads still
check the requested or frozen expected hash. Arbitrary Python bytes
enter through Record.from_bytes, not store-result
construction. The Python host clones its existing record owner into the
same work-owned result builder without serializing or reparsing it; heap
and native mmap backing remain shared. Typed fills use the same result
family, positional coverage, and submission validation as byte
transport.
Equivalent construction covers found rows, resolved identities, store
dispositions, post-store high-water, and one explicit whole-request
StoreError. Pending is host scheduling state,
not a StoreResult.
Before touching the machine, the shared binding layer validates the complete owned result against its StoreOp:
read_through;Invalid or partial construction is an integration error and leaves machine StoreCall unchanged.
Submission consumes StoreWork on success or failure. It verifies the
hidden issuer, active StoreCall id, and exactly-once state; moves one
already-validated Result<StoreResult,StoreError> into
StoreCall’s complete response; and submits once, consuming the active
call. Wrong-issuer (including sweep work submitted to an interlace
machine and the reverse), stale, incomplete, and repeated submission are
integration errors. No pointer equality participates.
Policy replacement uses the same exclusive machine owner and remains rejected while StoreCall is active. While one endpoint is blocked, its direct sibling may continue independently.
Immediate and potentially blocking foreign stores use the same work shape:
issue StoreWork
→ host executes its owned StoreOp
→ construct one complete owned result
→ for mutation, classify durability and publish through the owning Lace
→ the operation driving the machine submits StoreWork
An indexed-memory primitive executes work synchronously. OPFS, native
filesystem, and custom stores may move it through a JavaScript promise,
Python worker thread or an application-owned native executor. Only
scheduling differs. Rust records remain Arc-backed in StoreOp and
StoreResult. Native filesystem records retain
Arc<MmapRaw> plus one range that was admitted by the
immutable record-log prefix; no safe arbitrary mmap range crosses this
boundary.
Every semantic terminal transition retires StoreCall in its actual
owner: DirectPair retires both endpoint effects and
connected SessionMachine retires its endpoint effect.
ConnectedCarrier, FFI, and host adapters expose no discard
hook. If timeout, cancellation, failure, or drop wins after work starts,
a late read frees its work without submission. A late mutation first
refreshes and publishes any confirmed durable position through its
owning Lace, retains any store, refresh, or publication failure on that
Lace, then frees work without submission. Refresh failure is
Store; publication failure is Integration;
neither permits successful mutation submission. StoreWork itself never
publishes, schedules, or calls back.
Foreign builds expose one opaque policy-neutral Rust store that
executes StoreWork. It holds a lace-sans-io
StoreBackend implementation; only its constructors name a
backend:
LaceStore *lace_store_memory_new(void);
LaceStore *lace_store_fs_open(const uint8_t *path, size_t path_len, LaceErrorOut *error);
LaceStore *lace_store_lmdb_open(const uint8_t *path, size_t path_len, LaceErrorOut *error);
int32_t lace_store_execute_work(LaceStore *store, LaceStoreWork *work, LaceErrorOut *error);The native filesystem and optional LMDB constructors accept only the
root path. Cargo native-fs enables
lace_store_fs_open on non-WASM/non-WASI targets; C
consumers define LACE_FFI_FEATURE_NATIVE_FS only when
linking that feature. Cargo lmdb and
LACE_FFI_FEATURE_LMDB similarly enable
lace_store_lmdb_open on its supported native targets.
Neither constructor supplies disabled stubs or WASM exports. PyO3
enables its own optional dependency and wraps the same concrete backend
directly, not through C calls. Both fill existing StoreWork
results rather than defining another operation protocol.
The ABI’s
store-primitive contract defines scalar current-position and
execute-work status/error semantics. Execute-work status describes
result installation, not whether the installed result contains a backend
StoreError; ordinary machine submission consumes that
result.
A native C object owns its store directly. PyO3 has one
NativeStore class owning a
Mutex<Option<Box<dyn StoreBackend>>> with
indexed-memory, filesystem and LMDB constructors; its
inline property reports the backend’s execution class. Each
execute_work installs the complete result and returns the
store error text that result carries; only failure to install raises.
Built-in store objects are reachable only through their Lace. For each
backend, native C and PyO3 execute the same concrete store
implementation. Filesystem primitives retain exclusive lifetime root
ownership; LMDB primitives permit cooperating processes. No lock,
transaction, posting-list handle, refresh scheduler, or separate opener
identifier crosses FFI.
Native execution fills the work-owned result but does not submit it
or publish position. PyO3 detaches from the GIL around native open,
StoreWork execution, authoritative position reads, and consuming close;
the thread driving the operation, not a store worker, publishes position
and submits to the machine. Returned native records retain
Arc<MmapRaw> and their accepted immutable log-row
range across store close and foreign result ownership. Primitive
destruction releases only directly owned resources, performs no
posting-list checkpoint, and is not Lace participant close. A one-shot
indexed-memory builder may seed finite providers from validated records;
it is not a public mutable store API.
Keep focused tests for every request and result family, operation-neutral export names, constraint order/duplicate/absent-versus-empty projection, two simultaneous direct side work values, mixed side effects, one-work-per-ticket enforcement, move-across-thread/await use, immediate memory execution, exact-ticket submission, wrong-machine and stale work, incomplete/invalid result atomicity, replacement rejection while StoreCall is active, positional loads/stores without identity echoes, missing and malformed records, uncommitted reservations, durable publication on mutating errors, explicit StoreError propagation, late completion without submission, reservation cleanup, Arc-backed record preservation including native raw-mapped records after store close, PyO3 GIL release around blocking native work, primitive destruction without a checkpoint, and host side routing without a context or registry.