Specification §10

UnREST — the programming model.

The programming model over FrogNet Memory (§9). The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in RFC 2119. Worked code: unrest-examples.html.

FrogNet Specification — Draft 0.9, revision 2026-08-29. Cite a conformance claim against this revision, not against a section number alone.

1. Model

Croakus: §25, §38a

FrogNet Memory is the architecture: one addressable store per connected pond, implemented by api.php, read and written by every node. UnREST is the programming model over it. The two are separately specified and MUST NOT be conflated: §9 states what the store does, §10 states what a program does with it.

The model is Linda — Gelernter and Carriero, Yale, 1985 — in which independent processes never address each other. A producer writes a value into an associative memory without knowing who will read it; a consumer reads by pattern without knowing who wrote it. Linda matches arbitrary tuples and says nothing about field count or meaning; the three coordinates FrogNet uses are FrogNet's indexing convention (§9.2), not a requirement of the model.

The operative mental frame is one program with many threads over shared memory, whose threads happen to be on different machines. Threads do not correspond. Each writes what only it can know; each reads all of it; the answer is a pure function of that state. An implementer who finds themselves asking how two parties agree has left the model.

2. The exchange, before and after

Croakus: §34

The whole of this section is the difference in step count, and the first thing to notice is the symmetry. Under REST the producer and the consumer are the same sequence. Both form a message, locate a target, transit, dispatch, execute, return, read, and tear down. Only the payload differs — the producer's carries a value, the consumer's carries a query. Publishing and observing cost the identical eleven steps because both are implemented as the same thing: a call to somebody.

OLD WAY   — producer and consumer, identically

    form REST message           the payload is the only difference:
    find the target             a producer carries a value,
    send the message            a consumer carries a query
    receive message
    find executable
    run
    get response
    return response
    read reply
    close socket
    continue

NEW WAY   — and here they finally differ

    Producer    push tuple to shared memory

    Consumer    read tuple space

Every step above the line exists to move a value between two processes that cannot see each other's memory. None of them describes the work the application is doing. The model's asymmetry appears only below the line: a write and a read are genuinely different operations, unconnected in time and unaware of each other, and neither is a call to anybody.

A conforming implementation MUST expose those two operations as the whole of the coordination surface; an application MUST NOT be required to locate a peer, establish a session, or await a reply in order to publish or observe a value.

The steps do not move elsewhere — there is no equivalent sequence running underneath. Locating a peer is absent because nothing is addressed to a peer. Waiting for a reply is absent because a write has no reply and a read has no counterparty. What remains below the line is the substrate's own work: framing (§11), routing (§5), and the single serialization point (§9).

3. Operations

The surface is two operations. Their semantics are normative in §9 and are summarised here only as they appear to the caller.

OperationCaller statesReturns
put (write)the three coordinates, the valuecompletion; no addressee, no acknowledgement from any reader
get (read)one, two, or three coordinatesthe current value with its timestamp, a set, or absence

A write MUST be a single atomic upsert against the tuple key and MUST NOT require a prior read (§9.3). A read MUST NOT block on a remote condition (§9.4). There is no third operation: there is no subscribe, no publish, no notification, and no delivery. A program requiring timely observation MUST poll.

The three coordinates as the implementation names them are service, var and scope; the scope qualifies the variable name, and the service is the tuple's type. Scopes are constructed, not free-form:

ScopeFormFor
hosthost:<ip>:<pid>A long-running service whose tuple should be reaped when that instance stops. The pid makes it unique per instance and self-expiring: a new instance writes a new key and the dead one stops asserting.
nodehost:<ip>A periodic refresh writer whose tuple must outlive any single process — one upserted row per host, not a fresh key every cycle.
rolehost:<ip>:<role>Capability tuples. A node writing capability under two roles MUST qualify the scope with the role: the store's unique key does not carry the type, so a bare node scope yields one row whose role flip-flops as each write overwrites the other.
sessionsession:<id>Per-call state

A write MUST declare whether the writing process owns the tuple. An owned write is registered for cleanup at process exit, so a service's ephemeral coordination state — presence, call signalling — is removed when the service stops. An unowned write is fire-and-forget and ages out by staleness instead. A periodic or one-shot registrar MUST write unowned: arming cleanup on a short-lived process deletes the tuple milliseconds later, so its writes never persist — which is exactly the failure that left an election with no capability rows to read.

At the store, the tuple address is the unique key Name + Type + Address [TUPLE_ADDRESS_V1]. All three MUST be present in every write — empty where the caller supplies nothing — because the conflict clause that makes the write an upsert can only fire when the whole key is in the statement.

A write MUST be atomic resolve-or-create in one statement [ATOMIC_UPSERT_V1]. An implementation MUST NOT read to resolve and then insert: that races under concurrent writes from several peers and produces duplicate-key failures rather than the later write winning.

Underneath, both operations are ordinary HTTP against api.php on the elected host. The wire form is stable and callable directly:

POST <host>.frognet/api.php?entity=move&action=upsert_by_name
  { "_game":1, "game":"tictactoe", "table":"kitchen-1",
    "op":"move", "seat":"X", "cell":8, "intent_seq":4 }

Verified in var/www/html/api.php. Both parameters are required; entity MUST be one of the eleven below and action one of the nine.

EntityTablePrimary key
sensorsSensorSensorID
sensor_dataSensorDataSensorID
historyHistoryHistoryID
known_frognetsKnownFrogNetNetworkName
actuatorsActuatorActuatorID
iahostsAIHostAIHostID
well_known_sitesWellKnownSiteSiteID
usersUserCallSign
teamsTeamTeamName
team_membersTeamMemberTeamName, TeamUser
messagesMessagemessageID
ActionMethodsNotes
valuesGETsensors only. The read primitive.
listGETWhole-entity read
getGETBy primary key; every PK component required
createPOSTIdempotent for sensors and known_frognets only [IDEMPOTENT_CREATE_V1]; strict elsewhere — POST means expect a new row and conflict is a real error
updatePUT, POST
upsertPOST, PUTThe write primitive
upsert_by_namePOST, PUTResolve-or-create by SensorName; requires FrogID and SensorName
upsert_batchPOST, PUTRequires an items array. One round trip for N writes.
deleteDELETE, POST

Read filters on sensors&action=values: any whitelisted column as an equality filter, a __like suffix for pattern match, order (whitelisted columns only), limit (1–5000; list caps at 1000), fresh_s=N for rows written within N seconds, and parse=1 to include decoded JSON as data. order, limit and parse are excluded from the filter set and, per §11.6, MUST NOT enter the template identity.

Errors are a JSON body {"error", "detail"} with the status in the HTTP code:

400  missing entity or action; unknown action; missing PK; missing
     SensorName for upsert_by_name; no valid fields; empty request
     body; invalid JSON; upsert_batch without an items array
404  unknown entity
405  wrong method for the action
500  DB prepare/execute failed; SensorID resolution failed;
     upsert_batch or write_all failed

[NO_EMPTY_BODY_FALLBACK_V1] An empty body MUST be rejected as 400 and MUST NOT be treated as {}. The two are indistinguishable downstream, and every handler then runs with no data and reports success.

4. Coupling and lifetime

Producer and consumer are decoupled in time and in identity. Neither learns that the other exists. Their lifetimes are independent: a value survives the exit of its writer, and a reader observes values written before it started. A conforming implementation MUST NOT make either operation contingent on the liveness, reachability, or prior registration of any other participant.

This is the property that makes an offline node a whole network alone rather than a degraded participant. A node writes and reads its own store; mesh membership widens the set of writers whose values are visible, and nothing else about the program changes.

5. What the application no longer writes

Croakus: §42

The change is subtractive. An UnREST application makes four declarations: the shape of the data, the freshness of each field, where authority lives if anywhere, and what a received value means. The following are the substrate's and MUST NOT be reimplemented above it.

ConcernUnder RESTUnder UnREST
Addressingendpoint per peer, resolved and configuredthree coordinates; no peer is named
Retryapplication-level, per callsubstrate; a write is an upsert, a read is idempotent
Orderingsequence numbers, queues, or acceptance of noneone serialization point (§9.1)
Deduplicationidempotency keysa key holds a value; a repeat write is the same write
Reconnectbackoff, session re-establishmentsubstrate; absence is a read result, not an error
Versioninga shape contract owed to named consumersno named consumers; the template is the schema (§11)
Compressionoptional, per response, application's jobinfrastructure, automatic, no application change

An application that has reimplemented any row of the right column has rebuilt request/response with extra steps, and SHOULD be treated as non-conforming to the model even where it functions.

6. Ownership rules

Croakus: §38a, §42

Three rules govern what a participant may write. Each was established by a defect that no amount of care on the wrong side could fix.

6.1 A fact belongs to whoever can know it [FOUR_TUPLES_V1]

A participant MUST write only values it alone stands in a position to know, scoped to itself, updated in place. A held keyframe is the viewer's fact; a capture rate is the sender's; a socket's state is the relay's. A participant MUST NOT write another participant's value, and MUST NOT write a shared aggregate that several participants would each overwrite. A single row carrying a membership list written by whichever participant wrote last is the canonical violation: a departing member was reinstated by another member's heartbeat, because the row was not the leaver's to write.

6.2 A derivation MUST NOT read its own previous output [ONE_DERIVATION_V1]

Where several participants must arrive at the same answer, that answer MUST be a pure function over shared rows — no clock, no instance state, no I/O. Every participant runs the same function over the same rows and lands on the same result without being told, which is what makes agreement unnecessary rather than merely cheap. A function that consults its own last answer is a controller with memory, and two controllers with memory diverge. Where a fact must persist across derivations it MUST be written to the store as a row, so that every participant derives the same value and none keeps a private history. [A_RATE_THAT_FAILED_IS_NOT_A_CANDIDATE_V1]

6.3 Absence is not a measurement

A reader MUST distinguish "the producer wrote nothing" from "this reader is slow". Treating absence as a measurement of the reader's own condition produces a ratchet: the producer sheds, the consumer reports worse, the derived rate drops, the producer sheds more. Absence MUST be reported as absence and MUST NOT be substituted into a derivation as a value.

6.4 Authority

Where a decision requires an order across values — legality, seating, turn-taking — that decision MUST be routed through an elected role (§8) rather than inferred from the store. The role is the one writer of record for the values it owns and MUST ignore writes to those values from any other participant. A participant states intent in a value that is its own; the role reads intent and writes outcome. A reader that never writes is a spectator by construction, not by permission.

7. Handlers

Croakus: §28, §25

Every exchange on a FrogNet is processed by a handler: an object conforming to one interface with a small number of virtual slots — how to compress, whether it holds a role, how to announce itself. UnREST Core is the default behaviour of that interface. Ordinary content handlers fill the compression slot and inherit do-nothing defaults for the rest, which is why every JSON reply and every sensor read receives SAME/DIFF/FULL treatment and the fanned transport without any subclassing.

class MyPathway(UnRESTHandler):
    ROLE_NAME = None                       # a content handler, no role
    def learn_request_template(self, req):  ...   # learn the shape
    def extract_request_dynamic(self, req): ...   # pull the values
    def rebuild_reply(self, tpl, vals):     ...   # rebuild far side

A handler is registered in one format registry and dispatched uniformly. A handler MUST NOT be called by the application: it is matched and triggered by the substrate. A handler declaring a ROLE_NAME participates in election (§8); a handler declaring none is a content handler.

The interface, verified in core/unrest_handler.py. A handler MAY override any slot; unoverridden slots inherit do-nothing defaults.

ROLE_NAME                          None for a content handler
mode()                             the role name, or ""
learn_request_template(body)       learn shape from a request
learn_reply_template(body)         learn shape from a reply
extract_request_dynamic(body, tpl) pull the varying values out
extract_reply_dynamic(body, tpl)
rebuild_reply(fragment, values)    reconstruct on the far side
decode_payload(rebuilt_body)       to bytes
score(candidate)                   role handlers: capability scoring
evaluate(hosts, lan)               role handlers: election input
advertise(blob, ...)               publish capability; returns early
                                   if ROLE_NAME is unset
hostReset()                        calls advertise again (§6.6)

There is one implementation of advertise in the system and every role receives it identically. The difference between a handler with a lifecycle and one without is not an overridden method — it is a value on the object, and the inherited code returns immediately where ROLE_NAME is unset.

Dispatch is by body inspection, in a fixed order, and Content-Type is a weak hint that never decides alone (core/format_registry.py):

request    "json" in Content-Type            -> JSON
           "xml"  in Content-Type            -> XML
           "html" in Content-Type AND the body actually looks like HTML
                                             -> HTML
           otherwise: sniff the body         -> json | xml | html | text | raw

reply      application/json OR body starts { or [        -> JSON
           xml header OR  XML
           html hint AND real HTML evidence in the bytes  -> HTML
           any other text/*                               -> TEXT
           looks like readable text                       -> TEXT
           otherwise                                      -> RAW

The reply resolver MUST NOT trust text/html on its own: PHP endpoints default to that header while returning plain text, and classifying such a reply as HTML learns a template against a document that is not there. An empty or undecodable body classifies raw, and a raw body learns no template (§17.7).

8. Concurrency and completion

Spec: §11

Calls are not serial. Many requests MAY be in flight on one persistent socket, fanned in and interleaved, each matched home by sequence; replies wake their callers as they land, in any order. A conforming implementation MUST NOT head-of-line-block a completed reply behind an outstanding one.

writer -> seq 41  REQ_FULL     (dashboard poll)
writer -> seq 42  REQ_REPEAT   (sensor read)
writer -> seq 43  REQ_FULL     (file list)

reader <- seq 42  RESP_SAME    -> sensor caller   (first back)
reader <- seq 43  RESP_DIFF    -> file-list caller
reader <- seq 41  RESP_DIFF    -> dashboard caller

Frame layout, the opcode enumeration and sequence-matching rules are normative in §11 and are not restated here.

9. Non-goals

Not pub/sub. Nothing is published and nothing is delivered. There is no topic, no subscription, and no change notification of any kind.

Not RPC. No procedure is named, invoked, or awaited. A write has no return value from any reader.

Not an ORM or a schema system. The store imposes no schema; a key exists when it is first written (§9.2).

Not transactional. No atomicity across two keys and no consensus. Composition across keys is the application's problem by design (§9.3).

10. References

Specification §9 (FrogNet Memory), §8 (Election), §11 (FNWP-1 and BLDC-1), §15 (Reference applications). Build manual: Croakus §25, §34, §38a, §42. Worked code: UnREST by example. Lineage: Gelernter and Carriero, Linda, Yale, 1985.