[CODE]

CastLoop: Trust Is Load-Bearing Infrastructure

Taiwan's fishing community gates knowledge behind relationships. CastLoop's entire stack — from Zod boundary guards to a social-contact gate before helper approval — flows from one question: what makes a stranger's reliability legible before they show up.

6 min read AI-generated
product nextjs typescript software-engineering

Taiwan’s fishing community has solved information scarcity the way communities always do when platforms fail them: by keeping the good stuff off the internet. A spot named on a public forum gets crowded in three weekends. So the real intelligence — productive tide windows, seasonal migration patterns, where the fish are stacking this month — moves through Line groups, fishing shop counter conversations, and whoever introduced you to someone who already knows. Access is earned through relationship. You either know someone, or you don’t fish that spot.

CastLoop is a bet that quantified reputation can substitute for personal vouching — not by making information public, but by making an unknown person’s track record legible before the exchange happens. The platform has five Wish types: gear borrowing, fishing tips, buddy-matching, beginner guides, and a catch-all. Every one of them requires a stranger to do something they’d normally do only for someone they already know. The architecture starts from that constraint. Not “how do we build a matching system,” but “what makes a stranger trustworthy enough to lend an NT$8,000 rod to, share a spot with, or agree to fish next to for a day.”

Two Trust Tracks, Sequenced Not Competing

The trust layer runs on two parallel mechanisms: point_transactions and reviews. They solve different halves of the same problem and mature at different rates.

Reviews require a completed exchange: Wish accepted, help delivered, both parties confirm. That means a user who joined today has zero reviews and will remain at zero until a full transaction completes — which requires them to first have enough reputation for someone to trust them in the first place. Review-only systems create a catch-22 that penalizes exactly the people a community most needs to attract.

Points start on day one. The point_transactions table records every contributing action: welcome (a credit on registration), help_given, help_received, review_given. A user who offered to lend gear three times in their first week has a visible transaction history before any review exists. The total_points and help_count columns on the users row tell a story that a review average can’t tell for weeks. Points provide early signal; reviews deliver higher-resolution signal as activity accumulates. They’re sequenced mechanisms, not competing ones.

The Contact Gate

One mechanism most community platforms skip: before a helper can be approved on a Wish, the application layer runs validateSocialContacts. The user must have at least one non-empty real-world contact — Line ID, WhatsApp number, Instagram handle, Facebook, or Telegram — stored in the social_contacts JSON field on their user row. The check lives in CommunityService, not in a database constraint.

That layer choice is intentional. Database constraints encode invariants that must hold at every point in time. Business rules — especially ones that will evolve as the community matures — belong in the application layer where they can be tested in isolation and modified without a migration. But the deeper reasoning is product-level: trust transfer requires a coordination channel outside the platform. Two people who’ve never met need to arrange a gear handoff, confirm a meetup location, communicate about a spot. If a helper has no reachable contact, the exchange cannot complete in the physical world regardless of what the schema permits. The gate enforces a prerequisite for the real-world exchange, not just a profile completeness score.

Domain vs. DB Naming

The database tables are casts and loops. The domain layer calls the same concepts Wish and Hand. This split exists because the platform’s brand vocabulary and the domain’s ubiquitous language are different things. Conflating them creates coupling that compounds over time — a brand rename that should touch zero business logic ends up touching every query and every service.

The infra boundary has a mapper for each table. toWish() receives a Prisma casts row and calls WishSchema.parse() before returning anything to the application layer. That parse throws on non-conforming input. Prisma’s generated types can drift from reality when columns are added or nullability changes; WishSchema.parse() at the boundary converts a silent data corruption into a loud failure before bad data reaches domain logic.

The mapper also handles a subtle enum mismatch: the database stores the locale as "zh-TW" (hyphen) but the Prisma client generates the enum variant as zh_TW (underscore). That translation lives in one function and nowhere else.

State Machine Correctness

WishService.approveHand() enforces several invariants before committing: the Wish must be open, the Hand must be pending, and no active Hand can already exist for this Wish. The last check is the non-obvious one. Under concurrent load, two approve requests could both pass the first two guards before either writes — the application-level LoopAlreadyTakenError catches the common case, but it doesn’t prevent the race at the DB layer.

The database closes that gap with a partial unique index: uq_loops_cast_active permits only one row where status = 'active' per cast_id. The schema also uses a composite partial unique on (cast_id, helper_id) covering pending and active statuses, preventing a helper from submitting duplicate offers. Two guards, two failure surfaces. The application service handles the readable error path; the database prevents the race that slips through under concurrent writes.

Infrastructure Choices

Prisma v7 removed the bundled database driver. The application explicitly bootstraps a PrismaPg adapter from @prisma/adapter-pg and passes it to the client at startup. Connection behavior is now explicit rather than hidden behind framework defaults. The adapter handles pooling directly; there are no surprises when connection limits are hit under load.

Deployment is a Docker container with output: 'standalone' in next.config.mjs, running on Railway. The standalone bundle contains only what Next.js needs at runtime; the container starts with a persistent database connection, not a new one per request. For a platform where write correctness is load-bearing — a hand approval that fires twice, a point transaction that records once but debits twice — the persistent connection removes a class of timing bugs that don’t appear in synthetic benchmarks but surface at midnight when the database is under unrelated load.

Auth Scope

The platform uses Next Auth v5 beta with Google OAuth as the sole provider. No magic link infrastructure, no password reset flow, no token expiry edge cases to own. The signIn callback is intentionally minimal: user row creation on first login is a single auditable extension point, not a system to build and maintain.

Every hour not spent on auth infrastructure went to the trust schema and domain model — the part that determines whether there’s a reason for the product to exist at all.

What Isn’t Proven Yet

The schema is built. The trust system is instrumented. The state machine enforces correctness at both the application and database layers. What remains unproven is the cultural hypothesis: whether quantified reputation — point counts, transaction history, a verified social contact — is sufficient for someone to actually lend a stranger their NT$8,000 rod, or share a spot they’ve fished alone for years.

Taiwan’s fishing community has operated on relationship-gated access for decades. The platform exists to test whether a different model is viable. That question won’t be answered by architecture.