Lace / docs

Lace-100 · Developer Surfaces

Tags: dx, event-stream, porcelain, developer-contract

Lace’s durable truth is record bytes, record facts, Datalog policy, exchange plans, and interlace convergence. Applications should not need protocol phases or store internals to use that truth. This document defines the shared semantic shape of normal application surfaces above specs 010–060 so Rust, JavaScript, Python, and future runtimes do not drift into different developer models.

Lace-100 is authoritative for cross-runtime developer semantics: record construction intent, participant boundaries, interlace and porcelain behavior, event lifecycle, and operation outcomes. A language binding may use language-idiomatic spelling where this document shows multiple names, but a name change does not change the underlying operation or semantics. Specs 010–060 remain authoritative for protocol behavior.

Core truth remains record-based

Developer APIs compile to or operate through the records, policy, exposure, and interlace semantics defined by 010–060. They do not replace those semantics with remote commands, raw database access, WebSocket-specific objects, or mutable application truth shortcuts.

Records are table-shaped for developers

A validated record can be presented as an immutable row: the hash is the row identity and fields are columns. A projection chooses fields such as Group, App, Name, TAI, By, and Data-Length; repeated fields appear as an ordered sequence rather than one scalar.

This table shape does not make rows mutable, make Group/App/Name unique, or expose raw store iteration. Rules and selector DSLs define dynamic row subsets, including subsets that depend on evidence, links, manifests, or current name records. Interlace converges the records selected by both sides.

App-facing record creation infers the record form

App-facing constructors accept one record specification and infer the stored record form from caller intent:

By-secret value present                       -> Seal record
app, name, group, fields, tai, By-secret value -> coordinate-bearing intent
coordinate-bearing intent               -> Plex/Seal record with missing coordinate parts defaulted
only data present                        -> Blob record
omitted group                            -> "u"
omitted app                              -> "none"
omitted name                             -> "record"
omitted tai                              -> tai_now()
omitted data                             -> empty bytes

The default coordinate for coordinate-bearing creation is //u//none//record. A data-only specification remains a Blob record; coordinate defaults apply only after the caller provides a coordinate part, fields, tai, or a By-secret value.

String data is UTF-8; binary APIs accept their language’s ordinary byte value. App-defined fields use an ordered sequence so repeated values retain caller order. Constructors reject 010 reserved extra-field names. A By value alone cannot create a Seal because it provides no By-secret value.

Rust record surfaces use ByValue for canonical public V.<b64a>.H3 text and one zeroizing BySecret owner for canonical private &.<b64a>.H3 text; BySecret parses and emits only that canonical form. ByValue::parse(...) accepts canonical text, ByValue::from_verification_key(...) performs the low-level 009 translation, BySecret::by_value() derives the corresponding ByValue, and Record::by() returns an optional ByValue. JavaScript, Python, C, facts, and wire surfaces carry the same canonical strings rather than raw key bytes or a second public wrapper.

Foreign record-construction surfaces use the same direct names without aliases: JavaScript record specifications use bySecret, Python uses by_secret, and C uses lace_generate_by_secret and lace_record_builder_set_by_secret. CLI key generation prints canonical By-Secret: &.<payload>.H3 and By: V.<payload>.H3 lines, and record-building options use --by-secret.

An exact coordinate is sugar for group, app, and name. In one object or builder state, coordinate is mutually exclusive with explicit coordinate parts. During template merging, a later coordinate replaces earlier parts and later explicit parts replace coordinate-derived components. The final merged specification is validated once.

Creation returns a validated Record, not raw bytes. Exact Blob, Plex, and Seal byte constructors are advanced or protocol-test surfaces.

Application runtime surface

A runtime package exposes store-backed Lace participants, connected peer participants, interlace, and porcelain:

Rust/native:  Lace::filesystem(...), Lace::memory(...)
JS/core:      Lace.memory(...)
JS/browser:   Lace.opfs(...), Lace.browserLocalStorage(...), Lace.openBestEffort(...)
JS/Node:      Lace.filesystem(...)

open_interlace / openInterlace(first, second, policy) -> open handle
bounded_interlace / boundedInterlace(first, second, policy) -> bounded outcome

list(target, selector_or_coordinate, options) -> ListOutcome
get(target, selector_or_coordinate, options) -> GetOutcome
store(target, records, options) -> StoreOutcome
for_each / forEach(target, selector_or_coordinate, options) -> observation stream

Language bindings may adjust names that collide with host-language built-ins. Operation options share the five 050 limits: received control bytes, transferred record bytes, evaluation work, mandatory rounds, and phase timeout. They are local and are not negotiated with a peer. Fixed parser/wire caps and transport buffer sizes are not operation options.

Ordinary bounded and open interlace are duplex. Their policy argument is an operand template, not a merged executable or prepared exchange plan. When one runtime represents both store-backed participants, it instantiates the template in both operand slots. A direct interlace supports same-Lace composition, where both participant arguments refer to one Lace, without changing interlace behavior or outcomes. For a connected peer, the local participant contributes one operand and the peer contributes the other as the role-assigned operand Blob. Advanced per-slot builders may exist, but the ordinary shorthand retains these semantics.

An operand may derive TxDisabled/0 or RxDisabled/0; there is no separate transfer-direction option. One runtime-owned Lace is the non-clonable participant owner of one exclusively adopted store, mutation publication, current-StorePosition watch, and lifecycle. LaceHandle is its clonable opaque participant capability and duplicates none of that state. Operations retain a handle for each local participant, including both participant positions in same-Lace direct composition. Bounded interlace, list, get, and store return only their outcomes, not participant handles. Callers that need reusable local capabilities retain or clone handles before the call; a claimed connected participant is consumed and settled by a bounded operation. Neither Lace nor LaceHandle exposes a raw store, socket, transaction, or protocol machine.

The owning Lace has one final close boundary. Rust consumes Lace (close().await in Tokio and close() in the single-threaded runtime), JavaScript awaits Lace.close(), and Python calls Lace.close(). Close first prevents new work through every retained handle, requests closure of operations directly owned by bindings that provide that relationship, and waits for every already-started store read or mutation to finish its durable classification and confirmed position publication. It then checkpoints and releases the adopted store and its profile lock. Independently owned operation handles remain separately closeable and can no longer begin store access. A retained handle after close is invalid; it does not retain access to released resources. Repeated language-level close waits for or observes the same completed boundary. C ABI destructors and low-level wrappers around raw WASM allocations retain free() solely for allocation destruction; that operation is not Lace participant lifecycle. This includes advanced IltpItem, IltpEncoder, and IltpDecoder wrappers.

Without explicit close, final owner drop keeps store resources and physical exclusion alive while any handle, operation, or already-started work can still access them. Releasing the final such owner releases the store, so a durable store path can be reopened immediately in the same process. Close and final drop do not probe PIDs, delete lock files, sleep, or retry.

A connected runtime accepts an optional local By-secret value and one optional peer-proof override:

Rust:   ConnectedPeerOptions::new().with_by_secret(by_secret).with_peer_proof(PeerProofOverride::TrustConfidentialChannel)
JS:     connectWebSocket(address, { bySecret, peerProof: 'disabled' | 'trust-confidential-channel' })
Python: Lace.connect_websocket(address, by_secret=secret,
          peer_proof=PeerProofOverride.TRUST_CONFIDENTIAL_CHANNEL)
CLI:    --by-secret &.<b64a>.H3 --peer-proof disabled|trust-confidential-channel

Omission uses the transport default: QUIB is channel-bound, WSS is a trusted confidential channel, and WS/TCP/Unix/stdio are disabled. Each connected logical session starts with an ILTP 1.0 nonce preface pair, then runs role-ordered optional Seal proofs and complete operands. Rust freshly freezes Here(local_key), proof-derived PeerConfidential(peer_key), and Transport(address) before policy evaluation. PeerConfidential(peer_key) discloses that the live peer proved control of the matching By-secret value and that the complete session channel is confidential, integrity-protected, and endpoint-confined. Connected callers cannot inject final peer authority. A direct pair remains a separate trusted composition with explicit final SessionAuthority values.

One connected carrier may carry successive logical sessions, each with one immutable interlace policy and plan. An ordinary open interlace exposes hard replacement through the same exclusive mutable handle:

Rust/Tokio and Rust/single: open.replace_policy(next_policy)
JavaScript:                 open.replacePolicy(nextPolicy)
Python:                     open.replace_policy(next_policy)

The argument uses the same authoring/frame surface as opening the interlace. Preparation failure leaves the interlace unchanged. Success is synchronous and means only that an equal operand was a no-op, the current prefacing operand was replaced, or one latest replacement was staged for a running owner. The call does not poll or step, retire a session, execute store or transport IO, touch an accepted writer batch, consume or produce events, or wait for activation. A later successful call may supersede the staged operand before the next drive step.

At the next poll or step, before endpoint work or fixed-point watch delivery, a connected owner consumes the staged operand, retires the complete logical session, and begins ILTP 1.0 re-prefacing on the same carrier. Fresh proof, complete role-ordered operands, authority, immutable plan construction, and rounds follow. A peer re-preface performs the same hard cut. A direct open interlace instead stages both prepared operands and, on the next step after both endpoint store effects have settled, atomically recreates both endpoints with the same explicit authorities and limits. There is no per-slot direct replacement.

The open handle cannot be used for replacement concurrently with event driving, store execution, or another operation. JavaScript and Python reject replacement while asynchronous drive/store work owns the handle. Calling replacement after clean closure or cancellation is a Policy error stating that the interlace is not open; an already failed handle returns its owned typed error. Porcelain, capture, provider, and observer wrappers keep operation-owned fixed policy and do not expose replacement.

Bounded and open APIs wrap the same core fixed point. A bounded wrapper commits its semantic result there and returns after required final-output and route settlement. An open wrapper waits there on broad store position watches and may drive another ordinary round after local store progress. This host lifecycle choice does not change protocol states, limits, or wire grammar.

A connected carrier has one stable role: CarrierRole::Listener derives protocol side0 and CarrierRole::Connector derives protocol side1. Carrier role is not participant position, application identity, or policy authority.

A runtime may construct a connected participant from one host-established ILTP transport. The host passes that participant to the same ordinary interlace and porcelain APIs as a runtime-connected peer. Listener binding, acceptance, admission policy, transport handshake, task supervision, service reload, and shutdown/join behavior belong to the host application, not to a shared Lace listener surface. Host admission counts and transport buffer limits are not any of the five interlace operation limits.

Event streams report produced record and lifecycle observations

Applications observe records and lifecycle rather than raw control facts or store operations. The shared public observations are:

Bindings expose these as one tagged observation union:

RecordSent { kind, position, record_hash, optional owned Record }
RecordSaved { kind, position, record_hash, optional owned Record }
FixedPoint { kind }
Closed { kind, reason }

Only record observations carry a participant position, first or second, matching the application argument position. Rust spells these ParticipantPosition::First and ParticipantPosition::Second; bindings expose the same values idiomatically. FixedPoint and Closed are operation-wide. Public close reasons are PeerClosed and Cancelled. Round numbers, fixed-point identities, store positions, raw binding dictionaries, and protocol phases are diagnostics rather than required observation fields. Language-idiomatic spelling does not change the event meaning.

A RecordSaved observation stays with the participant that admitted the record; it is not a remote acknowledgement. An observation accepted by the host event path before replacement remains deliverable historical output; only observations still owned by the retired machine are dropped. Result-collecting wrappers derive their own list, get, store, and at-most-once observation results from these produced observations and fixed-point state rather than adding protocol event families.

A participant position correlates an observation with one application argument in that interlace. It does not establish authority, protocol side, locality, initiator status, or durable receipt. Runtimes may attach record bytes when ownership is clear, or report a hash plus a follow-up record result.

On QUIB, an open operation that has committed fixed point and authorized post-fixed-point transport settlement still emits exactly one Closed(PeerClosed) when the peer retires by zero-code application close or authenticated stateless reset. Those transport causes remain distinct diagnostics below this public event; they do not add close reasons or success events.

Fixed-point entry is observed once per entry. Each entry has fresh opaque local identity so replacement and later sessions cannot accept stale store-watch readiness. Policy replacement has no PolicyReplaced event, activation waiter, or policy-identifier event.

Every host exposes one-event reading as the canonical open-operation primitive. Iteration delegates to it, and a run-to-fixed-point helper consumes it and returns every observation consumed through FixedPoint or Closed; helpers do not silently discard observations or maintain a parallel driver. Rust open and observer handles additionally expose consuming close(self) operations (async in Tokio) that settle already-started work and connected transport ownership, then return the final Closed(Cancelled) boundary or observer event. Drop remains a non-waitable resource-safety fallback. Fixed-point entry yields FixedPoint once and leaves the stream open. Clean closure yields Closed once and then exhaustion. Terminal failure returns one typed error with no synthetic event and then exhausts, while the failed handle retains that error for methods such as policy replacement. Event queues and transport buffers remain bounded; a consumer that stops reading causes backpressure, cancellation, or closure rather than unbounded growth.

Porcelain is interlace-backed

get, list, store, and for_each construct operation-owned participants and run ordinary interlace. They do not inspect or mutate the target store directly.

Bounded operation terminals are exactly:

A bounded interlace outcome owns its terminal and ordered observations. List, get, and store outcomes additionally own their records or per-input results. The structured error exists only inside failed; stopped-by-limit has no error payload. Caller cancellation is an open/runtime closure reason, not a bounded terminal.

Successful argument validation, policy preparation, participant claiming, route setup, and driver construction occur before the operation-start linearization point. Failures there are ordinary typed call errors. Once driving starts, failures become the outcome terminal and preserve prior observations. A complete operation appends record observations in production order, settles obligations and required final output, settles its route, appends FixedPoint, then returns. For QUIB, peer settlement is authorized only after fixed point, an empty ILTP codec writer, and transfer of every final ILTP byte into the QUIC send stream; codec acceptance is insufficient. Normal graceful finish waits for connection-owned final-data/FIN acknowledgement, close, RouteDrainEvent, endpoint RouteRemoval, exact host-route removal, and confirmation. After authorization, peer application close with code zero or authenticated stateless reset may settle that transport instead without revoking complete. The two internal causes remain distinct, but neither creates another public success terminal, observation, or close reason.

The same close before authorization is premature peer EOF, and the same reset before authorization is a Transport failure. Nonzero application close remains a Transport failure. QUIB post-fixed-point transport settlement does not reuse the interlace phase timeout. A limit or failure returns prior observations without FixedPoint or an error observation. Bounded teardown does not append Closed.

The core reports LimitExceeded independently of host lifecycle; only a bounded wrapper maps it to stopped-by-limit. Phase expiry is a distinct failure. A boundary EOF is complete only at the fixed point defined by 050; premature semantic EOF is failure. Internal scheduling yields and would-block states are not operation outcomes. Open observations end by cancellation, closure, or failure rather than a bounded completion status.

Errors are typed consistently across runtimes

Rust, C, WebAssembly, JavaScript, and Python expose the same error categories: PeerProtocol, LimitExceeded, Timeout, Policy, Store, Transport, and Integration. Structured errors carry category, optional participant position (first or second), optional record hash, human-readable message, and—only for Timeout—one stable phase: Prefacing, Side0Proof, Side1Proof, Side0Operand, Side1Operand, Advertising, Requesting, Answering, or Fencing. Bindings do not require callers to parse debug text. Errors are not observations.

Malformed, truncated, oversized, or noncanonical incoming framing is PeerProtocol. Failure of a local encoder or construction invariant is Integration. Connected local failure closes the transport, so the peer may observe transport failure, framing truncation, or premature EOF rather than the originating side’s local error details.

One policy authoring source and frame model

Public runtime APIs use one cross-runtime authoring representation:

PolicyAuthoringSource = Interlang(text) | Datalog(text)
PolicyFrame = zero or more authoring-source contributions

There is no separate policy-source versus policy-module-source language or API. Each contribution is one private-helper and linked-provider ownership boundary; a label or path may travel separately for diagnostics but does not enter policy identity. A direct convenience policy is a one-source frame. Operation-owned capture/provider vetoes and endpoint-owned configuration are ordinary additional frame contributions, not special policy modes.

The ordinary string shorthand is Interlang. A bare coordinate pattern such as:

//todo-demo//todo/task//task/{*}

exposes and converges that checked record shape. converge selects the active shared set and implies exposure; expose selects whole local records that peer-origin rules may inspect. Exposure alone does not transfer a record. Interlang supports helpers, advertised-field candidate specialization, checked-support hash closure, quoted coordinates, and restricted nested converge inside expose. Raw 030/040 Datalog is the advanced surface.

Authoring operations own finality rather than switching parser types:

validate_standard_interlang(text)
validate_policy_frame(profile, frame) -> Complete | Incomplete(requirements)
finalize_policy_frame(profile, frame) -> canonical policy | no facet result
prepare_runtime_policy(profile, frame) -> prepared canonical module

Strict standard validation accepts only the portable Lace-120 subset and requires an effective policy. Local frame authoring additionally accepts the 030 extended policy-module surface, including linked relations. Frame validation never returns an operand: an unresolved name-and-arity requirement is a valid Incomplete result, while syntax, safety, profile, ownership, and other checks available at that stage are errors. Finalization requires every link, composes facet-bearing contributions, and returns no facet result for an empty, helper-only, or provider-only frame. Runtime preparation additionally requires a facet-bearing result and returns exactly one self-contained canonical module. It does not derive a side-local executable: that requires the two ordered modules, their compiled plan, and a typed local slot. Every finalized operand is ordinary self-contained canonical 030 Datalog.

Policy frames are semantic sets of normalized contributions. Caller order, duplicate identical entries, diagnostic paths, and equivalent provider tuple ordering do not change canonical output. Finalized linked values do change the canonical module and therefore operand and exchange-plan identity.

Language bindings may use idiomatic constructors and result spelling, but they must expose these same source kinds and operation outcomes rather than duplicate standard/extended compiler APIs, boolean dangling-link switches, or one-source plus separate-module containers.

Indexes may accelerate coordinate constraints, but selected truth still comes from validated record facts and policy. Coordinates do not create a separate lookup authority.

Diagnostics remain separate

Developer tools may explain selection, exposure, route choice, and runtime failure through source maps, rule identifiers, traces, and route reports. Diagnostics are not normal application events or protocol facts and do not alter canonical policy text.

Direction

The developer layer should be convenient without hiding Lace’s model: records are validated bytes, facts are explicit, exposure controls peer-origin inspection, and interlace converges selected record sets. Applications use participant handles, record/lifecycle events, and interlace-backed porcelain—not raw protocol phases or store APIs.