LDP — Lurpic Drive Protocol (major 2)
# LDP — Lurpic Drive Protocol (major 2)
LDP is the local automation seam: typed observations and GUI-backed actions
between the lurpicUI runtime and external drivers over an authenticated
Unix-domain socket. Protocol major 2 is a deliberate hard break: there
are no v1 adapters, aliases, or compatibility dispatch. The advertised
method set is the registry’s advertised surface — a major-2 build never
puts an unported method on the wire. TestDocsMatchRegistry fails when
this document and the registry drift.
# Enabling
LDP is off by default (no socket, no token, no listener, no goroutines). Enable per run:
LURPIC_DRIVE=on # $XDG_RUNTIME_DIR/lurpic-<pid>/drive.sock, read grant
LURPIC_DRIVE=on:read,input # explicit grants
LURPIC_DRIVE=unix:/abs/private/d.sock:read,input # explicit absolute pathUnknown grants — including the v1 write spelling — fail startup.
# Sessions and requests
Each app launch creates a random instance identity. An authenticated
session owns monotonically increasing request IDs (decimal strings on the
wire), a bounded outcome ledger, and a connection generation. A session
survives transport loss for ten minutes of wall time; resume proves the
resume credential, increments the generation, and fences the replaced
generation’s outstanding work — it never resumes execution automatically.
Request IDs are admitted contiguously: a retried retained ID replays its
original outcome, a changed payload is request.conflict, and an expired
ID is request.expired — it can never execute again. There is no direct
store mutation anywhere in the protocol: application actuation is GUI-backed
input.* and cmd.execute only.
# Grants
A capability set baked in by whoever launches the app and immutable per
session (resume never widens it): read, input, command, time,
admin. command is the application-command grant; admin authorizes
protocol administration only, never implicit input/command/time authority.
# Advertised methods (major 2)
The full table (including trusted in-process seam methods whose native v2
slices land in later slices) lives in drive/proto/registry.go. The
advertised wire surface today:
sys.hello— version/instance/session negotiation; create with the launcher token, or resume with the instance/session/credential triple. The result carries instance/session/generation,next_id, grants, the protocol schema digest, frozen read-export contract digest, and limits document. The export digest covers declared names/field patterns, kinds, schemas, state classes, and byte bounds; live values and versions do not change it.sys.ping— cut-exact liveness: the pong identifies the sealed cut and last submitted frame it observes.cmd.list— lists visible GUI-backed commands with their exact positive contract versions.cmd.execute— requires thecommandgrant and an exact{name, version}. The runtime returns an accepted operation receipt, schedules the shared GUI command dispatcher at the next frame activation boundary, and sendsop.completedonly after the activation frame is ready and all command-owned job continuations have committed in a later ready frame. Their commits are fenced by the owning session generation. A disconnect cancels the operation and prevents any uncommitted continuation from mutating app state; already applied callback effects are not rolled back.time.mode— requires thetimegrant.{"mode":"virtual"}acquires the session’s time lease and completes after its single zero-delta transition frame is ready.{"mode":"wall"}releases that lease after its transition frame. Repeating the current mode is idempotent and creates no frame. A driver transition during a held gesture is rejected.time.advance— requires thetimegrant and the controlling time lease.{"duration_ns":"16666667"}advances engine time by the bounded integer duration and completes after exactly one frame has a ready surface. Reads while Virtual is parked create no frame. Pure jobs in the prior cut’s frontier finish before an authorized Virtual frame begins; their results commit in submission order during phase 1.frame.run— requirestimeplus the union of grants used by its plan. It accepts GUI-backedcmd.executesteps and the registeredinput.*steps, an explicit bounded frame tail, and registered read-onlythenrequests. Input macros expand into their canonical per-frame atoms. A plan-localbind_gestureoninput.pressand matchinguse_gestureoninput.move/input.releaseretain capture only within that plan; every bound gesture must be released before success. Nested plans, mode changes, and operation-control requests are rejected. A plan completes only after its final submitted frame is ready.op.status— reports the retained state and outcome of an operation owned by the authenticated session; expired outcomes return the operation.expired error.op.cancel— requests cancellation of one of the authenticated session’s outstanding operations. Cancellation lands at the next safe boundary; already-applied effects stand and are never rolled back. Cancelling an already-terminal operation is idempotent and returns its outcome. The terminalop.completednotification still arrives for a cancelled operation.session.state— reports the authenticated session’s own protocol state: generation, next request ID, earliest retained outcome, active operation IDs, recovery-expiry metadata while detached, and the current time ownership. It never reports a credential.session.list— requires theadmingrant. Returns the redacted session metadata page (ID, lifecycle state, generation, grants), sorted by session ID, with 1..32 page bounds and a next-cursor continuation.session.revoke— requires theadmingrant. Terminally revokes one session with a typed reason (operator,policy, orinstance.shutdown), fences its outstanding operations at the next safe boundary, and cancels its subscriptions. Revoking an unknown or already-revoked session returns the session.expired error.own.state— reports the immutable control view: who owns input (human or client, with session/generation attribution), held-gesture count, the engine clock mode, and the time-ownership lease metadata.sub.watch— requires thereadgrant. Creates a subscription over the shared bounded event log: exactly one ofafter(a decimal cursor) orsnapshot:true(the current export-version snapshot plus the stream base), a credit window 1..8, and a filter selecting event classes (state.changed,tree.changed,frame.completed,frame.ready,own.preempted,operation.terminal) and, forstate.changed, export names. A position below the retained floor returns stream.invalid_cursor rather than pretending the gap is deliverable.sub.state— reports a stream’s lifecycle (live/expired/cancelled), terminal reason, sent/acknowledged cursors, credit, and the current retention window (floor/head) for the owning session.sub.cancel— terminally cancels a stream (idempotent); repeated calls return the terminal state.- Server notifications are
op.completed,sub.data(one sealed record per notification, carrying the events that matched the filter),sub.expired(once per terminal stream),own.preempted(retained event; authoritative state remains queryable), andsys.shutdownon server stop. Client notifications aresys.keepalive(the lease clock: a controller whose session owns input or time and stays silent beyond the documented keepalive idle bound loses that lease at the next safe boundary) andsub.ack(acknowledges a delivered cursor and returns credit). Client notifications carry no ID and never consume one. locate.resolve— resolves one exact author ID, role/name, scoped key, accessible name, kind, or hit-map coordinate locator. Ambiguity candidates are typed error details, capped at ten with an explicit truncation flag.tree.dump— returns pre-order structural pages from a bounded cut observation index. The first page captures an immutable, session-owned snapshot; later pages use its opaque snapshot ID and decimal-string offset. Snapshots expire after 60 seconds and count toward an 8 MiB per-runtime retention budget. Index capacity exhaustion returns the resource-limit error and leaves ordinary app rendering available.facet.query— reports structural/layout/focus/hit/layer facts and only explicitly declared typed detail captures.store.list— pages the frozen explicit export catalog, including each export’s domain/view class, kind, schema name, version, and byte bound.store.read— reads a named scalar or bounded collection page, or an explicitly declared{target, field}capability. A collection whose first page is incomplete returns a session-owned snapshot handle; later pages use that handle and a decimal-string offset, preserving one source version for the full read. Handles expire after 60 seconds and count toward the 8 MiB per-runtime frozen-export budget. Undeclared fields fail closed; derived/detail exports use their dedicated read method.derived.read— returns the last computed derived value and dirty/version metadata through Peek without flushing, evaluating, or changing counters.input.click,input.press,input.release,input.move,input.drag,input.type,input.scroll, andinput.focus— require theinputgrant and enter the ordinary platform event queue. Each synthesized atom is collected at a frame boundary; Virtual mode remains parked until the time owner explicitly advances it. Device defaults to the current input modality (mouseunless touch is active) and accepts explicitmouseortouch. Mouse click is move/press/release; touch click is touch-down/touch-up. Drag usesfromandtolocation oneofs plusmove_count(default 5, range 1..128), then emits its canonical start atom, interpolated moves, and release. Point methods acceptwindow:"primary"; unavailable windows fail explicitly. Results report the selected device and one atom per frame.input.pressreturns a session- and connection-generation-boundgesture_handle; matchinginput.moveandinput.releaserequests must present it. Human input or connection loss cancels the unstarted remainder and clears capture at the next safe boundary.input.focusperforms real Tab/Shift-Tab traversal and reports the number of hops.
Streams and artifact transfer remain later slices. The advertised registry is the authoritative list of methods implemented by this build. Read exports are declarations made by app/mark authors; the framework does not reflect over Go fields or infer writable capabilities.
# Wire
UTF-8 NDJSON, one object per line, 4 MiB hard bound. Identifiers that
carry uint64 precision (request IDs, cuts, frames) are decimal strings.
Decoding is strict: duplicate JSON keys, unknown envelope fields, wrong
major, non-canonical IDs, non-object params, nesting beyond 32,
non-finite/overflowing numbers, and trailing data are rejected
before admission with typed errors (protocol.malformed,
protocol.line_limit, protocol.version_mismatch) that never consume a
request ID.
# Security
The socket is the trust boundary: a private (0700), UID-owned parent
directory; socket and token inodes 0600; exclusive no-follow token
creation; a pre-existing socket or token path is a startup failure (stale
paths are cleaned by an operator, never unlinked blind); token comparison
is constant-time. Android: LDP compiles only in debuggable builds; the
socket never leaves the device (adb forward for remote debugging); MCP
exposure is the bridge author’s decision (see lurpic mcp init).
# Ops runbook
- Command automation: configure the launcher with
read,commandgrants, then uselurpic drive commands,lurpic drive execute --name <name> --version <n>, andlurpic drive op-status --operation-id <id>. Add--session-file <path>to reuse a local 0600 credential file across CLI invocations. Without that option each invocation creates a new session. - Observing a running app:
lurpic drive watch --snapshot --duration 30ssubscribes to the shared event log (filter with--classes state.changed,frame.completed,--names e1.chartType,--credit 1..8) and prints every delivered record with its cut/frame attribution, acknowledging each cursor.lurpic drive own-statereports who owns input and time;lurpic drive session-statereports this session’s ledger state;lurpic drive op-cancel --operation-id <id>cancels an outstanding operation at the next safe boundary. With theadmingrant,lurpic drive session-listpages the redacted session table andlurpic drive session-revoke --session-id <id> --reason operatorterminally revokes one. - Token rotation: restart the app. The token file is regenerated next to the socket on every launch; there is no rotation without restart.
- Lost command completion:
executeresumes the session and retries the same request ID after a lost receipt, then queriesop.statusif the completion notification is lost. Pass--session-fileto retain the 0600 credential if the CLI process exits; reconnect with that file and query the known operation ID. Do not issue the command under a new ID until its old outcome is known. A new app instance is a new identity and old outcomes are unavailable (instance.changed). - Virtual time: request time.mode with mode=virtual before advancing; only
the owning session may submit
time.advanceorframe.run. Disconnect releases ownership independently of engine time. Use time.advance with a duration_ns value from 1 through 1000000000 to request one frame, or frame.run for a bounded, ordered command plan. An input-only client never advances frames implicitly. - Request/result expiry: retention is bounded (entries and bytes per
session); an evicted ID answers
request.expiredand the high-water mark never moves backwards. - Preemption expectations: any human (platform) event severs an
in-flight
input.*step mid-gesture; ownership flips to human and the verb repliesinput.preempted. Drivers must handle it rather than retry blindly. Disconnect fences a lost generation’s future work at the next safe boundary; applied effects stand. - Budget interpretation: drive commands admit at the frame cut on the runtime thread; a frame carrying commands pays their handler cost inside that frame’s budget. The E5 drive row shows per-frame drained counts.
- Android: the drive seam compiles only into debuggable builds (the
lurpicdrivebuild tag, set bylurpic build androidfor debug APKs). Release builds refuseLURPIC_DRIVEat startup with “drive unavailable”. Forward the socket withadb forwardfor remotelurpic driveuse.