| Shape | Publish path | Tenant cost attribution | Best fit |
|---|---|---|---|
| Inline classifier | Block until one decision returns | Easy until retries appear | Low-volume internal tools |
| Queue plus tenant ledger | Accept, classify, then release or hold | Explicit per attempt | Multi-tenant fintech systems |
| Rules before a queue | Reject deterministic violations first | Split rules and model work | Stable, high-volume policies |
The hard trade-off is latency versus an auditable per-tenant bill. Decision: put user-generated fintech code changes through a queue, record every classification attempt against the tenant, and allow the classifier to abstain into human review. An inline call looks simpler, but retries, duplicate deliveries, and ambiguous output make its cost story fuzzy fast. This design uses chat completions only as a typed decision component; it never lets generated prose become a publish command.
Short answer: the best simple Node.js backend moderation pipeline is a queued state machine with idempotent jobs, a strict JSON response, and a tenant-scoped usage ledger.
What makes Node.js user-generated content moderation costs trustworthy?
Treat an uploaded patch as untrusted content. The API stores the immutable patch, creates one moderation job, and returns an acceptance response. A worker later claims the job, loads the tenant's active policy version, asks a chat completion for structured findings, validates the JSON, and makes one of three transitions: approved, blocked, or review_required.
Keep the state machine boring. That's a compliment.
The worker must not publish directly. It writes a decision tied to tenantId, contentId, policyVersion, and attemptId; a separate release step checks that decision. That boundary prevents a malformed response, a replayed queue message, or an operator retry from silently changing a code review into an approval. It also gives the review queue a stable unit of work: a finding set plus the exact input and policy revision that produced it.
Idempotency belongs at both expensive boundaries. Give the intake request a key so the same patch cannot create two logical jobs. Give each classification attempt its own key so a redelivered message can return the stored result instead of calling the model again. Exactly-once delivery is a tempting promise. Don't build the accounting model around it.
For code changes, deterministic checks should run first: payload size, file allowlists, forbidden paths, and syntax checks do not need a language model. The completion handles the contextual residue, such as a change that appears to expose account data or weaken an authorization check. High-confidence allow and block outcomes can move forward under policy; ambiguous, conflicting, or invalid results go to human review. This split is the first criterion because it makes the ledger legible: rule evaluation, completion work, and human review remain separate operations instead of becoming one unexplained moderation charge.
Every attempt needs a tenant ledger entry. Store the input and output usage reported by the selected runtime, the policy version, timestamps, final status, and a stable provider-independent operation name. Keep raw provider payloads out of the billing interface. A provider adapter can normalize usage into your own schema while preserving the original receipt in restricted audit storage.
The useful metric is not one blended monthly total. It is cost per accepted change, per tenant, with retries and review rates visible beside it. A tenant that submits tiny patches but triggers repeated ambiguous classifications has a different operational profile from one sending large, clean changes. Without attempt-level attribution, those differences disappear inside an average and the team starts optimizing the wrong thing.
The second criterion is uncertainty that survives serialization. A boolean safe field is config bloat in disguise because all the missing states leak into retry flags, magic error strings, and dashboard conventions. Use a small discriminated union instead. Require evidence locations and policy identifiers for block or review findings, cap their count, and reject extra fields. The model may propose findings; application code owns transitions.
No vibes.
Schema validation alone does not prove a decision is good. Maintain a versioned evaluation set of representative patches, including boundary cases for secrets, account identifiers, authorization changes, and harmless test fixtures. Run it when the prompt, policy, adapter, or model changes. Measure false releases, unnecessary blocks, review rate, latency, and usage by tenant cohort; a single accuracy number hides the failure that matters most in a fintech workflow.
Turn the ledger contract into one typed worker
The adapter below deliberately has no vendor SDK in its public contract. That keeps the worker testable and makes time-to-first-call depend on one interface, not a pile of provider config. The transport behind complete may call a hosted endpoint or a self-managed gateway, but it must return validated JSON plus usage data.
type Finding = {
ruleId: string;
severity: "low" | "medium" | "high";
file: string;
line: number;
reason: string;
};
type Decision =
| { outcome: "approved"; findings: [] }
| { outcome: "blocked"; findings: Finding[] }
| { outcome: "review_required"; findings: Finding[]; reason: string };
type CompletionResult = {
decision: unknown;
usage: { inputUnits: number; outputUnits: number };
receiptId: string;
};
type Job = {
id: string;
tenantId: string;
contentId: string;
policyVersion: string;
patch: string;
};
interface Runtime {
complete(input: {
operation: "code_change_moderation";
policyVersion: string;
content: string;
}): Promise<CompletionResult>;
}
interface Store {
findAttempt(jobId: string): Promise<Decision | null>;
commitAttempt(input: {
attemptId: string;
job: Job;
decision: Decision;
usage: CompletionResult["usage"];
receiptId: string;
}): Promise<void>;
}
declare function parseDecision(value: unknown): Decision;
declare function newAttemptId(): string;
async function moderate(job: Job, runtime: Runtime, store: Store): Promise<Decision> {
const existing = await store.findAttempt(job.id);
if (existing) return existing;
const result = await runtime.complete({
operation: "code_change_moderation",
policyVersion: job.policyVersion,
content: job.patch,
});
const decision = parseDecision(result.decision);
await store.commitAttempt({
attemptId: newAttemptId(),
job,
decision,
usage: result.usage,
receiptId: result.receiptId,
});
return decision;
}
commitAttempt should be one transaction: insert the unique attempt, append the tenant usage entry, and update the job state. If validation fails, record a zero-decision attempt status and route the content to review; do not reinterpret loose text with string matching. Transport failures use bounded retries with jitter, while the job remains unreleased. The exact retry count depends on the runtime's latency and error profile, so benchmark it with your own patch distribution rather than borrowing somebody else's number.
The test harness should inject a fake Runtime, replay the same job twice, and assert one committed usage entry. Then feed unknown fields, missing findings, impossible line numbers, and an unsupported outcome into parseDecision. This is where DX matters: one command should run contract tests and the evaluation set, with no cloud credentials required for the deterministic suite. Add a concurrency test as well: release two workers against the same job, delay one fake response, and verify that the unique attempt constraint leaves a single billable record even though both workers reached the adapter. That test does more for cost confidence than a dashboard assembled after launch, because it attacks the exact boundary where queue semantics and provider usage can diverge.
Where the queue loses to a smaller design
Stick with an inline classifier when the tool is internal, traffic is low, callers can safely wait, and per-tenant chargeback is irrelevant. It has fewer moving parts. The catch is that the request path now owns model latency and retry behavior, so a timeout policy must default to holding the change rather than releasing it.
Rules-first processing is the better runner-up when policy is mostly deterministic and changes slowly. It can reduce unnecessary completion calls and produce explanations that map directly to a checked rule. It is not suitable as the only layer when the decision depends on context spread across a patch, and a queue is still useful for the cases the rules cannot settle.
A self-managed gateway can help when multiple runtimes must share authentication, observability, or routing. It also adds an operational component and another place to map usage receipts. I'm not sure that trade pays off for a small team with one runtime; the deciding evidence is a benchmark of deployment work, p95 latency, failure handling, and tenant-ledger completeness against a direct adapter.
Whichever transport wins, keep the decision rule fixed: no validated outcome means no release. Preserve the immutable input, policy version, normalized usage, and review action long enough to answer both questions that arrive later: why did this change pass, and which tenant paid for the attempt?
Top comments (0)