Arkiv Ideathon · Open lane · Other
Layak is Indonesian for fit — fit to be used, fit to operate. Indonesia’s certificate of operational fitness for lifting equipment is a Surat Keterangan Layak Operasi; a roadworthy vehicle is layak jalan. The word already means the one thing this product returns.
Statutory inspection certificates written as entities whose lifetime is the certificate’s validity period — so an expired certificate is not a row with a stale date on it. It is a row that no longer exists to be returned.
Under Permenaker No. 8 Tahun 2020, every crane, hoist, forklift and lifting accessory operating in Indonesia must pass riksa uji — inspection and testing by a labour inspector or a registered inspection body — and carry a certificate of operational fitness. The first examination is followed by another within two years, and annually after that.
The person with the strongest motive to change that date is the contractor who owns the machine and loses a day’s revenue when it comes off the line. A site supervisor has ninety seconds at the gate and a laminated card. An investigator after an accident gets the history from the contractor’s own maintenance system. A machine with a bad record is sold, re-registered, and arrives clean — because the history lived in the seller’s database.
What already exists, and what LAYAK does not pretend to replace. Since 3 December 2025 Kemnaker issues K3 documents digitally through TemanK3 — certificates, SKP and operator licences (SIO), barcode-verified. Read precisely, it is a personnel-credential verifier: its lookups are certificate/licence, expert card, auditor card and doctor SKP. Equipment fitness — the SLO for the crane itself and the riksa uji report behind it — is not among the documents named, and the portal exposes no equipment register, no history and no API. So the honest division of labour: TemanK3 is the issuer of record for who an inspector is; LAYAK is the register for what they examined and whether it is still fit. The Registration entity references TemanK3’s licence number as its join key rather than duplicating it. What a verification portal structurally cannot do is the rest of this design: a certificate that is absent rather than stale, a gate check that is recorded, a register that survives the operator, and verification by a stranger without the portal being up.
Validity stops being data you must remember to check, and becomes the storage contract.
A live certificate must exist, and no live prohibition may. You get green, or you get nothing. There is no date comparison anywhere to get wrong, and nothing to remember.
The inspector’s own registration is also an entity with a lifetime, renewed only by the body that issued it. A lapsed inspector cannot backdate themselves into good standing.
$owner transfers with the machine; every examination keeps its immutable $creator. You cannot launder a machine’s past by selling it — the past was never the seller’s to leave behind.
What testing the design against the regulations found
A first draft let the certificate entity carry everything. Then the statutes were checked: LOLER requires the Report of Thorough Examination to be retained for the life of the equipment and produced on demand, and Indonesia’s riksa uji documentation carries the same expectation. A model where the record vanishes when the certificate lapses fails an audit on day one — and expiry removing an entity from the query surface is no help when the statutory duty is precisely to produce it on demand.
So one examination now writes two entities with deliberately opposite lifetimes, in a single batch. That is the diagram below, and it is the strongest thing in the design.
ExamRecord with outcomeCode: 2 and no certificate at all — the most important row in the system, and the one a paper regime loses most often. Absence is the fail state, and it is the same absence as expiry. That is the point.Arkiv never executes logic — no triggers, no enforcement — and only an entity’s owner may extend it. Both limits pushed the design somewhere better than where it started.
A tier-4 defect cannot revoke a certificate, because the database executes nothing. Real regimes solve this with a separate instrument, a prohibition notice, and so does LAYAK. The gate asks two questions instead of one, and revocation never has to reach back into an issued certificate.
After an accident the question is not only was it certified — it is did anybody check, and today that is a supervisor’s word against a contractor’s. Every scan writes a signed, timestamped entity no employer can delete. Diligence becomes provable rather than assertable.
Only the owner can extend, and a defect report’s owner is the worker who filed it — who may be off-shift or gone. So escalation is a new entity by the safety officer. A design needing the original reporter to still be around would fail on exactly the reports that matter most.
| Screen | Predicate | Why it holds |
|---|---|---|
| Gate check | eq(assetId, A) ∧ eq(certType, "SLO-angkat") must return ≥1eq(type,"prohibition") ∧ eq(assetId, A) must return 0 |
Green needs both. An out-of-date certificate cannot be in the first set; a lifted prohibition cannot be in the second. No date comparison exists to get wrong. |
| Site compliance | count of assets on site vs count of live certificates |
The gap between two counts is the risk number. A site that logs nothing scores worse, not better. |
| Renewal queue | eq(siteId, S) ∧ lt(expiresAtTs, now+7d) ∧ gte(expiresAtTs, now) |
Why expiresAtTs is mirrored as an integer: system expiry governs what is returned, but cannot be range-queried. |
| Statutory pull | eq(type,"exam") ∧ eq(assetId, A) ∧ gte(examTs, T0) |
Every examination this machine ever had, passes and failures alike. The reason ExamRecord is a separate entity. |
| Inspector’s book | eq(inspector, 0x…) ∧ gte(examTs, T0) |
Held by no employer, deletable by no employer. Protects the honest inspector and exposes the other kind. |
| Was the issuer registered? | eq(inspector, 0x…) ∧ eq(scheme, "PJK3") |
A live certificate from an inspector with no live registration is the highest-value anomaly in the system. |
| Open defects | eq(assetId, A) ∧ gte(severityTier, 3) |
A coarse integer bucket, chosen because there is no server-side ordering — it keeps the top-N honest. |
| Extension anomaly | gt(expiresAtTs, examTs + regimeSeconds) |
Every certificate living longer than its own examination justifies. This is what makes “never extended” a checkable statement rather than a promise — and anyone can run it over the whole register. |
| Was anybody checking? | count(eq(type,"gate") ∧ eq(siteId, S) ∧ gte(checkedTs, T0)) |
A site with hundreds of movements and no gate checks is not a clean record; it is a site that never looked. The absence of this number is the finding. |
Every primitive below does real work. The one that is not used is listed too, because leaving it out was the decision.
| Primitive | Where it does work |
|---|---|
expiresIn | The idea itself. Seven differentiated lifetimes: 30d defects · 90d gate checks · 1y certificates and escalations · 2y assets · the registration period · a prohibition until it is lifted · the service life of the machine. |
| Owner-only extension | Shaped three decisions: escalation is a new entity, revocation is a new entity, and certificate extension is made detectable rather than claimed impossible — the inspector owns the certificate and could extend it, so the honest guarantee is that they cannot do it in secret. |
extendEntity | Assets, registrations, records and prohibitions — and on certificates, an anomaly the extension report catches. |
| Cost = size × lifetime | Why a 30-day defect report is cheap enough to file honestly, and a 90-day gate check cheap enough to write on every single scan. |
$creator | The inspector’s signature; the accrediting body’s on a registration. Neither can be reassigned. |
$owner · changeOwnership | Operator of record, transferred when the machine is sold — so history travels with the asset. |
mutateEntities | Certificate and examination record written as one atomic batch. |
updateEntity | Avoided entirely. Corrections are new entities carrying supersedesCertId. |
deleteEntity | Deliberately unused. A contractor asking for a record to be removed is the request this system exists to refuse. |
| Numeric attributes | Proof-load ratio as basis points (12500 = 1.25×), never a float — a string cannot answer gte(testRatioBps, 12500). |
| String attributes | Enumerated slugs only. No wildcard search exists in the SDK, so none is designed for. |
| Counts · cursors | Site compliance as the gap between two counts; cursor pagination on the statutory pull. |
| Polled events | Costs this design nothing: the gate check is a pull when someone stands at the gate, and the renewal queue is a scheduled morning read. |
| Encrypted payloads | Defect diagnoses encrypted to owner, body and insurer; severityTier left public. The split is what keeps silence expensive. |
| Project namespace | project: "layak" on every entity, first in every predicate. In a fail-closed system that term is load-bearing — it stops another project’s "cert" entity turning a red gate green. |
Who pays. The inspection body pays for the certificate and the record — two writes against the fee for a scheduled visit by a qualified engineer with test equipment is a rounding error, and it buys them a verifiable credential rather than a compliance expense. The site pays for its own gate checks. The asset owner pays to keep the machine on the register, which is the point: paying is how you claim responsibility, and letting it lapse is how you stop.
“What happens when the gate has no signal?” This is the right objection to fail-closed. A port at 5am with a flaky uplink cannot stop every machine because a query timed out — a safety control that halts operations during a network blip gets switched off within a week, and then protects nothing.
So the gate app holds a signed local cache for assets on its own site. Offline, it answers from cache and writes the gate check with resultCode: 3 and cacheAgeSec — the check happened, it was degraded, and how degraded is a number. Past a site-set staleness bound the cache stops answering and the machine genuinely stops. The degradation is recorded rather than hidden, and an insurer will ask for that count.
“What stops an inspector backdating an examination?” Nothing stops them writing examTs as last Tuesday. But every Arkiv write is a transaction, so the time it was actually written is on-chain and not something the writer controls. A certificate claiming an examination three weeks before its own write is a permanent self-contradiction under the inspector’s own key.
Be precise about the limit: today that reads a transaction receipt per entity rather than a range predicate, so it is a check you run on a certificate you already doubt — not a standing sweep. Pretending otherwise would be inventing API, which is why it appears below as protocol feedback instead.
The roadmap says the phase after Devcon 8 is decided with the teams using Arkiv, and the current architecture came out of hackathon feedback. This is what this design earned from friction rather than from a wishlist.
changeOwnership to a successor works and needs the outgoing owner present and willing. A delegation that survives the delegator fits statutory retention far better.Neither blocks the design. Both would remove a workaround.
The fourth pillar, stated precisely. Block production stays centralised through November 2026 per the roadmap. LAYAK relies on the Ethereum anchor: the operator can order or refuse writes but cannot rewrite an anchored certificate, its $creator or its creation block without it showing against L1 — which is what the backdating and extension-anomaly checks stand on. Censorship-resistance is a later phase and is not claimed.
reportHash.Offline design, not a deployment — the fundamentals say sketching with @arkiv-network/sdk is fine. It type-checks with tsc --strict against the published @arkiv-network/sdk@0.7.0 package — installed from npm, not summarised from docs. That check corrected the first draft in four places: the API is client-based (publicClient.select().where().limit().fetch(), operators from @arkiv-network/sdk/query), attributes are an array of {key, value}, contentType is required, and select() returns a projected type rather than a full Entity. It also surfaced .createdBy(), not(key), validAtBlock(), createdAtBlock metadata, and cost in the on-chain events — none of which the summary docs mention.
// LAYAK โ entity-model sketch, type-checked against the REAL @arkiv-network/sdk@0.7.0 package.
// Offline design only: no client is ever connected here, and nothing is deployed.
// `tsc --strict` passes against the published types (output reproduced in the write-up).
import { createPublicClient, createWalletClient, type Attribute } from "@arkiv-network/sdk";
import { eq, gt, gte, lt, type Predicate } from "@arkiv-network/sdk/query";
import type { Hex } from "viem";
type Pub = ReturnType<typeof createPublicClient>;
type Wal = ReturnType<typeof createWalletClient>;
type HasAttrs = { readonly attributes: Attribute[] }; // select() returns a PROJECTED type, not full Entity
// ---------- constants ----------
const APP = "layak"; // project namespace โ on EVERY entity, first in EVERY predicate
const CT = "application/json";
const ONE_YEAR = 31_536_000;
const TWO_YEARS = 63_072_000;
const GATE_CHECK_SEC = 7_776_000; // 90d
const EXAM_RECORD_SEC = 157_680_000; // 5y, re-extended with the asset for the life of the equipment
const PAGE = 200; // SDK hard cap per page
// Permenaker 8/2020 cadence: first periodic exam โค2y after commissioning, then every 1y.
const REGIME_SECONDS: Record<number, number> = { 1: TWO_YEARS, 2: ONE_YEAR };
const even = (s: number) => (s % 2 === 0 ? s : s + 1); // InvalidExpirationError otherwise
const attrs = (o: Record<string, string | number>): Attribute[] => Object.entries(o).map(([key, value]) => ({ key, value }));
const attr = (e: HasAttrs, key: string) => e.attributes.find(a => a.key === key)?.value;
const now = () => Math.floor(Date.now() / 1000);
// `.count()` in 0.7.0 is the length of ONE page (โค200). Site-level tallies can exceed that โ sum pages.
async function countAll(p: Pub, preds: Predicate[]) {
const res = await p.select({ key: true }).where(preds).limit(PAGE).fetch();
let total = res.entities.length;
while (res.hasNextPage()) { await res.next(); total += res.entities.length; } // next() mutates in place
return total;
}
// ---------- 1. one riksa uji writes TWO entities with OPPOSITE lifetimes, in ONE atomic tx ----------
export async function recordExamination(w: Wal, x: {
assetId: string; siteId: string; certType: string; bodyId: string; examRecordId: string;
regimeCode: 1 | 2; outcomeCode: 0 | 1 | 2; defectCount: number; testRatioBps: number; reportHash: string;
}) {
const examTs = now();
const validity = REGIME_SECONDS[x.regimeCode];
const creates = [
// the statutory record โ passes AND failures โ must outlive the certificate
{ payload: new Uint8Array(0), contentType: CT, expiresIn: EXAM_RECORD_SEC,
attributes: attrs({ project: APP, kind: "exam", assetId: x.assetId, examRecordId: x.examRecordId, bodyId: x.bodyId,
examTs, outcomeCode: x.outcomeCode, defectCount: x.defectCount,
testRatioBps: x.testRatioBps, regimeCode: x.regimeCode, reportHash: x.reportHash }) },
];
if (x.outcomeCode !== 2) {
// the operative certificate โ its LIFETIME IS the validity. Never updated. Never extended by product rule.
creates.push({ payload: new Uint8Array(0), contentType: CT, expiresIn: even(validity),
attributes: attrs({ project: APP, kind: "cert", assetId: x.assetId, certType: x.certType, siteId: x.siteId,
bodyId: x.bodyId, examRecordId: x.examRecordId, issuedTs: examTs,
expiresAtTs: examTs + validity, outcomeCode: x.outcomeCode }) });
}
// a FAILED examination writes the record and NO certificate: absence is the fail state, same as expiry
return w.mutateEntities({ creates });
}
// ---------- 2. the gate check โ two trivial predicates, both must hold ----------
export async function gateCheck(p: Pub, assetId: string, certType: string) {
const certs = await p.select({ key: true, creator: true, attributes: true, createdAtBlock: true })
.where(eq("app", APP), eq("kind", "cert"), eq("assetId", assetId), eq("certType", certType))
.limit(5).fetch(); // an expired cert CANNOT be in this set
const prohibitions = await p.select({ key: true })
.where(eq("app", APP), eq("kind", "prohibition"), eq("assetId", assetId))
.limit(5).count(); // a lifted prohibition CANNOT be in this count
const assetLive = await p.select({ key: true })
.where(eq("app", APP), eq("kind", "asset"), eq("assetId", assetId))
.limit(1).count();
if (certs.entities.length === 0) return { code: "RED_NO_CERT" as const };
if (prohibitions > 0) return { code: "RED_PROHIBITION" as const };
if (assetLive === 0) return { code: "AMBER_UNCLAIMED" as const, cert: certs.entities[0] };
return { code: "GREEN" as const, cert: certs.entities[0] }; // card shows creator + createdAtBlock
}
// every scan is itself an entity: diligence becomes provable, not assertable
export async function logGateCheck(w: Wal, assetId: string, siteId: string, resultCode: 0 | 1 | 2 | 3, cacheAgeSec = 0) {
return w.createEntity({
payload: new Uint8Array(0), contentType: CT, expiresIn: GATE_CHECK_SEC,
attributes: attrs({ project: APP, kind: "gate", assetId, siteId, checkedTs: now(), resultCode, cacheAgeSec }),
});
}
// ---------- 3. site compliance โ the gap between two HONEST counts ----------
export async function complianceGap(p: Pub, siteId: string, certType: string) {
const assets = await countAll(p, [eq("app", APP), eq("kind", "asset"), eq("siteId", siteId)]);
const certs = await countAll(p, [eq("app", APP), eq("kind", "cert"), eq("siteId", siteId), eq("certType", certType)]);
return { assets, certs, gap: assets - certs }; // the gap is the risk number
}
// ---------- 4. renewal queue โ Q3: why expiresAtTs is mirrored as an integer ----------
// Entity.expiresAtBlock is returned as METADATA but is not a predicate key; the attribute mirror is.
export async function expiringWithin(p: Pub, siteId: string, seconds: number) {
const t = now();
const res = await p.select({ key: true, attributes: true })
.where(eq("app", APP), eq("kind", "cert"), eq("siteId", siteId), gte("expiresAtTs", t), lt("expiresAtTs", t + seconds))
.limit(PAGE).fetch();
return res.entities;
}
// ---------- 5. extension anomaly โ Q8: "never extended" as a CHECKABLE statement ----------
export async function extensionAnomalies(p: Pub, assetId: string) {
const exams = await p.select({ key: true, attributes: true })
.where(eq("app", APP), eq("kind", "exam"), eq("assetId", assetId)).limit(50).fetch();
const out: string[] = [];
for (const e of exams.entities) {
const bound = Number(attr(e, "examTs")) + REGIME_SECONDS[Number(attr(e, "regimeCode"))];
const over = await p.select({ key: true })
.where(eq("app", APP), eq("kind", "cert"), eq("examRecordId", String(attr(e, "examRecordId"))), gt("expiresAtTs", bound))
.limit(1).count();
if (over > 0) out.push(String(attr(e, "examRecordId")));
}
return out; // certificates living longer than their exam justifies
}
// ---------- 6. backdating check โ claimed issuedTs vs the block it was ACTUALLY written in ----------
// createdAtBlock is metadata on every entity (select createdAtBlock:true) โ no receipt lookup needed.
// It is not a predicate key, so this is a per-entity verification rather than a register-wide sweep.
export function looksBackdated(cert: HasAttrs & { readonly createdAtBlock: bigint },
blockToUnix: (b: bigint) => number, toleranceSec = 86_400) {
return Number(attr(cert, "issuedTs")) < blockToUnix(cert.createdAtBlock) - toleranceSec;
}
// ---------- 7. escalation is a NEW entity โ the reporter may be gone, and only the owner can extend ----------
export async function escalate(w: Wal, defectId: string, assetId: string, severityTier: number) {
return w.createEntity({
payload: new Uint8Array(0), contentType: CT, expiresIn: ONE_YEAR,
attributes: attrs({ project: APP, kind: "escalation", defectId, assetId, severityTier, escalatedTs: now() }),
});
}
// ---------- 8. resale: the machine changes hands, the history does not ----------
export async function sell(w: Wal, assetKey: Hex, buyer: Hex) {
return w.changeOwnership({ entityKey: assetKey, newOwner: buyer }); // $owner moves; every cert keeps its creator
}
// ---------- 9. keep the statutory record alive with the asset โ ONE atomic tx ----------
export async function renewAsset(w: Wal, assetKey: Hex, examRecordKeys: Hex[]) {
return w.mutateEntities({
extensions: [{ entityKey: assetKey, expiresIn: TWO_YEARS },
...examRecordKeys.map(entityKey => ({ entityKey, expiresIn: EXAM_RECORD_SEC }))],
}); // โค1000 ops per tx; chunk beyond that
}
// ---------- 10. what the register said at block N โ the after-accident question ----------
// validAtBlock() exists on the builder. Whether expired entities are served at a past block is not
// documented; treated as the upgrade path for investigations, not as a current guarantee.
export async function registerAtBlock(p: Pub, assetId: string, block: bigint) {
return (await p.select({ key: true, creator: true, attributes: true })
.where(eq("app", APP), eq("kind", "cert"), eq("assetId", assetId))
.validAtBlock(block).limit(PAGE).fetch()).entities;
}
The sketch code above runs, unchanged, against MemArkiv — an executable specification of the twelve Arkiv rules this design depends on, each cited to the SDK source or the fundamentals. It is not Arkiv and claims nothing about performance; it is the referee for the design’s logic. Nine LAYAK invariants, all passing, offline, in under a second. The first run of one of them failed because the test was wrong and the design was right; that is left in as a comment.
// Executable invariants for LAYAK and SELISIH โ runs the real sketch code (type-checked against
// @arkiv-network/sdk@0.7.0) against MemArkiv, an executable spec of the documented semantics.
// Run: node --test dist/invariants.test.js (after tsc). Nothing here touches a network.
import { test } from "node:test";
import assert from "node:assert/strict";
import { MemArkiv, NotOwnerError, InvalidExpirationError } from "./memarkiv.js";
import * as L from "../layak.sketch.js";
import * as S from "../selisih.sketch.js";
import type { Hex } from "viem";
const INSPECTOR = "0x1111111111111111111111111111111111111111" as Hex;
const CONTRACTOR = "0x2222222222222222222222222222222222222222" as Hex;
const BUYER = "0x3333333333333333333333333333333333333333" as Hex;
const W = (n: number) => ("0x" + String(n).repeat(40)) as Hex;
const any = (x: unknown) => x as any; // MemArkiv is structurally the surface the sketches use; the SDK's client type is a viem client
// ============================== LAYAK ==============================
test("LAYAK-1: an expired certificate is not returned โ with no date filter anywhere", async () => {
const db = new MemArkiv(INSPECTOR);
await L.recordExamination(any(db), { assetId: "A-4471", siteId: "S1", certType: "SLO-angkat", bodyId: "PJK3-7", examRecordId: "E1", regimeCode: 2, outcomeCode: 0, defectCount: 0, testRatioBps: 12500, reportHash: "0xabc" });
// First run of this test expected RED here and FAILED: the sketch returns AMBER โ certified, but no Asset entity
// claims responsibility. That is the designed behaviour (ยง9, orphaned certificate); the test expectation was wrong.
assert.equal((await L.gateCheck(any(db), "A-4471", "SLO-angkat")).code, "AMBER_UNCLAIMED", "certified but unclaimed โ amber, never green");
db.createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 63_072_000, attributes: [{ key: "app", value: "layak" }, { key: "kind", value: "asset" }, { key: "assetId", value: "A-4471" }] });
assert.equal((await L.gateCheck(any(db), "A-4471", "SLO-angkat")).code, "GREEN");
db.advanceSeconds(31_536_000 + 2); // one year + one block
assert.equal((await L.gateCheck(any(db), "A-4471", "SLO-angkat")).code, "RED_NO_CERT", "lifetime lapsed โ the row no longer exists to be returned");
assert.ok(db.events.some(e => e.name === "ArkivEntityExpired"), "expiry is an on-chain event, not a silent row state");
});
test("LAYAK-2: a FAILED exam writes the statutory record and NO certificate, atomically", async () => {
const db = new MemArkiv(INSPECTOR);
const r = await L.recordExamination(any(db), { assetId: "A-9", siteId: "S1", certType: "SLO-angkat", bodyId: "B", examRecordId: "E9", regimeCode: 2, outcomeCode: 2, defectCount: 3, testRatioBps: 12500, reportHash: "0x" });
assert.equal(r.createdEntities.length, 1);
const exams = await db.select().where({ type: "eq", key: "kind", value: "exam" }).fetch();
const certs = await db.select().where({ type: "eq", key: "kind", value: "cert" }).fetch();
assert.equal(exams.entities.length, 1); assert.equal(certs.entities.length, 0);
assert.equal((await L.gateCheck(any(db), "A-9", "SLO-angkat")).code, "RED_NO_CERT", "absence is the fail state โ the same absence as expiry");
});
test("LAYAK-3: the record outlives the certificate; the two lifetimes are opposite", async () => {
const db = new MemArkiv(INSPECTOR);
await L.recordExamination(any(db), { assetId: "A-1", siteId: "S1", certType: "SLO-angkat", bodyId: "B", examRecordId: "E1", regimeCode: 2, outcomeCode: 0, defectCount: 0, testRatioBps: 12500, reportHash: "0x" });
db.advanceSeconds(31_536_000 + 2);
const certs = await db.select().where({ type: "eq", key: "kind", value: "cert" }).fetch();
const exams = await db.select().where({ type: "eq", key: "kind", value: "exam" }).fetch();
assert.equal(certs.entities.length, 0, "certificate gone");
assert.equal(exams.entities.length, 1, "statutory record still served โ LOLER/riksa uji retention duty");
});
test("LAYAK-4: only the owner can extend โ and an owner extending a cert is CAUGHT by Q8", async () => {
const db = new MemArkiv(INSPECTOR);
await L.recordExamination(any(db), { assetId: "A-1", siteId: "S1", certType: "SLO-angkat", bodyId: "B", examRecordId: "E1", regimeCode: 2, outcomeCode: 0, defectCount: 0, testRatioBps: 12500, reportHash: "0x" });
const cert = (await db.select().where({ type: "eq", key: "kind", value: "cert" }).fetch()).entities[0];
await assert.rejects(async () => db.as(CONTRACTOR).extendEntity({ entityKey: cert.key, expiresIn: 63_072_000 }), NotOwnerError, "the contractor cannot extend a certificate it does not own");
assert.deepEqual(await L.extensionAnomalies(any(db), "A-1"), [], "clean before");
// the corrupt inspector CAN extend their own entity โ the protocol allows it (R3) โ but cannot do it in secret:
db.extendEntity({ entityKey: cert.key, expiresIn: 63_072_000 });
// the mirrored expiresAtTs must be rewritten for the extension to be useful on the renewal queue; a forger updating it exposes the lie
db.updateEntity({ entityKey: cert.key, payload: cert.payload, contentType: cert.contentType, expiresIn: 63_072_000,
attributes: cert.attributes.map(a => a.key === "expiresAtTs" ? { key: a.key, value: (a.value as number) + 31_536_000 } : a) });
assert.deepEqual(await L.extensionAnomalies(any(db), "A-1"), ["E1"], "certificate living longer than its own examination justifies โ flagged, by anyone, without permission");
});
test("LAYAK-5: a live certificate with a lapsed Asset is AMBER, not GREEN", async () => {
const db = new MemArkiv(INSPECTOR);
db.createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 60, attributes: [{ key: "app", value: "layak" }, { key: "kind", value: "asset" }, { key: "assetId", value: "A-1" }] });
await L.recordExamination(any(db), { assetId: "A-1", siteId: "S1", certType: "SLO-angkat", bodyId: "B", examRecordId: "E1", regimeCode: 2, outcomeCode: 0, defectCount: 0, testRatioBps: 12500, reportHash: "0x" });
assert.equal((await L.gateCheck(any(db), "A-1", "SLO-angkat")).code, "GREEN");
db.advanceSeconds(62);
assert.equal((await L.gateCheck(any(db), "A-1", "SLO-angkat")).code, "AMBER_UNCLAIMED", "certified, but nobody is renewing responsibility for the machine");
});
test("LAYAK-6: a Prohibition turns the gate red without touching the certificate (no triggers exist)", async () => {
const db = new MemArkiv(INSPECTOR);
db.createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 63_072_000, attributes: [{ key: "app", value: "layak" }, { key: "kind", value: "asset" }, { key: "assetId", value: "A-1" }] });
await L.recordExamination(any(db), { assetId: "A-1", siteId: "S1", certType: "SLO-angkat", bodyId: "B", examRecordId: "E1", regimeCode: 2, outcomeCode: 0, defectCount: 0, testRatioBps: 12500, reportHash: "0x" });
const pro = db.createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 86_400, attributes: [{ key: "app", value: "layak" }, { key: "kind", value: "prohibition" }, { key: "assetId", value: "A-1" }] });
assert.equal((await L.gateCheck(any(db), "A-1", "SLO-angkat")).code, "RED_PROHIBITION");
db.advanceSeconds(86_402); // prohibition lapses (or the inspector lifts it) โ green again, cert untouched
assert.equal((await L.gateCheck(any(db), "A-1", "SLO-angkat")).code, "GREEN");
assert.ok(pro.entityKey);
});
test("LAYAK-7: resale moves $owner and keeps $creator on every certificate", async () => {
const db = new MemArkiv(INSPECTOR);
const asset = db.as(CONTRACTOR).createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 63_072_000, attributes: [{ key: "app", value: "layak" }, { key: "kind", value: "asset" }, { key: "assetId", value: "A-1" }] });
await L.recordExamination(any(db), { assetId: "A-1", siteId: "S1", certType: "SLO-angkat", bodyId: "B", examRecordId: "E1", regimeCode: 2, outcomeCode: 1, defectCount: 2, testRatioBps: 12500, reportHash: "0x" });
await L.sell(any(db.as(CONTRACTOR)), asset.entityKey, BUYER);
const a = (await db.select().where({ type: "eq", key: "kind", value: "asset" }).fetch()).entities[0];
const e = (await db.select().where({ type: "eq", key: "kind", value: "exam" }).fetch()).entities[0];
assert.equal(a.owner, BUYER); assert.equal(a.creator, CONTRACTOR); assert.equal(e.creator, INSPECTOR, "the past was never the seller's to leave behind");
});
test("LAYAK-8: .count() is one page โ complianceGap sums pages, so 350 assets are 350, not 200", async () => {
const db = new MemArkiv(CONTRACTOR);
for (let i = 0; i < 350; i++) db.createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 63_072_000, attributes: [{ key: "app", value: "layak" }, { key: "kind", value: "asset" }, { key: "assetId", value: "A-" + i }, { key: "siteId", value: "S1" }] });
const naive = await db.select().where({ type: "eq", key: "kind", value: "asset" }).limit(200).count();
assert.equal(naive, 200, "the naive count the first draft relied on");
const gap = await L.complianceGap(any(db), "S1", "SLO-angkat");
assert.equal(gap.assets, 350); assert.equal(gap.gap, 350);
});
test("LAYAK-9: an odd expiresIn is rejected (2-second blocks)", () => {
const db = new MemArkiv(INSPECTOR);
assert.throws(() => db.createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 31_536_001, attributes: [] }), InvalidExpirationError);
});
// ============================== SELISIH ==============================
const snap = (round: number, hf: number, tier: 0|1|2|3|4 = 0) => ({ market: "aave-v3-eth-wsteth", round, blockNumber: 20_459_000, observedTs: 1_722_800_000, priceE8: 213_928_000_000, healthFactorBps: hf, totalDebtE6: 1, collateralE6: 1, atRiskCount: 0, deviationBps: 0, severityTier: tier, sourceHash: "0x", salt: "s" });
test("SELISIH-1: divergence board returns a SET per round, one row per witness, outlier attributable by creator", async () => {
const db = new MemArkiv(W(1));
for (const [w, hf] of [[1, 10420], [2, 10420], [3, 10420], [4, 9980], [5, 10420]] as const) {
await S.reveal(any(db.as(W(w))), snap(812, hf, w === 4 ? 4 : 0), []);
db.as(W(w)).createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 604_800, attributes: [{ key: "app", value: "selisih" }, { key: "kind", value: "witness" }, { key: "market", value: "aave-v3-eth-wsteth" }] });
}
db.as(W(7)).createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 604_800, attributes: [{ key: "app", value: "selisih" }, { key: "kind", value: "witness" }, { key: "market", value: "aave-v3-eth-wsteth" }] });
const d = await S.divergence(any(db), "aave-v3-eth-wsteth", 812);
assert.equal(d.rows.length, 5); assert.equal(d.median, 10420); assert.equal(d.missing, 1, "6 registered, 5 reported โ w7 is the interesting row");
const outlier = d.rows.find(r => r.attributes.some(a => a.key === "healthFactorBps" && a.value === 9980));
assert.equal(outlier?.creator, W(4), "the outlier is named, not anonymised into an error bar");
});
test("SELISIH-2: a correction is a NEW entity; the board shows originals only via not(supersedesRound)", async () => {
const db = new MemArkiv(W(1));
await S.reveal(any(db), snap(812, 10420), []);
await S.reveal(any(db), { ...snap(812, 10300), supersedesRound: 812 }, []);
const d = await S.divergence(any(db), "aave-v3-eth-wsteth", 812);
assert.equal(d.rows.length, 1); assert.equal(d.median, 10420, "the original is never silently edited; the correction sits beside it");
const all = await db.select().where({ type: "eq", key: "kind", value: "snapshot" }).fetch();
assert.equal(all.entities.length, 2);
});
test("SELISIH-3: only the owner may extend โ a disputant CANNOT preserve a witness's reading; a pin can", async () => {
const db = new MemArkiv(W(1));
const { entityKey } = await S.reveal(any(db), snap(812, 9980, 4), [], 3); // 72h floor
await assert.rejects(async () => db.as(W(9)).extendEntity({ entityKey, expiresIn: 7_776_000 }), NotOwnerError);
const original = (await db.select().where({ type: "eq", key: "kind", value: "snapshot" }).fetch()).entities[0];
await S.pin(any(db.as(W(9))), { attributes: original.attributes, creator: original.creator, payload: original.payload }, entityKey, 31_536_000);
db.advanceSeconds(259_200 + 2); // the original lapses
const snaps = await db.select().where({ type: "eq", key: "kind", value: "snapshot" }).fetch();
const pins = await db.select().where({ type: "eq", key: "kind", value: "pin" }).fetch();
assert.equal(snaps.entities.length, 0, "the witness's reading left the query surface");
assert.equal(pins.entities.length, 1); assert.equal(pins.entities[0].creator, W(9), "the pin is the reader's own entity, funded by the reader, carrying the original tx hash");
assert.equal(pins.entities[0].attributes.find(a => a.key === "originTxHash")?.value, entityKey);
});
test("SELISIH-4: conviction is a receipt โ cost in ArkivEntityCreated scales with the funded lifetime", async () => {
const db = new MemArkiv(W(1));
await S.reveal(any(db), snap(812, 10420), [], 3);
await S.reveal(any(db), snap(813, 10420), [], 90);
const [c3, c90] = db.events.filter(e => e.name === "ArkivEntityCreated").map(e => (e as any).cost as bigint);
assert.ok(c90 > c3 * 20n, `90-day funding costs ${c90} vs 3-day ${c3}: fundedDays is checkable against what was actually paid`);
});
test("SELISIH-5: pinQueue lists readings that will lapse BEFORE the dispute deadline", async () => {
const db = new MemArkiv(W(1));
await S.reveal(any(db), snap(812, 10420), [], 3); // lapses in 72h
await S.reveal(any(db), snap(813, 10420), [], 30); // funded past the deadline
const deadline = db.nowUnix() + 7 * 86_400;
const q = await S.pinQueue(any(db), "aave-v3-eth-wsteth", 800, 900, deadline);
assert.equal(q.length, 1); assert.equal(q[0].attributes.find(a => a.key === "round")?.value, 812);
});
test("SELISIH-6: silence is an event โ a witness that stops renewing emits ArkivEntityExpired under its own key", async () => {
const db = new MemArkiv(W(4));
const reg = db.createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 604_800, attributes: [{ key: "app", value: "selisih" }, { key: "kind", value: "witness" }, { key: "market", value: "m" }] });
await S.heartbeat(any(db), reg.entityKey); db.advanceSeconds(604_800 - 100);
assert.equal(db.events.filter(e => e.name === "ArkivEntityExpired").length, 0, "renewed โ still live");
db.advanceSeconds(200);
const ev = db.events.find(e => e.name === "ArkivEntityExpired") as any;
assert.equal(ev?.owner, W(4), "leaving is a log entry with your key on it");
});
test("SELISIH-7: track record uses createdBy natively โ no mirrored witness attribute exists in the schema", async () => {
const db = new MemArkiv(W(4));
await S.reveal(any(db), snap(1, 9000, 4), []); await S.reveal(any(db), snap(2, 9000, 3), []); await S.reveal(any(db), snap(3, 10420, 0), []);
db.as(W(8)).createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 86_400, attributes: [{ key: "app", value: "selisih" }, { key: "kind", value: "resolution" }, { key: "vindicatedWitness", value: W(4) }] });
const tr = await S.trackRecord(any(db), W(4));
assert.deepEqual(tr, { broke: 2, vindicated: 1 });
const s = (await db.select().where({ type: "eq", key: "kind", value: "snapshot" }).fetch()).entities[0];
assert.ok(!s.attributes.some(a => a.key === "witness"), "$creator is metadata, not an attribute to mirror");
});
test("SELISIH-8: RosterEpoch answers 'who was expected' after registrations have expired", async () => {
const db = new MemArkiv(W(1));
for (const w of [1, 2, 3]) db.as(W(w)).createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 604_800, attributes: [{ key: "app", value: "selisih" }, { key: "kind", value: "witness" }, { key: "market", value: "m" }] });
await S.rosterEpoch(any(db), "m", 1, 800, 900, [W(1), W(2), W(3)]);
const atRound = db.block;
db.advanceSeconds(30 * 86_400); // a month into a dispute: registrations long gone
const live = await db.select().where({ type: "eq", key: "kind", value: "witness" }).count();
assert.equal(live, 0, "querying live registrations now answers WRONG with total confidence");
const epoch = (await db.select().where({ type: "eq", key: "kind", value: "roster" }, { type: "lte", key: "roundFrom", value: 812 }, { type: "gte", key: "roundTo", value: 812 }).fetch()).entities[0];
assert.equal(epoch.attributes.find(a => a.key === "witnessCount")?.value, 3, "the fact was stored while it was true");
// and the upgrade path: validAtBlock() โ if the network serves history, this replaces RosterEpoch
assert.equal((await S.rosterAtBlock(any(db), "m", atRound)).length, 3);
});
// MemArkiv โ an executable specification of the Arkiv semantics this design depends on.
// NOT Arkiv, and not a substitute for it. Every rule below is cited to the published SDK source
// (@arkiv-network/sdk@0.7.0) or the arkiv-fundamentals doc, so that the sketches โ which already
// type-check against the real package โ can be EXECUTED and their invariants asserted, offline.
//
// R1 expiresIn is seconds; lifetimes are 2-second blocks (utils/expirationTime.ts, consts BLOCK_TIME=2)
// R2 an expired entity leaves the query surface (fundamentals: "drops off the query surface")
// R3 only the owner may update / delete / extend (ideation-guide ยง4)
// R4 updateEntity is a full replace (fundamentals: "An attribute you omit ... is silently removed")
// R5 $creator is immutable; $owner moves via changeOwnership (types/entity.ts, actions/wallet/changeOwnership.ts)
// R6 results are newest-first; no server-side ordering (docs: "always returns matching entities newest first")
// R7 string attributes support eq() only; range ops on numerics (fundamentals + query/predicate.ts)
// R8 not(key) = attribute absent; neq = not equal (query/predicate.ts)
// R9 .count() = length of ONE page, limit โค 200 (query/queryBuilder.ts count(): queryResult.data.length)
// R10 mutateEntities is one atomic transaction (actions/wallet/mutateEntities.ts: single sendArkivTransaction)
// R11 ArkivEntityCreated(..., cost) / ArkivEntityExpired / ArkivEntityBTLExtended(..., cost) are emitted
// (actions/public/subscribeEntityEvents.ts arkivABI); cost โ size ร lifetime (fundamentals)
// R12 createdAtBlock / expiresAtBlock / creator / owner are returned as metadata (types/entity.ts)
import type { Attribute } from "@arkiv-network/sdk";
import type { Predicate } from "@arkiv-network/sdk/query";
import type { Hex } from "viem";
export const BLOCK_TIME = 2;
const PAGE_MAX = 200;
type Row = {
key: Hex; creator: Hex; owner: Hex; payload: Uint8Array; contentType: string;
attributes: Attribute[]; createdAtBlock: bigint; expiresAtBlock: bigint; lastModifiedAtBlock: bigint; seq: number;
};
export type Event =
| { name: "ArkivEntityCreated"; entityKey: Hex; owner: Hex; expirationBlock: bigint; cost: bigint }
| { name: "ArkivEntityBTLExtended"; entityKey: Hex; owner: Hex; oldExpirationBlock: bigint; newExpirationBlock: bigint; cost: bigint }
| { name: "ArkivEntityExpired"; entityKey: Hex; owner: Hex }
| { name: "ArkivEntityOwnerChanged"; entityKey: Hex; oldOwner: Hex; newOwner: Hex };
export class NotOwnerError extends Error {}
export class InvalidExpirationError extends Error {}
// One shared store per chain; `as(signer)` returns a view over the SAME store with a different wallet.
type Store = { block: bigint; seq: number; rows: Map<Hex, Row>; events: Event[]; genesisUnix: number };
export class MemArkiv {
private st: Store;
constructor(public signer: Hex, st?: Store) {
// genesis chosen so that chain time โ wall-clock at construction: the sketches stamp `now()` from Date.now()
this.st = st ?? { block: 1000n, seq: 0, rows: new Map(), events: [], genesisUnix: Math.floor(Date.now() / 1000) - 1000 * BLOCK_TIME };
}
as(signer: Hex) { return new MemArkiv(signer, this.st); }
get block() { return this.st.block; }
get rows() { return this.st.rows; }
get events() { return this.st.events; }
// ---- time ----
advanceSeconds(s: number) {
this.st.block += BigInt(Math.ceil(s / BLOCK_TIME));
for (const r of this.rows.values()) // R2 + R11
if (r.expiresAtBlock <= this.block && !r.attributes.some(a => a.key === "__expired")) {
r.attributes.push({ key: "__expired", value: 1 });
this.events.push({ name: "ArkivEntityExpired", entityKey: r.key, owner: r.owner });
}
}
blockToUnix(b: bigint) { return this.st.genesisUnix + Number(b) * BLOCK_TIME; }
nowUnix() { return this.blockToUnix(this.block); }
// ---- wallet actions (same parameter shapes as the SDK) ----
private cost(payload: Uint8Array, attributes: Attribute[], expiresIn: number) {
const bytes = payload.length + JSON.stringify(attributes).length;
return BigInt(bytes) * BigInt(Math.ceil(expiresIn / BLOCK_TIME)); // R11: size ร lifetime
}
createEntity(p: { payload: Uint8Array; attributes: Attribute[]; contentType: string; expiresIn: number }) {
if (!Number.isInteger(p.expiresIn) || p.expiresIn <= 0 || p.expiresIn % 2 !== 0) throw new InvalidExpirationError(String(p.expiresIn)); // R1
const seq = ++this.st.seq;
const key = ("0x" + seq.toString(16).padStart(64, "0")) as Hex;
const exp = this.block + BigInt(p.expiresIn / BLOCK_TIME);
this.rows.set(key, { key, creator: this.signer, owner: this.signer, payload: p.payload, contentType: p.contentType,
attributes: [...p.attributes], createdAtBlock: this.block, expiresAtBlock: exp, lastModifiedAtBlock: this.block, seq });
const cost = this.cost(p.payload, p.attributes, p.expiresIn);
this.events.push({ name: "ArkivEntityCreated", entityKey: key, owner: this.signer, expirationBlock: exp, cost });
return { entityKey: key, txHash: ("0x" + "t".repeat(0) + key.slice(2)) as Hex };
}
private own(key: Hex) { const r = this.rows.get(key); if (!r) throw new Error("no such entity"); if (r.owner !== this.signer) throw new NotOwnerError(key); return r; } // R3
extendEntity(p: { entityKey: Hex; expiresIn: number }) {
const r = this.own(p.entityKey); const old = r.expiresAtBlock;
r.expiresAtBlock = this.block + BigInt(p.expiresIn / BLOCK_TIME); r.lastModifiedAtBlock = this.block;
this.events.push({ name: "ArkivEntityBTLExtended", entityKey: r.key, owner: r.owner, oldExpirationBlock: old, newExpirationBlock: r.expiresAtBlock, cost: this.cost(r.payload, r.attributes, p.expiresIn) });
return { entityKey: r.key, txHash: r.key };
}
updateEntity(p: { entityKey: Hex; payload: Uint8Array; attributes: Attribute[]; contentType: string; expiresIn: number }) {
const r = this.own(p.entityKey); // R4: full replace
r.payload = p.payload; r.attributes = [...p.attributes]; r.contentType = p.contentType;
r.expiresAtBlock = this.block + BigInt(p.expiresIn / BLOCK_TIME); r.lastModifiedAtBlock = this.block;
return { entityKey: r.key, txHash: r.key };
}
deleteEntity(p: { entityKey: Hex }) { this.own(p.entityKey); this.rows.delete(p.entityKey); return { entityKey: p.entityKey, txHash: p.entityKey }; }
changeOwnership(p: { entityKey: Hex; newOwner: Hex }) {
const r = this.own(p.entityKey); const old = r.owner; r.owner = p.newOwner; // R5: creator untouched
this.events.push({ name: "ArkivEntityOwnerChanged", entityKey: r.key, oldOwner: old, newOwner: p.newOwner });
return { entityKey: r.key, txHash: r.key };
}
mutateEntities(p: { creates?: Parameters<MemArkiv["createEntity"]>[0][]; extensions?: Parameters<MemArkiv["extendEntity"]>[0][];
ownershipChanges?: Parameters<MemArkiv["changeOwnership"]>[0][]; deletes?: { entityKey: Hex }[] }) {
// R10: atomic โ validate ownership/expiry for every op BEFORE applying any
for (const e of p.extensions ?? []) this.own(e.entityKey);
for (const o of p.ownershipChanges ?? []) this.own(o.entityKey);
for (const d of p.deletes ?? []) this.own(d.entityKey);
for (const c of p.creates ?? []) if (c.expiresIn % 2 !== 0 || c.expiresIn <= 0) throw new InvalidExpirationError(String(c.expiresIn));
const createdEntities = (p.creates ?? []).map(c => this.createEntity(c).entityKey);
const extendedEntities = (p.extensions ?? []).map(e => this.extendEntity(e).entityKey);
const ownershipChanges = (p.ownershipChanges ?? []).map(o => this.changeOwnership(o).entityKey);
const deletedEntities = (p.deletes ?? []).map(d => this.deleteEntity(d).entityKey);
return { txHash: "0xbatch" as Hex, createdEntities, updatedEntities: [] as Hex[], deletedEntities, extendedEntities, ownershipChanges };
}
// ---- public query surface (same chain shape as the SDK builder) ----
select(_fields?: unknown) { return new MemQuery(this); }
}
function matches(r: Row, p: Predicate): boolean {
if (p.type === "and") return p.predicates.every(q => matches(r, q));
if (p.type === "or") return p.predicates.some(q => matches(r, q));
const lp = p as Extract<Predicate, { key: string }>;
const a = r.attributes.find(x => x.key === lp.key);
if (lp.type === "not") return a === undefined; // R8
if (a === undefined) return false;
const v = a.value;
switch (lp.type) {
case "eq": return v === lp.value;
case "neq": return v !== lp.value;
default:
if (typeof v !== "number" || typeof lp.value !== "number") return false; // R7: ranges on numerics only
return lp.type === "gt" ? v > lp.value : lp.type === "gte" ? v >= lp.value : lp.type === "lt" ? v < lp.value : v <= lp.value;
}
}
export class MemQuery {
private preds: Predicate[] = []; private _limit = PAGE_MAX; private _offset = 0; private _creator?: Hex; private _owner?: Hex; private _at?: bigint;
constructor(private db: MemArkiv) {}
where(...ps: (Predicate | Predicate[])[]) { this.preds.push(...ps.flat()); return this; }
createdBy(h: Hex) { this._creator = h; return this; }
ownedBy(h: Hex) { this._owner = h; return this; }
limit(n: number) { this._limit = Math.min(n, PAGE_MAX); return this; }
validAtBlock(_b: bigint) { this._at = _b; return this; }
private all() {
const at = this._at ?? this.db.block;
return [...this.db.rows.values()]
.filter(r => r.createdAtBlock <= at && r.expiresAtBlock > at) // R2 (validAtBlock reads history in the spec; see design note)
.filter(r => (!this._creator || r.creator === this._creator) && (!this._owner || r.owner === this._owner))
.filter(r => this.preds.every(p => matches(r, p)))
.sort((a, b) => b.seq - a.seq); // R6: newest-first, nothing else
}
async fetch() {
const rows = this.all(); const page = rows.slice(this._offset, this._offset + this._limit);
const entities = page.map(r => ({ key: r.key, creator: r.creator, owner: r.owner, payload: r.payload, contentType: r.contentType,
attributes: r.attributes.filter(a => a.key !== "__expired"), createdAtBlock: r.createdAtBlock, expiresAtBlock: r.expiresAtBlock, lastModifiedAtBlock: r.lastModifiedAtBlock })); // R12
const self = this;
const res = {
entities,
hasNextPage: () => self._offset + self._limit < rows.length,
async next() { self._offset += self._limit; const n = await self.fetch(); res.entities = n.entities; res.hasNextPage = n.hasNextPage; },
};
return res;
}
async count() { return (await this.fetch()).entities.length; } // R9: ONE page, not a total
}
0.8.0-devThe September testnet runs a rebuilt architecture, and its SDK is already on npm under the dev tag. It does not invalidate this design; it makes it smaller, and it confirms one piece of feedback.
| On 0.7.0 | On 0.8.0-dev | Effect |
|---|---|---|
expiresAtTs mirrored for the renewal queue | $expiresAt is a queryable system attribute | The mirror disappears; $expiresAt < now+7d is native |
testRatioBps = 12500 | dec stores 18 fractional digits exactly | dec("1.25"). Same query, no scaling convention |
| ids and addresses as strings | key and addr attribute types | Relationships become typed references |
| validity as seconds from now | ExpirationTime.atDate(date, { atLeast }) | A certificate’s expiry is literally the date on the report. The core idea gets a first-class primitive |
updateEntity is a full replace | patchEntity with set / unset | The trap is gone; certificates stay append-only anyway — an evidence decision, not a workaround |
| no prefix search | startsWith on strings | A yard’s fleet by asset-id prefix in one query |
createdAtBlock returned, not filterable | $createdAt is still result-only | Protocol feedback #1 stands: backdating is a per-entity check, not a sweep |
One asset class, one certificate type, one screen and one phone. An inspector writes a certificate and its examination record in a single batch, with expiresIn set to the validity period. A site supervisor scans the QR code and gets green with the inspector’s address, or red with nothing at all.
Then the demo that proves it: create a certificate with a two-minute lifetime, show green, wait, refresh. Red — with no code having run, no job having fired and no date having been compared.
Not building in v1: the physical tagging supply chain, and any system mapping inspector addresses to human beings.
The honest limit: LAYAK proves who signed and when. It cannot prove the examination happened. It raises forgery from editing a PDF to committing attributable fraud under a revocable registration — a real improvement, and not a solution.