Skip to content

fix(server): don't start the check-in clock for an unconfirmed owner (#326) - #356

Merged
Jolah1 merged 1 commit into
mainfrom
fix/326-gate-clock-on-owner-verification
Aug 25, 2026
Merged

fix(server): don't start the check-in clock for an unconfirmed owner (#326)#356
Jolah1 merged 1 commit into
mainfrom
fix/326-gate-clock-on-owner-verification

Conversation

@Jolah1

@Jolah1 Jolah1 commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Closes #326.

The problem

vaults.owner_contact_verified_at has existed since June and nothing has ever gated on it. An owner who never confirmed their email still receives reminders, alarms and escalations, and their heir still gets a claim link at the end.

Seven of sixteen mainnet vaults are in that state. 2e198479 was rejected outright by the relay with 550 Invalid 'to' field. 4a7aaf77 ran a reminder, an alarm, three escalations and a claim link, every row reading sent, and quite possibly nobody at the other end of any of them.

The fix

The cascade has exactly one entry point. Reminders, the ok -> alarmed flip, escalations and heir contact all filter status = 'ok', and only activate_funded_vaults puts a vault there. So the gate goes there.

Funding is necessary to start the clock, not sufficient. A funded vault whose owner email is unconfirmed stays unfunded and records one activation_held event. Confirming the email starts everything, so it is a hold, not a wall.

Only the email channel, and only with a sealed contact on file. There is no verification flow for sms or whatsapp, and none for a legacy row with a channel but no ciphertext. Holding those would brick a vault permanently instead of prompting anyone. The condition mirrors the one already behind VaultView::owner_contact_verified.

Not retroactive. Vaults already ok or alarmed keep running. The asymmetry: retroactively holding a live vault risks the unrecoverable failure, a dead owner's heir never inheriting, to prevent a recoverable one, since a live owner still holds their key and scheduler.rs:1434 already gates heir contact on on-chain CSV maturity. And unverified is not the same as invalid: only one of the seven has hard evidence. Those seven want deciding one at a time against delivery data, not a blanket rule.

What the owner sees

status: "unfunded" now means two different things: waiting for coins, and coins are here and waiting for you. Telling someone who has just sent Bitcoin to go and send Bitcoin is how a person concludes their money is gone.

So VaultView carries activation_held, and the dashboard swaps the fund card for one that says the money arrived, it is safe, only they can spend it, and one tap in an email starts the plan. Deliberately not an error tone: nothing is wrong and nothing is at risk.

The existing confirm-email banner's stakes line changes too. It used to say a missed reminder "can trigger the inheritance by accident", which is now backwards; the clock is stopped, so nothing starts and nobody is contacted.

Operator visibility

/health gains vaults_owner_unverified next to the existing vaults_heir_unverified. That is the number that would have surfaced this months ago without a DB query.

Testing

Four new scheduler tests:

  • funded + unconfirmed email stays unfunded, records the hold, and does not record funded
  • funded + confirmed email activates and starts the clock from now
  • the hold is recorded once across three ticks, not once per tick
  • funded with no owner contact at all still activates

cargo test -p ghostkey-server 310 passed. RUSTFLAGS=-D warnings cargo clippy --workspace --all-targets clean, cargo fmt --check clean. Web: tsc, eslint --max-warnings=0, 134 vitest tests.

Worth knowing before merging

This changes signet testing too. A tester who funds a vault and doesn't confirm their email now has an inert vault, which is the point, but it will look like a bug the first time it happens.

The scheduler does not re-send the verification email while holding. The dashboard has a resend with a cooldown, and re-mailing a possibly-invalid address on a timer is a good way to hurt sending reputation. Worth revisiting if held vaults pile up.

Follow-up

Holding the cascade on a negative delivery verdict rather than an unset flag. That is evidence rather than an unenforced boolean, so it carries none of the objection to retroactivity above, and it would have caught 2e198479 automatically.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VFkuFVtou7Rg1ipHLs6zCW

…326)

`vaults.owner_contact_verified_at` has existed since June and nothing
has ever gated on it. An owner who never confirmed their email still
got reminders, alarms, escalations and, at the end, handed their heir a
claim link. Seven of sixteen mainnet vaults are in that state. One,
`2e198479`, was rejected outright by the relay with a 550. Another,
`4a7aaf77`, ran a reminder, an alarm, three escalations and a claim
link with every row reading `sent` and, quite possibly, nobody at the
other end of any of them.

The cascade has exactly one entry point. Reminders, the ok->alarmed
flip, escalations and heir contact all filter `status = 'ok'`, and only
`activate_funded_vaults` puts a vault there. So the gate goes there:
funding is necessary to start the clock, not sufficient. A funded vault
whose owner email is unconfirmed stays `unfunded` and records one
`activation_held` event.

Only the email channel, and only with a sealed contact on file. There
is no verification flow for sms or whatsapp and none for a legacy row
with no ciphertext, so holding those would brick a vault permanently
instead of prompting anyone.

Not retroactive. Vaults already `ok` or `alarmed` keep running. Holding
a live vault risks the unrecoverable failure (a dead owner's heir never
inherits) to prevent a recoverable one (a live owner still holds their
key, and heir contact is already gated on on-chain maturity). The seven
existing ones want deciding one at a time against delivery evidence,
not a blanket rule.

`status: "unfunded"` now means two different things, so `VaultView`
carries `activation_held` to tell them apart. Telling someone who just
sent Bitcoin to go and send Bitcoin is how a person concludes their
money is gone, so the dashboard swaps the fund card for one that says
the money arrived and the clock is waiting on their email. `/health`
gains `vaults_owner_unverified`, the number that would have shown this
months ago.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFkuFVtou7Rg1ipHLs6zCW
@vercel

vercel Bot commented Aug 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ghost-key Ready Ready Preview Aug 25, 2026 10:06am

@Jolah1
Jolah1 merged commit 56265fc into main Aug 25, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Owner email verification is a flag nobody enforces

1 participant