---
name: arkiv-data-modeling
description: Design the entity schema for Arkiv — the queryable database on Ethereum — BEFORE writing code. Use when modeling data with @arkiv-network/sdk; choosing entity types, attributes vs payload, relationships/foreign keys, project namespaces, status state machines, version history, tags, media/blob storage; or mapping a Postgres/Supabase schema onto Arkiv entities. Trigger words; Arkiv, @arkiv-network/sdk, entity schema, data model, entityType, attributes, payload, relationships, foreign keys, namespace, Braga, jsonToPayload. Decides defaults (not menus) from 124 real hackathon submissions and encodes the attribute-typing and contentType gotchas before you hit them.
license: MIT
metadata:
  arkiv.skill-version: "1.1.0"
  arkiv.sdk-package: "@arkiv-network/sdk"
  arkiv.sdk-range: ">=0.6.5 <0.7.0"
  arkiv.sdk-verified: "0.6.8"
  arkiv.network-fingerprint: "braga:60138453102"
  arkiv.surface: "createPublicClient, createWalletClient, http, privateKeyToAccount, braga, createEntity, updateEntity, mutateEntities, getEntity, buildQuery, where, createdBy, withAttributes, withPayload, limit, fetch, eq, jsonToPayload, ExpirationTime, Attribute, NoEntityFoundError, toJson"
  arkiv.last-verified-at: "2026-06-11"
  arkiv.evidence: "14 patterns from 124 hackathon submissions across 5 events"
---

# Arkiv Data Modeling

Use this skill when an app is about to store anything on Arkiv. The entity schema is the architectural decision everything else depends on — queries, costs, ownership, and expiration all fall out of it. Design the schema FIRST (worksheet below), then write code. Arkiv is a queryable database on Ethereum: model it like a database, not like a key-value blob store.

## Ground rules

- **Network (mutable — verify before relying on it):** Braga testnet, chainId `60138453102`, gas token GLM. RPC `https://braga.hoodi.arkiv.network/rpc` · faucet `https://braga.hoodi.arkiv.network/faucet/` · explorer `https://explorer.braga.hoodi.arkiv.network`. **Networks rotate. Verify at https://docs.arkiv.network before relying on these. Testnet entities do NOT migrate between networks.**
- **SDK:** `@arkiv-network/sdk` `>=0.6.5 <0.7.0` (verified against 0.6.8). The SDK re-exports all of viem — never install viem separately.
- **Node:** pin **Node 22 LTS**. Node 24 hangs forever on entity updates (sdk issue #14 — tx lands on-chain, the promise never resolves).
- An entity = `payload` (bytes, usually JSON) + `attributes` (flat `{key, value}` list, the query index) + `contentType` + an expiration date (a.k.a. TTL) set in seconds via `expiresIn`. Entities are public; attributes are always readable by anyone.
- Sorting happens **in JS after fetching**. Never call `.orderBy()` — the server silently ignores it and it is removed in 0.7.0.

## The design worksheet (do this before any code)

1. **List every query/screen the app needs.** The read side designs the schema ("design the query list FIRST" — the discipline behind 30 of 124 submissions).
2. **Derive attributes from the queries.** Every filter becomes an attribute: `string` for equality/enums, `number` for ranges and anything you'll sort (in JS). Nothing you won't filter on goes in attributes.
3. **Everything else goes in the payload** as one JSON document (`jsonToPayload`).
4. **Fill the entity-type table** — this IS your schema:

| entityType | indexed attributes | payload shape | owner wallet | expiresIn |
|---|---|---|---|---|
| `job` | status, company_key, minSalary | full job JSON | poster | 60d |
| `application` | job_key, status | cover letter etc. | **applicant** | 60d |
| `profile` | wallet, role | bio, links | the user | 365d |

4–6 entity types is the observed sweet spot (apps ranged 2–9). Design the expiresIn column with **arkiv-expiration-design** — every type gets its own deliberate lifetime, never one blanket value.

## Client setup (two clients, always)

```ts
import { createPublicClient, createWalletClient, http } from "@arkiv-network/sdk"
import { privateKeyToAccount } from "@arkiv-network/sdk/accounts"
import { braga } from "@arkiv-network/sdk/chains"

// The wallet client has NO read actions (getEntity/buildQuery only exist on the
// public client). One reads, one writes.
export const publicClient = createPublicClient({ chain: braga, transport: http() })
export const walletClient = createWalletClient({
  chain: braga,
  transport: http(),
  account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
})
// STOP before the first write: fund this wallet with testnet GLM at the faucet
// (https://braga.hoodi.arkiv.network/faucet/ — verify at docs.arkiv.network) or
// every createEntity reverts with an insufficient-funds error.
```

## Patterns

### entityType-discriminator (table = type attribute)

Model each "table" as an entity type discriminated by a stable `entityType` attribute, so one shared chain hosts your whole relational schema and every read is a flat attribute query. Declare the schema ONCE as a constant and generate typed create wrappers from it — don't scatter attribute names across the codebase.

```ts
import { jsonToPayload, ExpirationTime } from "@arkiv-network/sdk/utils"
import type { Attribute } from "@arkiv-network/sdk"

export const PROJECT = "myapp-v1-7f3a" // see project-namespace pattern below

export const ENTITY_TYPES = {
  job:         { index: ["status", "company_key", "minSalary"], expiresIn: ExpirationTime.fromDays(60) },
  application: { index: ["job_key", "status"],                  expiresIn: ExpirationTime.fromDays(60) },
  profile:     { index: ["wallet", "role"],                     expiresIn: ExpirationTime.fromDays(365) },
  edge:        { index: ["from", "to", "relation"],             expiresIn: ExpirationTime.fromDays(365) },
} as const

// Mirrors index fields out of the payload so they stay in sync (one team
// hand-maintained ~50 mirrored attributes — automate it from day one).
export function deriveAttributes(
  payload: Record<string, unknown>,
  indexFields: readonly string[],
): Attribute[] {
  return indexFields.flatMap((key) => {
    const v = payload[key]
    if (typeof v === "string" || typeof v === "number") return [{ key, value: v }]
    if (typeof v === "boolean") return v ? [{ key, value: 1 }] : [] // true → 1; false → OMIT (numeric 0 has a wire quirk)
    return [] // bigint/array/object are NOT legal attribute values — keep them payload-only
  })
}

export async function createTyped(
  type: keyof typeof ENTITY_TYPES,
  payload: Record<string, unknown>,
) {
  const schema = ENTITY_TYPES[type]
  return walletClient.createEntity({
    payload: jsonToPayload(payload),
    contentType: "application/json",          // REQUIRED on every create/update
    attributes: [
      { key: "project", value: PROJECT },     // namespace on EVERY write
      { key: "entityType", value: type },     // the "table"
      ...deriveAttributes(payload, schema.index),
    ],
    expiresIn: schema.expiresIn,              // seconds — design per type (arkiv-expiration-design)
  })
}

// Creating MORE THAN ONE entity? Call createTypedBatch (chunked at 1000), NOT
// createTyped in a loop — looping single creates collides on the nonce, is far
// slower, and is non-atomic. mutateEntities writes up to 1000 ops in ONE tx (one
// signature, one nonce, all-or-nothing); 1000 is the TOTAL cap across all arrays.
export async function createTypedBatch(
  items: { type: keyof typeof ENTITY_TYPES; payload: Record<string, unknown> }[],
) {
  const creates = items.map(({ type, payload }) => ({
    payload: jsonToPayload(payload),
    contentType: "application/json",
    attributes: [
      { key: "project", value: PROJECT },
      { key: "entityType", value: type },
      ...deriveAttributes(payload, ENTITY_TYPES[type].index),
    ],
    expiresIn: ENTITY_TYPES[type].expiresIn,
  }))
  const results = []
  for (let i = 0; i < creates.length; i += 1000)       // chunk larger sets into ≤1000-op batches
    results.push(await walletClient.mutateEntities({ creates: creates.slice(i, i + 1000) }))
  return results
}
```

Seen in 44 of 124 submissions — Hostr, Ark-Agent, Ark Hive, Credchain, MeshOWN, Arklib.

### project-namespace on EVERY write and query — and its security twin, the creator filter

The testnet is one shared public store: without a unique project attribute on every entity AND every query, your reads return other apps' entities. Declare a collision-resistant slug once (`<appname>-<version>-<random>`). **But the namespace is organization, NOT auth** — anyone can write entities carrying YOUR project attribute. Trusted reads must ALSO filter by the immutable `$creator` via `.createdBy()`, or attacker-written entities silently render in your UI. Ship both in one wrapper; never query without it:

```ts
import { eq } from "@arkiv-network/sdk/query"

const APP_WALLET = "0xYourWriterWallet" as `0x${string}`

// Fresh builder per call — fetch() mutates the builder (it stores the cursor),
// so reusing one builder returns page 2. Data flags default to EMPTY: always
// .withAttributes(true).withPayload(true) when you need content, and pagination
// requires .limit(n).
const sanitize = (s: string) => s.replace(/["\\\n\r]/g, "") // quote-injection defense (sdk issue #67)

export function scopedQuery(type: string, filters: Record<string, string | number> = {}) {
  const preds = Object.entries(filters).map(([k, v]) => eq(k, typeof v === "string" ? sanitize(v) : v))
  return publicClient
    .buildQuery()
    .where([eq("project", PROJECT), eq("entityType", type), ...preds])
    .createdBy(APP_WALLET) // namespaces organize; $creator authenticates
    .withAttributes(true)
    .withPayload(true)
    .limit(100)
}

const jobs = await scopedQuery("job", { status: "open" }).fetch()
for (const e of jobs.entities) console.log(e.toJson())
```

Sanitize any user-supplied string before passing it into `eq()` — predicate values are quoted but not escaped, so a `"` in user data breaks or alters the query (sdk issue #67; helper in **arkiv-query-patterns**).

Namespace seen in 41 of 124 submissions — Fhedin ("Best Practice #1"), Arkivox, SealVault, AeroTrack, Cortex. Creator filter seen in 2 of 124 — Susurro ("the immutable $creator rejects injected data"), SealVault; most namespace-only teams were silently vulnerable.

### foreign-keys via shared attribute values (Arkiv has no joins)

A relationship = the parent's `entityKey` (or a shared business id) stored as a plain attribute on the child. Naming convention: `<parentType>_key`. Fetch all children with one equality query; joins happen client-side.

```ts
const { entityKey: jobKey } = await createTyped("job", {
  title: "Solidity dev", status: "open", company_key: companyKey, minSalary: 90_000,
})
await createTyped("application", { job_key: jobKey, status: "pending", note: "hi!" })
// Same-workflow related records you already hold? Batch them in ONE mutateEntities
// call (createTypedBatch, ≤1000 ops) instead of N sequential createTyped writes.

const applications = (await scopedQuery("application", { job_key: jobKey }).fetch()).entities
```

There is no cascade delete — if children must die with the parent, delete them explicitly or align their expirations (**arkiv-expiration-design**). Seen in 38 of 124 submissions — VEIL ("Arkiv has no FKs"), Fhedin, Credchain, NEXUS, holomem.

### attributes-for-filtering, payload-for-content

Attributes are the index; the payload is the document. Put ONLY what you filter on into attributes; serialize the full record (descriptions, markdown, chat text, encrypted blobs, exact coordinates) into the JSON payload. List views then never need payloads (drop `.withPayload(true)` for them — cheaper). The `deriveAttributes` helper above keeps the mirror honest. Seen in 30 of 124 submissions — Hostr, holomem, Licita Verify, ArkivLog, AeroTrack ("qualitative AI justification in the payload, riskScore as number attribute").

### numeric-attributes discipline (the schema half)

Anything you will range-filter (`gt`/`lt`) — or sort in JS — MUST be a `number` attribute; strings silently break ranges. Numerics are integers server-side (BigInt, no floats), so scale decimals and document the scale:

```ts
{ key: "createdAt", value: Date.now() }              // timestamps: epoch ms
{ key: "priceCents", value: Math.round(price * 100) } // money: integer-scale ×10^n
{ key: "lat6", value: Math.round(lat * 1_000_000) }   // coordinates: microdegrees (MeshOWN)
{ key: "confidential", value: 1 }                     // booleans: 1 = true, OMIT for false (0 has a wire quirk)
```

Seen in 24 of 124 submissions — Fhedin, Mnemos ("importance and time are numbers so I can range-query them"), MeshOWN, vialibre (queries "margin < 15" on-chain).

### status-attribute state machine

Drive workflow state through a `status` attribute with a closed enum, progressed via `updateEntity`, and filter views on it (`eq("status", "live")`). Updates are FULL REPLACE — re-read, then re-send the COMPLETE attribute set and payload, or you silently drop your namespace and index:

```ts
import { NoEntityFoundError } from "@arkiv-network/sdk"

const TRANSITIONS: Record<string, string[]> = {
  draft: ["published"], published: ["live", "unlisted"], live: ["ended"],
}

export async function setStatus(entityKey: `0x${string}`, next: string) {
  const e = await publicClient.getEntity(entityKey) // THROWS NoEntityFoundError if missing/expired
  const current = e.attributes.find((a) => a.key === "status")?.value as string
  if (!TRANSITIONS[current]?.includes(next)) throw new Error(`bad transition ${current}→${next}`)
  return walletClient.updateEntity({
    entityKey,
    payload: e.payload!,                              // full replace: re-send payload…
    contentType: e.contentType ?? "application/json", // …and contentType…
    attributes: e.attributes.map((a) => (a.key === "status" ? { ...a, value: next } : a)), // …and ALL attributes
    expiresIn: ExpirationTime.fromDays(30),           // …and a fresh expiration (it is NOT carried over)
  })
}
```

Prefer status flips over `deleteEntity` for soft-delete/archive (history stays recoverable and auditable); reserve delete for true removal. Seen in 9 of 124 submissions — espresso, Pulse, Gath3r ("draft/published/unlisted"), ArkEve, NEXUS.

### version history: immutable revisions + a mutable pointer

Updates are full-replace and history is not automatic. When you need it: keep ONE mutable canonical entity per document and append an immutable revision entity on every edit — atomically, in one transaction:

```ts
await walletClient.mutateEntities({
  updates: [{
    entityKey: pageKey,
    payload: jsonToPayload(newDoc),
    contentType: "application/json",
    attributes: canonicalAttrs,                 // the COMPLETE set — full replace
    expiresIn: ExpirationTime.fromDays(365),
  }],
  creates: [{
    payload: jsonToPayload(newDoc),
    contentType: "application/json",
    attributes: [
      { key: "project", value: PROJECT },
      { key: "entityType", value: "pageRevision" },
      { key: "pageKey", value: pageKey },
      { key: "rev", value: revNumber },          // numeric → sort revisions in JS
    ],
    expiresIn: ExpirationTime.fromDays(30),      // revisions die young
  }],
})
```

Rule: "mutable pointers, immutable content." Seen in 7 of 124 submissions — Arklib (`kb.revision`), Datalog, Ocean, RecallKit (snapshot + revision + manifest trio), OpenClu.

### interaction-entity-owned-by-the-actor

Only the owning wallet can mutate an entity. So any cross-user interaction becomes its OWN entity, owned by whoever performs it: approvals, applications, flags, likes, RSVPs. **If wallet B reacts to wallet A's entity, B creates a NEW entity referencing A's key — B never tries to mutate A's.** This reshapes your entity-type table, so apply it BEFORE freezing the schema (it's why `application` is its own type above, owned by the applicant — "self-sovereign career history", NEXUS). Seen in 6 of 124 submissions — Agora, Gath3r, NEXUS, ArkivJobs, Hostr.

### graph topology as entities (typed edges)

For many-to-many or typed relationships, mint a dedicated edge entity with `from`, `to`, `relation` attributes — both directions stay queryable and the whole graph reconstructs from attribute queries, no extra database:

```ts
await createTyped("edge", { from: profileA, to: profileB, relation: "mentors" })
const mentees = (await scopedQuery("edge", { from: profileA, relation: "mentors" }).fetch()).entities
const mentors = (await scopedQuery("edge", { to: profileB, relation: "mentors" }).fetch()).entities
```

Seen in 5 of 124 submissions — MentorGraph (winner, $4K Sub0 Argentina — a full social graph of profiles/asks/offers/edges), holomem, Care Passport, Rootgraph.

### multi-value tags (no array attributes exist)

Attributes are a flat key-value list — no arrays. Default: **enumerated slots** `tag_0…tag_4` (lowercase, dedupe, cap at 5). Matching "any tag = X" means OR-ing across the slots (composition recipe in **arkiv-query-patterns**).

```ts
export const tagsToAttributes = (tags: string[]): Attribute[] =>
  [...new Set(tags.map((t) => t.toLowerCase()))].slice(0, 5)
    .map((t, i) => ({ key: `tag_${i}`, value: t }))
```

Switch to **junction entities** (`{ entityType: "tag", tag, listingKey }`, one entity per tag) only when tags are unbounded or each tag needs its own query surface. Seen in 2 of 124 submissions — arkiv-notes-mvp (slots), OpenClu (junction entities, "foreign-key style, not tag arrays").

### denormalize-for-single-query

For each child query ask: "can it run without first fetching the parent?" If not, copy the 2–3 parent fields you filter by onto every child and accept the redundancy — the hot read becomes ONE query. Seen in 2 of 124 submissions — MeshOWN (coordinates denormalized onto every Reading → "single-query geographic mesh queries across multiple device owners with no joins").

### media/blob storage decision tree

Payloads are for KBs, not MBs (no official size threshold is documented — that ambiguity burned teams; stay conservative):

1. **Tiny (≤ a few KB):** inline in the payload (base64 inside JSON, or raw bytes with the real `contentType`, e.g. `image/jpeg`).
2. **Medium:** a dedicated media entity (raw bytes + correct contentType) plus a separate metadata entity (filename, size, sha256 checksum) linked by key.
3. **Large:** bytes off-chain (IPFS/CDN); the Arkiv entity stores `cid`/URL + `sha256` as attributes. Arkiv is the queryable index; content-addressed storage is the warehouse.

Seen in 10 of 124 submissions — Eventra (inline covers), Workiv (media + metadata entities), Gath3r, voisss, Certik (IPFS + hash anchor).

### Arkiv as the boring app database (Postgres → Arkiv mapping)

For a CRUD app, translate directly — no token, no contracts needed:

| Postgres | Arkiv |
|---|---|
| table | `entityType` attribute value |
| row | entity |
| primary key | `entityKey` (32-byte hex, assigned at create) |
| indexed column | attribute (`string` or `number`) |
| other columns | JSON payload |
| `WHERE` | attribute query (`eq`/`gt`/`lt`…) |
| `FOREIGN KEY` | parent entityKey as child attribute |
| `JOIN` | two queries + client-side stitch |
| `UNIQUE` | query-before-write (no constraint primitive) |
| `ORDER BY` | fetch, then sort in JS (server ignores orderBy) |
| retention job / cron cleanup | per-type expiration — rows remove themselves |
| soft delete | `status` attribute flip |

Seen in 4 of 124 submissions — Roxium (DAO proposals/tasks), PayFlow (payroll), DYNEXA (loyalty ledger), tango.fun.

## Anti-patterns / gotchas

- **Attribute values are `string | number` ONLY** (sdk gotcha 15). `bigint` and `boolean` values are **silently dropped at encode time** — no error, the attribute just never lands. Booleans → `1` = true / OMIT the attribute for false; bigints → string or scaled number. Numeric `0` is encoded as `""` on the wire (deliberate server workaround) but round-trips as `0`. One flat list — no nesting, no arrays.
- **`contentType` is required on every create AND update** (sdk gotcha 20). The canonical pairing is `contentType: "application/json"` + `jsonToPayload(obj)`; for binary media use the real MIME type so readers can interpret the bytes.
- **Full-replace updates** (sdk gotcha 1): `updateEntity` requires `payload`, `attributes`, `contentType`, `expiresIn` — every time. Sending only the attributes you changed deletes the rest, including your `project` namespace (the official best-practices skill's own update example makes this mistake). Always read → merge → write, as in `setStatus` above. Concurrency, batching, and the full error catalog live in **arkiv-write-safety**.
- **Don't put secrets or PII in attributes — they are always public.** Encrypt sensitive payloads client-side and keep a minimal plaintext index (**arkiv-privacy-patterns**).
- **Huge lifetimes are a workaround, not permanence.** Arkiv has no permanence primitive; data lives `bytes × lifetime`. If something must outlive the app, design renewal or pair with another layer (**arkiv-expiration-design**).

## Compose with

- **arkiv-expiration-design** — fill the expiresIn column of your entity-type table; expiry as a feature.
- **arkiv-query-patterns** — pagination loops, raw query syntax, OR-composition, sanitizeQueryValue, counting.
- **arkiv-write-safety** — full-replace mechanics, mutateEntities sharp edges, write queues, error catalog.
- **arkiv-privacy-patterns** — encrypted payloads, hash anchors, what leaks anyway.
- **arkiv-app-architecture** — who signs and where the key lives; app topology around this schema.
