honest framework

DATAOS

The DOM is the state. There is no other state.

You know Livewire. DATAOS eliminates Livewire's state sync entirely. The server renders; HTMX swaps. That's all.
What you do now
const [items,    setItems]   = useState([])
const [filter,   setFilter]  = useState("all")
const [loading,  setLoading] = useState(false)
const [editingId,setEditing] = useState(null)

// State lives in React. DOM reflects it.
// Server returns JSON. Client rebuilds the DOM.
// Four sources of truth that must stay in sync.
// useEffect wires them together. Bugs live there.
What DATAOS does
<!-- The DOM IS the state. -->
<main>
  <form hx-post="/todos"
        hx-target="#todo-list"
        hx-swap="beforeend">
    <input name="text" placeholder="What needs doing?">
  </form>
  <ul id="todo-list">
    <!-- Server renders items here. -->
    <!-- HTMX swaps new items in.  -->
  </ul>
</main>
<!-- No useState. No useEffect. No sync bugs. -->
What you do now
<!-- Server renders HTML. JavaScript adds behaviour.
     State leaks: server session, client JS, DOM, URL.
     Turbo/htmx helps but state sync is still manual. -->

<div id="filter-panel">
  <!-- Alpine.js x-data holds filter state -->
  <div x-data="{ filter: 'all' }">
    <button @click="filter = 'active'">Active</button>
  </div>
</div>
What DATAOS does
<!-- The DOM IS the state. No x-data. No JS state.
     Filter state lives on the element as a data attribute.
     domx reads it before every HTMX request. -->

<div id="filter-panel">
  <button hx-get="/items"
          hx-target="#results"
          data-filter="active">Active</button>
</div>

<!-- const appManifest = {
  filter: { selector: '[data-filter]', read: 'data:filter' }
} -->
What you do now
// Server renders HTML.
// Client state: session cookie + JS variables + URL params.
// On each request, reconcile all three. Bugs live here.

func handleFilter(w http.ResponseWriter, r *http.Request) {
    filter := r.URL.Query().Get("filter")
    session := getSession(r)  // third source of truth
    items := queryItems(filter, session.UserID)
    renderTemplate(w, items)
}
What DATAOS does
// DOM holds filter state as a data attribute.
// domx collects it and sends as _state on every request.
// Server reads manifest["filter"] — one source of truth.

func handleFilter(w http.ResponseWriter, r *http.Request) {
    manifest := intake.Classify(r, vocab, bind)
    items    := queryItems(manifest["filter"], manifest["user_id"])
    renderTemplate(w, items)
    // No session reconciliation. No URL param parsing.
}
What you do now
<!-- Livewire holds filter state in PHP property -->
<div wire:model="filter">
  <select>
    <option value="all">All</option>
    <option value="active">Active</option>
  </select>
</div>
<!-- PHP class property + DOM + AJAX call.
     Three things that must agree. -->
What DATAOS does
<!-- DOM holds the state. No wire:model. No PHP property.
     domx reads the data attribute before every HTMX request. -->
<select data-filter="all"
        hx-get="/items"
        hx-target="#results"
        hx-trigger="change">
  <option value="all">All</option>
  <option value="active">Active</option>
</select>
<!-- One source of truth: the DOM. -->
The conventional pattern
// State lives in multiple places:
// 1. Server session
// 2. Client store (Redux/Pinia/Zustand)
// 3. The DOM
// 4. URL query parameters
//
// Frameworks exist to synchronize them.
// Bugs exist because synchronization fails.
The honest pattern
<!-- The DOM is the state. -->
<!-- domx collects it before every request. -->
<!-- Server classifies it. -->
<!-- There is no step 4. -->

const appManifest = {
  filter: { selector: '#filter', read: 'value' },
  sort:   { selector: '#sort',   read: 'data:order' },
}
<!-- One source of truth. -->

Every client-side framework in widespread use today is built on the same assumption: state lives in the application layer. The DOM is the output of state management, not the state itself. Redux has a store. Pinia has a store. Alpine.js has x-data. They all maintain a model that the DOM reflects.

DATAOS discards that assumption. The DOM is the state. Not a reflection of state, not a projection of state — the state itself. The browser already has a state store. It is called the DOM. Building a parallel model and synchronizing it with the DOM creates the synchronization problem that every client-side framework exists to manage. DATAOS refuses to create the problem.

The practical consequence: when the user changes a filter, the value lives on the DOM element as a data attribute. When an HTMX request fires, domx reads the DOM and sends current state to the server as _state. The server classifies it and renders the response. The browser swaps the fragment. The cycle is complete. There is no parallel model to update, no sync to manage, no stale state to detect.

The request cycle:

User interaction
    ↓
domx: read DOM state into manifest
    ↓
HTMX: POST with _state in body
    ↓
Server: classify(_state) → typed manifest
    ↓
Server: pure function chain → HTML fragment
    ↓
HTMX: swap fragment into DOM
    ↓
DOM updated — this IS the new state

There is no step where a client store is updated. There is no step where the DOM is reconciled with a model. The DOM update is the state update.

domx and the application manifest:

domx is the client library that implements DATAOS. It reads a manifest declaration — a plain JS object describing which DOM elements constitute user state — and collects their current values before every HTMX request.

const appManifest = {
    filter: { selector: '#filter-select', read: 'value' },
    sort:   { selector: '#sort-control',  read: 'data:order' },
    page:   { selector: '#page-input',    read: 'value' },
}

domx sends the collected values as _state in the request body. The server receives named, typed data. The client stores nothing.

The abstract principle

The DOM is a state store. It always was.

This is not a new idea. It is the original idea. HTML was designed to carry state: form values, checkbox states, selected options, input contents. The browser persists these across renders. The DOM is a persistent, observable, user-editable state store that has been present in every browser for thirty years.

React, Redux, Zustand, Pinia, MobX, and Angular services all exist to solve one problem: the DOM state is not trusted. The framework maintains its own copy. The two copies must be kept synchronized. This synchronization is the source of the majority of frontend bugs.

DATAOS says: stop making the copy. Trust the DOM. Read state from the DOM before every request. Write state to the DOM in the server response. There is no step three.

The formal property

The DOM as state store has one formal property that matters: single source of truth. For any piece of user-visible state, there is exactly one place it lives. For form input, that place is input.value. For a filter selection, that place is select.value or element.dataset.filter. For a sort direction, that place is th.dataset.sortDir.

When the server responds, it writes new state into the DOM via HTMX fragment swap. The DOM updates. collect() reads the new state on the next request. No reconciliation. No synchronization. No diff between a virtual DOM and a real one.

The React Architecture has this property per-component, when implemented correctly. Redux adds global state with this property, if you never use local state. Zustand, Pinia, and similar stores have it if you enforce single-ownership. Every framework that manages state explicitly is trying to recover this property after having violated it. DATAOS does not violate it in the first place.

Why the server-rendered model is not a limitation

The objection to DATAOS is always: "but what about optimistic updates? What about offline? What about real-time collaboration?"

These are solved by the same mechanism that solves everything in DATAOS: the DOM carries the state. An optimistic update writes to the DOM immediately and queues a server request. The server response either confirms or corrects. The DOM reflects whichever is authoritative. No separate optimistic state layer is needed. The DOM is already the buffer.

Offline support is honest-persist's write queue: writes are acknowledged to the DOM immediately and flushed when connectivity returns. The DOM state is always what the user sees. The server state is always what eventually persists. The gap between them is managed explicitly at the boundary, not hidden inside a state management library.

The _state mechanism

collect(appManifest) is the formal interface between the DOM and the server. The app manifest declares the semantic meaning of each DOM element: what slot it fills, what attribute to read. The result is a named dict — a manifest — that the server classifies via honest-type.

The manifest is the single crossing point between DOM state and server logic. Everything the server needs to know about the current user context is in it. Everything the server writes back to the user context is in the HTML fragment it returns. The DOM is the wire format for user state.

Full specification

The collect() Algorithm

FUNCTION collect(app_manifest):
    state ← {}
    FOR EACH (slot, descriptor) IN app_manifest:
        element ← document.querySelector(descriptor.selector)
        IF element IS null: CONTINUE
        value ← read_value(element, descriptor.read)
        state[slot] ← value
    RETURN state

read_value(element, read_spec) dispatches on the read spec format:

Read specWhat it reads
"value"element.value
"checked"element.checked
"text"element.textContent
"data:key"element.dataset.key
"attr:name"element.getAttribute("name")
"class:name"element.classList.contains("name")

The app manifest is the complete declaration of what DOM state means for this application. It is declared once, at module scope, as a plain dict.

The _state Injection Contract

Before every HTMX request, domx calls collect(appManifest) and merges the result into the request as _state. This happens via an HTMX event listener on htmx:configRequest.

document.addEventListener("htmx:configRequest", (e) => {
    const state = collect(appManifest)
    e.detail.parameters["_state"] = JSON.stringify(state)
})

The server's intake middleware extracts _state from the request body and merges it into the token list before classify() runs. Token priority: _state > query params > path params.

_state is always a flat dict. Nested structures are not supported. If a value is complex, serialize it to a string in the read function.

The apply() Function

apply() writes collected state back to the DOM:

FUNCTION apply(state, app_manifest):
    FOR EACH (slot, value) IN state:
        descriptor ← app_manifest.get(slot)
        IF descriptor IS null: CONTINUE
        element ← document.querySelector(descriptor.selector)
        IF element IS null: CONTINUE
        write_value(element, descriptor.write, value)

apply() is used to restore DOM state from server responses or from cache-and-replay on page reload.

The MutationObserver Contract

A single MutationObserver watches the entire document. When DOM mutations occur, domx:

  1. Re-runs collect(appManifest) on the changed subtree
  2. Compares to the previous state
  3. If any slot changed: emits hf.dom.changed to honest-observe via sendBeacon()
  4. Updates its internal state snapshot

One observer for the whole page. Multiple observers degrade performance linearly with component count. domx uses a single shared observer regardless of how many organisms are mounted.

Cache-and-Replay

domx can cache the last _state before a page navigation and replay it on reload:

// Before navigation: cache is written automatically
window.addEventListener("beforeunload", () => {
    sessionStorage.setItem("_honest_state", JSON.stringify(collect(appManifest)))
})

// On load: replay if cache exists
window.addEventListener("DOMContentLoaded", () => {
    const cached = sessionStorage.getItem("_honest_state")
    if (cached) apply(JSON.parse(cached), appManifest)
})

Cache-and-replay is opt-in via the replay: true flag on the app manifest. It is safe because the DOM is the state: restoring the DOM restores the application state exactly.

Server-Side Intake

The server intake middleware implements the token merge:

FUNCTION intake(request, vocab, bind):
    path_tokens  ← extract_path_params(request)
    query_tokens ← extract_query_params(request)
    state_tokens ← extract_state(request.body._state)

    # Priority: _state > query > path
    tokens ← merge(path_tokens, query_tokens, state_tokens)

    manifest ← classify(tokens, vocab, bind)
    RETURN manifest

The merged token list is passed to classify() as a flat list. No nesting. No special handling. The type system sees a list of strings regardless of where they came from.

Conformance Requirements

RequirementTest
collect() reads every slot declared in the app manifestDeclare all read types, verify each
Missing element produces no entry (not null entry)Remove element, check manifest
_state is injected on every HTMX requestIntercept htmx:configRequest, inspect
Token priority: _state > query > pathSet same key in all three, verify
Single MutationObserver for the whole documentVerify via getEventListeners
hf.dom.changed emitted via sendBeacon on state changeIntercept network, verify payload
apply() restores DOM state from a state dictApply, then collect, compare
Cache-and-replay restores state across reloadSet state, reload, verify
No JavaScript state stores anywhere in the applicationhonest-check HC-P011

Reference