diff --git a/DEMO.md b/DEMO.md
index 8915641..f8cd329 100644
--- a/DEMO.md
+++ b/DEMO.md
@@ -144,9 +144,11 @@ one run.
disagree at small n. At n = 30 a p95 is close to the second-largest sample — a weak tail
estimate. Treat 151 ms as "the tail we have observed", not a service-level guarantee.
-**The drift sample is a population, not a sample.** All 30 renewals this deployment has ever
-armed are in it, because the contract emits `RenewalScheduled(agent, schedule, firesAt)` and
-every one of those pairs to a `scheduled=true` CONTRACTCALL. Nothing was selected out.
+**The drift sample is a population, not a sample.** All 30 renewals this deployment had armed as
+of the run above are in it, because the contract emits `RenewalScheduled(agent, schedule, firesAt)`
+and every one of those pairs to a `scheduled=true` CONTRACTCALL. Nothing was selected out. The
+deployment keeps renewing, so a later `yarn bench` reports a larger n — the table is the run it is
+dated with, not a running total.
**Disclosed seeding.** That traffic is operator-generated: every renewal came from subscriptions
we funded ourselves on testnet, at a demo period of 90 seconds. There are no external users in
diff --git a/JUDGE.md b/JUDGE.md
index 7a63564..0e0c950 100644
--- a/JUDGE.md
+++ b/JUDGE.md
@@ -163,9 +163,11 @@ Four real ones. None of them is fixed here.
balance; neither schedules anything. Opening a subscription again is the only way back —
`subscribe()`, or the `subscribeFor()` the server calls when the agent pays the next 402. So
the unattended part runs exactly as far as the money does: until the subscriber's balance or
- the seller's gas reserve runs dry, and then someone outside has to send a transaction. At the
- demo settings — 90-second periods, 2 ℏ held back per armed renewal — that is minutes, not
- months.
+ the seller's gas reserve runs dry, and then someone outside has to send a transaction. With
+ 2 ℏ held back per armed renewal, the period length decides how long that takes: at the
+ 90-second periods the demo ran until 2026-09-09, minutes; at the hourly periods it runs now,
+ days. Either way it is the reserve that ends it, and `periodSeconds` in `/api/retainer/status`
+ says which setting is live.
Also true: not audited, testnet only, and `RENEWAL_COST_ESTIMATE` is an explicit estimate — a
contract cannot know a future network fee.
diff --git a/README.md b/README.md
index 74959bf..95bc82e 100644
--- a/README.md
+++ b/README.md
@@ -565,8 +565,13 @@ runs exactly as far as the money does: until the subscriber's balance or the sel
reserve runs dry, and no further. Nothing on-chain is holding a wake-up call after a lapse, so
the restart has to come from outside — the agent paying again, or the seller topping up
`fundGasReserve()`, which nothing in the contract does on its own. That boundary is easy to
-miss at the demo settings on the current deployment: 90-second periods against a reserve that
-holds back 2 ℏ per armed renewal, so a funded Retainer burns down in minutes rather than months.
+miss, because it is the gas reserve and not the subscription that ends up being the clock: a
+renewal that re-arms costs about 1.6 ℏ of gas to collect 1 ℏ of revenue, and 2 ℏ is held back per
+armed renewal while it is pending. The period length turns that into a duration. The demo ran
+90-second periods until 2026-09-09 and burned down in minutes; it runs hourly periods now and the
+same reserve lasts days. `packages/hardhat/scripts/retermAndRestart.ts` derives both the period and
+the reserve from the date the demo has to survive to, rather than from a number someone picked, and
+`periodSeconds` in `/api/retainer/status` is the live answer for whatever it is set to today.
## 📐 Reproduce the numbers
diff --git a/RUNBOOK.md b/RUNBOOK.md
index 39ca63e..e709703 100644
--- a/RUNBOOK.md
+++ b/RUNBOOK.md
@@ -119,7 +119,7 @@ typings generated into `packages/hardhat/typechain-types`.
yarn hardhat:test
```
-Expect **40 passing**. The suite takes roughly two minutes; each test redeploys the contract
+Expect **64 passing**. The suite takes roughly two minutes; each test redeploys the contract
and reinstalls the mock scheduler.
The tests run against a forked Hedera environment (`HEDERA_FORKING=true`) with a
diff --git a/docs/proof.md b/docs/proof.md
index f9cf526..1b4e1a2 100644
--- a/docs/proof.md
+++ b/docs/proof.md
@@ -56,7 +56,8 @@ current source concrete:
source (`subscribe()` is `0x8f449a05`, `subscribeFor(address)` is `0x6da6c39c`).
- The run used a **60-second period**. The current source sets `MIN_PERIOD_SECONDS = 61`, a
bound added after this run so the anyone-callable `renew()` window stays a strict minority
- of every period; the current deployment runs 90-second periods.
+ of every period; the current deployment ran 90-second periods when this run was transcribed and
+ hourly ones since 2026-09-09, `periodSeconds` in `/api/retainer/status` being the live answer.
Neither affects what the run demonstrates: the Schedule Service really does call back into a
contract, on time, unattended, and re-arm itself — and what that costs.
diff --git a/e2e/landing.spec.ts b/e2e/landing.spec.ts
index cfc8734..dbffb1d 100644
--- a/e2e/landing.spec.ts
+++ b/e2e/landing.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test } from "@playwright/test";
+import { type Page, expect, test } from "@playwright/test";
import { version } from "../package.json";
/**
@@ -13,6 +13,50 @@ import { version } from "../package.json";
const CLAIM = "renews itself";
+/**
+ * The status the page reads on arrival, stubbed.
+ *
+ * The landing page asks the chain once, unprompted, whether the demo agent's window is open, and
+ * behaves differently depending on the answer. Left unstubbed that makes these tests a function of
+ * what testnet happens to be doing at the minute they run — which is exactly how a green suite went
+ * red the moment the demo was restarted, with no code change between the two runs. So the answer is
+ * supplied here, both ways round, and the chain gets to decide nothing.
+ */
+const statusBody = (open: boolean) => ({
+ agent: "0xD14CA86A1483e9b2147a7B86fB74D437d3d2Cc66",
+ contract: "0x433050c9bd203FBdd49FAB6b5E20eD3E1FB2a931",
+ hasAccess: open,
+ expiresAt: open ? Math.floor(Date.now() / 1000) + 1800 : 0,
+ secondsRemaining: open ? 1800 : 0,
+ periodSeconds: open ? 3600 : 0,
+ pricePerPeriodTinybar: "100000000",
+ balanceTinybar: open ? "17600000000" : "0",
+ periodsFunded: open ? 176 : 0,
+ active: open,
+ nextRenewalSchedule: open
+ ? "0x00000000000000000000000000000000009F56b5"
+ : "0x0000000000000000000000000000000000000000",
+ renewalsReserveCanArm: open ? 176 : 0,
+ usage: { used: 0, allowance: 5, remaining: open ? 5 : 0 },
+ unavailable: [],
+ now: Math.floor(Date.now() / 1000),
+});
+
+/** Answer every status read with `open`, after `delayMs`. Resolves the count of reads served. */
+async function stubStatus(page: Page, open: boolean, delayMs = 0) {
+ const served = { count: 0 };
+ await page.route("**/api/retainer/status**", async route => {
+ if (delayMs) await new Promise(r => setTimeout(r, delayMs));
+ served.count += 1;
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(statusBody(open)),
+ });
+ });
+ return served;
+}
+
test.describe("/ — the landing page a cold visitor gets", () => {
test("carries exactly one h1 with the claim, and the instrument above the fold", async ({
page,
@@ -39,6 +83,9 @@ test.describe("/ — the landing page a cold visitor gets", () => {
}) => {
await page.goto("/");
const instrument = page.getByTestId("instrument");
+ // Ask for the recorded run rather than assuming it is what a cold visitor gets: when the demo
+ // agent's window is open on chain, the page legitimately opens on the live tab instead.
+ await instrument.getByRole("tab", { name: "Recorded run" }).click();
await expect(instrument).toHaveAttribute("data-mode", "replay");
await expect(
instrument.getByText(/REPLAY · 2026-09-07 · 0\.0\.10406083/),
@@ -79,6 +126,7 @@ test.describe("/ — the landing page a cold visitor gets", () => {
}) => {
const context = await browser.newContext({ reducedMotion: "reduce" });
const page = await context.newPage();
+ await stubStatus(page, false);
await page.goto("/");
const play = page
.getByTestId("instrument")
@@ -102,6 +150,43 @@ test.describe("/ — the landing page a cold visitor gets", () => {
).toBeLessThanOrEqual(1);
});
+ test("opens on the live chain when the demo agent's window is open and nobody has touched it", async ({
+ page,
+ }) => {
+ await stubStatus(page, true);
+ await page.goto("/");
+ const instrument = page.getByTestId("instrument");
+ await expect(instrument).toHaveAttribute("data-mode", "live");
+ });
+
+ /**
+ * The regression this file exists to prevent from recurring.
+ *
+ * The arrival read lands a few hundred milliseconds after paint. While the demo agent was lapsed
+ * it answered "closed" and changed nothing, so nothing here ever ran against the other branch.
+ * Once the demo was restarted the same read started switching the instrument to live *after* a
+ * visitor had begun working the replay — the controls unmounted under a click already in flight
+ * (`element is not stable`). The suite caught it, on a branch that touched no UI code at all.
+ */
+ test("a late arrival read never pulls the mode away from a visitor already working the replay", async ({
+ page,
+ }) => {
+ const served = await stubStatus(page, true, 1200);
+ await page.goto("/");
+ const instrument = page.getByTestId("instrument");
+
+ // Take hold of the instrument before the read can land.
+ await instrument.getByRole("tab", { name: "Recorded run" }).click();
+ await expect(instrument).toHaveAttribute("data-mode", "replay");
+
+ // Now let it land — and keep landing, because the live window polls.
+ await expect.poll(() => served.count, { timeout: 15_000 }).toBeGreaterThan(0);
+ await expect(instrument).toHaveAttribute("data-mode", "replay");
+ await expect(
+ instrument.getByRole("button", { name: /Pause|Play/ }),
+ ).toBeVisible();
+ });
+
test("has the landmarks and the version stamp a real page has", async ({
page,
}) => {
diff --git a/packages/nextjs/app/layout.tsx b/packages/nextjs/app/layout.tsx
index 02d1ab9..5ee0042 100644
--- a/packages/nextjs/app/layout.tsx
+++ b/packages/nextjs/app/layout.tsx
@@ -31,7 +31,7 @@ export const metadata = getMetadata({
"An x402-gated service on Hedera whose access renews itself on-chain via the Hedera Schedule Service. An agent pays once; the network keeps it alive.",
// 101 chars — the card copy has to survive a mobile feed, which clips around 125.
cardDescription:
- "An agent pays once, then Hedera renews its access on-chain by itself — 39 renewals, no user, no cron.",
+ "An agent pays once, then Hedera renews its access on-chain by itself — on schedule, no user, no cron.",
});
const ScaffoldHbarApp = ({ children }: { children: React.ReactNode }) => {
diff --git a/packages/nextjs/app/page.tsx b/packages/nextjs/app/page.tsx
index 0a6c5da..0876381 100644
--- a/packages/nextjs/app/page.tsx
+++ b/packages/nextjs/app/page.tsx
@@ -38,6 +38,9 @@ const Home: NextPage = () => {
const live = useLiveWindow();
useReveal([live.valid, live.status !== null]);
const checkedDemo = useRef(false);
+ // The address the arrival read put there on nobody's behalf. Anything the visitor asks for
+ // clears it, so the instrument can tell "the chain happened to be open" from "show me the chain".
+ const [arrivalAgent, setArrivalAgent] = useState