sitemap.xml llms.txt
Skip to main content

Understand Consensus

This guide explains how to use the Consensus adapter to coordinate a role-based Epicenter application with a consensus barrier.

The reference application is a bike shop managed by three roles: Sales, Operations, and Finance. At each round or step, every participant edits their inputs, then submits to a shared barrier for that step. The barrier is a readiness gate: it answers "who is done with this round?" Once everyone has arrived, the barrier triggers the model action that advances the game.

Implementing a consensus barrier

All code samples below are taken from a template Epicenter application in the consensus branch of the dev-base-build repo.

Create or load the barrier for the current step

A barrier corresponds to one round of the game and one model step. A barrier is uniquely identified by the world key, a name, and a stage.

The reference application names the barrier after the run and the current model step, so a new run in the same world always starts with fresh barriers:

src/query/consensus.ts
import { queryOptions } from '@tanstack/react-query';
import { consensusAdapter, Fault } from 'epicenter-libs';
import { BarrierReadOutSchema } from '~/schemas/consensus';
import { Role } from '~/schemas/world';

const ROUND_TTL_SECONDS = 120;

const barrier = ({
worldKey,
runKey,
name,
stage = 'confer',
}: {
worldKey: string;
runKey: string;
name: string;
stage?: string;
}) =>
queryOptions({
queryKey: ['consensus', worldKey, runKey, name, stage],
queryFn: () =>
consensusAdapter
.load(worldKey, name, stage)
.catch((error) =>
error instanceof Fault && error.status === 404
? consensusAdapter.create(
worldKey,
name,
stage,
{
Sales: 1,
Operations: 1,
Finance: 1,
} satisfies Record<Role, number>,
{ null: [{ objectType: 'execute', name: 'step', arguments: [] }] },
{ allowChannel: true, ttlSeconds: ROUND_TTL_SECONDS }
)
: Promise.reject(error)
)
.then(BarrierReadOutSchema.parse),
});

export const ConsensusQuery = {
barrier: barrier,
};

Naming the barrier

src/routes/play/index/index.tsx
const barrierName = `${run.runKey}:${step}`;
const barrierStage = 'confer';

Key points to note

  • expectedRoles ({ Sales: 1, Operations: 1, Finance: 1 }) tells the barrier exactly one participant per role must arrive before it triggers.
  • defaultActions maps a role to the Actionable objects that define the actions taken on its behalf if it never arrives. Here, every role shares the same default: { null: [step] }, so the round always advances even if a role times out.
  • ttlSeconds sets the timer for the round to 120 seconds.
  • allowChannel: true opts the barrier into push notifications so every open session can react when someone arrives.
  • A 404 from load() means the barrier for this step hasn't been created yet, so the query falls back to create(). Any other error is rethrown.
Opaque, not transparent

The barrier is created without transparent: true, so it defaults to opaque. In opaque mode, the first two roles to arrive are recorded, but their submitted step action is discarded. Only the final arrival's submitted action actually runs. Because every role submits the identical step action, it doesn't matter which role happens to arrive last — the model steps exactly once. A transparent barrier would run every arriving role's action independently, which would step the model once per arrival instead of once per round.


Guard each role's inputs in the model

Consensus enforces when the model advances, but it doesn't restrict what a role can write. That's the job of the role-based write guards defined in the model context file model/model.ctx2, which restrict each world role to its own input ranges:

  • Sales can write Price and Demand.
  • Operations can write Capacity and Variable_Cost.
  • Finance can write Fixed_Costs.
model/model.ctx2
{
"protections": {
"guards": [
{
"role": {
"regex": "^(Price|Demand)(\\[.*\\])?$",
"domain": "variable",
"grant": "allow",
"role": "Sales"
}
},
{
"role": {
"regex": "^(Capacity|Variable_Cost)(\\[.*\\])?$",
"domain": "variable",
"grant": "allow",
"role": "Operations"
}
},
{
"role": {
"regex": "^Fixed_Costs(\\[.*\\])?$",
"domain": "variable",
"grant": "allow",
"role": "Finance"
}
}
]
}
}

The front end mirrors this split with role-specific zod schemas, so each player only ever fetches (and can only submit) the variables their role owns:

src/schemas/model.ts
export const SalesVariablesSchema = BaseVariablesSchema.extend({
Price: numberArray,
Demand: numberArray,
});

export const OperationsVariablesSchema = BaseVariablesSchema.extend({
Capacity: numberArray,
Variable_Cost: numberArray,
});

export const FinanceVariablesSchema = BaseVariablesSchema.extend({
Fixed_Costs: numberArray,
});

export const ROLE_SCHEMAS = {
Sales: SalesVariablesSchema,
Operations: OperationsVariablesSchema,
Finance: FinanceVariablesSchema,
} as const satisfies Record<Role, z.ZodTypeAny>;
src/query/run.ts (role-scoped variables query)
const variablesByRole = ({ runKey, role }: { runKey: string; role: Role }) =>
variables({ runKey, schema: ROLE_SCHEMAS[role] });

Submit a role's decision

When a player submits their decision, the app writes their inputs to the run for the current step, then submits the shared step action to the barrier. Both calls use the same step index so the barrier and the model always agree on which round is being decided:

src/routes/play/index/index.tsx (submit)
const stepActions = [
{ objectType: 'execute', name: 'step', arguments: [] as Record<string, unknown>[] },
];

const handleSubmit = async (values: Record<string, number>) => {
const updates = Object.entries(values).reduce<Record<string, number>>(
(acc, [key, value]) => {
acc[`${key}[0,${step}]`] = value;
return acc;
},
{}
);

await runAdapter.updateVariables(run.runKey, updates);
await consensusAdapter.submitActions(
world.worldKey,
barrierName,
barrierStage,
stepActions
);

queryClient.invalidateQueries({ queryKey: ['consensus', world.worldKey] });
return queryClient.invalidateQueries(
RunQuery.variablesByRole({ runKey: run.runKey, role: myRole })
);
};
Important

consensusAdapter.submitActions() marks the current user as arrived. For the last expected role to arrive, the barrier is triggered, and its submitted step action runs against the model, advancing every player's run to the next year in a single call.


Track arrivals and render status

The BarrierReadOutView object returned by consensusAdapter.load() and consensusAdapter.create() includes arrivedRoles, a map of role to the participants who have already submitted their decisions:

src/schemas/consensus.ts
export const BarrierReadOutSchema = BaseBarrierReadOutSchema.extend({
expectedRoles: z.record(RoleSchema, z.number()),
impendingRoles: z.record(RoleSchema, z.array(PseudonymReadOutSchema).default([])),
arrivedRoles: z.record(RoleSchema, z.array(BarrierArrivalReadOutSchema).default([])),
});

List arrived roles

Use it to render a simple readiness list:

src/routes/play/index/barrier-status.tsx
const hasArrived = (barrier: BarrierReadOutView, role: Role) =>
(barrier.arrivedRoles?.[role]?.length ?? 0) > 0;

export const BarrierStatus = ({ barrier, myRole }: { barrier: BarrierReadOutView; myRole: Role }) => (
<div className={styles.roleStatusList}>
{ROLES.map((role) => {
const arrived = hasArrived(barrier, role);
return (
<div key={role} className={cn(styles.roleStatusItem, role === myRole && styles.roleStatusYou)}>
<span className={cn(styles.statusDot, arrived ? styles.dotArrived : styles.dotPending)} />
<span className={styles.roleStatusLabel}>{role}</span>
<span className={styles.roleStatusText}>{arrived ? 'Submitted' : 'Waiting'}</span>
</div>
);
})}
</div>
);

Show arrival confirmation

Once the current player has submitted, hide their form and show a confirmation instead:

src/routes/play/index/index.tsx (submitted state)
const hasSubmitted = (barrier.arrivedRoles[myRole].length ?? 0) > 0;

Show the round's remaining time

The barrier reports secondsLeft and ttlSeconds for its timer. Rather than polling the server every second, the app derives a live countdown client-side from the last time the barrier data was fetched:

src/routes/play/index/useCountdown.ts
export const useCountdown = ({ name, duration, startTime }: UseCountdownProps): number => {
const remaining = useCallback(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
return Math.max(0, duration - elapsed);
}, [duration, startTime]);

const [countdown, setCountdown] = useState(remaining);

useEffect(() => {
const tick = () => {
const next = remaining();
setCountdown(next);
if (next <= 0) clearInterval(intervalId);
};
const timeoutId = setTimeout(tick, 0);
const intervalId = setInterval(tick, 1000);
return () => {
clearTimeout(timeoutId);
clearInterval(intervalId);
};
}, [name, remaining]);

return countdown;
};

duration is seeded from barrier.secondsLeft and startTime from the query's dataUpdatedAt, so the countdown resumes correctly even if the component remounts.

Reading a barrier doesn't close it

The secondsLeft value can reach zero before the barrier actually closes. Reading a barrier never resolves it, even if its timer has expired. Only a write function (submitActions(), forceClose(), and similar) can resolve an expired barrier. This is why the app needs the Continue On action described next.


Handle a missing role with Continue On

Once the countdown reaches zero and at least one role still hasn't arrived, the sidebar swaps the countdown for a Continue On button. Clicking it resubmits the same shared step action:

src/routes/play/index/index.tsx (Continue On)
<Button
onClick={() =>
consensusAdapter.submitActions(world.worldKey, barrierName, barrierStage, stepActions)
}
>
Continue On
</Button>

Subscribe to consensus pushes

Subscribe to the world's CONSENSUS push channel so every open session refreshes the barrier as soon as any role arrives, without polling:

src/routes/play/play.tsx
const consensusChannel = useChannel({
scopeBoundary: SCOPE_BOUNDARY.WORLD,
scopeKey: world.worldKey,
pushCategory: PUSH_CATEGORY.CONSENSUS,
});

const onConsensusPush = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['consensus', world.worldKey] });
}, [queryClient, world.worldKey]);

useChannelEffect({
token: session.token,
channel: consensusChannel,
callback: onConsensusPush,
});

The push payload itself carries no meaningful state to merge into the cache. It's only a signal that something about this world's barriers changed, so the handler simply invalidates every cached barrier for the world and lets TanStack Query refetch.


Let a player undo their submission

Before the round advances, a player can retract their own arrival with consensusAdapter.undoSubmit(), which brings back their decision form:

src/routes/play/index/index.tsx (undo)
const handleUnsubmit = () =>
consensusAdapter
.undoSubmit(world.worldKey, barrierName, barrierStage)
.then(() => queryClient.invalidateQueries({ queryKey: ['consensus', world.worldKey] }));

Summary

StepWhereWhat
1consensus.tsCreate or load the round's barrier with consensusAdapter.load() and consensusAdapter.create(), expecting one arrival per role and defaulting every role to the same shared step action.
2model.ctx2 / model.tsGuard each role's inputs so a role can only read and write the variables it owns.
3index.tsxWrite the role's inputs with runAdapter.updateVariables(), then submit the shared action with consensusAdapter.submitActions().
4barrier-status.tsx / index.tsxRender arrival status from barrier.arrivedRoles and swap in a confirmation once the current role has submitted.
5useCountdown.tsDerive a live countdown from barrier.secondsLeft without polling.
6index.tsxResubmit after the timeout with a Continue On action so the round always advances.
7play.tsxSubscribe to PUSH_CATEGORY.CONSENSUS and invalidate cached barriers on any push.
8index.tsxUndo a submission with consensusAdapter.undoSubmit() before the round triggers.