An enthusiastic community prompt can quietly become operational debt.
You invite people to nominate work for a showcase. The first few replies feel manageable. Then submissions keep arriving, reviewers assume somebody else owns them, and authors cannot tell whether the original promise still stands.
The uncomfortable part is not that the community participated too much. It is that a conversational promise became a queue without acquiring queue semantics.
Creating another channel rarely fixes this. It moves the same ambiguity somewhere fewer people can see it. A more reliable design keeps the existing community conversation as the surface while adding a small commitment ledger behind it.
In this tutorial, we will build that ledger in TypeScript. It will provide:
- bounded admission, so the application cannot accept unlimited work;
- one nomination per author, with idempotent retries;
- explicit campaign states for accepting, draining, and closing;
- reviewer leases, so every accepted item has temporary ownership;
- protection against late reviewers completing reassigned work;
- verifiable progress independent of chat-notification delivery.
Tencent RTC's Social Messaging solution covers the relevant conversation surfaces, including group discussions, communities, rich media, and interest-based social experiences. We will treat that messaging layer as the place where people discover and interact with the showcase—not as the database that decides whether a promise has been fulfilled.
First decide whether you need this machinery
Not every community prompt needs a workflow service.
| Situation | Better choice |
|---|---|
| A casual request with no completion promise | Keep it as a normal thread |
| A small, time-boxed prompt owned by one moderator | Use a pinned checklist and publish a closing update |
| Every accepted submission must receive an action | Use a commitment ledger |
| Several reviewers can claim work | Use leases or another explicit assignment mechanism |
| The organizer needs to stop intake while finishing accepted work | Separate open from draining |
The important distinction is conversation versus commitment. Messages can arrive in any order and may be missed. A commitment requires admission rules, ownership, terminal states, and a definition of done.
The lifecycle users should be able to see
Our campaign has three states:
open -----> draining -----> closed | | | +-- no new submissions +-- accepts work while capacity and deadline allow Each accepted entry follows a separate lifecycle:
queued -----> leased -----> done | | | +-------> queued (lease expires) | +----------------------> withdrawn draining matters because “we stopped accepting submissions” is not the same as “we completed the promise.” Closing the campaign while entries remain queued would hide unfinished work.
A reviewer claim is a lease rather than permanent ownership. If a browser closes or a worker crashes, the entry becomes claimable after the lease expires. Each lease receives a token, preventing the original reviewer from submitting a late result after somebody else has taken over.
Create the reproducible project
Use Node.js 20 or later:
mkdir community-promise-ledger cd community-promise-ledger npm init -y npm install --save-dev typescript tsx @types/node mkdir src Add the test command to package.json:
{ "scripts": { "test": "tsx src/ledger.test.ts" } } Now create src/ledger.ts:
import { randomUUID } from "node:crypto"; export type CampaignState = "open" | "draining" | "closed"; export type EntryState = "queued" | "leased" | "done" | "withdrawn"; export type Campaign = { id: string; state: CampaignState; capacity: number; acceptedCount: number; closesAt: number; }; export type Lease = { token: string; reviewerId: string; expiresAt: number; }; export type Entry = { id: string; campaignId: string; authorId: string; sourceConversationId: string; sourceMessageId: string; title: string; url: string; state: EntryState; createdAt: number; lease?: Lease; reviewSummary?: string; completedBy?: string; }; export class LedgerError extends Error { constructor(public readonly code: string) { super(code); } } export class CommitmentLedger { private campaigns = new Map<string, Campaign>(); private entries = new Map<string, Entry>(); constructor(private readonly now: () => number = Date.now) {} createCampaign(input: { id: string; capacity: number; closesAt: number; }): Campaign { if (input.capacity < 1) throw new LedgerError("INVALID_CAPACITY"); if (this.campaigns.has(input.id)) { throw new LedgerError("CAMPAIGN_EXISTS"); } const campaign: Campaign = { ...input, state: "open", acceptedCount: 0, }; this.campaigns.set(campaign.id, campaign); return structuredClone(campaign); } submit(input: { campaignId: string; authorId: string; sourceConversationId: string; sourceMessageId: string; title: string; url: string; }): Entry { const campaign = this.requireCampaign(input.campaignId); const previous = [...this.entries.values()].find( entry => entry.campaignId === input.campaignId && entry.authorId === input.authorId, ); // A repeated delivery of the same message returns the original receipt. if (previous?.sourceMessageId === input.sourceMessageId) { return structuredClone(previous); } if (previous) throw new LedgerError("AUTHOR_ALREADY_SUBMITTED"); if (campaign.state !== "open") { throw new LedgerError("CAMPAIGN_NOT_OPEN"); } if (this.now() >= campaign.closesAt) { throw new LedgerError("SUBMISSION_DEADLINE_PASSED"); } if (campaign.acceptedCount >= campaign.capacity) { throw new LedgerError("CAMPAIGN_AT_CAPACITY"); } const entry: Entry = { id: randomUUID(), campaignId: input.campaignId, authorId: input.authorId, sourceConversationId: input.sourceConversationId, sourceMessageId: input.sourceMessageId, title: input.title, url: input.url, state: "queued", createdAt: this.now(), }; campaign.acceptedCount += 1; this.entries.set(entry.id, entry); return structuredClone(entry); } claimNext( campaignId: string, reviewerId: string, leaseDurationMs: number, ): Entry | undefined { if (leaseDurationMs < 1) throw new LedgerError("INVALID_LEASE"); this.requireCampaign(campaignId); this.reapExpiredLeases(campaignId); const candidate = [...this.entries.values()] .filter( entry => entry.campaignId === campaignId && entry.state === "queued", ) .sort((a, b) => a.createdAt - b.createdAt)[0]; if (!candidate) return undefined; candidate.state = "leased"; candidate.lease = { token: randomUUID(), reviewerId, expiresAt: this.now() + leaseDurationMs, }; return structuredClone(candidate); } complete(entryId: string, leaseToken: string, summary: string): Entry { const entry = this.requireEntry(entryId); if (entry.state !== "leased" || !entry.lease) { throw new LedgerError("ENTRY_NOT_LEASED"); } if (entry.lease.token !== leaseToken) { throw new LedgerError("LEASE_TOKEN_MISMATCH"); } if (this.now() >= entry.lease.expiresAt) { entry.state = "queued"; delete entry.lease; throw new LedgerError("LEASE_EXPIRED"); } if (!summary.trim()) throw new LedgerError("EMPTY_REVIEW"); entry.state = "done"; entry.reviewSummary = summary.trim(); entry.completedBy = entry.lease.reviewerId; delete entry.lease; return structuredClone(entry); } withdraw(entryId: string, authorId: string): Entry { const entry = this.requireEntry(entryId); if (entry.authorId !== authorId) { throw new LedgerError("NOT_ENTRY_AUTHOR"); } if (entry.state === "done") { throw new LedgerError("ENTRY_ALREADY_COMPLETED"); } entry.state = "withdrawn"; delete entry.lease; return structuredClone(entry); } beginDraining(campaignId: string): Campaign { const campaign = this.requireCampaign(campaignId); if (campaign.state !== "open") { throw new LedgerError("CAMPAIGN_NOT_OPEN"); } campaign.state = "draining"; return structuredClone(campaign); } close(campaignId: string): Campaign { const campaign = this.requireCampaign(campaignId); if (campaign.state !== "draining") { throw new LedgerError("CAMPAIGN_NOT_DRAINING"); } this.reapExpiredLeases(campaignId); const unfinished = [...this.entries.values()].some( entry => entry.campaignId === campaignId && (entry.state === "queued" || entry.state === "leased"), ); if (unfinished) throw new LedgerError("UNFINISHED_ENTRIES"); campaign.state = "closed"; return structuredClone(campaign); } progress(campaignId: string) { const relevant = [...this.entries.values()].filter( entry => entry.campaignId === campaignId, ); return { accepted: relevant.length, queued: relevant.filter(entry => entry.state === "queued").length, leased: relevant.filter(entry => entry.state === "leased").length, done: relevant.filter(entry => entry.state === "done").length, withdrawn: relevant.filter(entry => entry.state === "withdrawn").length, }; } private reapExpiredLeases(campaignId: string): void { for (const entry of this.entries.values()) { if ( entry.campaignId === campaignId && entry.state === "leased" && entry.lease && this.now() >= entry.lease.expiresAt ) { entry.state = "queued"; delete entry.lease; } } } private requireCampaign(id: string): Campaign { const campaign = this.campaigns.get(id); if (!campaign) throw new LedgerError("CAMPAIGN_NOT_FOUND"); return campaign; } private requireEntry(id: string): Entry { const entry = this.entries.get(id); if (!entry) throw new LedgerError("ENTRY_NOT_FOUND"); return entry; } } This is intentionally an in-memory core. It makes the policy easy to reproduce before database behavior, SDK callbacks, or UI state can obscure it.
Make the ownership race fail in a test
Create src/ledger.test.ts:
import assert from "node:assert/strict"; import { CommitmentLedger, LedgerError } from "./ledger.js"; let clock = 1_000; const ledger = new CommitmentLedger(() => clock); ledger.createCampaign({ id: "showcase-2026", capacity: 2, closesAt: 10_000, }); const alice = ledger.submit({ campaignId: "showcase-2026", authorId: "alice", sourceConversationId: "community-main", sourceMessageId: "message-101", title: "Alice's selected project", url: "https://example.test/alice", }); // Redelivery of the same source message is idempotent. const aliceRetry = ledger.submit({ campaignId: "showcase-2026", authorId: "alice", sourceConversationId: "community-main", sourceMessageId: "message-101", title: "Alice's selected project", url: "https://example.test/alice", }); assert.equal(aliceRetry.id, alice.id); const bob = ledger.submit({ campaignId: "showcase-2026", authorId: "bob", sourceConversationId: "community-main", sourceMessageId: "message-102", title: "Bob's selected project", url: "https://example.test/bob", }); assert.throws( () => ledger.submit({ campaignId: "showcase-2026", authorId: "carol", sourceConversationId: "community-main", sourceMessageId: "message-103", title: "Carol's project", url: "https://example.test/carol", }), (error: unknown) => error instanceof LedgerError && error....