Repository navigation
Asks: route, offer, close out and credit (replaces #149) - #151
Merged
Merged
Conversation
…he server A feed whose every item cannot be acted on is noise, so people the viewer declined or revoked now drop out like blocked ones (pending stays, since Requested / Respond are real states). A thin recent feed read as an empty product. Posts and GitHub activity older than the 30-day window (up to a year) now follow every recent item, marked earlier, newest first, never outranking recent ones; paging stays an offset into one ordered list so it is consistent across the boundary. Replies used to be decided in the browser (DM or request) against the raw network endpoints, so the server could not tell a reply came from the feed and trusted the client's choice. POST /cards/feed/reply takes only the item id and the message; the server resolves the author, re-checks blocks, hides, publication and the item itself, picks the channel from the real connection state, and keeps the weekly request limits. feed_view and feed_reply are SERVER_EVENTS (so POST /events cannot forge them) and carry counts and kinds only, never message text.
A beacon today is a line on a card; nothing happens after it is posted. The plan (docs/plans/ZYND_ASKS_PLAN.md) turns it into an ask that reaches the people who can help, records who offered, closes with an outcome and leaves a credit the helper controls. 0033 is expand-only: three service-role-only tables with RLS, FKs with on delete cascade to agent_profile_cards(id) (text), checks on text length, end reason, outcome and channel, a unique (ask, helper) offer, and an index for every query cards-api will run. Rehearsed on a throwaway local Postgres (apply, constraint and cascade checks, db:drift, rollback SQL, re-apply); not applied anywhere shared.
A beacon was a line on a card with no follow-through. It now has an id (stored in the beacon JSON, made in Python) and, once the card write succeeds, an asks row; a new ask ends the old one (replaced), a dismiss ends it. A background task runs the existing people search with the ask as the query, keeps claimed matches at STRONG_MATCH (the saved-search alert threshold) minus anyone blocked, declined, revoked or hidden in either direction, and stores the top five in ask_routes: routed people see the ask in /feed with a boost and, once, in the morning digest. A feed reply to an ask records one ask_offers row (unique per helper). The owner closes with an outcome; only people who offered can be credited (422), a live ask is ended and its beacon cleared, and credited helpers get a Helped block on their public card that they can hide. Digest lines are marked sent only after the email goes out; previews mark nothing. Everything is optional until cards 0033 is applied: asks.py detects the missing relation once (PGRST205 / 42P01), logs it at info level and checks again a minute later; publishing, the feed and replies never fail because of asks. Nothing logs or stores message text. The credit query needs foreign key hints in its embed (found against a real PostgREST: unhinted it is ambiguous and silently returns nothing).
The owner answers 'Did your ask get answered?' on /home: tick who offered, or 'Solved another way' / 'Still looking' (which links to the beacon editor), and hide any Helped credit on their own card. The helper's public card shows 'Helped N people' with up to three entries linking to the askers; data.json carries the same block. Both render nothing until the asks tables exist.
The cards network regression test pins the number of applied cards migrations, so adding 0033_asks failed the migrations CI check with 34 !== 33. Same bump every earlier migration made.
…ut notes The rollback dropped the three tables but left 0033's row in drizzle.__cards_migrations, so a later db:migrate would think 0033 was still applied and never recreate them. Rehearsed on a throwaway pg17: apply, rollback, the dry run lists 0033_asks pending again, re-apply. The plan also said cards deploys by hand; a merge to dev deploys dev.cards.zynd.ai through deploy-single-box (migrations are never applied by CI).
Retargeting a PR doesn't trigger ci.yml (pull_request runs on opened, synchronize and reopened only), so the base change to dev ran nothing but the migrations check.
walker-null-byte
added a commit
that referenced
this pull request
Oct 29, 2026
…event is accepted events.emit() silently drops a name that is in neither ALLOWED_EVENTS nor SERVER_EVENTS. The asks work (#151) emits ask_offer and ask_outcome but never added them, so no offer or close-out has been recorded since it shipped. They go in SERVER_EVENTS (server-only, so the public POST /events cannot forge them), and a new test scans services/ and api/ for every literal emit name so the next one left out fails CI instead of vanishing.
walker-null-byte
added a commit
that referenced
this pull request
Oct 29, 2026
#152) * Give asks a kind and an optional link, and a daily posting limit The composer needs ask types (team, co-founder, hiring, ...) and a link, but a migration is out of scope, so both live in the beacon JSON on the card: optional, so beacons already stored parse unchanged, and read back as None when invalid. POST/DELETE /cards/mine/ask take the card from the session and go through the existing save_beacon, so the compare-and-swap, replaced handling and background routing are unchanged. The limit (5 publishes per card per 24h) is counted from the asks table and skipped when it is missing, like the rest of asks. The feed gets a kind filter and kind/url on ask items, GET /cards/mine/asks gets kind, url, routed and offer_count in one extra query, and the digest labels asks by kind. ask_published carries the kind and whether it replaced an ask, never the text. * Send the viewer's own identity with the feed The ask composer previews a post exactly as the feed will show it, which needs the poster's name, headline, photo and place. They are the viewer's own public card data, so they ride along with the viewer block instead of costing the composer another request. * Add the Post button, the ask composer and Your live ask to the feed One composer, reused: the floating Post button on /feed and the edit page's ask panel open the same dialog (a bottom sheet on phones). The feed item is split out as FeedItemView so the composer's preview is the very component the feed renders. Ask items get a kind chip, the link (http(s) only, nofollow ugc), a per-kind reply button and prefill from one table, a Report control that reuses the report-a-card API, and the asks tab gets kind filters. BeaconEditor is gone because nothing else used it; the public card shows the kind and the link. feed.css moves next to the component that owns it so the composer can load it outside the feed page. * Test the ask composer in the browser Playwright specs for focus and Esc, posting, the replace warning and take-down, the 429 and 409 messages inline, the 360px layout, the no-card prompt and the kind filter with reply and report. The fixture API gains the feed and the owner's ask endpoints; the old beacon spec now goes through the composer. * Document the ask composer in the asks plan * Record ask_offer and ask_outcome events, and test that every emitted event is accepted events.emit() silently drops a name that is in neither ALLOWED_EVENTS nor SERVER_EVENTS. The asks work (#151) emits ask_offer and ask_outcome but never added them, so no offer or close-out has been recorded since it shipped. They go in SERVER_EVENTS (server-only, so the public POST /events cannot forge them), and a new test scans services/ and api/ for every literal emit name so the next one left out fails CI instead of vanishing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces #149. It is the same commits, brought onto
devafter #146 and #150 (the feed fixes) were squash-merged. One digest conflict with #147 was resolved by keeping both #147's "Review Zynd matches" line and the asks sections.devis merged in rather than rebased, because force-pushing wasn't available.What this adds
A beacon becomes an ask that reaches the people who can help, records who offered, closes with an outcome, and leaves a "Helped" credit on the helper's card. The flow, tables, consent model and rollback are in
docs/plans/ZYND_ASKS_PLAN.md.services/beacons.py): the beacon JSON gets an optionalid(UUID made in Python; existing beacons parse unchanged). After the existing compare-and-swap card write succeeds, anasksrow is inserted and any previous un-ended ask on the card ends asreplaced; dismiss ends it asdismissed. A failed ask insert is logged and the publish carries on.services/asks.py): a background task (two-thread pool, never on the request path) runssearch.search_agents(the entry point owner suggestions use) with the ask as the query. It keeps claimed people atSTRONG_MATCH(0.6, the saved-search alert threshold, reused rather than invented), drops the asker and anyone blocked / declined / revoked / hidden in either direction, and stores the topASK_ROUTE_MAX(5) inask_routes(idempotent on the primary key). If the exclusions cannot be read, nobody is routed.ASK_ROUTED_BOOSTand the reason "Asked people like you" (one query per feed request). Feedaskitems use the ask id in their key when there is one./feed?filter=asks), plus one "Your ask ended. Did you find help?" line (once per ask) and one "{Asker} credited you for helping with ..." line (once per credit).digest_sent_at/outcome_prompted_at/credit_notified_atare set only after a successful send; previews mark nothing. All text from other people is escaped. No new email type, no immediate email.POST /cards/feed/replyon anaskitem records anask_offersrow (unique per ask and helper, so a second reply still sends but does not duplicate) and emits a server eventask_offer(channel only). Not on your own ask, not on an ended or expired one.api/asks.py, all owner-only; another owner's ask or offer id is a 404):GET /cards/mine/asks: the live ask (once someone has offered) plus asks that ended in the last 14 days without an answer, each with its offers. Three queries however many asks.POST /cards/mine/asks/{ask_id}/outcome{outcome: helped|solved_elsewhere|still_looking, helper_card_ids: []}:helpedneeds at least one helper and every helper must have offered on this ask (422 otherwise); the other outcomes take none. Idempotent (same answer is a no-op, a different one is 409). A live ask answeredhelped/solved_elsewhereends assolvedand its beacon is cleared throughsave_beacon(None). Chosen offers getcredited_at, their public cards are revalidated,ask_outcomeis emitted (counts only).GET /cards/mine/creditsandPOST /cards/mine/credits/{offer_id}/hide: the helper hides a credit.helped: {count, items: [{asker_handle, asker_name, ask}]}onGET /cards/by-handle/{handle}(andzynd:helpedindata.json), one query by helper card with the ask and asker embedded. A credit shows only whencredited_atis set, it is not hidden, and the asker's card is still published; offer ids are never public./home(tick who helped, "Solved another way", "Still looking" linking to the beacon editor, Hide), and a "Helped" block on the public card.Migration: cards
0033_asks(expand-only, not applied anywhere)Three tables,
cards.asks,cards.ask_offers,cards.ask_routes; RLS on with service-role-only policies likecard_events; FKson delete cascadetoagent_profile_cards(id)(which istext); checks on text length, end reason, outcome and channel; unique(ask_id, helper_user_id); an index for every query (asks by card, offers by ask via the unique index, offers by helper partial index, routes by helper). Needs a human to apply (AGENTS.md section 6). Rollback SQL is in the file header:The tracking-row delete is new: without it a later
db:migratewould think 0033 was still applied and never recreate the tables. Rehearsed on a throwaway pg17: apply all, roll back, the dry run lists0033_asksas the only pending migration again, re-apply recreates the 3 tables.Rehearsed on a throwaway local Postgres (pgvector image on 127.0.0.1): all histories built from empty, constraint / unique / cascade / RLS checks,
db:driftclean, rollback SQL run, re-applied, drift clean again. Also exercisedservices/asks.pyagainst a real local PostgREST on that database (this found and fixed an ambiguous embed in the credit query).Until the migration is applied
Everything keeps working: beacons publish and dismiss, the feed and replies work.
asks.pydetects the missing relation once from PostgREST (PGRST205/42P01), logs one info line and switches the asks-only parts off, re-checking a minute later so applying the migration needs no restart. Publishing a beacon never fails because of asks.Best practices (checked by tests)
authz on every route (owner derived from the session, lookups through the owner's own card, cross-user 404s); pydantic validation and length caps (
extra="forbid"); no N+1 (fixed query counts for close-out, one query for credits and routes); search off the request path; idempotent writes backed by unique constraints / primary keys; no message text in logs, events or tables; HTML escaped in emails; blocks, hides, declines and revokes respected in routing, feed, digest and credits.Tests
cards-api after rebasing onto
dev: 1081 passed, 13 failed. Those are the same 13 that fail on a cleandev: 10 intest_agent_read_receipt_mail.pythat came in with other recentdevwork,test_owner_home.py::test_weekly_job_is_idempotent_per_owner_and_honors_optoutand the twotest_x_bot.pyfailures. None are new. Asks adds 62 tests (test_asks.py,test_asks_api.py,test_asks_digest.py,test_asks_public.py). Web: lint has 0 errors, andtsc --noEmitandnpm run buildare clean. packages/db:db:checkanddb:lintpass.Not verified
The UI was not driven in a browser; no real database, SMTP or search-embedding service was involved (the PostgREST run used a local container with the rehearsal schema and hand-seeded cards).
Deploy
Merging to
devdeploys dev.cards.zynd.ai throughdeploy-single-box. It never applies migrations, and the digest runs against prod only, so until this reachesmainthe digest sections reach nobody. dev shares the prod database, so once 0033 is applied, asks published on dev write real rows.