Short answer: build the internal dashboard as a small control plane that writes versioned flag state and an append-only change event in one operation; then log the exact flag version used for each pricing decision. CRUD alone tells you what exists now. Incident reconstruction needs to explain what existed when a patient was quoted a price, who changed it, and why.
For a healthtech rollout, that distinction matters more than a polished switch. The dashboard can be plain. The evidence can't.
Before and after: from mutable switches to reconstructable decisions
The tempting design is one pricing_flags object behind four Express handlers. PUT sets a rule, GET lists it, DELETE removes it, and POST toggles it. An administrator flips new-copay-rule at 14:03. Support receives a disputed quote at 14:20. By then, the row only says enabled: true, so the team can't establish whether the quote used version 6 or version 7, which audience rule matched, or whether the change preceded the request.
Use a different mental model: current state is a projection; the change log is evidence. A write produces both a new current version and an immutable event. A pricing evaluation reads a specific version and records that version beside its request or trace identifier. The diagram in words is: admin request -> authenticated command -> transactional state plus event -> evaluator snapshot -> pricing decision log. Keep the control-plane request ID all the way through.
This creates two timelines. The change timeline answers, “Who altered the rule?” The evaluation timeline answers, “Which rule did this request actually use?” Joining them by flag key and version gives the incident story without guessing from deployment times.
The sequence matters.
| Evidence | Written when | Question it answers |
|---|---|---|
| Change event | An administrator issues a command | Who changed which version, and why? |
| Current projection | The same command commits | What should new evaluations load now? |
| Decision record | A pricing request evaluates the flag | Which version affected this exact quote? |
Don't store patient details in either timeline. Use opaque request identifiers, a stable administrator subject identifier, and a reason that describes the operational change rather than a person. GDPR Article 17 establishes a right to erasure and also lists circumstances where it does not apply; retention and deletion policy therefore need legal and security review for the system's jurisdiction and purpose. Keeping personal data out of operational evidence reduces the conflict rather than trying to solve it after collection.
How should a Node.js Express admin dashboard set, list, delete, and toggle feature flags?
Start with one command path and one repository contract. The command handler validates the expected version, derives the next state, and asks the repository to persist state and event atomically. This copyable example uses memory so the mechanics stay visible. It deliberately isn't a production database.
import express, { Request, Response } from "express";
import { randomUUID } from "node:crypto";
type Flag = {
key: string;
enabled: boolean;
rule: { percentage: number };
version: number;
archived: boolean;
updatedAt: string;
};
type ChangeKind = "set" | "toggle" | "delete";
type ChangeEvent = {
id: string;
requestId: string;
flagKey: string;
version: number;
kind: ChangeKind;
actorSubject: string;
reason: string;
occurredAt: string;
before: Flag | null;
after: Flag;
};
const app = express();
app.use(express.json({ limit: "16kb" }));
const flags = new Map<string, Flag>();
const changes: ChangeEvent[] = [];
function readCommandContext(req: Request): {
actorSubject: string;
requestId: string;
reason: string;
} {
const actorSubject = req.header("x-actor-subject")?.trim();
const reason = req.header("x-change-reason")?.trim();
if (!actorSubject || !reason) {
throw new Error("actor and reason are required");
}
return {
actorSubject,
reason,
requestId: req.header("x-request-id")?.trim() || randomUUID(),
};
}
function applyChange(
req: Request,
key: string,
kind: ChangeKind,
expectedVersion: number,
mutate: (current: Flag | null, now: string) => Flag,
): Flag {
const context = readCommandContext(req);
const current = flags.get(key) ?? null;
const actualVersion = current?.version ?? 0;
if (actualVersion !== expectedVersion) {
throw new Error(`version conflict: expected ${expectedVersion}, got ${actualVersion}`);
}
const now = new Date().toISOString();
const next = mutate(current, now);
flags.set(key, next);
changes.push({
id: randomUUID(),
requestId: context.requestId,
flagKey: key,
version: next.version,
kind,
actorSubject: context.actorSubject,
reason: context.reason,
occurredAt: now,
before: current,
after: next,
});
return next;
}
function expectedVersion(req: Request): number {
const value = Number(req.header("if-match"));
if (!Number.isInteger(value) || value < 0) {
throw new Error("if-match must be a non-negative integer version");
}
return value;
}
app.get("/admin/flags", (_req: Request, res: Response) => {
res.json([...flags.values()].filter((flag) => !flag.archived));
});
app.put("/admin/flags/:key", (req: Request, res: Response) => {
const percentage = Number(req.body.percentage);
if (!Number.isInteger(percentage) || percentage < 0 || percentage > 100) {
return res.status(400).json({ error: "percentage must be an integer from 0 to 100" });
}
const next = applyChange(req, req.params.key, "set", expectedVersion(req), (current, now) => ({
key: req.params.key,
enabled: Boolean(req.body.enabled),
rule: { percentage },
version: (current?.version ?? 0) + 1,
archived: false,
updatedAt: now,
}));
return res.status(200).json(next);
});
app.post("/admin/flags/:key/toggle", (req: Request, res: Response) => {
const next = applyChange(req, req.params.key, "toggle", expectedVersion(req), (current, now) => {
if (!current || current.archived) throw new Error("flag not found");
return { ...current, enabled: !current.enabled, version: current.version + 1, updatedAt: now };
});
return res.status(200).json(next);
});
app.delete("/admin/flags/:key", (req: Request, res: Response) => {
const next = applyChange(req, req.params.key, "delete", expectedVersion(req), (current, now) => {
if (!current || current.archived) throw new Error("flag not found");
return { ...current, enabled: false, archived: true, version: current.version + 1, updatedAt: now };
});
return res.status(200).json(next);
});
There is a sharp edge in that snippet: the Map.set() and changes.push() calls aren't a real transaction. They teach the contract, but a process restart loses both, and concurrent Node.js instances don't share the map. In production, put current rows and append-only events in a persistent store and commit them in one database transaction. Add a unique constraint on (flag_key, version) so two administrators starting from version 6 cannot both create version 7. Translate the version-conflict error to HTTP 409, validation failures to 400, missing flags to 404, and authentication or authorization failures to 401 or 403 in centralized Express error middleware.
The If-Match value makes a stale browser visible. Without it, the last click silently wins. With it, the dashboard can reload the current state, show the intervening edit, and ask the administrator to make a fresh decision. That's useful friction.
Archive on delete rather than erasing the definition from operational history. The active list hides archived flags, while incident tools can still resolve an older version. A separate, governed privacy process should erase personal data where required; an admin CRUD endpoint should not make that legal decision implicitly.
Record the pricing decision, not every implementation detail
The evaluator needs a stable snapshot. Given the flag, a subject-independent allocation key, and the pricing input version, it should emit a compact decision record. Avoid dumping request bodies into logs — healthtech payloads can carry sensitive data, and a feature-flag investigation rarely needs them.
type DecisionRecord = {
requestId: string;
flagKey: string;
flagVersion: number;
enabled: boolean;
pricingRuleVersion: string;
decidedAt: string;
};
function recordPricingDecision(
requestId: string,
flag: Flag,
pricingRuleVersion: string,
): DecisionRecord {
if (flag.archived) throw new Error("archived flags cannot be evaluated");
return {
requestId,
flagKey: flag.key,
flagVersion: flag.version,
enabled: flag.enabled,
pricingRuleVersion,
decidedAt: new Date().toISOString(),
};
}
Suppose request quote_7f31 produced an unexpected copay. Start with its decision record, which says new-copay-rule, version 7, enabled, pricing rule copay-2026-04. Next, query the change stream for that flag and version. The matching event identifies the command that created version 7, the opaque administrator subject, the timestamp, the prior snapshot, and the stated reason. Compare its timestamp with the decision timestamp, but don't use time alone as proof: the version on the decision is the stronger link. Then replay the version 7 snapshot against the same non-personal pricing inputs in a controlled environment. If the result matches, the flag path is explained and the investigation can move to the pricing rule itself; if it differs, inspect evaluator configuration and snapshot loading. That is a reconstruction with falsifiable steps. A log line saying only flag=true is a clue.
Keep event fields bounded. Flag keys and versions work well as metric dimensions only when their cardinality is controlled; request IDs belong in logs or traces, not metric labels. Alert on outcomes that operators can act on: a spike in pricing evaluation failures, an unusual change rate, or a mismatch between the loaded flag version and the intended rollout version. An alert for every toggle trains people to ignore the channel.
Two objections worth settling before rollout
“Why not update the database row and rely on ordinary application logs?” Because application logs and business state can diverge. A log may be emitted before a transaction rolls back, or omitted after state commits. The durable change event must share the state transaction; log shipping can happen afterward. If the team needs a searchable log stream, publish from the committed event store rather than treating a best-effort log call as the record of truth.
“Isn't this too much machinery for a simple internal tool?” Sometimes. If a flag only changes cosmetic copy and cannot affect money, care, access, or compliance, a version column plus ordinary access logs may be proportionate. The catch is that a new pricing rule changes a user-visible financial decision. For that case, optimistic concurrency, immutable changes, decision correlation, and rehearsed rollback earn their keep. I'm not sure how long your evidence must remain available; that depends on policy, jurisdiction, and the data involved, so settle retention with security and counsel before launch.
The dashboard also needs boring controls around the code: single sign-on, role-based permission for writes, CSRF protection if browser cookies authenticate requests, rate limits, a reason field, and a review path for high-impact changes. Test the whole before/after chain. Create version 1, race two updates against it, confirm one receives 409, toggle the winner, archive it, and prove that a historical pricing decision still resolves to the exact earlier snapshot.
Keep it small. Keep the evidence.
Operational limits and the go-live rule
An append-only event trail is not a rollback system by itself. A rollback is another authenticated command that creates a new version from a known earlier configuration; never rewrite the old event. Before enabling the pricing rule broadly, run a canary, compare expected and actual pricing outcomes without patient identifiers, and verify that on-call staff can trace one synthetic request from evaluation to change event.
The design is not suitable when the dashboard is expected to become a general experimentation platform with statistical analysis, complex targeting, approval orchestration, and multi-region propagation guarantees. In that situation, use a dedicated flag control plane or build those capabilities deliberately behind the same evaluation contract. Also avoid this in-memory implementation for every production deployment. Its value is the shape of the boundary, not its storage engine.
Ship when one test can answer four questions from durable records: what rule ran, which version supplied it, which command created that version, and who was authorized to issue the command. If any answer depends on somebody remembering a dashboard click, observability isn't finished.
References
- GDPR Article 17, “Right to erasure”: https://gdpr-info.eu/art-17-gdpr/
Further reading
- GDPR Article 17, “Right to erasure”: https://gdpr-info.eu/art-17-gdpr/
Top comments (0)