Goals
Versioned goal records with acceptance criteria and evidence, explicit waiting reasons, budgets and deadlines, and wakeup and event envelopes, moved forward by pure transitions.
Le contenu des pages de documentation est en anglais. La navigation, la recherche et l’interface suivent la langue choisie.
@deuz-sdk/core/goal (2.3) is the data contract for work that outlives one
model call: a goal that is done only when its acceptance criteria pass on
evidence, that says what it is waiting for, and that a schedule, a webhook or
another goal can wake. It is edge-safe and pure. Nothing in it runs a model, a
timer or a background service: you keep the records in your own storage and
move them forward with transitionGoal, giving it the time.
| Piece | What it is |
|---|---|
GoalRecord | A versioned JSON record: objective, criteria, evidence, verdicts, budget, deadline, status |
transitionGoal | The one way a record changes: a pure function of the record, a command and now |
assessGoal | Where acceptance stands: passed, failed or inconclusive, per criterion and overall |
GoalEvent | A wakeup or outside event addressed to a goal, with a dedupe key |
GoalStore | Your persistence boundary; createInMemoryGoalStore is the reference |
In 2.4 the goal runtime drives these records: a supervisor that runs goals with the native executor, durable SQLite and PostgreSQL goal stores, a time-zone aware durable scheduler, webhook delivery into a goal's inbox, and acceptance verified on evidence. The records on this page are the contract it builds on, and everything here still works without it.
examples/16-goals-and-wakeups
runs the contract end to end without a key: a goal woken by a schedule
occurrence and by a webhook event, each claimed once in SQLite, with one leased
native run per wake supplying the evidence.
A goal
createGoal returns an active record at revision 1. Each criterion is
required unless it says required: false, and a goal needs at least one
required criterion. Without criteria, the objective itself is the one
criterion.
import { createGoal } from '@deuz-sdk/core/goal';
const now = Date.parse('2026-10-12T09:00:00Z');
const goal = createGoal(
{
goalId: 'fix-nightly',
scope: 'acme',
objective: 'Make the nightly build green.',
criteria: [
{ id: 'build', description: 'The nightly build passes.' },
{ id: 'notes', description: 'The changelog explains the fix.', required: false },
],
budget: { usd: 5 },
deadline: now + 2 * 86_400_000,
},
{ now },
);
console.log(goal.status, goal.revision); // active 1| Field | Meaning |
|---|---|
goalId, scope | Identity, and the tenant or workflow it belongs to |
parentGoalId, runId | The goal this one serves; the native run working on it now, or last |
revision | Increases by one on every change; a store saves only the next revision |
objective, criteria | What to achieve, and what must be true for it to count |
evidence, results | References to evidence, append-only; the latest verdict per criterion |
budget, spent, deadline | Limits for the goal as a whole, what its runs spent, and when it fails |
status, waiting, outcome | active, waiting (with waiting) or ended: completed, failed, cancelled (with outcome) |
seen, charged | The dedupe keys of the last 64 events the goal applied; apart from them, the keys of its last 64 charges |
meta | Your own data, JSON |
Transitions
Every change is a command given to transitionGoal with the time:
const next = transitionGoal(goal, command, { now: Date.now() });
if (next.changed) await store.save(next.record);It never changes the record it is given. It returns the next record, one revision higher, and says what it did:
outcome | Meaning |
|---|---|
'applied' | The command or event took effect |
'ignored' | An event, a charge or a no-op the goal dropped; reason says why |
'rejected' | The goal refused the command; reason says why |
Time comes first: once the deadline passed, the goal fails before the command
applies, so changed can be true on any outcome. A resume after the deadline
is 'rejected' and an event 'ignored', both with reason 'terminal' and the
goal now failed. Save whenever changed is true, as above.
A malformed record or command, an event meant for another goal, and an
expectedRevision the record is not at throw a GoalError instead, with a
code: invalid_goal, invalid_command, invalid_event, scope_mismatch or
revision_conflict.
| Command | Does |
|---|---|
{ type: 'wait', wait } | Waits, for a stated reason (below) |
{ type: 'resume' } | Ends a wait yourself, for example when a person answered in your own UI |
{ type: 'event', event } | Applies a wakeup or outside event |
{ type: 'evidence', evidence } | Records references to evidence |
{ type: 'evaluate', results, evidence? } | Records verdicts on criteria |
{ type: 'complete' } | Completes the goal, only once acceptance passed |
{ type: 'fail', reason }, { type: 'cancel', reason? } | Ends the goal |
{ type: 'charge', usd?, tokens?, key? } | Adds what a run spent |
{ type: 'revise', objective?, criteria?, budget?, deadline?, meta? } | Changes the goal; null removes the budget, the deadline or meta |
{ type: 'link-run', runId } | Names the native run working on the goal |
{ type: 'tick' } | Applies time alone: a passed deadline, a due wakeup |
An ended goal refuses every command ('terminal'), except a repeated cancel,
which it ignores, and a charge, which it still records.
Waiting, explicitly
A goal no model call is working on is not done, and it is not idle either: it
is active until a command says what it waits for. waiting.reason is one of:
| Reason | Waits for | Ends on |
|---|---|---|
'user' | A person | A user event, or resume |
'event' | An outside event from source (a type, and an ID when given) | A matching event, or resume |
'dependency' | Other goals or native runs, in on | An event from each of them (goal or run source with its ID), or resume |
'wakeup' | A time, at, optionally a schedule's occurrence | A tick at or after at, an event dated then, or resume |
'budget' | More budget: a charge reached a limit | A revise that raises the budget, or a cancel; the wait it interrupted comes back |
Every wait has an id (by default <goalId>#<revision>) and a since. An
event that answers the wait carries the id as correlationId. A new wait
replaces the current one, except a budget wait, which only a revision raising
the budget ends.
A spent budget does not replace the wait it interrupts: the budget wait keeps
it, as it was, in interrupted. Events that answer it still apply meanwhile,
and so does a tick once its wakeup is due; what ends it removes it. A revision
raising the budget puts it back, with its id and since, or makes the goal
active when nothing is left to wait for. A goal that waited for a person when a
run's charge spent its budget still waits for that person once the budget is
raised.
import { createGoal, goalEventFromSignal, transitionGoal } from '@deuz-sdk/core/goal';
const start = Date.parse('2026-10-12T09:00:00Z');
let goal = createGoal(
{ goalId: 'fix-nightly', scope: 'acme', objective: 'Make the nightly build green.' },
{ now: start },
);
goal = transitionGoal(
goal,
{ type: 'wait', wait: { reason: 'event', source: { type: 'signal', id: 'ci' } } },
{ now: start + 60_000 },
).record;
console.log(goal.waiting); // { reason: 'event', id: 'fix-nightly#2', since: …, source: { type: 'signal', id: 'ci' } }
// The CI webhook arrives; handleSignal's dispatch builds the event.
const event = goalEventFromSignal(
{ key: 'delivery-812' },
{ scope: 'acme', goalId: 'fix-nightly', name: 'ci', at: start + 600_000 },
);
const answered = transitionGoal(goal, { type: 'event', event }, { now: start + 600_000 });
console.log(answered.outcome, answered.record.status); // applied active
// The sender retries the same delivery.
const again = transitionGoal(answered.record, { type: 'event', event }, { now: start + 601_000 });
console.log(again.outcome, again.reason); // ignored duplicateAcceptance on evidence
A goal completes only when every required criterion passed, and a passed
verdict counts only when something other than the working model reached it,
on evidence from outside the model. A model saying it is done is evidence of
what the model said, not of the result: evaluate records such a verdict as
inconclusive, with a note, and complete refuses it with
'acceptance-not-passed'.
Evidence and verdict source | |
|---|---|
'test', 'artifact', 'human', 'verifier', 'tool', 'external' | Can carry a passed verdict |
'model' | The working model's own claim: never passes a criterion on its own |
import { assessGoal, createGoal, transitionGoal } from '@deuz-sdk/core/goal';
const now = Date.parse('2026-10-12T12:00:00Z');
const goal = createGoal(
{
goalId: 'fix-nightly',
scope: 'acme',
objective: 'Make the nightly build green.',
criteria: [{ id: 'build', description: 'The nightly build passes.' }],
},
{ now },
);
// The model says the build is fixed.
const claimed = transitionGoal(
goal,
{
type: 'evaluate',
evidence: [{ id: 'answer', source: 'model', ref: 'run:fix-nightly-1', summary: 'Fixed it.' }],
results: [{ criterionId: 'build', verdict: 'passed', source: 'model', evidence: ['answer'] }],
},
{ now: now + 1 },
);
console.log(claimed.acceptance?.verdict); // inconclusive
console.log(transitionGoal(claimed.record, { type: 'complete' }, { now: now + 2 }).reason);
// acceptance-not-passed
// The CI run says so too.
const tested = transitionGoal(
claimed.record,
{
type: 'evaluate',
evidence: [{ id: 'ci-812', source: 'test', ref: 'https://ci.example.com/runs/812' }],
results: [{ criterionId: 'build', verdict: 'passed', source: 'test', evidence: ['ci-812'] }],
},
{ now: now + 3 },
);
console.log(assessGoal(tested.record).verdict); // passed
const done = transitionGoal(tested.record, { type: 'complete' }, { now: now + 4 });
console.log(done.record.status, done.record.outcome); // completed { reason: 'accepted', at: … }A failed verdict on a required criterion makes acceptance failed, but the
goal stays open, so the work can go on. To give up, send fail. A verdict
names its criterion and the evidence it rests on; an unknown one is refused
('unknown-criterion', 'unknown-evidence'), and so is evidence under a known
ID that says something else ('evidence-conflict'). When a revision changes a
criterion's description or required, its verdict is dropped; evidence is
kept. Passing goal.criteria back unchanged changes nothing.
2.4 adds optional evidence fields (criterionId, verifier, specGeneration,
artifactDigest, workspaceDigest), spec generations for goals that opt in
with specGeneration, and verifyGoalAcceptance, which runs your verifiers
and returns the evaluate command to apply. See
Acceptance on evidence. A
goal without specGeneration keeps the rules above exactly.
Budget and deadline
charge adds what a run spent. When the spend reaches budget.usd or
budget.tokens, the goal waits with reason 'budget' until a revise raises
the limit, which makes it active again, or puts back the wait the budget
interrupted (see Waiting, explicitly). A charge without
usd, for a model without a price, counts its tokens only, as everywhere else
in 2.3. Give a charge a key, such as the run ID, and the same charge again is
ignored as a duplicate. Charge keys are kept apart from event keys, in
charged, so a run's charge and the event that reports its completion can both
be keyed by the run ID.
import { createGoal, transitionGoal } from '@deuz-sdk/core/goal';
const now = Date.parse('2026-10-12T09:00:00Z');
let goal = createGoal(
{ goalId: 'report', scope: 'acme', objective: 'Write the report.', budget: { usd: 1 } },
{ now },
);
const charge = { type: 'charge', usd: 1.2, tokens: 9_000, key: 'run-1' } as const;
goal = transitionGoal(goal, charge, { now: now + 1 }).record;
console.log(goal.status, goal.waiting?.reason); // waiting budget
goal = transitionGoal(goal, { type: 'revise', budget: { usd: 3 } }, { now: now + 2 }).record;
console.log(goal.status); // activeA goal whose deadline passed fails with outcome.reason 'deadline' at the
first transition at or after it, before that transition's command applies;
a tick is enough. A revision can extend or remove the deadline while it has
not passed, but never set one at or before now ('deadline-passed').
Wakeups and events
A GoalEvent is a small, versioned envelope: what happened, to which goal, and
how to recognise it again. Keep the payload in your own storage and pass a
reference to it.
| Field | Meaning |
|---|---|
id | The event's identity; defaults to key |
source | Who sent it: schedule, signal, user, run, goal, wakeup or custom, and an id |
scope, goalId, runId | Where it goes; an event for another goal or scope throws scope_mismatch |
key | Its dedupe key |
at, notBefore, expiresAt | When it happened; not to be applied before, or at and after |
payload | { ref, digest?, mediaType?, bytes? }: where the payload is |
correlationId, causationId | The wait it answers; the event that caused it |
Its key shares one space with the keys the schedule
module already produces, so one durable claim, such as the ops stores' claims,
dedupes all of them:
| Builder | key |
|---|---|
goalEventFromOccurrence(occurrence, target) | The occurrence key, <scheduleId>@<at> |
goalEventFromSignal({ key }, { ...target, name, at }) | handleSignal's key: your delivery ID, or sha256:<hex> of the body |
goalWakeupEvent(goal) | goalWakeupKey(goalId, at): goal:<goalId>@<at> |
createGoalEvent(fields) | Your own |
Applying an event never throws for the goal's own reasons. It ends the wait it answers (a dependency wait once every dependency reported), and remembers its key; or it reports why it was dropped, checking in this order:
reason | When |
|---|---|
'terminal' | The goal ended |
'duplicate' | The goal applied this key before |
'expired' | now is at or after expiresAt |
'early' | now is before notBefore: deliver it again then |
'unmatched' | The goal is not waiting |
'stale' | It answers another wait (correlationId), or happened before this wait began, unless it reports a dependency |
'unmatched' | The wait is for something else: another source, a dependency not listed, a wakeup not yet due |
During a budget wait, the wait an event must answer is the one the budget
interrupted; with none, every event is 'unmatched'.
A dependency's completion is a state, so it settles a dependency wait whenever
it happened, even just before the wait began. An event that arrives before the
goal waits for it is 'unmatched' and its key is not remembered: deliver it
again once the goal waits. A host that saw a dependency finish before it
entered the wait can instead mark it settled: true in the wait request; a
dependency wait with every dependency settled is over as it begins, and the
goal stays, or becomes, active.
A goal remembers the keys of its last 64 events in seen, and those of its
last 64 charges apart from them in charged. Further back, claim an event's
key durably before you apply the event, as handleSignal does with its
dedupe.
Nothing in @deuz-sdk/core/goal fires a wakeup by itself. The
goal supervisor (2.4) applies time on every tick;
without it, a periodic job of yours finds the due ones and ticks them. Several processes can run it at once: the store's revision
check lets one save win. A pass whose save conflicts, with another pass or with
a charge that landed since the listing, leaves the goal to the next pass, which
sees the fresh record. Resume a goal's run only once your save went through:
import { GoalError, createGoal, createInMemoryGoalStore, transitionGoal } from '@deuz-sdk/core/goal';
const goals = createInMemoryGoalStore();
/** Ticks the goals whose wakeup is due, and returns those this pass woke. */
async function wakeDueGoals(now: number): Promise<string[]> {
const woken: string[] = [];
let cursor: string | undefined;
do {
const page = await goals.list({ statuses: ['waiting'], cursor });
for (const goal of page.goals) {
const wait = goal.waiting;
if (wait?.reason !== 'wakeup' || wait.at > now) continue;
const next = transitionGoal(goal, { type: 'tick' }, { now });
if (!next.changed) continue;
try {
await goals.save(next.record);
} catch (error) {
// Another writer saved the goal since the listing: the next pass sees it.
if (error instanceof GoalError && error.code === 'revision_conflict') continue;
throw error;
}
// This pass woke it: resume the goal's run here (a passed deadline failed it instead).
if (next.record.status === 'active') woken.push(goal.goalId);
}
cursor = page.cursor;
} while (cursor);
return woken;
}
const start = Date.parse('2026-10-12T09:00:00Z');
const digest = createGoal(
{ goalId: 'digest', scope: 'acme', objective: 'Send the weekly digest.' },
{ now: start },
);
await goals.save(digest);
const hourLater = { type: 'wait', wait: { reason: 'wakeup', at: start + 3_600_000 } } as const;
await goals.save(transitionGoal(digest, hourLater, { now: start }).record);
console.log(await wakeDueGoals(start + 3_600_000)); // [ 'digest' ]Revisions and the persistence boundary
The records are yours to store. A GoalStore is the boundary the SDK expects:
load(goalId)returns a copy, orundefined.save(record)commits durably before it resolves, and only whenrecord.revisionis the stored revision plus one, or 1 for a new goal. Any other save rejects with a revision conflict and writes nothing.list({ statuses?, scope?, limit?, cursor? }), optional, pages through the goals newest change first.
createInMemoryGoalStore() is the reference implementation, for tests and a
single process; the SDK's test suite runs a conformance fixture against it, and
a store of yours on SQL or a key-value store with compare-and-set should pass
the same checks. In 2.4, load(goalId, { scope }) reads a goal of another
scope as undefined, and the
goal runtime stores on SQLite
and PostgreSQL are durable GoalStores that pass the same fixture. Around a store, every writer runs the same loop: load,
transition, save, and on a conflict load again. A transition is pure, so the
retry is safe, and a duplicate event or charge is recognised on the fresh copy:
import { GoalError, createInMemoryGoalStore, createGoal, transitionGoal } from '@deuz-sdk/core/goal';
import type { GoalCommand, GoalStore } from '@deuz-sdk/core/goal';
async function updateGoal(store: GoalStore, goalId: string, command: GoalCommand) {
for (let attempt = 0; ; attempt++) {
const current = await store.load(goalId);
if (!current) throw new Error(`Unknown goal ${goalId}`);
const next = transitionGoal(current, command, { now: Date.now() });
if (!next.changed) return next;
try {
await store.save(next.record);
return next;
} catch (error) {
const conflict = error instanceof GoalError && error.code === 'revision_conflict';
if (!conflict || attempt >= 3) throw error;
}
}
}
const store = createInMemoryGoalStore();
await store.save(createGoal({ goalId: 'g', scope: 'acme', objective: 'x' }, { now: Date.now() }));
console.log((await updateGoal(store, 'g', { type: 'link-run', runId: 'g-run-1' })).record.revision); // 2Pass expectedRevision to transitionGoal to refuse a command made for an
older revision (revision_conflict), for example a change a person made in a
UI that showed revision 5 when the goal is now at 7.
Versions
Records and events carry kind and version. serializeGoal and
parseGoal (serializeGoalEvent, parseGoalEvent) write and read their JSON:
- A reader accepts every version up to its own,
GOAL_RECORD_VERSION(GOAL_EVENT_VERSION), and refuses a newer one withunsupported_version. 2.3 and 2.4 write and read version 1; the 2.4 fields are optional additions. - Within a version, later 2.x releases only add optional fields. A reader keeps fields it does not know, through every transition, and fills in the ones a writer may leave out (no evidence, verdicts, spend or keys yet). It compares criteria and evidence on the fields it knows, so a criterion a revision passes back keeps what a later release added to it, and so does its verdict.
- Anything JSON would change, such as a
Date, a function orNaNinmeta, is refused (invalid_goal).
Goals and native runs
A goal and the native runs that work on it meet through IDs: link-run names
the run, and the run's result feeds the goal. A run that completes is not a
completed goal. Its usage is a charge, its verifier verdict or test output is
evidence, and a suspended run is a goal waiting for a person.
import { createInMemoryAgentRunStore, runAgent } from '@deuz-sdk/core/agent';
import { createGoal, transitionGoal } from '@deuz-sdk/core/goal';
import { createMockModel } from '@deuz-sdk/core/testing';
const now = Date.now();
let goal = createGoal(
{
goalId: 'summary',
scope: 'acme',
objective: 'Summarise the incident.',
criteria: [{ id: 'reviewed', description: 'An on-call engineer approved the summary.' }],
},
{ now },
);
const runs = createInMemoryAgentRunStore();
goal = transitionGoal(goal, { type: 'link-run', runId: 'summary-1' }, { now }).record;
const result = await runAgent({
model: createMockModel({ responses: [{ text: 'Disk filled up; rotated logs.' }] }),
prompt: goal.objective,
session: { store: runs, runId: 'summary-1', scope: goal.scope },
});
goal = transitionGoal(
goal,
{ type: 'charge', tokens: result.usage.totalTokens, key: result.runId },
{ now: now + 1 },
).record;
// The run completed, but the goal waits for its reviewer.
goal = transitionGoal(goal, { type: 'wait', wait: { reason: 'user' } }, { now: now + 2 }).record;
console.log(result.status, goal.status); // completed waitingLeases, run discovery and cross-process cancellation of native runs are on the
operations
page: a goal's cancel is yours to forward to its run, by signalling 'cancel' to
the run's lease key. The goal supervisor
(2.4) records the cancel and forwards it for the runs it drives.
Design note: plugin manifests and hook provenance
2.3 defines no plugin format. Where a result came from is recorded instead:
evidence and verdicts name their source, evidence its runId, and events
their source, causationId and correlationId. A later plugin manifest is
expected to declare the hooks a plugin adds, and its hooks to stamp what they
produce with the plugin's name and version through these same fields, so that
acceptance can tell a plugin's verdict from a test's. Nothing here installs,
loads or isolates plugins.
Schedules and signals
Five-field cron in UTC or an IANA time zone, a tick-driven scheduler with dedupe and catch-up, verified GitHub, Slack and HMAC webhooks, and when to use the durable scheduler instead.
Goal runtime
A supervisor that drives durable goals with the native executor, the goal runtime stores, a time-zone aware durable scheduler, webhook delivery into a goal's inbox, and acceptance verified on evidence (2.4).