Skip to content

Asks: route, offer, close out and credit (replaces #149) - #151

Merged
walker-null-byte merged 8 commits into
devfrom
feat/asks-v2
Oct 29, 2026
Merged

walker-null-byte merged 8 commits into
devfrom
feat/asks-v2

Conversation

@walker-null-byte

@walker-null-byte walker-null-byte commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Replaces #149. It is the same commits, brought onto dev after #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. dev is 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.

  1. Publish / dismiss (services/beacons.py): the beacon JSON gets an optional id (UUID made in Python; existing beacons parse unchanged). After the existing compare-and-swap card write succeeds, an asks row is inserted and any previous un-ended ask on the card ends as replaced; dismiss ends it as dismissed. A failed ask insert is logged and the publish carries on.
  2. Route (services/asks.py): a background task (two-thread pool, never on the request path) runs search.search_agents (the entry point owner suggestions use) with the ask as the query. It keeps claimed people at STRONG_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 top ASK_ROUTE_MAX (5) in ask_routes (idempotent on the primary key). If the exclusions cannot be read, nobody is routed.
  3. Feed: an ask routed to the viewer gets ASK_ROUTED_BOOST and the reason "Asked people like you" (one query per feed request). Feed ask items use the ask id in their key when there is one.
  4. Digest: the existing morning digest gets "Asks you could help with" (at most 2, linking to /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_at are set only after a successful send; previews mark nothing. All text from other people is escaped. No new email type, no immediate email.
  5. Offers: a successful POST /cards/feed/reply on an ask item records an ask_offers row (unique per ask and helper, so a second reply still sends but does not duplicate) and emits a server event ask_offer (channel only). Not on your own ask, not on an ended or expired one.
  6. Close-out (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: []}: helped needs 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 answered helped / solved_elsewhere ends as solved and its beacon is cleared through save_beacon(None). Chosen offers get credited_at, their public cards are revalidated, ask_outcome is emitted (counts only).
    • GET /cards/mine/credits and POST /cards/mine/credits/{offer_id}/hide: the helper hides a credit.
  7. Credits on the public card: helped: {count, items: [{asker_handle, asker_name, ask}]} on GET /cards/by-handle/{handle} (and zynd:helped in data.json), one query by helper card with the ask and asker embedded. A credit shows only when credited_at is set, it is not hidden, and the asker's card is still published; offer ids are never public.
  8. Web: "Did your ask get answered?" and "Helped credits on your card" panels on /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 like card_events; FKs on delete cascade to agent_profile_cards(id) (which is text); 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:

BEGIN;
DROP TABLE IF EXISTS cards.ask_routes;
DROP TABLE IF EXISTS cards.ask_offers;
DROP TABLE IF EXISTS cards.asks;
DELETE FROM drizzle.__cards_migrations WHERE created_at = 1793285560055;  -- 0033's journal "when", so a later db:migrate re-applies it
COMMIT;

The tracking-row delete is new: without it a later db:migrate would think 0033 was still applied and never recreate the tables. Rehearsed on a throwaway pg17: apply all, roll back, the dry run lists 0033_asks as 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:drift clean, rollback SQL run, re-applied, drift clean again. Also exercised services/asks.py against 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.py detects 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 clean dev: 10 in test_agent_read_receipt_mail.py that came in with other recent dev work, test_owner_home.py::test_weekly_job_is_idempotent_per_owner_and_honors_optout and the two test_x_bot.py failures. 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, and tsc --noEmit and npm run build are clean. packages/db: db:check and db:lint pass.

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 dev deploys dev.cards.zynd.ai through deploy-single-box. It never applies migrations, and the digest runs against prod only, so until this reaches main the digest sections reach nobody. dev shares the prod database, so once 0033 is applied, asks published on dev write real rows.

…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).
#150 landed as a squash commit, so this branch's copy of the feed fixes
(2254fa1) and dev's (dafcae3) are different commits with the same
content. Merging keeps the PR diff to the asks work without a
force-push.

# Conflicts:
#	services/cards-api/services/feed.py
@walker-null-byte
walker-null-byte changed the base branch from feat/feed-fixes to dev October 29, 2026 16:03
@walker-null-byte walker-null-byte changed the title Asks: route, offer, close out and credit (stacked on #150, replaces #149) Asks: route, offer, close out and credit (replaces #149) Oct 29, 2026
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
walker-null-byte merged commit 09583db into dev Oct 29, 2026
6 checks passed
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.
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.

1 participant