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.code === "CAMPAIGN_AT_CAPACITY",
);
const firstLease = ledger.claimNext("showcase-2026", "reviewer-a", 500)!;
assert.equal(firstLease.id, alice.id);
// Reviewer A disappears. The lease expires and Reviewer B receives the item.
clock += 501;
const secondLease = ledger.claimNext("showcase-2026", "reviewer-b", 500)!;
assert.equal(secondLease.id, alice.id);
// The late completion from Reviewer A must not overwrite Reviewer B's claim.
assert.throws(
() => ledger.complete(alice.id, firstLease.lease!.token, "Late result"),
(error: unknown) =>
error instanceof LedgerError && error.code === "LEASE_TOKEN_MISMATCH",
);
ledger.complete(
alice.id,
secondLease.lease!.token,
"Reviewed with an actionable response.",
);
const bobLease = ledger.claimNext("showcase-2026", "reviewer-c", 500)!;
ledger.withdraw(bob.id, "bob");
// Withdrawal invalidates an outstanding reviewer lease.
assert.throws(
() => ledger.complete(bob.id, bobLease.lease!.token, "Should not commit"),
(error: unknown) =>
error instanceof LedgerError && error.code === "ENTRY_NOT_LEASED",
);
ledger.beginDraining("showcase-2026");
ledger.close("showcase-2026");
assert.deepEqual(ledger.progress("showcase-2026"), {
accepted: 2,
queued: 0,
leased: 0,
done: 1,
withdrawn: 1,
});
console.log("All commitment-ledger checks passed.");
Run it:
npm test
You should see:
All commitment-ledger checks passed.
The critical assertion is not the happy-path completion. It is the rejection of Reviewer A's late result after the lease has been reassigned.
Connect it to a Tencent RTC community without inventing a second inbox
Use the Tencent RTC community or group discussion as the interaction surface. Keep the workflow integration behind an application-owned adapter instead of coupling policy to a particular UI callback:
export interface CommunitySurface {
showCampaignCard(input: {
conversationId: string;
campaignId: string;
state: "open" | "draining" | "closed";
accepted: number;
capacity: number;
}): Promise<void>;
acknowledgeSubmission(input: {
conversationId: string;
sourceMessageId: string;
entryId: string;
positionLabel: string;
}): Promise<void>;
announceClosure(input: {
conversationId: string;
completed: number;
withdrawn: number;
}): Promise<void>;
}
Map this adapter to the documented messaging integration used by your application. The ledger itself should not guess SDK method names or treat a rendered chat message as a successful database transition.
A practical request path is:
- A member uses a “Nominate this work” action in the existing community conversation.
- Your server authenticates the member and validates the submitted fields.
- The ledger transaction accepts or rejects the nomination.
- The response immediately renders the entry ID and current status to that member.
- A community acknowledgement is posted as a separate delivery attempt.
- The campaign card is refreshed from
progress(), not from counted chat replies.
Step 4 prevents a notification failure from erasing the user's receipt. If step 5 fails, store the notification as pending and retry it separately. Do not run submit() again merely because acknowledgement delivery failed.
That gives you two independent facts:
Commitment fact: entry 8f... was accepted
Delivery fact: acknowledgement for entry 8f... is pending
Conflating those facts is how duplicate submissions and misleading “please retry” messages appear.
Production persistence needs four atomic guarantees
The in-memory implementation is deterministic but not safe across multiple server processes. Preserve these invariants when moving it to a database:
-
One author per campaign: enforce a unique constraint on
(campaign_id, author_id). - Capacity admission: increment the accepted count and insert the entry in one transaction. If either fails, roll back both.
- Exclusive claiming: claim only a queued entry—or atomically reclaim an expired lease—and store a new opaque token.
-
Conditional completion: update to
doneonly when the entry is still leased with the supplied token and an unexpired lease.
Do not implement capacity as “count rows, then insert.” Two concurrent requests can both observe the final free slot. The capacity check must participate in the same transaction as admission.
Keep an append-only audit record for moderation and dispute handling:
type AuditEvent = {
eventId: string;
campaignId: string;
entryId?: string;
actorId: string;
action:
| "entry.accepted"
| "entry.claimed"
| "lease.expired"
| "entry.completed"
| "entry.withdrawn"
| "campaign.draining"
| "campaign.closed";
occurredAt: number;
};
The audit log explains transitions; it does not replace the current-state table used for conditional writes.
Put translation around the workflow, not inside its authority rules
A multilingual community may need campaign instructions and reviewer responses translated. Tencent RTC documents on-demand text-message translation for TUIChat, together with applicable content types, supported languages, and edition limitations: TUIChat message translation.
Check those documented constraints for your deployment before presenting translation as available.
Translation should remain a display operation:
- preserve the original campaign deadline and capacity values;
- calculate state transitions from structured fields, not translated prose;
- keep the original reviewer response accessible;
- label translated text as translated;
- let a translation failure leave the original message and ledger status usable.
For example, never parse “two places remaining” from either the original or translated message. Render that sentence from { capacity, acceptedCount } every time.
Failure behavior users can understand
A reliable workflow tells people what happened without asking them to infer it from silence.
| Failure | Ledger behavior | User-visible response |
|---|---|---|
| Submission is delivered twice | Return the existing entry for the same source message | “Already accepted” with the original receipt |
| A different second nomination is submitted | Reject it | Link to the existing nomination and its status |
| Capacity is exhausted | Do not create an entry | “Intake is full; this work was not accepted” |
| Deadline passes | Do not create an entry | Show the recorded deadline and closed intake state |
| Reviewer closes the browser | Lease eventually expires | Return the entry to the review queue |
| Original reviewer returns late | Reject the stale token | Tell the reviewer the item was reassigned |
| Author withdraws during review | Invalidate the lease | Remove it from active reviewer work |
| Acknowledgement message fails | Keep the accepted entry | Show the receipt locally and retry notification |
| Translation fails | Preserve workflow state | Show the original text and a retry control |
| Moderator removes unsafe content | Apply the moderation policy and record the action | Show a neutral unavailable or removed state |
External links and rich media should still pass your community's moderation and safety policy. Avoid fetching and publishing arbitrary previews before validation. Also decide whether review summaries are public, author-only, or moderator-only; that visibility choice should not be inferred from the entry state.
Release verification checklist
Before opening the real campaign, verify the whole procedure rather than only the reducer:
- [ ] Two simultaneous requests cannot consume the same final capacity slot.
- [ ] Redelivery of one source message returns the same entry ID.
- [ ] A second, different submission from the same author is rejected clearly.
- [ ] Intake stops at both the configured deadline and manual draining state.
- [ ] An expired lease can be reassigned.
- [ ] A stale lease token cannot complete reassigned work.
- [ ] Author withdrawal invalidates an active lease.
- [ ] The campaign cannot close with queued or leased entries.
- [ ] A failed chat acknowledgement does not change admission state.
- [ ] Progress is derived from ledger records, not message counts.
- [ ] Original text remains available when translation fails.
- [ ] Moderators can trace who accepted, claimed, completed, or withdrew an entry.
- [ ] The final community update distinguishes completed, withdrawn, and unaccepted work.
The useful reframing
When participation exceeds expectations, the answer is not automatically more automation or another channel. First name what changed: a community conversation accumulated obligations.
Once that happens, reliability comes from making four things explicit:
- what the community is still accepting;
- which submissions were actually accepted;
- who currently owns each accepted action;
- what must be true before the promise can be called complete.
The chat remains where the community gathers. The ledger makes sure enthusiasm does not turn into invisible, unowned work.
Discussion: If your community makes a review, showcase, or mentorship promise, what counts as completion—and can contributors see that state without asking a moderator?
Disclosure: I wrote this article in collaboration with Tencent RTC, and used official Tencent RTC documentation as the implementation reference.
Top comments (0)