Skip to content

Constraints

Every rule an application inside a container must follow, each with the reason it exists and what enforces it. The id is permanent: it is this page's anchor, it is what a dai check finding cites, and it is how the model file names the same rule — the model file is generated from the same source as this page.

Enforcement says what refuses a violation: the compiler at build, the runtime when the application runs, or the lint (dai check, the MCP server and the paste page). "Not checked by anything" is stated plainly so nobody assumes a check exists that does not; those constraints are still constraints.

Which apply to you depends on the shape — Choose a shape first.

By shape

The shape, decided first

SHAPE-FIRST · Decide the shape before writing a table

Applies to every shape. Enforcement: not checked by anything.

Before writing schema.sql, decide which of the four shapes the application is — solo, passable, session or broadcast — using the two questions above, and state the decision in a comment at the top of schema.sql. The shape decides which tables are replicated, whether there is a session profile, and every rule below that applies only to shared tables.

Why. The shape is the decision every table depends on, and it cannot be fixed afterward by editing a table. The first chess application written against these instructions was never asked it, and it stored the board — derived state that is wrong the moment two copies merge.

Depends on tests/fixture/chess/schema.sql.

SHAPE-BROADCAST-CONVENTION · Broadcast has no runtime enforcement yet

Applies to broadcast. Enforcement: not checked by anything.

Build a broadcast application exactly as a solo one. Do not tell the reader their copy is read-only, locked, or protected: it is an ordinary document they can write to. If the application should not be edited by recipients, say that it is the publisher's, and put nothing in it that the publisher needs back.

Why. The mechanism that separates a publisher from its readers — the confidentiality levels — is not implemented. Claiming a protection the runtime does not provide is worse than saying nothing.

Depends on src/container.ts.

What the container forbids

NO-NETWORK · Nothing is fetched

Applies to every shape. Enforcement: checked by the lint (cdn-script, remote-stylesheet, network-call, remote-image, speculative-fetch); refused at run time.

Fetch nothing by URL. No CDN script tags: inline the library, or write the code without it. No hosted stylesheets or fonts: write the CSS inline and use system font stacks. No remote images: use inline SVG, a data: URI, or an emoji. No fetch, XMLHttpRequest, WebSocket, EventSource or sendBeacon, and no preconnect, dns-prefetch, prefetch or prerender links.

Why. The container permits no connections and the browser enforces it, so anything fetched fails silently and the application breaks in front of whoever opened it, far from the cause.

Depends on src/lint.ts.

STORE-IN-SQLITE · The database is the only storage

Applies to every shape. Enforcement: checked by the lint (browser-storage).

Keep every piece of data in the SQLite database inside the document, opened with await window.dai.openDatabase(). Never use localStorage, sessionStorage, IndexedDB, cookies, the Cache API or the File System API.

Why. Browser storage belongs to the browser rather than to the file, so data kept there does not travel: send the document to somebody and it arrives empty.

Depends on src/lint.ts.

MODULE-FOR-AWAIT · A script that awaits is a module

Applies to every shape. Enforcement: checked by the lint (await-in-classic-script).

Any <script> that uses top-level await must be type="module".

Why. In a classic script top-level await is a syntax error, and the application opens blank.

Depends on src/lint.ts.

NO-INLINE-HANDLERS · No onclick attributes

Applies to every shape. Enforcement: checked by the lint (inline-event-handler).

Attach every event handler in script with addEventListener. Never write an event attribute such as onclick or onsubmit in HTML.

Why. A container allows no inline script, so the attribute never runs and the control does nothing, with no error to explain why.

Depends on src/lint.ts.

NO-NEW-WINDOWS · No new windows, no redirects

Applies to every shape. Enforcement: checked by the lint (new-window, meta-refresh).

Never open a window or tab (window.open, target="_blank") and never redirect with a meta refresh.

Why. A container cannot open windows or navigate anywhere, so the link does nothing or the application blanks.

Depends on src/lint.ts.

ONE-DOCUMENT · One document, many rows

Applies to every shape. Enforcement: not checked by anything.

Model every separate instance — a game, a match, a list, a save slot — as a row in the application's own schema, with a local setting recording which one is showing. Never offer a "new file": nothing in a running application can create a second document.

Why. A document is one sealed file with one database, and no call creates, forks or duplicates one. A "New Game" button that promises a new file promises something that cannot happen.

Depends on tests/fixture/chess/schema.sql.

SHARE-THROUGH-HOST · Sharing is the host's

Applies to every shape. Enforcement: not checked by anything.

To let a person share, put a Share button in the application and call window.dai.requestShare(). Never build a share flow of your own with the browser's share API. For a shared document the host sends a link, which is also how an invite reaches the other party.

Why. The host builds the link, shows the card, and asks whether to include the person's data; a substitute flow would carry none of that, and for a shared document a file sent any other way carries no key and can never sync.

Depends on src/runtime/bootloader.ts, apps/runner/src/main.ts.

Data

SCHEMA-FILE · Every table is in schema.sql

Applies to every shape. Enforcement: not checked by anything.

Declare every table in one file named schema.sql, each with CREATE TABLE IF NOT EXISTS. Create no table anywhere else — not in index.html, not in JavaScript.

Why. schema.sql runs first on every open and its shape is sealed with the file; that record is what protects a person's data when a later version changes the application.

Depends on src/schema.ts.

SEED-IDEMPOTENT · Seed rows are idempotent, and local

Applies to every shape. Enforcement: not checked by anything.

Put a few example rows in a <script type="application/sql"> block in index.html so the application is not an empty shell on first open, written so a second open adds nothing (INSERT … WHERE NOT EXISTS, or INSERT OR IGNORE with a fixed id). Never seed from schema.sql. Seed only local tables this way; see SHARED-SEED-THROUGH-SURFACE for shared ones.

Why. The block runs on every open, so a seed that is not idempotent duplicates itself each time the document is opened.

Depends on src/runtime/bootloader.ts.

WRITE-AS-IT-HAPPENS · The database is the state

Applies to every shape. Enforcement: not checked by anything.

Write every action a person takes — a tick, a new row, an edit — to the database at the moment they take it. Never hold the application's state in a JavaScript variable to write later. On load, read the database and draw from it; after a write, read again and redraw.

Why. Whatever is only in a variable is lost when the document closes, and it is not in the copy that gets sent.

Depends on src/runtime/bootloader.ts.

NO-INPUT-LOST-WHILE-OPENING · Nothing a person does while the app is opening is lost

Applies to every shape. Enforcement: not checked by anything.

Until the application's script has finished starting — the database open, the handlers attached, the first draw done — show nothing a person can type into or submit. Put the interactive part of the page in an element marked both hidden and inert, <main id="app" hidden inert>, beside a short line such as "Opening…". inert is what keeps it out of reach: hidden alone is undone by any style rule that sets display on that element, while an inert element takes no focus, typing or clicks whatever the CSS says. Run the whole start-up — openDatabase(), the first draw — inside try/catch. On success, hide the line and remove both attributes. On failure, replace the line with what went wrong and what the person can do next: never leave "Opening…" showing over a start-up that has already ended. Do not wait for an event to learn that start-up failed — the frame's dai:error message is posted outward to the host, is never delivered to the application, and nothing acts on it today; the application's own catch is the only place that knows. A page built only from the kit's elements is already safe: they are not <form> elements, so a button inside them submits nothing before the kit has started. A <form> of your own is not.

Why. The script's start-up waits on await window.dai.openDatabase(), which in a shared document waits for the host's write rules. A form on screen before then takes a person's typing while the application cannot yet handle it. Pressing Enter or its button then either makes the browser submit the form itself and replace the page, or does nothing at all — and the script's own start-up resets the form a moment later. Which of the two happens varies between browsers and between one opening and the next; either way what they typed is gone, with nothing to say why. Both blind runs copied examples that showed their forms early. The same failure arriving mid-edit, when another copy's rows land, is SHARED-REDRAW-ON-MERGE.

Depends on tests/fixture/chess/app.js, examples/receipts/index.html, examples/tic-tac-toe/index.html, src/runtime/bootloader.ts, src/kit.ts.

NO-SAVE-BUTTON · Saving is automatic

Applies to every shape. Enforcement: not checked by anything.

Build no Save button, no "saved" indicator and no dirty flag. Under a host every write is saved as it happens (window.dai.autosaves is true). A page that uses the kit includes <dai-save> once, at the bottom: it appears only when the file was opened straight in a browser with no host, where saving takes a tap, and hides itself everywhere else. A page without the kit that must save with no host calls window.dai.saveDatabase(db) from a control shown only when window.dai.autosaves is false. A shared document needs no such control: with no host it cannot write its shared tables at all (SHARED-NEEDS-HOST).

Why. A save control under a host is a control that does nothing, and a person who presses it learns to distrust the rest.

Depends on src/runtime/bootloader.ts.

MIGRATE-CHANGED-TABLES · A changed table needs a migration

Applies to every shape. Enforcement: refused at build.

When a later version changes an existing table, add a migration — one file in migrations/, named with the next number (migrations/002-add-priority.sql), holding the ALTER statements that move the old shape to the new — and update schema.sql to match. Adding a table needs no migration. Never drop a table to get past the check.

Why. A version whose schema moved without a migration is refused at build, because the old file holds somebody's data and nothing else says how to carry it forward.

Depends on src/schema.ts.

TIMES-IN-UTC · Store UTC, show words

Applies to every shape. Enforcement: not checked by anything.

Store times as SQLite text in UTC (datetime('now')) and show them the way a person reads them — "today", "2 hours ago" — never as 2026-09-04 15:01:27. A time on a shared table is a fact somebody entered (a receipt's date), never a record of when a row was written: see SHARED-NO-DERIVED-STATE.

Why. Raw timestamps read as a machine talking. And a write time on a shared row is a second opinion about order that the replication already records.

Depends on tests/fixture/chess/schema.sql.

Shared tables

SHARED-MARKER · Mark a shared table

Applies to passable, session. Enforcement: refused at build.

Put the line -- dai:replicated directly above each table that more than one copy writes, with nothing but whitespace between the comment and CREATE TABLE. Only tables every party must agree on get it; everything about one copy stays local (SHARED-LOCAL-STAYS-LOCAL).

Why. The compiler rewrites a marked table into an append-only one with the columns, key, triggers and views replication needs. A marker further up is an ordinary comment and declares nothing.

Depends on src/replicated.ts.

SHARED-DECIDE-UP-FRONT · Decide which tables are shared before the first release

Applies to passable, session. Enforcement: not checked by anything.

Decide whether each table is shared before the application is first released, and do not plan to convert a local table into a replicated one later: that path is not supported or tested today.

Why. A replicated table carries the rewrite in every copy already saved, and whether a migration can turn an existing local table into one has not been established. Deciding the shape first (SHAPE-FIRST) is what makes this cheap.

Depends on src/replicated.ts.

SHARED-NO-KEY · No PRIMARY KEY, no AUTOINCREMENT

Applies to passable, session. Enforcement: refused at build.

A replicated table declares no PRIMARY KEY and no AUTOINCREMENT. Its identity is the entity the write surface returns (SHARED-ENTITY-IDENTITY).

Why. The key belongs to replication. Two copies both advancing one counter allocate the same ids for different rows.

Depends on src/replicated.ts.

SHARED-NO-UNIQUE-CHECK · No UNIQUE, no CHECK

Applies to passable, session. Enforcement: checked by the lint (shared-table-constraint).

A replicated table declares no UNIQUE and no CHECK constraint, on a column or on the table.

Why. A UNIQUE(game_id, ply) refuses exactly the rows a merge exists to surface: two people acting at the same point is a conflict to show a person, not an error to raise at them. A CHECK that differs between two versions of the application rejects the other copy's honest rows, and they arrive as rejected rows rather than a refused merge, so nobody can see what happened.

Depends on src/lint.ts.

SHARED-NO-R-COLUMNS · No column names beginning _r_

Applies to passable, session. Enforcement: refused at build.

Name no column of your own with the prefix _r_.

Why. That prefix is replication's; the rewrite adds _r_replica, _r_seq, _r_lc, _r_entity, _r_parents, _r_deleted, _r_superseded, _r_sig and, in a session document, _r_session.

Depends on src/replicated.ts.

SHARED-WRITE-SURFACE · Write shared rows only through window.dai.replicated

Applies to passable, session. Enforcement: refused at run time; checked by the lint (shared-raw-write).

Write to a replicated table only with window.dai.replicated.insert(table, values), .change(table, entity, values) and .remove(table, entity), after await window.dai.openDatabase(). values is an object of your own columns. change takes every one of your columns, not only the ones that changed. Never run INSERT, UPDATE or DELETE against a replicated table — not in JavaScript, not in a kit control, not in a seed block.

Why. Rows are appended and never changed in place: an UPDATE or DELETE is refused with REPLICATED_TABLE_IMMUTABLE, and a raw INSERT fails because it lacks the replication columns only the write surface fills in.

Depends on src/runtime/bootloader.ts, src/replicated.ts.

SHARED-READ-CURRENT · Read shared rows from the _current view

Applies to passable, session. Enforcement: checked by the lint (shared-base-read).

Read a replicated table t only through the view t_current, which holds one row per live entity. Never SELECT from t itself for display or logic. Use t_conflicts or t_heads only to show or resolve a conflict (SHARED-SURFACE-CONFLICTS).

Why. The base table holds every version of every row: superseded edits, tombstones of deleted rows, and — in a session — rows from non-members and rows written after the close. Reading it shows all of them at once.

Depends on src/replicated.ts.

SHARED-SURFACE-CONFLICTS · Show conflicts; never pick silently

Applies to passable, session. Enforcement: checked by the lint (shared-conflicts-unshown); not checked by anything.

There are two kinds of conflict, and an application with shared tables must show both to the person and let them resolve it. (1) The same row edited on two copies: t_current still shows one version, with _r_conflicted = 1; the competing versions are the rows of t_heads for that entity (t_conflicts lists the entities). Show that it happened and offer the versions; the person resolves it by choosing, which the application writes as change(table, entity, chosenValues) — a change names every current head, so it settles the conflict. (2) Two new rows that claim one slot — two moves at the same turn, two people taking the same shift. Replication cannot see this (they are different rows); the application derives it from the rows (two rows with the same ply) and shows it, and the person keeps one while the other is removed.

Why. A merge that picked one version or one row and hid the other would read to a person as lost data. The runtime surfaces conflicts precisely so that the person, not the order the files arrived in, decides.

Depends on src/replicated.ts, src/replicated-rows.ts.

SHARED-REDRAW-ON-MERGE · Redraw when the other copy's rows arrive

Applies to passable, session. Enforcement: checked by the lint (shared-no-merge-listener).

Listen for the dai:merged event on window and redraw everything drawn from shared tables when it fires: window.addEventListener("dai:merged", (event) => { redraw(); }). event.detail carries applied, duplicate, rejected, newReplicas, conflicts and via — "carrier" when a file or link was opened, "mailbox" when rows arrived in the background. If the page uses the kit's reading elements, call window.daiKit.refresh() in the listener. A redraw must never discard what the person is in the middle of — text typed into a field, an editor that is open, a selection: rows arrive whenever the other copy's changes do, including mid-sentence. Keep work in progress outside what the redraw rebuilds — in a form written once in the HTML rather than recreated on every draw, or in a local drafts table the redraw reads back — or leave the element being edited untouched until it is saved or cancelled. The same failure arriving at start-up rather than mid-edit is NO-INPUT-LOST-WHILE-OPENING.

Why. Nothing else tells the application that another copy's rows landed. Without it the application draws once and redraws only after its own writes, so a two-person document looks broken in exactly the case it exists for. And a redraw that rebuilds an open editor from the stored wording throws away what was being typed, silently — found by running a blind candidate over the mailbox, where a background merge landed while a term was being edited.

Depends on src/runtime/bootloader.ts, tests/fixture/chess/schema.sql.

SHARED-NO-DERIVED-STATE · Store facts, derive everything else

Applies to passable, session. Enforcement: not checked by anything.

Store in a shared table only the facts people enter or acts they take — a receipt, a move, a mark. Never store anything computable from them: no board, no score, no turn, no total, no balance, no "last updated", no status that follows from other rows. Compute it from the rows each time it is drawn, preferably in SQL.

Why. A stored total is a second opinion about what the rows say, and after a merge it is wrong: each copy computed it from the rows it had, and neither computed it from the union.

Depends on tests/fixture/chess/schema.sql.

SHARED-LOCAL-STAYS-LOCAL · What belongs to one copy stays local

Applies to passable, session. Enforcement: not checked by anything.

Keep in ordinary local tables everything about this copy rather than the document: settings, drafts, which item the screen is showing, what this person has hidden, the name this person goes by. Local tables are never merged, so they may use PRIMARY KEY, UNIQUE and CHECK freely. They travel only in a whole-document copy — a file, or the host menu's share — where a person opening it for the first time starts from the sender's local rows. An invite into one session carries none of them, and a copy that already exists keeps its own local rows when another copy's shared rows are merged into it.

Why. A setting in a shared table changes the other person's screen, and a draft in one is sent before it is finished.

Depends on tests/fixture/chess/schema.sql.

SHARED-ENTITY-IDENTITY · A shared row's identity is its entity

Applies to passable, session. Enforcement: not checked by anything.

Refer to a shared row by the entity the write surface returned: 32 lowercase hex characters. Read it back as lower(hex(_r_entity)). To point one shared row at another (a move at its game), store that hex string in an ordinary TEXT column and compare it with lower(hex(_r_entity)). A particular version of a row is its key, (_r_replica, _r_seq) — the same on every copy — so an application that needs to name "this exact wording" (what a person accepted, say) can use it.

Why. The entity is the same on every copy, where an id of your own would be allocated separately on each.

Depends on tests/fixture/chess/schema.sql.

SHARED-SEED-THROUGH-SURFACE · Shared rows are never seeded with SQL

Applies to passable, session. Enforcement: refused at run time; checked by the lint (shared-raw-write).

Seed no replicated table from a <script type="application/sql"> block. Prefer to seed nothing shared: an empty shared table with a good empty state is correct. If a shared example row is essential, insert it with window.dai.replicated.insert once, guarded by a flag in a local table, so a second open and a second copy do not add it again.

Why. A raw INSERT into a replicated table fails (it lacks the replication columns), and a seed that every copy writes on its own first open puts a duplicate in every merge.

Depends on src/replicated.ts.

SHARED-NEEDS-HOST · A shared document writes only under a host

Applies to passable, session. Enforcement: refused at run time.

Expect shared tables to be writable only when the document is opened by a host — the DAI opener or the desktop app — which delivers the write rules. Opened straight in a browser as a plain file, openDatabase() resolves after the rules wait (about 10 seconds) and every shared write is refused with WRITE_SURFACE_UNAVAILABLE. Catch that error around writes and tell the person to open the document in the opener; local tables still work.

Why. The rules that stamp and merge a replicated row come from the host. A shared write without them would be a row no other copy could merge.

Depends on src/runtime/bootloader.ts.

Sessions

SESSION-PROFILE · Declare the session profile

Applies to session. Enforcement: refused at build.

Declare a session document with one line comment in schema.sql: -- dai:profile session max_parties=N close=any|creator. N is the most people the document allows, at least 1 — but today a session seats two whatever N says: session.create() mints the creator's seat and one open seat, and no call adds another (backlog D6). Declare max_parties=2, and do not build an application that needs a third member. close=any lets any member close a session; close=creator lets only the person who created it; it defaults to any. The document must also have at least one table marked -- dai:replicated. Every replicated table then carries the session of each row.

Why. The profile is signed into the document, so the size of the group is the creator's stated limit rather than something the application decides. A malformed profile, or one with no replicated table, is refused at build.

Depends on src/replicated.ts.

SESSION-CREATE · A new game is a new session

Applies to session. Enforcement: not checked by anything.

Start each game, match or agreement with const { session, seat } = window.dai.replicated.session.create(). It seats the creator and leaves one open seat for the invitee; session is the id to keep (hex), seat is the open seat. Then insert the thing itself — the games row — with that session (SESSION-ROW-CARRIES-SESSION). Do both in one transaction if you write local rows beside them: session.create() and insert work inside a BEGINCOMMIT you open. The creator is a member from the moment the session exists, so the creator's rows are admitted before anyone has joined — the first move can be made before the invite is sent.

Why. A session is the unit of membership. Rows written outside one belong to nobody, and a second game in the same session would share the first game's roster.

Depends on src/runtime/bootloader.ts.

SESSION-ROW-CARRIES-SESSION · Every insert names its session

Applies to session. Enforcement: refused at run time.

In a session document, pass the session id as the third argument of every insert: window.dai.replicated.insert("moves", values, session). change and remove take no session — they inherit the entity's.

Why. Every replicated row in a session document belongs to a session. Run against the write rules: an insert without one throws "A row for … carries no session, but <table> declares the session profile", and nothing is written.

Depends on src/replicated-rows.ts, src/replicated.ts.

SESSION-JOIN-ON-OPEN · Take the open seat when an invite is opened

Applies to session. Enforcement: not checked by anything.

When this copy opens an invite, bind its open seat with window.dai.replicated.session.join(session, seat): once at start-up, and again in the dai:merged listener only when event.detail.via === "carrier" — never for "mailbox". Join only if this copy is not already a member and an open seat exists: the open seat is a _dai_seat_current row for the session whose seat no _dai_binding_current row binds. Join the session the invite was sent for. An invite carries only that session and none of the sender's local rows (SESSION-INVITE), so it is a session with an open seat that this copy did not create and is not a member of — in a fresh copy made from an invite there is exactly one. That includes a copy whose seat was contested or replaced: opening the creator's fresh invite is how it gets back in, and excluding copies that were ever seated would lock it out for good. Prefer the item that is showing when it is joinable (a copy that arrived as a whole document carries the sender's local rows, including which item was showing), otherwise take the newest joinable one, and make it the item showing.

Why. Membership comes from opening an invite, not from rows arriving. A copy that joined on every background merge would re-take a seat it had lost, and a copy that joined twice would contest its own seat.

Depends on src/runtime/bootloader.ts, tests/fixture/chess/app.js.

SESSION-MEMBERSHIP · Read membership, and show the three ways to be outside

Applies to session. Enforcement: not checked by anything.

This copy's replica id is SELECT lower(hex(id)) AS id FROM _dai_replica. It is a member of a session when _dai_member has a row for (session, replica). Enable writing only for members, and show a copy that is not one which of three states it is in: it holds the rows but never joined (it was forwarded the document, not invited); it joined but its seat was contested or replaced (SESSION-CONTESTED-SEAT); or the session is closed (SESSION-CLOSE). The _current views of a session document show only admitted rows — rows by members, written before any close.

Why. A non-member's rows are kept but never admitted, so an application that let a non-member play would show them their own moves and nobody else ever would. Saying which state a copy is in is the difference between a message and a hang.

Depends on src/replicated.ts.

SESSION-CONTESTED-SEAT · A contested seat is a state to show

Applies to session. Enforcement: refused at run time; not checked by anything.

A seat bound by two or more different replicas is contested — two people opened the same invite — and admits neither. Detect it as a _dai_binding_current seat with count(DISTINCT _r_replica) > 1 for the session. Show the creator that the invite went to more than one device and offer a fresh invite: window.dai.replicated.session.reseat(session), then share again. Show a copy whose own seat was lost that nothing it did lost its place, and that the creator can send a new invite. reseat refuses with NOT_SEAT_CREATOR for anyone but the creator and with CANNOT_RESEAT when no seat is contested.

Why. It is resolved without a clock deciding who opened the invite first, so neither copy can be admitted until the creator repairs it; an application that treated it as an error would leave both people stuck.

Depends on src/runtime/bootloader.ts.

SESSION-CLOSE · Closing is separate from finishing

Applies to session. Enforcement: refused at run time; not checked by anything.

Ending the activity is an ordinary row: a resignation, a final mark, a signature. Closing the session is a separate, heavier act — window.dai.replicated.session.close(session) — after which rows written later than what the closer had seen are not admitted. Offer it only on a finished session, never as the way to end a live one. Closing as part of an act whose point is finality — sealing an agreement once both have accepted it — is exactly what close is for: write the act as a row, then close. Read whether a session is closed from _dai_close_current (any row for the session). Under close=creator a non-creator's close is refused with CLOSE_NOT_PERMITTED; hide or disable the control for them.

Why. A close is final for the group, and it is decided by what the closer had seen rather than by a clock. Folding it into "resign" would end a session the other person had not finished with.

Depends on src/runtime/bootloader.ts.

SESSION-INVITE · An invite is a shared link

Applies to session. Enforcement: not checked by anything.

Invite the other party by asking the host to share, naming the session: a button that calls window.dai.requestShare(session). There is no invite call of your own. The copy that travels holds only that session's rows — none of the document's other sessions, and none of this copy's local tables — so the recipient gets this one game and nothing else of the sender's. Without a session, requestShare() offers the whole document, every session in it, as the host's own menu does; use it for that, never for an invite. After a copy has been shared by link, the host moves new rows between the copies on its own, and they arrive as dai:merged with via "mailbox"; a copy handed over as a file carries its rows when it is opened. The application never sends rows itself; a "send" button that calls requestShare() again is only needed where copies travel as files.

Why. The host mints the key that lets the two copies exchange rows and makes the link; the application only asks, and only the application knows which game it is inviting to. Filtering to that game is what keeps a person's other games — and whatever they keep only on their own device — out of every invite they send.

Depends on src/runtime/bootloader.ts, apps/runner/src/main.ts.

The kit

KIT-FIRST · Prefer the kit for local tables

Applies to every shape. Enforcement: not checked by anything.

Use dai-kit for local tables: <dai-rows>, <dai-value>, <dai-form>, <dai-attach> and <dai-save>, with <script type="module" src="./dai-kit.js"></script> at the end of the body. Reach for JavaScript only for what the kit cannot express. Do not write dai-kit.js yourself or put it in the bundle: the compiler adds it to every container.

Why. The kit removes the dangerous sinks by construction — no statement built from a value, text-only rendering — and does the querying, rendering and redrawing a hand-written application gets wrong.

Depends on src/kit.ts, tests/kit.spec.ts.

SHARED-KIT-READS · The kit reads shared tables; it does not write them

Applies to passable, session. Enforcement: checked by the lint (shared-raw-write).

The kit's write controls — data-run, <dai-form run=…>, <dai-attach run=…> — run plain SQL, so they are for local tables only. On a shared table they fail (SHARED-WRITE-SURFACE), and the kit neither catches the error nor shows it. The kit's reading elements, <dai-rows> and <dai-value>, work over t_current views; redraw them on a merge with window.daiKit.refresh() (SHARED-REDRAW-ON-MERGE). Write shared rows in JavaScript through window.dai.replicated.

Why. Run against the rewrite: a kit INSERT into a replicated table fails with SQLite's NOT NULL error, and an UPDATE or DELETE with REPLICATED_TABLE_IMMUTABLE — uncaught, so the person sees nothing happen.

Depends on src/kit.ts.

The screen and the card

ICON-SVG · An icon that reads at 48 pixels

Applies to every shape. Enforcement: not checked by anything.

Include icon.svg: a simple, bold mark on a square canvas (viewBox="0 0 100 100"), with a filled background, no text smaller than a third of the canvas, and no external references.

Why. It becomes the application's icon on a phone's home screen and in a browser tab.

Depends on src/core.ts.

DESCRIBE-ON-CARD · One line, and three things it does

Applies to every shape. Enforcement: not checked by anything.

In the <head> of index.html: <meta name="description" content="…"> — what it is for, under 60 characters, the way a store page puts a line under an app's name; and exactly three <meta name="dai:does" content="…"> lines, each under 90 characters, starting with a verb, saying what somebody would tell a friend it does. Three, or none. Beside them, <meta name="theme-color" content="…"> with the application's own background color.

Why. These are the whole of what a person sees on the card before they decide to open the document; two lines is a card with a gap in it.

Depends on apps/runner/src/card.ts.

EDGE-TO-EDGE · Color to the edge, content inside it

Applies to every shape. Enforcement: not checked by anything.

Paint the background to every edge of the screen, and push content clear of the strips a phone covers using the four custom properties the host sets: var(--dai-safe-top, 0px), var(--dai-safe-right, 0px), var(--dai-safe-bottom, 0px), var(--dai-safe-left, 0px). Usually that is padding at the top of what is first and at the bottom of what is last.

Why. Nothing is reserved for the host, so an application that ignores this puts its own title under the clock and its last row under the home indicator.

Depends on src/runtime/bootloader.ts.

TOP-RIGHT-CLEAR · Nothing tappable in the top right corner

Applies to every shape. Enforcement: not checked by anything.

Leave the top right corner clear of anything tappable.

Why. The host floats one small round button there, over the application, and it is how a person reaches the menu.

Depends on apps/runner/index.html.

ONE-LAYOUT · One layout for every screen

Applies to every shape. Enforcement: not checked by anything.

Build one layout that holds from about 320px wide to a wide desktop window: a single column that grows, sensible maximum widths on text, tap targets no smaller than 44px, no fixed pixel widths on anything that holds content. Check it at 390px and at 1280px. Do not ask which device it is for and do not build two.

Why. A document is sent as a link, and the sender does not choose whether it is opened on a phone, a tablet or a desktop.

Depends on examples/receipts/app.css.

LOOK-FINISHED · Make it look finished

Applies to every shape. Enforcement: not checked by anything.

Real spacing, a considered empty state, keyboard support, and a dark mode through prefers-color-scheme.

Why. It is a document somebody will keep.

Depends on examples/receipts/app.css.

Handing it over

HANDOVER-BUNDLE · One bundle, or a tool call

Applies to every shape. Enforcement: not checked by anything.

When a tool is available, call it with the files as its arguments. Otherwise write the whole application as ONE fenced code block in the bundle format shown under HOW TO HAND IT OVER — one fence around every file, each file starting with a line "--- file: <path>". The bundle's second line is name: followed by the application's name, which becomes its title and file name. index.html is the entry point; other files are referenced from it by relative path.

Why. Outside a fence a chat window draws the file markers as dividing lines and breaks the application into pieces nobody can copy.

Depends on src/bundle.ts.

Released under the MIT License. Dynamic Application Interface standard.