Arkiv Ideathon · Challenge 3 · DeFi

SELISIH

Selisih is Indonesian for the difference between two numbers. Berselisih is to be in dispute. One word for both halves of this product.

A multi-witness flight recorder for DeFi risk state. Independent watchers each publish their own signed snapshot of the same lending market — and the product is not the snapshot, it’s the disagreement between them.

Off the execution hot path Append-only Divergence is the query
MARKET aave-v3-eth-wsteth  /  ROUND 812 10500 10200 9900 healthFactorBps median 10420 — never written 9980 outlier — named by $creator no report filed w1 w2 w3 w4 w5 w6 w7
The core screen, and the whole idea. Five witnesses agree, one reports a health factor 440 basis points lower, and a seventh never filed at all. A single-writer log can show neither: the outlier would simply be the value, and the silence would be an absence in someone’s server. Here the outlier is attributable to an immutable $creator, and the gap is checkable against the roster that was live at round 812.

It is not an oracle. That is the idea.

The nearest neighbours are oracle networks, so the difference is worth stating plainly rather than letting it be assumed away. Chainlink aggregates many sources into one feed. Pyth does the same at high frequency. UMA takes a proposed value, opens a challenge window, and escalates disputes to a token vote that returns a resolved answer. Different mechanisms, one shared purpose: collapse N observations into 1 value something downstream can settle against.

PropertyOracle networksSELISIH
Outputone canonical valueN attributable readings, kept apart
Disagreement isa failure to resolve awaythe product
Settles anything?yes, that is the pointno, ever
Slashing / token votecentral to the designabsent by construction
Positionon the hot pathexplicitly off it

An oracle returning seven different numbers has malfunctioned. SELISIH returning seven different numbers is SELISIH working.

The median is drawn as a faint dashed line and is never written anywhere. The moment a canonical value exists, something builds on it — and SELISIH is on a hot path it was designed to stay off. What it does borrow honestly is the dispute window: UMA runs a 48-hour liveness period, and 72 hours is chosen as the snapshot lifetime for the same reason. It is roughly how long a challenge to market evidence stays live.


UNISWAP SPOT − CHAINLINK, SAME BLOCK · BASIS POINTS · REAL MAINNET STATE -400 -200 +0 +200 +400 STRESS · 5 Aug 2024 · blocks 20,455,000–20,462,500 block 20,455,000: chainlink $2911.12, uniswap $2911.92, ฮ” +2.8 bps block 20,455,250: chainlink $2911.93, uniswap $2917.02, ฮ” +17.5 bps block 20,455,500: chainlink $2920.21, uniswap $2914.08, ฮ” -21.0 bps block 20,455,750: chainlink $2896.59, uniswap $2892.38, ฮ” -14.5 bps block 20,456,000: chainlink $2851.23, uniswap $2848.20, ฮ” -10.6 bps block 20,456,250: chainlink $2829.78, uniswap $2827.33, ฮ” -8.7 bps block 20,456,500: chainlink $2833.22, uniswap $2823.44, ฮ” -34.5 bps block 20,456,750: chainlink $2708.64, uniswap $2685.03, ฮ” -87.2 bps block 20,457,000: chainlink $2701.19, uniswap $2699.04, ฮ” -7.9 bps block 20,457,250: chainlink $2741.58, uniswap $2745.87, ฮ” +15.7 bps block 20,457,500: chainlink $2762.14, uniswap $2767.29, ฮ” +18.6 bps block 20,457,750: chainlink $2746.40, uniswap $2747.56, ฮ” +4.2 bps block 20,458,000: chainlink $2730.03, uniswap $2740.50, ฮ” +38.4 bps block 20,458,250: chainlink $2733.28, uniswap $2740.14, ฮ” +25.1 bps block 20,458,500: chainlink $2709.62, uniswap $2706.36, ฮ” -12.0 bps block 20,458,750: chainlink $2671.62, uniswap $2671.55, ฮ” -0.3 bps block 20,459,000: chainlink $2233.80, uniswap $2139.28, ฮ” -423.1 bps -423 bps · block 20,459,000 chainlink $2,234 · uniswap $2,139 block 20,459,250: chainlink $2319.04, uniswap $2318.81, ฮ” -1.0 bps block 20,459,500: chainlink $2307.44, uniswap $2318.33, ฮ” +47.2 bps block 20,459,750: chainlink $2320.01, uniswap $2323.20, ฮ” +13.7 bps block 20,460,000: chainlink $2354.19, uniswap $2360.45, ฮ” +26.6 bps block 20,460,250: chainlink $2315.73, uniswap $2317.19, ฮ” +6.3 bps block 20,460,500: chainlink $2295.66, uniswap $2286.15, ฮ” -41.4 bps block 20,460,750: chainlink $2266.29, uniswap $2274.25, ฮ” +35.1 bps block 20,461,000: chainlink $2360.29, uniswap $2353.88, ฮ” -27.1 bps block 20,461,250: chainlink $2308.17, uniswap $2298.68, ฮ” -41.1 bps block 20,461,500: chainlink $2324.21, uniswap $2325.30, ฮ” +4.7 bps block 20,461,750: chainlink $2272.21, uniswap $2272.41, ฮ” +0.9 bps block 20,462,000: chainlink $2272.92, uniswap $2274.95, ฮ” +8.9 bps block 20,462,250: chainlink $2290.11, uniswap $2300.42, ฮ” +45.0 bps block 20,462,500: chainlink $2230.83, uniswap $2224.46, ฮ” -28.5 bps median 17.5 · p90 41.4 · max 423.1 bps CALM · ~25 Aug 2024 · blocks 20,600,000–20,607,500 block 20,600,000: chainlink $2791.70, uniswap $2795.77, ฮ” +14.6 bps block 20,600,250: chainlink $2797.31, uniswap $2798.39, ฮ” +3.9 bps block 20,600,500: chainlink $2788.76, uniswap $2797.73, ฮ” +32.2 bps block 20,600,750: chainlink $2795.40, uniswap $2793.68, ฮ” -6.1 bps block 20,601,000: chainlink $2798.42, uniswap $2793.84, ฮ” -16.4 bps block 20,601,250: chainlink $2767.32, uniswap $2765.09, ฮ” -8.0 bps block 20,601,500: chainlink $2757.75, uniswap $2756.43, ฮ” -4.8 bps block 20,601,750: chainlink $2754.04, uniswap $2758.74, ฮ” +17.1 bps block 20,602,000: chainlink $2768.60, uniswap $2779.42, ฮ” +39.1 bps block 20,602,250: chainlink $2760.19, uniswap $2760.78, ฮ” +2.1 bps block 20,602,500: chainlink $2760.96, uniswap $2760.41, ฮ” -2.0 bps block 20,602,750: chainlink $2760.96, uniswap $2766.21, ฮ” +19.0 bps block 20,603,000: chainlink $2765.44, uniswap $2758.88, ฮ” -23.7 bps block 20,603,250: chainlink $2761.50, uniswap $2763.33, ฮ” +6.6 bps block 20,603,500: chainlink $2768.24, uniswap $2768.46, ฮ” +0.8 bps block 20,603,750: chainlink $2763.55, uniswap $2760.79, ฮ” -10.0 bps block 20,604,000: chainlink $2756.52, uniswap $2755.56, ฮ” -3.5 bps block 20,604,250: chainlink $2744.55, uniswap $2744.15, ฮ” -1.4 bps block 20,604,500: chainlink $2744.28, uniswap $2751.37, ฮ” +25.8 bps block 20,604,750: chainlink $2753.29, uniswap $2751.20, ฮ” -7.6 bps block 20,605,000: chainlink $2756.63, uniswap $2758.02, ฮ” +5.0 bps block 20,605,250: chainlink $2753.63, uniswap $2759.57, ฮ” +21.6 bps block 20,605,500: chainlink $2752.58, uniswap $2753.13, ฮ” +2.0 bps block 20,605,750: chainlink $2753.28, uniswap $2753.32, ฮ” +0.1 bps block 20,606,000: chainlink $2767.70, uniswap $2761.14, ฮ” -23.7 bps block 20,606,250: chainlink $2762.16, uniswap $2766.99, ฮ” +17.5 bps block 20,606,500: chainlink $2765.62, uniswap $2771.65, ฮ” +21.8 bps block 20,606,750: chainlink $2762.61, uniswap $2766.91, ฮ” +15.6 bps block 20,607,000: chainlink $2764.25, uniswap $2766.20, ฮ” +7.0 bps block 20,607,250: chainlink $2771.54, uniswap $2771.59, ฮ” +0.2 bps block 20,607,500: chainlink $2771.54, uniswap $2771.18, ฮ” -1.3 bps median 7.6 · p90 23.7 · max 39.1 bps
The kill test, run against real Ethereum mainnet state. Two independent observers of the same quantity at the same block. Under stress the median disagreement is 2.3× the calm baseline — but the tail is 10.8×. At block 20,459,000, the bottom of the cascade, Chainlink read $2,234 while Uniswap spot read $2,139: a 4.2% disagreement at exactly the moment liquidation engines were deciding who to liquidate. On the divergence board, that is one orange dot with a $creator on it. Script and raw output ship with the submission; hover any mark for its values.
CHAINLINK ETH/USD · (MAX − MIN) / MEDIAN OF THE 31 NODE OBSERVATIONS, PER ROUND · BASIS POINTS 0 200 400 600 800 STRESS · 5 Aug 2024 · 161 rounds block 20,456,521: 31 nodes, $2823.20โ€“$2825.51, median $2824.95, spread 8 bps block 20,456,536: 31 nodes, $2808.39โ€“$2820.86, median $2809.52, spread 44 bps block 20,456,596: 30 nodes, $2787.78โ€“$2801.60, median $2791.15, spread 50 bps block 20,456,598: 30 nodes, $2770.30โ€“$2795.71, median $2772.67, spread 92 bps block 20,456,601: 30 nodes, $2732.92โ€“$2790.50, median $2736.57, spread 210 bps block 20,456,628: 31 nodes, $2741.85โ€“$2754.69, median $2754.13, spread 47 bps block 20,456,657: 31 nodes, $2739.60โ€“$2745.87, median $2740.21, spread 23 bps block 20,456,715: 31 nodes, $2722.75โ€“$2731.71, median $2725.78, spread 33 bps block 20,456,750: 31 nodes, $2706.30โ€“$2718.77, median $2708.64, spread 46 bps block 20,456,765: 31 nodes, $2674.33โ€“$2704.33, median $2678.68, spread 112 bps block 20,456,767: 31 nodes, $2654.41โ€“$2700.56, median $2655.66, spread 174 bps block 20,456,772: 31 nodes, $2652.31โ€“$2672.74, median $2669.85, spread 76 bps block 20,456,782: 31 nodes, $2663.40โ€“$2694.89, median $2692.71, spread 117 bps block 20,456,820: 31 nodes, $2699.91โ€“$2701.39, median $2700.56, spread 6 bps block 20,456,927: 31 nodes, $2683.01โ€“$2689.98, median $2686.79, spread 26 bps block 20,457,000: 31 nodes, $2695.19โ€“$2703.18, median $2701.19, spread 30 bps block 20,457,094: 31 nodes, $2714.45โ€“$2720.50, median $2717.93, spread 22 bps block 20,457,102: 31 nodes, $2726.40โ€“$2735.55, median $2734.45, spread 33 bps block 20,457,119: 31 nodes, $2728.54โ€“$2732.00, median $2729.33, spread 13 bps block 20,457,169: 31 nodes, $2711.55โ€“$2719.89, median $2712.69, spread 31 bps block 20,457,187: 31 nodes, $2719.67โ€“$2727.27, median $2726.94, spread 28 bps block 20,457,239: 31 nodes, $2738.22โ€“$2742.11, median $2741.58, spread 14 bps block 20,457,261: 31 nodes, $2750.74โ€“$2756.40, median $2755.32, spread 21 bps block 20,457,338: 31 nodes, $2740.05โ€“$2743.53, median $2741.12, spread 13 bps block 20,457,396: 31 nodes, $2750.25โ€“$2755.63, median $2755.39, spread 20 bps block 20,457,418: 31 nodes, $2761.28โ€“$2764.34, median $2762.14, spread 11 bps block 20,457,617: 31 nodes, $2747.90โ€“$2751.09, median $2748.08, spread 12 bps block 20,457,717: 31 nodes, $2745.16โ€“$2748.74, median $2746.40, spread 13 bps block 20,457,917: 31 nodes, $2729.61โ€“$2736.62, median $2730.03, spread 26 bps block 20,458,018: 31 nodes, $2729.92โ€“$2732.57, median $2730.98, spread 10 bps block 20,458,100: 31 nodes, $2741.69โ€“$2749.75, median $2749.17, spread 29 bps block 20,458,127: 31 nodes, $2732.27โ€“$2738.45, median $2733.28, spread 23 bps block 20,458,317: 31 nodes, $2723.70โ€“$2724.89, median $2724.14, spread 4 bps block 20,458,396: 31 nodes, $2708.95โ€“$2713.28, median $2709.62, spread 16 bps block 20,458,593: 31 nodes, $2692.84โ€“$2696.14, median $2693.84, spread 12 bps block 20,458,616: 31 nodes, $2682.52โ€“$2684.27, median $2683.31, spread 7 bps block 20,458,688: 31 nodes, $2667.92โ€“$2674.97, median $2669.62, spread 26 bps block 20,458,708: 31 nodes, $2654.38โ€“$2659.96, median $2655.29, spread 21 bps block 20,458,721: 31 nodes, $2655.31โ€“$2678.62, median $2674.63, spread 87 bps block 20,458,726: 31 nodes, $2674.54โ€“$2688.52, median $2688.16, spread 52 bps block 20,458,733: 31 nodes, $2670.45โ€“$2687.45, median $2671.62, spread 64 bps block 20,458,756: 31 nodes, $2652.63โ€“$2669.82, median $2654.86, spread 65 bps block 20,458,821: 30 nodes, $2637.93โ€“$2645.15, median $2639.81, spread 27 bps block 20,458,828: 30 nodes, $2625.20โ€“$2633.79, median $2626.27, spread 33 bps block 20,458,833: 31 nodes, $2601.17โ€“$2625.05, median $2607.23, spread 92 bps block 20,458,868: 31 nodes, $2609.29โ€“$2628.69, median $2621.31, spread 74 bps block 20,458,883: 30 nodes, $2606.18โ€“$2612.13, median $2607.42, spread 23 bps block 20,458,916: 31 nodes, $2572.20โ€“$2601.95, median $2573.67, spread 116 bps block 20,458,923: 30 nodes, $2552.07โ€“$2573.89, median $2554.04, spread 85 bps block 20,458,926: 30 nodes, $2511.57โ€“$2565.18, median $2519.75, spread 213 bps block 20,458,942: 31 nodes, $2524.71โ€“$2534.46, median $2533.73, spread 38 bps block 20,458,946: 31 nodes, $2513.43โ€“$2531.78, median $2516.48, spread 73 bps block 20,458,971: 31 nodes, $2494.02โ€“$2516.78, median $2496.42, spread 91 bps block 20,458,973: 31 nodes, $2461.39โ€“$2516.45, median $2464.70, spread 223 bps block 20,458,976: 31 nodes, $2413.31โ€“$2515.81, median $2421.96, spread 423 bps block 20,458,981: 31 nodes, $2339.81โ€“$2468.70, median $2349.56, spread 549 bps block 20,458,984: 31 nodes, $2304.20โ€“$2463.69, median $2310.73, spread 690 bps block 20,458,987: 31 nodes, $2314.54โ€“$2413.58, median $2330.27, spread 425 bps block 20,458,988: 31 nodes, $2320.81โ€“$2424.99, median $2342.73, spread 445 bps block 20,458,992: 31 nodes, $2310.61โ€“$2396.79, median $2312.72, spread 373 bps block 20,458,996: 31 nodes, $2163.89โ€“$2311.96, median $2202.20, spread 672 bps block 20,458,998: 31 nodes, $2141.32โ€“$2335.32, median $2233.80, spread 868 bps block 20,459,003: 31 nodes, $2194.48โ€“$2286.36, median $2258.01, spread 407 bps block 20,459,006: 31 nodes, $2194.48โ€“$2256.39, median $2245.94, spread 276 bps block 20,459,013: 31 nodes, $2233.17โ€“$2310.34, median $2306.87, spread 335 bps block 20,459,019: 31 nodes, $2259.92โ€“$2334.15, median $2330.88, spread 318 bps block 20,459,021: 31 nodes, $2253.99โ€“$2322.95, median $2317.83, spread 297 bps block 20,459,031: 31 nodes, $2315.75โ€“$2353.58, median $2351.81, spread 161 bps block 20,459,033: 31 nodes, $2331.08โ€“$2373.63, median $2370.17, spread 180 bps block 20,459,038: 31 nodes, $2326.32โ€“$2357.30, median $2338.26, spread 132 bps block 20,459,041: 31 nodes, $2319.91โ€“$2358.39, median $2356.36, spread 163 bps block 20,459,061: 31 nodes, $2306.31โ€“$2363.00, median $2313.79, spread 245 bps block 20,459,065: 31 nodes, $2265.99โ€“$2352.57, median $2273.66, spread 381 bps block 20,459,068: 31 nodes, $2288.06โ€“$2351.97, median $2300.63, spread 278 bps block 20,459,075: 31 nodes, $2303.34โ€“$2333.76, median $2328.46, spread 131 bps block 20,459,092: 31 nodes, $2302.40โ€“$2338.37, median $2305.75, spread 156 bps block 20,459,108: 31 nodes, $2305.97โ€“$2327.25, median $2318.00, spread 92 bps block 20,459,108: 31 nodes, $2324.97โ€“$2356.67, median $2354.45, spread 135 bps block 20,459,113: 31 nodes, $2325.42โ€“$2345.04, median $2337.46, spread 84 bps block 20,459,118: 31 nodes, $2343.70โ€“$2354.58, median $2352.32, spread 46 bps block 20,459,128: 31 nodes, $2338.10โ€“$2370.60, median $2368.09, spread 137 bps block 20,459,135: 31 nodes, $2353.60โ€“$2364.21, median $2355.40, spread 45 bps block 20,459,156: 31 nodes, $2336.25โ€“$2358.14, median $2337.87, spread 94 bps block 20,459,162: 31 nodes, $2345.93โ€“$2353.37, median $2352.11, spread 32 bps block 20,459,167: 31 nodes, $2329.30โ€“$2347.74, median $2331.20, spread 79 bps block 20,459,184: 31 nodes, $2334.50โ€“$2348.60, median $2347.28, spread 60 bps block 20,459,194: 31 nodes, $2332.66โ€“$2339.38, median $2334.51, spread 29 bps block 20,459,202: 31 nodes, $2332.91โ€“$2358.20, median $2357.27, spread 107 bps block 20,459,207: 31 nodes, $2341.49โ€“$2347.96, median $2342.65, spread 28 bps block 20,459,214: 31 nodes, $2341.86โ€“$2349.79, median $2343.41, spread 34 bps block 20,459,224: 31 nodes, $2328.63โ€“$2341.45, median $2330.70, spread 55 bps block 20,459,237: 31 nodes, $2315.34โ€“$2322.32, median $2319.04, spread 30 bps block 20,459,262: 31 nodes, $2300.82โ€“$2314.81, median $2301.85, spread 61 bps block 20,459,274: 31 nodes, $2280.57โ€“$2289.91, median $2281.35, spread 41 bps block 20,459,282: 31 nodes, $2291.39โ€“$2306.52, median $2305.52, spread 66 bps block 20,459,297: 31 nodes, $2284.12โ€“$2303.33, median $2288.14, spread 84 bps block 20,459,302: 31 nodes, $2271.42โ€“$2291.42, median $2273.07, spread 88 bps block 20,459,307: 31 nodes, $2257.23โ€“$2281.82, median $2258.73, spread 109 bps block 20,459,317: 31 nodes, $2268.54โ€“$2273.68, median $2272.32, spread 23 bps block 20,459,322: 31 nodes, $2249.48โ€“$2258.24, median $2251.98, spread 39 bps block 20,459,324: 31 nodes, $2218.51โ€“$2265.17, median $2223.04, spread 210 bps block 20,459,327: 31 nodes, $2226.15โ€“$2265.17, median $2238.48, spread 174 bps block 20,459,329: 31 nodes, $2243.95โ€“$2260.72, median $2250.19, spread 75 bps block 20,459,337: 31 nodes, $2237.42โ€“$2245.61, median $2238.43, spread 37 bps block 20,459,344: 31 nodes, $2244.20โ€“$2255.41, median $2254.28, spread 50 bps block 20,459,349: 31 nodes, $2247.85โ€“$2270.24, median $2269.57, spread 99 bps block 20,459,357: 31 nodes, $2247.28โ€“$2282.00, median $2281.23, spread 152 bps block 20,459,364: 31 nodes, $2281.14โ€“$2303.17, median $2300.66, spread 96 bps block 20,459,372: 31 nodes, $2287.74โ€“$2295.94, median $2288.70, spread 36 bps block 20,459,404: 31 nodes, $2294.79โ€“$2304.52, median $2301.53, spread 42 bps block 20,459,427: 31 nodes, $2288.12โ€“$2301.03, median $2289.38, spread 56 bps block 20,459,437: 31 nodes, $2272.25โ€“$2281.31, median $2274.13, spread 40 bps block 20,459,449: 30 nodes, $2260.91โ€“$2272.05, median $2261.60, spread 49 bps block 20,459,479: 31 nodes, $2263.06โ€“$2283.88, median $2282.22, spread 91 bps block 20,459,484: 31 nodes, $2285.32โ€“$2296.21, median $2295.18, spread 47 bps block 20,459,492: 31 nodes, $2293.73โ€“$2309.83, median $2307.44, spread 70 bps block 20,459,509: 31 nodes, $2310.36โ€“$2321.26, median $2319.81, spread 47 bps block 20,459,519: 31 nodes, $2315.50โ€“$2318.45, median $2317.01, spread 13 bps block 20,459,524: 31 nodes, $2318.67โ€“$2329.44, median $2328.75, spread 46 bps block 20,459,527: 31 nodes, $2318.51โ€“$2336.43, median $2335.76, spread 77 bps block 20,459,554: 31 nodes, $2321.13โ€“$2331.35, median $2322.49, spread 44 bps block 20,459,564: 31 nodes, $2306.15โ€“$2323.36, median $2308.65, spread 75 bps block 20,459,584: 31 nodes, $2293.36โ€“$2297.72, median $2294.17, spread 19 bps block 20,459,637: 30 nodes, $2298.85โ€“$2306.69, median $2306.27, spread 34 bps block 20,459,654: 31 nodes, $2311.49โ€“$2320.61, median $2320.01, spread 39 bps block 20,459,761: 31 nodes, $2329.22โ€“$2334.00, median $2333.49, spread 20 bps block 20,459,811: 31 nodes, $2320.43โ€“$2325.10, median $2322.80, spread 20 bps block 20,459,866: 31 nodes, $2322.66โ€“$2337.14, median $2335.70, spread 62 bps block 20,459,896: 31 nodes, $2346.22โ€“$2350.73, median $2350.10, spread 19 bps block 20,459,899: 31 nodes, $2345.87โ€“$2368.57, median $2367.19, spread 96 bps block 20,459,926: 31 nodes, $2372.38โ€“$2383.28, median $2382.08, spread 46 bps block 20,459,964: 31 nodes, $2367.32โ€“$2373.01, median $2367.87, spread 24 bps block 20,459,983: 31 nodes, $2352.78โ€“$2357.92, median $2354.19, spread 22 bps block 20,460,029: 31 nodes, $2339.49โ€“$2345.82, median $2339.95, spread 27 bps block 20,460,049: 31 nodes, $2346.64โ€“$2354.17, median $2353.20, spread 32 bps block 20,460,104: 31 nodes, $2336.96โ€“$2347.62, median $2338.70, spread 46 bps block 20,460,116: 31 nodes, $2337.86โ€“$2342.07, median $2339.14, spread 18 bps block 20,460,146: 31 nodes, $2325.97โ€“$2330.97, median $2327.01, spread 21 bps block 20,460,154: 31 nodes, $2312.18โ€“$2315.53, median $2313.47, spread 14 bps block 20,460,174: 31 nodes, $2297.30โ€“$2305.65, median $2298.95, spread 36 bps block 20,460,189: 31 nodes, $2282.75โ€“$2295.02, median $2284.98, spread 54 bps block 20,460,201: 31 nodes, $2265.11โ€“$2287.41, median $2268.64, spread 98 bps block 20,460,204: 31 nodes, $2272.75โ€“$2284.11, median $2282.20, spread 50 bps block 20,460,206: 31 nodes, $2280.57โ€“$2297.81, median $2294.97, spread 75 bps block 20,460,215: 31 nodes, $2285.67โ€“$2313.09, median $2312.00, spread 119 bps block 20,460,216: 31 nodes, $2297.94โ€“$2308.53, median $2299.86, spread 46 bps block 20,460,244: 31 nodes, $2302.59โ€“$2317.82, median $2315.73, spread 66 bps block 20,460,254: 31 nodes, $2318.15โ€“$2328.36, median $2327.42, spread 44 bps block 20,460,269: 31 nodes, $2323.46โ€“$2339.70, median $2339.21, spread 69 bps block 20,460,276: 31 nodes, $2340.05โ€“$2354.23, median $2353.12, spread 60 bps block 20,460,341: 31 nodes, $2340.14โ€“$2346.27, median $2341.00, spread 26 bps block 20,460,353: 31 nodes, $2320.51โ€“$2335.92, median $2322.20, spread 66 bps block 20,460,358: 31 nodes, $2329.77โ€“$2342.01, median $2340.74, spread 52 bps block 20,460,397: 31 nodes, $2327.82โ€“$2338.54, median $2328.60, spread 46 bps block 20,460,412: 31 nodes, $2325.52โ€“$2331.02, median $2328.95, spread 24 bps block 20,460,429: 31 nodes, $2313.99โ€“$2321.71, median $2315.06, spread 33 bps block 20,460,452: 31 nodes, $2292.57โ€“$2306.83, median $2296.14, spread 62 bps block 20,460,471: 31 nodes, $2278.00โ€“$2286.58, median $2279.76, spread 38 bps block 20,460,473: 31 nodes, $2284.81โ€“$2292.26, median $2291.37, spread 33 bps block 20,460,478: 31 nodes, $2274.78โ€“$2283.01, median $2277.70, spread 36 bps block 20,460,483: 31 nodes, $2284.76โ€“$2297.39, median $2295.66, spread 55 bps 868 bps · block 20,458,998 nodes saw $2,141–$2,335 · feed said $2,234 median 49.5 · p90 212.8 · max 868.5 bps CALM · ~25 Aug 2024 · 18 rounds block 20,603,061: 31 nodes, $2760.43โ€“$2761.92, median $2761.50, spread 5 bps block 20,603,360: 31 nodes, $2768.00โ€“$2768.29, median $2768.24, spread 1 bps block 20,603,658: 31 nodes, $2763.11โ€“$2764.06, median $2763.55, spread 3 bps block 20,603,953: 30 nodes, $2756.07โ€“$2757.29, median $2756.52, spread 4 bps block 20,604,060: 31 nodes, $2741.64โ€“$2744.44, median $2742.07, spread 10 bps block 20,604,249: 31 nodes, $2744.17โ€“$2744.89, median $2744.55, spread 3 bps block 20,604,276: 31 nodes, $2743.84โ€“$2744.75, median $2744.28, spread 3 bps block 20,604,547: 31 nodes, $2752.92โ€“$2753.58, median $2753.29, spread 2 bps block 20,604,850: 31 nodes, $2755.68โ€“$2756.75, median $2756.33, spread 4 bps block 20,604,852: 31 nodes, $2756.10โ€“$2756.98, median $2756.63, spread 3 bps block 20,605,146: 31 nodes, $2753.47โ€“$2753.88, median $2753.63, spread 2 bps block 20,605,449: 31 nodes, $2752.33โ€“$2754.38, median $2752.58, spread 7 bps block 20,605,742: 31 nodes, $2752.97โ€“$2753.43, median $2753.28, spread 2 bps block 20,605,948: 31 nodes, $2766.25โ€“$2768.06, median $2767.70, spread 7 bps block 20,606,039: 31 nodes, $2761.99โ€“$2762.28, median $2762.16, spread 1 bps block 20,606,337: 31 nodes, $2765.11โ€“$2765.73, median $2765.62, spread 2 bps block 20,606,635: 31 nodes, $2762.43โ€“$2763.27, median $2762.61, spread 3 bps block 20,606,932: 31 nodes, $2763.67โ€“$2765.00, median $2764.25, spread 5 bps median 3.3 · p90 6.5 · max 10.2 bps
The second kill test: the incumbent’s own witnesses. Chainlink’s aggregator emits every node’s observation in NewTransmission and publishes only the median — so the disagreement SELISIH exists to keep is already signed and on-chain, and discarded. At block 20,458,998 the 31 nodes saw $2,141 to $2,335 and the feed said $2,233.80; six nodes were more than 200 bps from that number. Median node spread is 15× higher under stress; the maximum is 85×. Day one, SELISIH’s witnesses are these nodes, decoded — nothing to recruit. Hover any bar for its round.

Why a single-writer log is not evidence

When a lending market has a bad day, the post-mortem comes from the protocol’s own logs — written by the party the investigation is about, from a database it controls, weeks later. Risk committees deciding on reimbursement, underwriters settling a claim, and liquidated borrowers all need the same thing and none of them can get it.

The gap is not that there is no data. It is that there is no data anyone on the other side of the dispute will accept.

Divergence is the alarm

Seven witnesses reporting the same number is noise. One reporting something else is a signal — a bad feed, a partial view, or an agenda. The product surfaces the spread, not the value, and every reading is attributable.

Silence is evidence

A witness registration is alive only while it keeps being renewed. So “w7 did not report in round 812” is a checkable fact rather than a missing row. A watcher that goes quiet exactly when a market blows up cannot hide it.

Lifetime is a bet

Cost is size × lifetime, so a witness paying for ninety days on a reading is staking money on being right. And a witness letting a damning reading lapse is making a visible choice. Both are queryable.

Only the owner can extend — and that is the best thing about it

Arkiv permits only an entity’s owner to update, delete or extend it. A first draft had a dispute reaching in and extending every witness’s snapshot; that is simply not possible, and finding out reshaped the design around a better idea.

Retention is never done to evidence by an interested party. Each witness funds its own lifetime, priced in storage, which turns conviction into the numeric attribute fundedDays. Everyone else preserves what they care about by writing their own EvidencePin — their entity, their cost, their name on it — carrying the copy and the original’s tx hash. Expiry removes an entity from the query surface without erasing on-chain history, so a pin stays checkable against the original write long after the original has left the index.

Nobody can destroy someone else’s evidence. Nobody needs permission to save it.

The free-rider hole, and the entity that closes it

There is a hole big enough to hollow the whole product out: the cheapest way to be a witness is to read what witness 1 published and copy it. A copier costs nothing to run, never diverges, never gets flagged, and quietly turns seven independent observers into one observer and six mirrors — while the board keeps reporting reassuring agreement.

Consensus produced by copying looks exactly like consensus produced by correctness.

So each round runs in two beats. Witnesses first write a tiny Commit carrying only a salted hash; once the commit window closes they publish the snapshot with the salt. Arkiv’s own guidance is to keep secrets off and store commitments instead — nothing confidential is written, and the reading is public a minute later anyway. A witness cannot copy a number nobody has revealed, and one that reveals a value not matching its commit has published a discrepancy under its own key that anyone can check forever.

The cost is one cheap write per round and a minute of latency — affordable precisely because SELISIH is off the execution hot path. On a hot path this would be unacceptable; here it is free.

RiskSnapshot append-only · never updated lifetime 72h WitnessRegistration lifetime 7d · heartbeat RosterEpoch lifetime 1y · append-only Dispute one writer · window +30d Resolution append-only · binds nothing witness market + epoch extendEntity disputeId joined by shared attribute keys — there are no joins primary key: (market, round, witness)
Five entity types, four lifetimes, one deliberate correction. RosterEpoch exists because registrations expire in days while disputed snapshots live for months — so “who was on the roster in round 812?” cannot be answered by querying live registrations. It stores that fact while it is still true. The orange edge is the only write that touches an existing entity: a dispute extending the evidence it is about.
ONE READING · THREE FATES · WHO PAYS DECIDES floor witness pays 72h lapses — and letting it lapse is an act conviction witness pays 90d fundedDays = 90 pinned reader pays EvidencePin — a different entity, a different owner write +72h +90d Almost every reading is the top lane. That is what makes the other two affordable.
Owner-only extension, turned into the mechanic. A disputant cannot preserve a witness’s reading, so retention is never done to evidence by an interested party. The witness bets on its own reading by funding its lifetime; anyone else who needs it kept writes their own pin and pays for it. The pin carries the original’s tx hash, and expiry never touched on-chain history — so it stays checkable long after the original left the index.

The queries the product lives on

String attributes support eq() only; ranges need integers. Results come back newest-first with no server-side ordering, so every filter below is narrow enough that a page is a real answer rather than an arbitrary slice.

ScreenPredicateWhy it holds
Divergence board eq(market, M) ∧ eq(round, 812) Bounded by roster size, not position count. Needs no ordering at all.
Who is missing count of the above vs count of live registrations Two counts, no fetch. The dashboard never pulls what it only needs to tally.
Witness track record eq(witness, 0x…) ∧ gte(severityTier, 3)
and eq(vindicatedWitness, 0x…)
Two numbers, never one. Times it broke from its peers, and times it broke from them and was right — because a reputation query that only counts disagreement punishes the best witness in the system.
Conviction eq(market, M) ∧ gte(fundedDays, 30) Readings their own authors paid to keep well past the dispute window.
Danger scan eq(market, M) ∧ lt(healthFactorBps, 10500) ∧ gte(round, N) Health factor is an integer in basis points. As text it would be unqueryable.
Open disputes → pin queue eq(statusCode, 0) ∧ gte(deadlineTs, now), then lt(expiresAtTs, deadlineTs) The readings that will lapse before the dispute they matter to is resolved. That list is the call to action: anyone who cares can pin them, at their own cost, before they go.
Incident pull eq(market, M) ∧ gte(observedTs, T0) ∧ lte(observedTs, T1) Cursor-paginated. Verifiable by the reader without asking the protocol for anything.

The Arkiv surface this design uses

Every primitive below does real work. The two that are not used are listed too, because leaving them out was a decision.

PrimitiveWhere it does work
$creatorAttribution on every reading — the reputation primitive. Immutable, so a track record cannot be sold.
$owner · changeOwnershipOperational control of a registration, separated from authorship. Keys rotate and desks get acquired; authorship does not move.
Owner-only extensionThe constraint the design is built on. Nobody can preserve or destroy someone else’s evidence — so each party funds what it believes.
Cost = size × lifetimeTurned into fundedDays: conviction priced in storage, and filterable. A conventional database charges the writer nothing for keeping a row, so this signal cannot exist there.
expiresInFive differentiated lifetimes: 72h floor snapshots · 7d registrations · window+30d disputes · 1y roster epochs · pinner-funded pins.
extendEntityThe witness heartbeat, and a witness choosing to stand behind its own reading.
mutateEntitiesSnapshot plus roster update in one batch, chunked under the 1000-operation cap.
updateEntityOne entity type only — Dispute — where a single writer makes full-replace safe.
deleteEntityDeliberately unused. A system where evidence can be removed on request is not an evidence system.
Numeric attributesPrices ×1e8, debt ×1e6, health in basis points. The scale is in the attribute name.
String attributesmarket, witness, kind — enumerated slugs, never free text, because eq() is all they support.
Counts · cursors“Who is missing” without fetching rows; cursor pagination on the incident pull.
Tx hashesRendered beside each reading, so the reader verifies rather than trusts.
Hash commitmentsThe commit round. Arkiv’s guidance is to store commitments, not secrets — a digest is exactly that.
Project namespaceproject: "selisih" on every entity and first in every predicate. A shared public database has no other separation, and type: "snapshot" is a word other projects will reach for too.

Who pays. Witnesses pay for their own readings — a cost the writer bears is what makes fundedDays mean anything. Pinners pay for pins. The roster epoch is written once a day by whoever runs the board. Nobody pays for anyone else’s opinion, which is the only reason the cost signal is readable at all.

What this design would ask of Arkiv

The roadmap says the phase after Devcon 8 is decided with the teams using Arkiv, and the current architecture came out of hackathon feedback. So here is the feedback this design actually earned — from friction, not from a wishlist.

Neither is needed for SELISIH to work as designed. Both would make it smaller.


Why Arkiv, not Postgres

  • The writers are adversarial to each other. Witnesses compete, disputants have money at stake, and the protocol under examination has the strongest motive of anyone to shape the record. There is no operator all of them would accept as custodian — and a conventional database requires exactly that operator to exist.
  • The reader must verify without permission. “Trust our API, this row came from witness 4” is worth nothing in the one situation the product exists for.
  • Expiry has to be a guarantee. The retention argument, and with it the cost model, rests on that difference. Expiry removes an entity from the query surface without erasing on-chain history — exactly the semantics evidence needs.

Remove any one and SELISIH is a worse Grafana.

The fourth pillar, stated precisely. Block production stays centralised through November 2026 per the roadmap. SELISIH relies on the Ethereum anchor, not on decentralised sequencing: the operator can order or refuse writes but cannot rewrite an anchored $creator or tx hash without it showing against L1. Verifiability now; censorship-resistance is a later phase and is not claimed.

What deliberately stays off

  • Execution. No liquidations, no matching, no feed anyone trades against. Nothing in DeFi should block on this.
  • Aggregation. The median is computed in the client and never written. This is the one omission that is load-bearing rather than merely prudent.
  • Enforcement. Arkiv answers queries; it never executes logic. Bonds live in a contract — SELISIH supplies the evidence a slashing decision is made from.
  • Raw feed bundles. Payloads are capped summaries behind a sourceHash.
  • Real-time alerting. Change events are polled, not pushed. Arkiv is where the claim is filed, not the pager.

Entity-model sketch in TypeScript

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.

selisih.sketch.ts — click to expand
// SELISIH โ€” 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, gte, lte, lt, not, type Predicate } from "@arkiv-network/sdk/query";
import type { Hex } from "viem";

// Clients are constructed by the app, never here. Types only.
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 = "selisih";                        // project namespace โ€” on EVERY entity, first in EVERY predicate
const CT = "application/json";                // contentType is required by CreateEntityParameters
const SNAPSHOT_FLOOR_SEC = 259_200;           // 72h floor; the witness may fund longer โ€” that is the bet
const COMMIT_SEC = 86_400;                    // 24h
const REGISTRATION_SEC = 604_800;             // 7d heartbeat; lapsing IS deregistration (emits ArkivEntityExpired)
const ROSTER_EPOCH_SEC = 31_536_000;          // 1y
const PIN_DEFAULT_SEC = 31_536_000;           // 1y, pinner chooses and pays
const PAGE = 200;                             // SDK hard cap per page

// expiresIn must be a positive multiple of 2 (2-second blocks) or the SDK throws InvalidExpirationError
const even = (s: number) => (s % 2 === 0 ? s : s + 1);
// attributes are an ARRAY of { key, value }; value is string | number (numbers must be integers)
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 enc = (v: unknown) => new TextEncoder().encode(JSON.stringify(v));
const now = () => Math.floor(Date.now() / 1000);

// `.count()` in 0.7.0 is the length of ONE page (โ‰ค200). For anything that can exceed a page, 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. commit โ†’ reveal (closes the copy-the-leader hole) ----------
export async function commit(w: Wal, market: string, round: number, digest: string) {
  return w.createEntity({
    payload: new Uint8Array(0), contentType: CT, expiresIn: COMMIT_SEC,
    attributes: attrs({ project: APP, kind: "commit", market, round, digest, committedTs: now() }),
  });
}

export async function reveal(w: Wal, s: {
  market: string; round: number; blockNumber: number; observedTs: number;
  priceE8: number; healthFactorBps: number; totalDebtE6: number; collateralE6: number;
  atRiskCount: number; deviationBps: number; severityTier: 0 | 1 | 2 | 3 | 4; sourceHash: string;
  salt: string; supersedesRound?: number;
}, topK: unknown, fundedDays = 3) {
  const expiresIn = even(Math.max(SNAPSHOT_FLOOR_SEC, fundedDays * 86_400));
  const { supersedesRound, ...rest } = s;
  // No `witness` attribute: the SDK exposes `.createdBy()` and `entity.creator` natively, so mirroring
  // $creator into an attribute is redundant. The earlier draft did that and was wrong.
  // The cost actually paid for `expiresIn` is emitted on-chain in ArkivEntityCreated(..., cost) โ€”
  // so `fundedDays` is checkable against the event, not merely self-reported.
  return w.createEntity({
    payload: enc(topK), contentType: CT, expiresIn,
    attributes: attrs({ project: APP, kind: "snapshot", ...rest, fundedDays, expiresAtTs: now() + expiresIn,
                        ...(supersedesRound !== undefined ? { supersedesRound } : {}) }),
  });
}

// ---------- 2. the divergence board โ€” Q1 + Q2 ----------
export async function divergence(p: Pub, market: string, round: number) {
  const res = await p.select({ key: true, creator: true, attributes: true, expiresAtBlock: true, createdAtBlock: true })
    .where(eq("app", APP), eq("kind", "snapshot"), eq("market", market), eq("round", round),
           not("supersedesRound"))                        // `not(key)` = attribute ABSENT โ†’ originals only
    .limit(PAGE).fetch();
  const rows = res.entities;                               // bounded by roster size โ†’ one page holds it
  const hf = rows.map(r => Number(attr(r, "healthFactorBps"))).sort((a, b) => a - b);
  const median = hf[Math.floor(hf.length / 2)];            // computed client-side and NEVER written
  const registered = await p.select({ key: true })
    .where(eq("app", APP), eq("kind", "witness"), eq("market", market)).limit(PAGE).count();
  return { rows, median, missing: registered - rows.length };
}

// ---------- 3. reputation as two numbers โ€” Q3 (native createdBy, not a mirrored attribute) ----------
export async function trackRecord(p: Pub, witness: Hex) {
  const broke = await countAllBy(p, witness, [eq("app", APP), eq("kind", "snapshot"), gte("severityTier", 3)]);
  const vindicated = await countAll(p, [eq("app", APP), eq("kind", "resolution"), eq("vindicatedWitness", witness)]);
  return { broke, vindicated };                            // one number alone punishes the best witness
}
async function countAllBy(p: Pub, creator: Hex, preds: Predicate[]) {
  const res = await p.select({ key: true }).createdBy(creator).where(preds).limit(PAGE).fetch();
  let total = res.entities.length;
  while (res.hasNextPage()) { await res.next(); total += res.entities.length; }
  return total;
}

// ---------- 4. pin queue โ€” Q5: readings that lapse before their dispute resolves ----------
export async function pinQueue(p: Pub, market: string, roundFrom: number, roundTo: number, deadlineTs: number) {
  const res = await p.select({ key: true, creator: true, attributes: true, payload: true })
    .where(eq("app", APP), eq("kind", "snapshot"), eq("market", market),
           gte("round", roundFrom), lte("round", roundTo), lt("expiresAtTs", deadlineTs))
    .limit(PAGE).fetch();
  return res.entities;
}

// Only the OWNER may extend, so a reader preserves evidence by writing their OWN entity.
export async function pin(w: Wal, original: HasAttrs & { readonly creator: Hex; readonly payload: Uint8Array },
                          originTxHash: Hex, expiresIn = PIN_DEFAULT_SEC) {
  return w.createEntity({
    payload: original.payload, contentType: CT, expiresIn: even(expiresIn),
    attributes: attrs({ project: APP, kind: "pin", market: String(attr(original, "market")),
                        round: Number(attr(original, "round")), pinnedWitness: original.creator,
                        originTxHash, pinnedTs: now(), expiresAtTs: now() + expiresIn }),
  });
}

// ---------- 5. heartbeat โ€” a witness stands behind its own registration ----------
export async function heartbeat(w: Wal, registrationKey: Hex) {
  return w.extendEntity({ entityKey: registrationKey, expiresIn: REGISTRATION_SEC });
}

// ---------- 6. daily roster epoch โ€” ONE atomic tx ----------
export async function rosterEpoch(w: Wal, market: string, epoch: number, roundFrom: number, roundTo: number, witnesses: Hex[]) {
  return w.mutateEntities({                                // โ‰ค1000 ops per tx; chunk beyond that
    creates: [{
      payload: enc(witnesses), contentType: CT, expiresIn: ROSTER_EPOCH_SEC,
      attributes: attrs({ project: APP, kind: "roster", market, epoch, roundFrom, roundTo, witnessCount: witnesses.length }),
    }],
  });
}

// ---------- 7. "who was on the roster at round 812?" โ€” the upgrade path ----------
// The builder exposes validAtBlock(). IF the network serves historical state there, this replaces
// RosterEpoch entirely. Whether expired entities are returned at a past block is not documented,
// so the design keeps RosterEpoch and treats this as the upgrade path โ€” not as a promise.
export async function rosterAtBlock(p: Pub, market: string, block: bigint) {
  const res = await p.select({ key: true, creator: true })
    .where(eq("app", APP), eq("kind", "witness"), eq("market", market))
    .validAtBlock(block).limit(PAGE).fetch();
  return res.entities.map(e => e.creator);
}

The sketch is executed, not just compiled

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. Eight SELISIH 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.

invariants.test.ts — click to expand
// 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.ts — the executable spec, with every rule cited
// 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
}

And against what comes next: 0.8.0-dev

The September testnet runs a rebuilt architecture, and its SDK is already on npm under the dev tag. I read that too. It makes this design smaller and confirms one piece of feedback.

On 0.7.0On 0.8.0-devEffect
expiresAtTs mirrored so the pin queue can range-filter$expiresAt is a queryable system attributeThe mirror disappears
integers, scale in the keydec type, 18 exact fractional digits; addr for witnessesPrices stored as written; severityTier stays a bucket on purpose
validAtBlock() as the RosterEpoch upgrade pathatBlock() — still thereThe upgrade path survives the rewrite
.count() = one pageno count() in the builderPage-and-sum is the right shape on both
createdAtBlock returned, not filterable$createdAt is still result-onlyProtocol feedback #2 stands on the new architecture

The weekend slice

Day one, the witnesses already exist. A single process tails the ETH/USD aggregator’s NewTransmission logs and writes each node’s observation as its own entity — 31 per round, 161 rounds in the crash window alone — with nothing asked of Chainlink. Independent witnesses join the same schema. The minimum human slice is still: one market. Three witnesses, run from three machines by three people. One screen showing the divergence row and a “6 of 7 reporting” badge. If three independent parties can look at one screen and agree about what they disagreed about, the idea is proven.

Not building in v1: the arbitration flow, and the bond contract.

The honest limit: divergence detects disagreement, not a cartel. What SELISIH guarantees is attributable readings, not correct ones — a smaller claim than it first appears, and one worth stating plainly.