---
name: arkiv-expiration-design
description: Expiration design for Arkiv — the queryable database on Ethereum where every entity carries a mandatory expiration date. Use when deciding how long entities should live, choosing expiresIn values, building self-revoking access grants / rentals / check-in codes, heartbeats and dead-man's switches with extendEntity, expiry countdowns, parent/child lifetime alignment, dispute windows, or long-lived records on @arkiv-network/sdk. Triggers — Arkiv, expiresIn, expiration, ExpirationTime, extendEntity, expiresAtBlock, TTL, time-to-live, entity lifetime, ephemeral data, auto-expiring, disappearing messages, ZeroBtl, Braga.
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, eq, ExpirationTime, jsonToPayload, createEntity, updateEntity, extendEntity, mutateEntities, getEntity, buildQuery, getBlockTiming, NoEntityFoundError"
  arkiv.last-verified-at: "2026-06-11"
  arkiv.evidence: "14 patterns from 124 hackathon submissions across 5 events"
---

# Arkiv Expiration Design

Use this skill when designing how long Arkiv entities live. Every entity has an expiration date (a.k.a. TTL) set via `expiresIn` — and that is Arkiv's superpower, not housekeeping. You pay bytes × lifetime; data deletes itself; absence of an entity is itself a queryable signal. Design lifetime as part of the schema, BEFORE writing code: every entity type gets a deliberate duration with a business meaning, and expiry becomes the enforcement mechanism, the product feature, or the alert — not a cleanup chore.

## Ground rules

> **Networks rotate. Verify at https://docs.arkiv.network before relying on these. Testnet entities do NOT migrate between networks** (Kaolin sunset 2026-05-15 stranded every app pinned to it — your Braga entities will not outlive Braga).
>
> - Network: **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`
> - SDK: `@arkiv-network/sdk` **>=0.6.5 <0.7.0** (verified 0.6.8). Pin **Node 22 LTS** — Node 24 hangs forever on entity updates (sdk issue #14).
> - `expiresIn` is **SECONDS** (converted client-side at a hardcoded 2 s/block). Always use `ExpirationTime` helpers, never hand-rolled arithmetic.
> - Never call `.orderBy()` — the server silently ignores it and it is removed in 0.7.0. Sort fetched results in JS.

All snippets assume this setup (two clients — the wallet client has NO read actions):

```ts
import { createPublicClient, createWalletClient, http } from "@arkiv-network/sdk"
import { privateKeyToAccount } from "@arkiv-network/sdk/accounts"
import { braga } from "@arkiv-network/sdk/chains"
import { eq } from "@arkiv-network/sdk/query"
import { jsonToPayload, ExpirationTime } from "@arkiv-network/sdk/utils"

const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`) // throwaway testnet key
const wallet = createWalletClient({ chain: braga, transport: http(), account }) // writes + extends
const reader = createPublicClient({ chain: braga, transport: http() })          // all reads
const PROJECT = "myapp-v1" // namespace EVERY write and query — the testnet is a shared store
```

---

## The patterns

### expiry-as-enforcement (self-revoking grants, rentals, codes)

**The killer differentiator vs web2 databases — reach for this first.** Model permissions, licenses, rentals, and one-time codes as entities whose `expiresIn` IS the access window. No cron job, no revocation table, no "check if still valid" column: when the entity expires, access is gone at the protocol layer. A 30-day medical-data grant literally falls off the chain on day 30; a music rental's license revokes itself; a 2-hour check-in code can only prove presence while you are physically at the event.

```ts
// Grant access for exactly 30 days — the lifetime IS the policy.
const { entityKey, txHash } = await wallet.createEntity({
  payload: jsonToPayload({ wrappedKey: "<content key encrypted to the grantee>" }),
  contentType: "application/json",
  attributes: [
    { key: "project", value: PROJECT },
    { key: "type", value: "access_grant" },
    { key: "grantedTo", value: granteeAddress.toLowerCase() }, // validate /^0x[0-9a-f]{40}$/ first
    { key: "scope", value: `report:${reportId}` },
  ],
  expiresIn: ExpirationTime.fromDays(30),
})

// Checking access = an existence query — FILTERED BY THE ISSUER'S $creator.
// Existence alone is forgeable: on the shared testnet ANY wallet can write an
// entity carrying your project/type/grantedTo attributes. $creator cannot be
// forged, so the grant is only real if the issuing wallet signed it.
const TRUSTED_ISSUER = "0xYourIssuerWallet" as `0x${string}` // the wallet that creates grants
const sanitize = (s: string) => s.replace(/["\\\n\r]/g, "")  // quote-injection defense

async function isAccessLive(grantee: string, scope: string): Promise<boolean> {
  const res = await reader.buildQuery()
    .where([eq("project", PROJECT), eq("type", "access_grant"),
            eq("grantedTo", sanitize(grantee.toLowerCase())), eq("scope", sanitize(scope))])
    .createdBy(TRUSTED_ISSUER) // authorization checks ALWAYS pin the creator
    .limit(1)
    .fetch()
  return res.entities.length > 0
}
```

Early revocation = `deleteEntity` (instant, unrecoverable) or a status flip (auditable, but every reader must check it) — pick one and document it. Sanitize any user string you interpolate into a query (quote-injection — see arkiv-query-patterns).

Seen in 16 of 124 submissions — SealVault, HealthArkiv, Vitalchain, Susurro, Clink, Saregama.ai (winner, 2nd place ETH Argentina 2025), Fhedin, ASide.

### per-entity-type-expiration-matrix

Declare a retention policy per entity type up-front, like a schema. Default durations (override per business rule, not per vibe):

| Entity type | Business meaning of expiry | Default duration | Extend? |
|---|---|---|---|
| session / working state | forget by default | 1 h | on use |
| message / ephemeral content | disappearing is the feature | minutes–3 d | no |
| access grant / code | access window ends | = the window | renew = re-grant |
| operational record (job, listing) | goes stale | 30–90 d | on activity |
| revision / history | short audit tail | 30 d | no |
| profile / identity | dormant accounts decay | 365 d | on login |
| credential / proof / receipt | regulatory or trust horizon | 1–10 y | rarely |

Centralize it as a constants module — one team shipped the SAME field as minutes in one file and seconds in another; a constants file makes that impossible:

```ts
// expiration.ts — every lifetime in the app is declared HERE, once.
import { ExpirationTime } from "@arkiv-network/sdk/utils"
export const EXPIRES = {
  session: ExpirationTime.fromHours(1),
  message: ExpirationTime.fromDays(3),
  accessGrant: ExpirationTime.fromDays(30),
  job: ExpirationTime.fromDays(60),
  revision: ExpirationTime.fromDays(30),
  profile: ExpirationTime.fromDays(365),
  credential: ExpirationTime.fromYears(10),
} as const
// Cheat-sheet: 1h=3600s · 24h=86400s · 7d=604800s · 30d=2592000s · 365d=31536000s
```

**The derived-data rule:** anything DERIVED must outlive its source — Arkmy gives extracted insights 2× the lifetime of the conversation they came from, so the summary survives the raw transcript. When in doubt: derived duration ≥ 2× source duration.

Seen in 39 of 124 submissions — Datalog, MeshOWN, NotarIA, AeroTrack, Sidebrain, Arkmy, voisss.

### extendEntity-as-heartbeat-and-reinforcement

`extendEntity` **ADDS** seconds to the current expiration — which turns it into an active lifecycle verb. Three recipes:

```ts
// 1) Cite-to-extend, use-it-or-lose-it agent state (builders colloquially call it
//    "memory"; precisely it is ephemeral coordination state): each citation buys +24h.
await wallet.extendEntity({ entityKey: stateKey, expiresIn: ExpirationTime.fromHours(24) })

// 2) Batch reinforcement: extend everything cited this turn in ONE transaction.
//    mutateEntities writes up to 1000 ops/tx (TOTAL across extensions+creates+updates+
//    deletes) — chunk citedKeys into ≤1000-key slices for larger sets.
await wallet.mutateEntities({
  extensions: citedKeys.map((entityKey) => ({ entityKey, expiresIn: ExpirationTime.fromHours(24) })),
})

// 3) Dead-man's switch: the heartbeat IS extendEntity on a schedule.
//    Stop signing → the vault entity expires → expiry itself fires the switch.
//    Create with expiresIn = 2 × heartbeat period; each heartbeat extends by one period.
```

Cap runaway growth in app logic (Cortex caps memories at 1 year regardless of citations). Forgetting is free; remembering costs intent.

Seen in 7 of 124 submissions — Cortex, VEIL (dead-man's switch), espresso, MemoryForge, Ramen Protocol (winner, Out of the Box, ETH Argentina 2025).

### credential-outlives-operation

Split every action into a short-lived operational entity and a long-lived receipt — and ask, for each action: "what is the receipt, and how long must it outlive the action?" Write both in one atomic batch:

```ts
await wallet.mutateEntities({
  creates: [
    { ...accessGrant, expiresIn: ExpirationTime.fromDays(30) },  // the action: access window
    { ...grantRecord, expiresIn: ExpirationTime.fromYears(2) },  // the receipt: deliberately outlives it
  ],
})
// Issuing MANY grants/invite codes at once? Batch all the creates into mutateEntities
// (≤1000 ops/tx — one signature, one nonce), chunked for larger sets; never loop createEntity.
```

SealVault's 2-year `grant_record` outlives the grant it logs "so history is preserved even after access expires"; Agora's attendance credentials last 10 years while the event entity dies 30 days after the event.

Seen in 6 of 124 submissions — Agora, SealVault, ArkivGate, Clink.

### event-anchored-dynamic-expiry

Never give domain objects a blanket duration — compute `expiresIn` from the object's own end time plus a grace buffer (post-event check-ins, disputes):

```ts
export function computeExpiresIn(endDateMs: number, bufferDays = 7): number {
  const seconds = Math.ceil((endDateMs - Date.now()) / 1000) + ExpirationTime.fromDays(bufferDays)
  if (seconds <= 0) throw new Error("endDate already passed — expiresIn must be > 0 (ZeroBtl revert)")
  return seconds
}

await wallet.createEntity({ /* event entity */ expiresIn: computeExpiresIn(event.endDateMs, 30) })
// RSVPs/tickets: anchor to the SAME end date so children die with the event.
```

Buffer guidance: 7 d for consumer events (HAVEN), 30 d when attendance/disputes are processed after (Eventra, Agora).

Seen in 6 of 124 submissions — Eventra, Gather3, Agora, HAVEN, ArkEve.

### expiration-as-the-product

Sometimes the expiration date IS the user-facing feature: vanishing messages, ephemeral profiles, 24-hour ad slots (the duration is the placement the advertiser bid for), certificates whose validity window is the lifetime. Design rules: pick `expiresIn` from the DOMAIN rule, surface remaining lifetime in the UI (countdown helper under Gotcha 17 below), and treat natural expiry as the happy path — ship NO delete code for it. An expired certificate needs no revocation list: `getEntity` throwing `NoEntityFoundError` IS the "invalid" answer.

Seen in 6 of 124 submissions — Poof (winner, Best Usage of Expiration Time), Arkgram (winner, honorary), AdChain, Certik.

### parent-child-expiry-alignment

There is **no cascade primitive** — alignment is manual, in both directions: (a) parent must outlive children; (b) children co-expire with the parent (anchor both to the same end date, or give children a shorter duration — ClearShield's confirmations co-expire with their parent "so there are no orphaned audit records"). For (a), check and extend the parent IN THE SAME atomic transaction as the child create, with a buffer against races (Web3 Job Board uses 12 h):

```ts
async function createChildEnsuringParent(
  parentKey: `0x${string}`,
  child: Parameters<typeof wallet.createEntity>[0], // exact create-params shape, no extra type import
) {
  const parent = await reader.getEntity(parentKey) // throws NoEntityFoundError if already gone
  const timing = await reader.getBlockTiming()
  // BigInt-subtract FIRST, then Number() the small difference (see Gotcha 17):
  const parentSecondsLeft = Number(parent.expiresAtBlock! - timing.currentBlock) * timing.blockDuration
  const deficit = child.expiresIn + ExpirationTime.fromHours(12) - parentSecondsLeft

  return wallet.mutateEntities({
    creates: [child],
    ...(deficit > 0 ? { extensions: [{ entityKey: parentKey, expiresIn: Math.ceil(deficit) }] } : {}),
  })
}
```

Rule of thumb: child duration ≤ parent duration; derive the child's `expiresIn` from the parent's remaining lifetime when possible.

Seen in 5 of 124 submissions — Web3 Job Board, ClearShield, Casebook.

### expiry-as-game-or-incentive-mechanic

Expiry encodes product rules with zero enforcement code: streaks that expire every 90 days "to force members to re-earn" (Pulse), bounties time-boxed at 30 days, uncorroborated intel reports decaying in 90 days instead of polluting the feed forever (ClearShield), and a free "active games" lobby that is just a query for unexpired sessions (Casebook). If your design doc says "after N days, X stops counting" — that is an `expiresIn`, not a cron job.

Seen in 3 of 124 submissions — Pulse, ClearShield, Casebook.

### expiry-as-business-alert (absence is a signal)

Design what your app DOES when an entity is no longer found. PAWSI's rabies-vaccine records expire at 365 d; expiry flips the animal's status to "Expirado" and fires an alert prioritizing the zone — entity expiration triggering a real-world government workflow.

```ts
// Poll for expected-but-missing entities; the missing record IS the event.
const live = await reader.buildQuery()
  .where([eq("project", PROJECT), eq("type", "vaccination"), eq("animalId", animalId)])
  .limit(1).fetch()
if (live.entities.length === 0) flagForRevaccination(animalId) // expired = re-certify
```

Pairs naturally with certificate / compliance / inspection verticals.

Seen in 2 of 124 submissions — PAWSI AI, VendorPass.

### effectively-permanent-via-huge-expiration

**Arkiv has no permanence primitive — be honest about this.** When data should outlast the app, the workaround is a huge expiration (Pulse: 36,500 days for reputation records; voisss: 730 d as its top attestation tier). Say "effectively permanent", never more: cost scales bytes × lifetime, the owner can still delete, and a testnet entity dies with the testnet regardless of its expiration.

```ts
export const EFFECTIVELY_PERMANENT = ExpirationTime.fromYears(100)
```

Prefer renewal-on-use (extend on each access) over a giant up-front lifetime when the data has a user who shows up; for true forever-credentials, pair with a durable layer elsewhere (see arkiv-crosschain's two-tier durability).

Seen in 2 of 124 submissions — Pulse, voisss.

### ephemeral-to-canonical-promotion

Data is born cheap and expiring; only what proves worth keeping gets promoted — everything else disappears for free ("promote or perish"). Two promotion variants:

```ts
// Variant A — stays on Arkiv: buy it a long life.
await wallet.extendEntity({ entityKey, expiresIn: ExpirationTime.fromYears(1) })
// Variant B — canonize elsewhere: anchor a hash/reference on a durable chain, let the original lapse.
```

Artefact's AR messages live on Arkiv; users "canonize" chosen ones onto another chain.

Seen in 2 of 124 submissions — Artefact.

### dispute-and-claim-windows

Time-boxed objection periods as expiring entities: open a window by creating a claim entity with `expiresIn` = the window; downstream systems decide "execute or block" by polling for liveness instead of running timers. The window closing IS the entities expiring.

```ts
// canExecute = "no live blocking entities for this case".
const blockers = await reader.buildQuery()
  .where([eq("project", PROJECT), eq("type", "claim"), eq("caseId", caseId)])
  .limit(1).fetch()
const canExecute = blockers.entities.length === 0
```

Seen in 1 of 124 submissions — Mediation Rooms (48 h evidence/claim windows gating an external marketplace).

### soft-delete-via-status

When history must stay recoverable, archive by flipping a `status` attribute instead of `deleteEntity` — and remember `updateEntity` is a **FULL REPLACE** (every field required, and the new `expiresIn` restarts the lifetime from now):

```ts
const e = await reader.getEntity(noteKey) // getEntity returns payload + attributes + metadata
await wallet.updateEntity({
  entityKey: noteKey,
  payload: e.payload!,                         // unchanged content must be RE-SENT
  contentType: e.contentType ?? "application/json",
  attributes: [
    ...e.attributes.filter((a) => a.key !== "status" && a.key !== "archivedAt"),
    { key: "status", value: "archived" },
    { key: "archivedAt", value: Date.now() },  // numeric epoch-ms → range-queryable
  ],
  expiresIn: ExpirationTime.fromDays(90),      // archived items get a shorter remaining life
})
// Active listings: .where([eq("project", PROJECT), eq("type", "note"), eq("status", "active")])
```

Reserve `deleteEntity` for true removal. Trade-off vs delete: status flips are auditable but every reader must filter on them.

Seen in 1 of 124 submissions — arkiv-notes-mvp.

### sealed-bid-windowed-expiry

Bids as encrypted entities during the bidding window (nobody reads competitors' bids — entities are public, so encrypt the amount in the payload); the auction window maps onto the entity lifetime so stale auctions clean themselves up:

```ts
await wallet.createEntity({
  payload: encryptedBidBytes,                  // ciphertext only — see arkiv-privacy-patterns
  contentType: "application/octet-stream",
  attributes: [
    { key: "project", value: PROJECT },
    { key: "type", value: "bid" },
    { key: "auctionId", value: auctionId },
    { key: "bidder", value: account.address.toLowerCase() },
  ],
  expiresIn: computeExpiresIn(auctionCloseMs, 1), // window + 1 d for reveal/settle
})
```

Seen in 1 of 124 submissions — TixBid (Dutch reverse auction, anti-scalping).

---

## Gotchas (encode these BEFORE you hit them)

**Gotcha 2 — `expiresIn` semantics.**
- It is a plain `number` of **SECONDS**, converted client-side to blocks at a hardcoded 2 s/block (`Math.ceil(expiresIn / 2)`).
- Always build values with `ExpirationTime.fromSeconds/fromMinutes/fromHours/fromDays/fromWeeks/fromMonths(30d)/fromYears(365d)/fromBlocks/fromDate` — hand-rolled `86400*365` arithmetic is where unit bugs breed (one team treated the same field as minutes in one file, seconds in another).
- `expiresIn: 0` passes the SDK and **reverts on-node with `ZeroBtl`**. Validate `> 0` before sending.
- A past date in `ExpirationTime.fromDate(...)` (negative seconds) throws a **cryptic viem `toHex` error**, not a friendly one. Validate the date is in the future first (`computeExpiresIn` above does).
- `extendEntity` **ADDS** seconds to the current expiration — it does not set an absolute date. To extend only, use `extendEntity`; a full `updateEntity` REPLACES the lifetime with its new `expiresIn` (fresh window from now).
- "BTL" / `numberOfBlocks` are legacy names from the old `golem-base-sdk` package — if you see them, you are reading dead code.

**Gotcha 17 — `getBlockTiming().currentBlock` is a `bigint` (since 0.6.8), and so is `Entity.expiresAtBlock`.**
- `JSON.stringify` on anything containing these **throws** (`TypeError: Do not know how to serialize a BigInt`). Convert with `Number()`/`.toString()` at the edge before logging or returning JSON.
- Wall-clock conversion: subtract in BigInt FIRST (the difference is small), then convert:

```ts
// expiresAtBlock → wall-clock milliseconds.
async function expiresAtMs(entityKey: `0x${string}`): Promise<number> {
  const [entity, timing] = await Promise.all([reader.getEntity(entityKey), reader.getBlockTiming()])
  const blocksLeft = Number(entity.expiresAtBlock! - timing.currentBlock) // bigint math, then Number
  return Date.now() + blocksLeft * timing.blockDuration * 1000
}

// UI countdown sketch: fetch once, tick locally, RE-SYNC from chain after any extend/update —
// a client-side timer that never re-syncs will lie the moment the entity is extended.
function countdown(expiresMs: number): string {
  const s = Math.max(0, Math.floor((expiresMs - Date.now()) / 1000))
  return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m ${s % 60}s`
}
```

- Query results only carry `expiresAtBlock` if you asked: add `.withMetadata(true)` to the builder (`getEntity` returns everything by default).
- `getEntity` on an expired/missing entity **throws `NoEntityFoundError`** — try/catch it; an expired entity and a never-existing one look identical, which is exactly what expiry-as-enforcement relies on.

---

## Compose with

- **arkiv-data-modeling** — the entity-type design worksheet; this skill fills in its per-type expiration column.
- **arkiv-write-safety** — `mutateEntities` sharp edges, the read→merge→write full-replace helper, write queues, Node 22 pin.
- **arkiv-query-patterns** — existence queries, pagination (`.limit()` is mandatory), and sanitizing user strings interpolated into queries.
- **arkiv-agent-state** — cite-to-extend memory tiers (working → episodic → semantic) in depth; "ephemeral coordination state" for AI agents.
- **arkiv-realtime** — presence heartbeats (60 s-expiry entities; expiry IS the offline signal) and polling done well.
- **arkiv-privacy-patterns** — encrypting payloads for sealed bids, grants with wrapped keys; entities are PUBLIC.
- **arkiv-crosschain** — two-tier durability: expiring Arkiv state + a durable credential on another chain.
