Skip to content

feat: OG Diviner badge for early app posters with no Keycast account - #443

Open
mbradley wants to merge 29 commits into
mainfrom
feat/og-diviner-app-poster-grants
Open

mbradley wants to merge 29 commits into
mainfrom
feat/og-diviner-app-poster-grants

Conversation

@mbradley

@mbradley mbradley commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Merge and production roster loading are on hold. The prior approval was dismissed pending agreement on data ownership. See the maintainer's ownership proposal. The proposal is to keep the private eligibility cohort in Funnelcake, serve a boolean through the profile API, migrate the app, and retire the Keycast lookup after compatibility is addressed. That cross-repository direction has not been approved by this PR. The implementation and rollout notes below describe the existing proposal; they are not authorization to merge, deploy, generate a production roster, or load it.

Relates to #440

What this solves

The OG Diviner badge goes to people who joined Divine before invite codes were removed. Since #416, the only evidence Keycast checks is its own signup records. People who used the Divine app before the cutoff with a Nostr key they created or brought themselves never had a Keycast signup, so they get eligible: false even when they have been posting since before launch. A user in that position wrote in, and Rabble agreed we should cover this group (#440).

Why this approach

The group is closed, since nobody new can have posted before the cutoff, so it is found once from production relay data and loaded once, using the criteria in #440. Keycast keeps the list in a new per-tenant table and checks it as one more way to qualify. The list itself is private and never committed, in keeping with #416's aim of keeping the cohort out of public view. The loader prints counts only, never pubkeys.

  • New table, empty on deploy. With nothing loaded, no answer changes, so this can merge before the data exists.
  • Deleting an account removes the grant. That includes people with no Keycast account: the account-deletion coordinator calls Keycast for every deletion made in the Divine app, and the grant is removed even when Keycast has no account to delete.
  • The loader never grants an account Keycast has recorded as deleted, and removes any grant such an account already has. It has a dry run that reports what a load would do.

What it deliberately does not change

  • Nobody who is eligible or ineligible under the existing rule changes. The mobile app already asks Keycast and needs no change.
  • No lock between a load and account deletion. Someone who deletes their account during the seconds a load is writing can keep a grant. The runbook closes that with a cleanup that only removes grants, run straight after the load and again the next day. An earlier version of this branch used a lock instead, but that made every account deletion wait on a one-time job, so it was taken out.
  • A deletion made only from another Nostr app never reaches Keycast, so that grant stays. This is accepted.
  • Deleting an account through Keycast's own DELETE /api/user/account route leaves no deletion record. So whoever builds the list must drop accounts that have vanished on the relay (NIP-62), and the post-load cleanup cannot catch such a deletion if it races the load.

Migration: 20261023120000_add_og_diviner_grants adds one empty table. Nothing else in the schema changes.

Deploying

Ship the code. Loading the list is a separate, one-time production step, described in docs/DEPLOYMENT.md under "OG Diviner grants": build the list right before loading, dry run, check the database line and the counts, apply, then run the cleanup.

How it was verified

  • The full core and api test suites pass: 1,395 tests, 0 failed, against local Postgres and Redis. The final commit changes only docs and the loader tests, which pass 12/12 at the head.
  • Each guard has a test that fails when the guard is removed. Checked by mutation: the deletion exclusion and its tenant filters, the revoke and its tenant filter, each dry-run count, counting an account with two deletion records once, and grant removal on account deletion.
  • The dry run takes 1.4s locally on 200,000 pubkeys against 20,000 deletion records. The earlier query took 29s on a tenth of that.
  • The cleanup statement was checked against a test database: it removed only the grant with a deletion record in its own tenant.
  • Several rounds of fresh review were run on the branch before opening it, and every finding was fixed or is listed above.
Agent-Model: anthropic/claude-opus-5-5
Agent-Effort: high
Agent-Harness: claude-code/2.1.305
Agent-Context: shared
Initiated-By: @mbradley
Agent-Model: anthropic/claude-opus-5-5
Agent-Effort: high
Agent-Harness: claude-code/2.1.305
Agent-Context: fresh
Initiated-By: @mbradley
Agent-Model: anthropic/claude-opus-5-5
Agent-Effort: high
Agent-Harness: claude-code/2.1.305
Agent-Context: fresh
Initiated-By: @mbradley

People who posted through a Divine app before the cutoff with a key they
created or brought themselves have no Keycast signup record, so the chit
endpoint answered false for them. Add og_diviner_grants, a tenant-scoped
list of granted pubkeys with a closed evidence vocabulary, and let
is_og_diviner accept a grant in the same statement.

The grant lookup sits beside the users lookup rather than inside it:
most of this cohort has no users row, and a branch inside that join would
never match them. With an empty table every answer is unchanged.

Refs #440.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
The roster for keycast#440 is built privately from relay data and loaded
once per environment. Parsing rejects the whole file if any non-blank
line is not a 64-character lowercase hex pubkey, naming line numbers but
never contents. Loading is one INSERT ... ON CONFLICT DO NOTHING, so a
re-run grants nothing new and reports the rows as already granted. A dry
run counts without writing, and an unknown tenant fails either way.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
A cargo example, following migrate-vine-users, that reads a roster file
and loads it through OgDivinerGrantRepository. It is a dry run unless
--apply is passed, requires the tenant and evidence label explicitly, and
prints counts and line numbers only: never a pubkey, a line of the file,
or its path.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
Nothing caught numbering bad lines by non-blank entry rather than by line
in the file, which would send an operator to the wrong line. Also correct
the repository doc: load runs two statements, one after the other, not
one.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
Follows the support-admin seed section: the migration ships an empty
table, the roster stays private, and the loader is run dry first and
then with --apply over a connection that bypasses the transaction-mode
pooler.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
Only the loader needs the pubkey list, so nothing outside keycast_core
can print it. Also bring the eligibility test's summary up to date with
the grant coverage it now holds.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
Account deletion never touched og_diviner_grants, so a deleted grantee
stayed eligible. Most grantees have no Keycast account, so the grant is
removed before the ownership check: the service deletion path, run by
the account-deletion coordinator on the person's own vanish request,
returns already_absent for them and still commits. The delete is scoped
to the calling tenant, so another tenant's grant for the same pubkey
survives.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
Nothing caught a dry run that counted grants from every tenant, which
would report pubkeys granted elsewhere as already granted here and
under-predict the real load.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
is_err() also passed on unrelated failures such as a missing tenant's
foreign key. Assert SQLSTATE 23514 so the test proves the table's own
CHECK constraints did the rejecting.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
A repeated flag let the last value win, so --tenant-id 2 --tenant-id 1
silently loaded into tenant 1. The loader now refuses any flag given
twice. Its report also prints the tenant's domain and name beside the
id, so the operator can confirm the tenant on the dry run before
applying.

The example's argument parsing is now tested, and core/Cargo.toml opts
the example into `cargo test` so CI runs those tests.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
The loader prints load errors. It stays quiet about pubkeys only because
RepositoryError keeps a database error's message and drops its detail,
where Postgres puts the failing row. Pin that with a real CHECK violation
and say why at the conversion.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
Say which connection the loader needs (the migration job's direct one),
and have the operator confirm the tenant and match the dry run's unique
count against the roster builder's before applying. Explain that the
endpoint's day-long Cache-Control can keep showing false after a load,
so the spot-check bypasses caches. Add a transactional removal for a bad
load, and say which deletions remove a grant on their own and which do
not.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
The deletion comments and runbook described the coordinator's call as
following a "vanish" and told operators a grantee could keep their row
until it was removed by hand. The coordinator actually calls service
deletion for every account deleted in the Divine app, including people
Keycast never held an account for, so deletion in the app is what removes
the grant and no account holder needs a manual step. The removal SQL is now
framed only as an operator undo for a bad load, and the plan records the
deletion requirement added after review.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
A load would grant someone who had already deleted their account, and
nothing would ever take the grant away: the deletion coordinator does not
call Keycast again for a completed deletion, and a replayed request id is
answered from the stored record. The load now skips any roster pubkey with
a service_account_deletions row in the same tenant, in the dry run and the
apply alike, and revokes a grant such a pubkey already holds. The report
counts them as "account deleted" (and "grants revoked"), so unique pubkeys
still equal newly + already + excluded.

Checking the records is not enough on its own. A deletion deletes the
grant and records itself in one transaction, the load reads records and
inserts grants in another, and under READ COMMITTED neither sees the
other's uncommitted rows, so a deletion running alongside a load could
miss the grant while the load missed the deletion. A sweep after the
insert narrows that window but does not close it: a deletion whose delete
ran before the insert committed but which commits after the sweep still
escapes both. So both sides take one tenant-scoped advisory lock: account
deletion shared, so deletions never wait on each other, and the load
exclusive, for its single transaction. A load then waits for deletions in
flight and sees their records, and a later deletion waits for the load and
sees its grant. One lock rather than one per pubkey, so a large roster
cannot exhaust the lock table, and the load stays one transaction with one
created_at.

The apply also refuses to commit if its counts ever fail to add up to the
roster, instead of wrapping around in a release build.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
delete_account's step list said the grant is removed even when no account
row exists. On that path delete_account returns NotFound without
committing, so the removal rolls back. Say it only where it holds:
delete_account_in_tx, whose caller can commit an AlreadyAbsent outcome, as
service deletion does when it records already_absent.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
The runbook pointed operators at "the migration job's direct connection",
but production's migrate job reaches Cloud SQL through the Auth Proxy with
IAM auth, so there was no string to copy, and putting one inline leaves it in
shell history. It also implied an old roster could be re-run safely and that
in-app deletion covers every deletion, and its undo SQL used a hand-typed
time window and a count check that legitimately fails once grantees delete
their accounts.

Say to build the roster immediately before loading, since Keycast's deletion
records can be compacted after 30 days; give the proxy and export steps;
show headers on the spot-check; match the undo on the exact created_at and
check it against the SELECT's count; and state plainly that a deletion made
only from another Nostr app never reaches Keycast, which is accepted.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
Moves the tenant grant lock into groups/service.rs next to
lock_group_account and takes it in delete_account_in_tx right after that
lock, so lock order is explicit at both sites: deletion takes group-account
then the grant lock; the load takes only the grant lock, so there is no
cycle. One key per tenant rather than per pubkey, because a lock per roster
pubkey held in one transaction could exhaust Postgres's server-wide lock
table. Adds the reverse-order test, a deletion arriving while a load holds
the lock, through the real deletion path.

Tradeoff: while a load holds the exclusive lock (a few seconds at most),
account deletions in that tenant wait for it. The dry run takes no lock.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
Tasks 1-3 shipped earlier on this branch but were still unchecked. Task 5
records the review-driven change that keeps a load from granting anyone
whose account deletion Keycast has recorded, and the tenant grant lock that
keeps a load and a deletion from missing each other.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
Compaction deleted a deletion record under only its per-request lock, so
it could land between a load's insert, sweep and count. The load then
reported that pubkey as already granted and could skip revoking a grant
whose deletion was recorded when it started. Compaction now takes the
tenant's grant lock shared, after its deletion lock; the load takes only
the grant lock, so there is no cycle.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
…keys

Keycast's deletion records miss compacted deletions and accounts deleted
through DELETE /api/user/account, which writes no record. Say so in the
runbook and in the deletion lock comment, and list every table the
loader's database identity needs.
It purged a key another loader test grants and counts. #[serial] only
orders tests within one process, so a process-per-test runner could race
them.
A commit can land on the server and still return an error if the
connection drops before the reply. Point the operator at the dry run,
which shows what landed.
While a load holds or waits for the tenant's grant lock, every account
deletion there waits too, and nothing limited that wait: a deletion
that never finished, or an operator's stalled session, could block
them indefinitely. The load now sets transaction-local lock, statement
and idle-in-transaction timeouts, and gives up writing nothing.

It also pins READ COMMITTED, since a REPEATABLE READ default would take
its snapshot before the lock wait and miss deletions committed during
it, and runs the insert, revoke and count as one statement, so the
roster is sent once and the counts share one snapshot.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
Every database seeds tenant 1 as login.divine.video, so the tenant line
cannot tell production from staging or a local database. The loader now
prints the database, user and server address before loading, and names
its session so an operator can find and end a stuck load. The runbook
gives the proxy an explicit port, the IAM connection string shape, and
what to do if a load stalls.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
The tenant lock kept a one-time grant load and account deletion from
missing each other, but it put every account deletion behind that load,
and needed timeouts and an isolation pin to keep that wait bounded. The
failure it prevented is small: a grant left on an account deleted in the
seconds a load is writing. The runbook now has the operator re-run the
dry run straight after the load and apply again if it reports anything
to revoke; the existing revoke step removes those grants.

The dry run now joins against the same "deleted" set the load uses. Its
per-row EXISTS was inlined into each count and scanned the deletion
records once per roster row per count, which took minutes at full size.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
…n records

No test failed if the load's revoke lost its tenant filter, which would
delete other tenants' grants, and nothing covered a pubkey with two
deletion records. The runbook's post-load re-check now uses the same
roster file, adds a next-day check, and says it cannot see a
self-deletion that raced the load.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
The runbook's post-load re-check re-applied the roster. Re-applying also
re-grants anyone whose grant was removed since by a deletion that left
no record, such as a self-deletion through DELETE /api/user/account, so
the check could put a badge back on a deleted account for good. It is
replaced by a cleanup statement that only removes grants whose account
has a recorded deletion, run straight after the load and again the next
day.

The two newest loader tests get their own key prefix, so a test runner
that runs tests in separate processes cannot have them purge each
other's rows.

Claude-Session: https://claude.ai/code/session_013BpfRgy8mDVdoVjg7ms53Y
@mbradley
mbradley requested a review from dcadenas October 25, 2026 13:17
@mbradley

Copy link
Copy Markdown
Member Author

@dcadenas once you've reviewed this and you're happy with it, the rest needs your prod access. the full steps are in docs/DEPLOYMENT.md under "OG Diviner grants", short version:

  1. build the list from production relay data, right before loading, using the criteria in feat: count early Divine app posters who never signed up through Keycast as OG Diviners #440:

    • at least one original (non-archive) video published with a Divine app client tag
    • relay-recorded arrival before 2026-08-18T04:00:00Z, not the event's own created_at
    • drop anyone already eligible under the existing rule
    • drop anyone who has vanished (NIP-62)
    • keep banned or suspended accounts, same as the existing rule

    one 64-char lowercase hex pubkey per line. keep the file private: no list, query output or pubkeys here or in the repo. counts are fine.

  2. before loading, confirm prod Postgres isn't logging statement parameters (the list goes in as one bound parameter), and that tenant 1 is login.divine.video in prod.

  3. dry run through the Cloud SQL proxy. check the database line shows prod and your IAM user, and that unique pubkeys matches the count from step 1.

  4. apply, then run the cleanup SQL from the runbook right after and again the next day. it only removes grants, so it's safe to repeat.

  5. spot-check a known affected account with the cache-busting curl in the runbook, then close feat: count early Divine app posters who never signed up through Keycast as OG Diviners #440.

if you'd rather do any of this differently, tell me here and we'll adjust.

dcadenas
dcadenas previously approved these changes Oct 25, 2026

@dcadenas dcadenas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tenant-scoped grant path covers early app posters without a Keycast signup while preserving the existing eligibility rule.
Reviewed cbb39a2 with no blocking findings and no branch changes.

Checks

  • Formatting and workspace all-target, all-feature Clippy passed.
  • bun run test:deploy-preflight passed.
  • bun run test:ci passed against isolated local Postgres and Redis: 773 nextest passes with 22 skips, followed by overlapping integration suites with zero failures and seven existing ignored tests.
  • The 12 grant-loader tests passed, including tenant isolation, deletion exclusion, revocation and dry-run counts.
  • Current-head CI passed Rust tests, Web Tests and the semantic title check. Image publication and deployment were skipped for the PR.
  • Local web typechecking, web unit tests, browser checks and E2E were not run. There are no frontend changes.

Accepted limits and handback

The one-time load does not lock account deletion.
The documented revoke-only cleanup addresses recorded deletions; unrecorded self-deletions and deletions made only through other apps remain explicit limits.

@mbradley the code review is complete without a push or behavior changes.
Production roster generation, loading and spot checks have not been performed; those remain separate operational work, not conditions on this code approval.
Merge remains with Daniel.

Agent-Model: openai/gpt-6.1-sol
Agent-Effort: medium
Agent-Harness: opencode/unknown
Agent-Context: fresh
Initiated-By: @dcadenas

@dcadenas

Copy link
Copy Markdown
Member

I approved the code here, but I'd like to hold the merge and keep this data out of Keycast.

TL;DR

  • Wrong owner. The badge is a fact about a pubkey, not about a Keycast account. The people in feat: count early Divine app posters who never signed up through Keycast as OG Diviners #440 never had a Keycast account.
  • Profile reads compete with signing. og-diviner is about a quarter of Keycast's HTTP requests, and it uses the same database pool the signer needs for bursts.
  • Caching it in Keycast would duplicate Funnelcake. Putting Fastly in front of the login domain rebuilds a read path Funnelcake already has.
  • Erosion. This PR would add the first Keycast table for people who are not Keycast users. One reasonable step at a time, the signing service becomes a profile store.

Proposal

  1. Export once from Keycast the pubkeys that pass today's rule (registered, claimed an account, or authorized the app before the cutoff).
  2. Compute once in Funnelcake the early app posters feat: count early Divine app posters who never signed up through Keycast as OG Diviners #440 describes.
  3. Keep both in one private Funnelcake table and expose a yes/no on /api/users/<pubkey>.
    The cohort stays unpublished, and Funnelcake already handles NIP-62 vanish and account deletion.
  4. Switch the app to that field, and retire the Keycast endpoint once older app versions are gone.

The roster work is the same as in this PR.
The extra cost is the Funnelcake field and an app change.

Wrong owner: why this belongs with the profile

Every pubkey that posts is in Funnelcake. Only custodial accounts are in Keycast.

A test for what Keycast should store: would this fact exist for someone who never used Keycast?

  • The verified-minor flag would not. It describes a Keycast account, so it fits in Keycast.
  • Today's OG rule reads account facts: when the account was created, when a pre-made account was claimed, when the app was first authorized. Those fit too.
  • The grants in this PR would. They describe what a pubkey did on the relay.

This PR shows the mismatch in its own code:

  • The grant check has to sit beside the users lookup, because most grantees have no users row.
  • Account deletion must now remove a grant even when Keycast answers already_absent, because that call is the only signal Keycast gets about these people.
  • A deletion made from another Nostr app never reaches Keycast, so the grant stays. Funnelcake sees NIP-62 vanish requests directly.
Profile reads compete with signing: the shared pool

Keycast is sized for signing bursts.
Each instance has one database pool (SQLX_POOL_SIZE=50 in cloudbuild.yaml), shared by the NIP-46 signer and every HTTP route, including og-diviner.
Both take database.pool.clone() in keycast/src/main.rs.

Signing leans on that pool hardest when it is busiest.
After a deploy the handler cache is empty, so each signing request looks up its authorization in the database.
docs/DEPLOYMENT.md already describes signing lookups being shed under that burst, with clients timing out and retrying.
Profile reads take connections from the same pool at the same time.

Measured on production:

  • og-diviner was 23% of all Keycast HTTP requests in the hour sampled.
  • p50 66 ms, p99 260 ms. It is fast today. The problem is what it competes with.

Its load follows profile views, not logins or signing.
The app asks once per profile per app session and keeps the answer only in memory, so every app launch asks again.
A traffic spike raises profile views and signing together.

Caching it in Keycast would duplicate Funnelcake

The two domains answer very differently today:

  • api.divine.video/api/users/<pubkey>: Fastly (x-served-by: cache-…), Cache-Control: public, max-age=60.
  • login.divine.video/.../og-diviner: served straight from Cloud Run, Cache-Control: private.

Caching og-diviner would mean CDN rules on the login domain, a public response, and purges on account deletion.
A CDN on a domain that sets session cookies must never cache a personalized response, so every rule there needs extra care.

Funnelcake already has all of that, and already serves badges: NIP-58 badges at /api/users/<pubkey>/badges.
The app already calls /api/users/<pubkey> for every profile, so the OG answer could come with a request the app already makes.
Rebuilding this in Keycast is a sign the data is in the wrong service.

Notes for the roster query
  • The Divine client tag format changed on 2026-05-23 (divine-mobile 0ed9ea9), so match the earlier values too. The user who wrote in started posting before then.
  • The app can turn the client tag off, so some early posters may have no tag.
  • Funnelcake's stored arrival time reflects the latest insert of an event, so a re-insert can push an early post past the cutoff. Worth checking before treating it as first arrival.
  • Arrival time is not indexed, so compute the set once rather than per request. The cohort is closed, so once is enough.

Anyone the query misses can get a manual exception: the badge is cosmetic and the cohort is closed.

@rabble rabble left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory review of current feedback and next action at cbb39a2.

The outstanding blocker is the ownership decision recorded in the maintainer comment, not a failing check or an unanswered inline code-fix request. The prior approval was deliberately dismissed with the reason “Holding the merge until we agree where this data should live.” There are no review threads or inline comments to resolve.

Change made: added a prominent hold notice to the PR description, preserving the existing description below it. It now distinguishes the existing implementation/rollout proposal from authorization to merge, deploy, generate a production roster or load it. The source branch and head are unchanged; no code or migration was pushed.

Verified: current-head CI ran and passed Rust tests, clippy, formatting and Web Tests; image publication/deployment were skipped. The semantic check rerun after the description edit passed. Local cargo fmt --all -- --check and cargo clippy --workspace --all-targets --all-features -- -D warnings -A deprecated also passed in an isolated checkout. No local database integration suite or production operation was run. GitHub's effective main rules require one approving review; the approval is currently dismissed. Another approval must not be used to bypass the explicit hold.

Next action: Rabble, dcadenas and mbradley need to settle whether eligibility moves to Funnelcake's private profile/read path with a mobile migration and a compatibility period for the Keycast endpoint. That proposal changes service ownership and client behavior, so I have asked for that specific decision in the active session rather than implementing it implicitly. Until agreed, keep this PR unmerged and do not run its production loading procedure.

This is an advisory feedback/next-action review, not a renewed whole-diff code approval. The ownership proposal remains unresolved; no approval or request-changes verdict is being substituted for that decision.

Agent-Model: openai/gpt-6[exact-checkpoint-unknown]
Agent-Effort: unknown
Agent-Harness: codex/unknown
Agent-Context: shared
Initiated-By: @rabble

@rabble

rabble commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Prepared the Funnelcake migration proposal in #448 (documentation commit 1f894be).

#443 remains held. Its source head is still cbb39a24168c0d0e760bb11635c75ee7e10f1b40; no source changes were pushed to this branch. The proposal does not authorize a production roster export/load, merge, deployment or implementation.

@dcadenas review routed to you on #448. @mbradley please review its legacy-rule parity and older-client compatibility contract. The proposed direction preserves the existing account predicate, moves the private cohort/boolean serving to Funnelcake, and makes mobile/cache/deletion migration explicit.

Two activation decisions need evidence: whether retained relay arrival data can establish the historical poster cohort, and how to account for past direct Keycast deletions that a future outbox cannot recover retroactively. The proposal also distinguishes pending vanish filters from durable deletion history and records the cache limit of existing mobile binaries. No production data was accessed for this work.

Local formatting and strict workspace Clippy passed. Local workspace tests reached two secret-pool timing failures, reproduced serially; measured cost-10 hashes took 9.79s/9.61s against a 1s pool wait on the contended host. The extra standalone build was stopped for disk pressure. These gaps are recorded in #448; its current-head CI passed on 1f894be57e6060156e9365a0a17c94f99bfd7b24, including executed tests, Clippy and formatting: https://github.com/divinevideo/keycast/actions/runs/40831358575. Web and contract checks passed too. This is an architecture proposal handback, not approval to merge #443.

This branch has not been deployed

No deployments
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.

3 participants