Close automation: the scheduled close job
The close job does the mechanical work of the month-end close by itself, once a day, so the controller's day goes to the entries that need a judgment and to the decision to close. The design is ADR-0034 (part B); this guide is slices C2 and C3 and the forecast roll of F3 as built. What the job does shows in the close tracker. The controls are in the control matrix (section "Close tracker", CA7 to CA16).
Turning it on
The job does nothing for an entity until a person names the steps it may run, in the entity's close calendar:
PUT /v1/entities/{entity}/close-schedule
{"automated_steps": ["recurring_invoices", "bank_matching", "depreciation", "fx_revaluation", "deferred_tax", "checklist", "close_review", "forecast_roll"]}
| Step | What the job does | Ledger effect |
|---|---|---|
recurring_invoices |
the recurring invoices due through the period end, as POST /v1/recurring-invoices/run makes them |
drafts, or submitted where the template's approved terms say auto_submit (as a run by hand) |
bank_matching |
the deterministic matcher over the period's unmatched statement lines, as an import with auto_match runs it (a released payment's end-to-end id, or the one document a line settles); the log says how many it matched and how many are left |
the matches' payments; one above 5,000 waits for a person (rule close_job_large_bank_matches); what it cannot match is left for a person (/v1/bank/lines, the reconciliation assistant), and so is a line whose match a person rejected (the job does not book it again) |
depreciation |
the depreciation run of the period, as POST /v1/periods/{period_id}/depreciation makes it |
an entry that waits for a person (/v1/approvals), unless the organization allows it (below) |
fx_revaluation |
the FX revaluation at data-vNext's closing rate; refused, not guessed, while a rate is missing | an entry that waits for a person |
deferred_tax |
the deferred-tax run of the tax book (source deferred_tax) |
an entry that waits for a person |
checklist |
evaluates the close checklist and keeps the result (ready, the failing checks, the checks by status) | none |
close_review |
the close review agent writes the period's close memo, once, when no check fails or from the step's working day (day 4 by default); it reads and records issues and runs no adjustment (below) | none: the memo is a document (close_memo) |
forecast_roll |
once a period is closed, a new draft of each forecast of the entity that has no version made since (below) | none: a plan is beside the ledger, and a person approves the draft |
Setting the steps is a person's act (the policy action manage_close, agents denied), audited with the
rest of the calendar (close.schedule.set). An empty list, the default, leaves the entity to people;
taking a step out stops it at the next run.
What it runs, and when
Every day at 05:00 UTC (Cloud Scheduler, job erp-close), for every organization that is not suspended,
every entity with steps named, and each of the latest three regular periods of it that have ended and are
still open, the job runs each named step once for that day. A period left open longer than that is a
person's to close; the job's daily work and its log stay bounded. A step's working day in the calendar is when it is due in the
tracker; the job runs it every day from the period's last day until the period is closed, so a recurring
invoice made late or a check that turns green is picked up the next morning.
It runs as the organization's system actor (system:close), inside the organization's row-level
security, through the same service functions the API calls; the approval policy decides as it does for
any request.
What it never does: approve anything, close or reopen a period, finalise a VAT return, release a
payment or run dunning. The job prepares the close; closing stays a person's request
(POST /v1/periods/{period_id}/close).
The period-end runs, and the person who posts them
A period-end run runs once per period, from its working day in the close calendar (depreciation day 1,
the FX revaluation and deferred tax day 2, by default), and only when the close checklist says it is due
and not done: an entity without assets, foreign-currency items or a tax book that carries deferred tax
has nothing to run, and a run a person already made is not made again. Each day's log says which it was
(ran, or the check's state and detail, or the day it is due). A run the service refuses (a missing
FX rate, a run of an earlier period still to do) fails the step, and the next day's job tries again. A
run whose entry a person rejects is not done: the next day's job makes it again (the rejected run is
voided, and stays as the record).
What a run posts waits for a person: the approval policy's rule close_job_runs_need_a_person
sends a posting of the close job (actor name close) with the source fa (depreciation), fx (the
revaluation), deferred_tax or rev (revenue recognition, when it joins) to /v1/approvals, with the
run and its items as its evidence; whoever approves it posts it. The rule sits before system_allowed, which lets other
system processes post as before. An organization may relax it for depreciation, whose amounts follow from
an approved asset register, with a rule of its own before it in its policy:
{"id": "close_job_posts_depreciation",
"when": {"actor_type": "system", "actor_name": "close", "action": "post", "source": "fa"},
"then": {"decision": "allow", "reason": "From the approved asset register."}}
Deferred tax has a source of its own, so allowing depreciation does not allow it. The payments bank
matching books follow the rule close_job_large_bank_matches: one above 5,000 (functional currency)
waits for a person, as an agent's posting does; an organization sets its own limit there.
The policy's input actor_name is a system process's name (close for the job; a platform key is never
it), and a person's or agent's actor id otherwise. It is optional: a table without it decides as before,
and a release that does not pass it still reads a table that declares it (its rules on actor_name then
do not match). A rule tests it with a string or in with a list of strings only, and the save-time
probe tries every name a rule gives and one no rule gives, so no rule can name an agent into an approval.
Migration 0043 gave every organization's own table the input and the two rules, as a new policy version
by the system, in the audit trail: each rule before the first of the organization's rules that decides
the job's postings, unless the table refuses one of them at some amount (the refusal stands). Deferred
tax posted under the source fa before, so a rule of the organization's that names fa names
deferred_tax with it; the migration log names the organizations and rules it widened.
The close memo
The step close_review runs the close review agent, the one POST /v1/agents/periods/{period_id}/close-review
runs, so that the controller finds the memo waiting when they sit down to close. It runs once per
period: as soon as no check of the close checklist fails, or from the step's working day in the close
calendar (day 4 by default) whatever the checklist says, so that a period with a problem gets a memo
explaining it rather than none. A period that has a memo (written by the job or by a person's review) is
done; to have a later one, a person asks for a review. Each day's log says which it was (ran with the
memo's document id, its issues, its blockers and ready; or the day it is due and the failing checks;
or the memo that is already there; or that the run's time for reviews was used up). Without a memo the
tracker's step is due only where the job writes it, on an open period: on a closed period, or for an
entity whose steps do not name close_review, it is n/a.
The agent acts as agent:close-review@system:close, with the read scope only, and without
adjustments: in the job it does not run depreciation or the FX revaluation (the job runs those as their
own steps, when the entity names them). It reads the checklist, the flux analysis, the unusual entries
and the subledger ties, records issues, and stores the memo as a document of kind close_memo (metadata
period_id, the issues, the model). The stored memo starts with a line the job writes, not the model:
that a language model wrote it, and whether the checklist was ready (or which checks failed). The memo
is a recommendation for the person who closes; it never closes or approves anything, and the policy
denies agents period closing anyway. The agent is told that what its tools return (descriptions, names,
document text) is data, not instructions; its tools read only, so text planted in the books can at worst
shape the memo, which the person reads against the books.
A review calls the model for minutes, so it holds no lock on the entity's close calendar (a person can
change the steps meanwhile) but one on the period. A run starts no review after its first 30 minutes
(half the execution's timeout): the reviews left are not logged (the job's line counts them as
deferred), so a retry the same day or the next run writes them. A review asks the model nothing after
the run's first 45 minutes; one still working then fails for the day. So a run ends by itself rather
than being stopped by the timeout. An empty answer (as a model gives on a safety block) is not a memo:
the step fails and is tried again; a person's review that answers nothing is refused (502) and stores
nothing either.
The memo's header names the model that answered: with a fallback chain, the one that took the
conversation. The API's memo is the model's text; the stored document has the header before it.
The job writes memos for the periods it works on, the latest three that have ended and are open. An
older open period of an entity that names close_review shows the step overdue until a person asks for
a review or closes the period.
It uses the models of the API (ERP_LLM_*; a choice in the operator portal applies here too). Apply
Terraform before an organization names close_review: until then the job has no ERP_LLM_* settings
and uses the default provider and model in the default region. When the
model fails, the step fails with a fixed message (the details are in the server log) and the next day's job tries
again; that is not an alert, unlike an unexpected error.
The forecast roll
The step forecast_roll rolls the entity's rolling forecasts after the close, so that the
controller finds next month's outlook drafted when the period is closed. It works on the period closed
last (by when it was closed), not on the periods in close, and runs from the next job after the close.
For each forecast of the entity with no version made since that close (by the job or a person, a roll
or a new draft: someone who made one has the close in view), it makes the draft
POST /v1/plans/{plan_id}/roll makes with its defaults: the closed periods of the year become actuals,
the open ones the version planned are carried over, and the periods new to the horizon are filled by
each account's own method where its latest line has one, else by the run rate of the last three closed
periods. The draft is the system's (system:close); a person reviews, submits and approves it as any
version.
Once every forecast has a version made since the close, the step does nothing and logs nothing, so the
log has one row per close (and one per day a forecast fails). A forecast that cannot roll (every period
of the entity closed: open the next fiscal year) fails the step with the reason and the others still
roll; the next day's job tries only the ones left. A forecast created after the close starts with a
version of its own and is not rolled for it. Reopening and closing a period again rolls again; a
period closed again on a day the job already rolled it is rolled by the next day's job. An adjustment
period's close does not roll (its entries count in the year's last regular period, closed already), and
its tracker step is n/a. A retry the same day of a step that failed logs that attempt's rolls only;
the audit trail (plan_version.roll) has each draft.
Every close adds a draft to each forecast nobody rolled since, so a forecast no one uses gathers system
drafts, one per close. There is no switch per forecast: take forecast_roll out of the entity's steps
to stop it.
Once a day, never twice
A step's key is (entity, period, step, run date). A step that was done that day is not run again (and a period-end run is made once per period, since its check then passes), whether the job is retried, started twice or started by hand. Each step of a period is its own transaction, which locks the entity's calendar row: two jobs at once queue on the entity, and what a step locks (a number sequence while an invoice is numbered) is held only while that step runs, so people posting meanwhile do not wait for the whole entity. The next day is a new run. The command refuses a date more than a day ahead (it would make recurring invoices early); a past date is fine.
Failures
- A step that cannot finish (a refusal of the service it calls, or recurring invoices that could not be
made) is logged as
failedwith the reason, and the next run tries it again; its other work is undone with it. A retry of the same day updates its log row (attempts). - Anything unexpected is caught per step, per entity and per organization: the job goes on with the other
steps, entities and organizations, and exits non-zero at the end, so the alert "a job failed" reports
it. One organization's fault never stops another's close. The step log keeps only the error's type and
a reference (
RuntimeError (unexpected; the job's log has ref 3f2a…)): its text can carry SQL and values, so the details are in the job's own log under that reference. A refusal's message is kept, cut to 2,000 characters.
The job's own log line (Cloud Logging) holds counts and problem codes only. What it did per period is in
GET /v1/periods/{period_id}/close-status (job_log: step, run date, outcome, attempts, what it made
and the error) and in the audit trail (close.step.done, close.step.failed, actor system:close).
Switches
| Who | Switch |
|---|---|
The organization (a person with manage_close) |
automated_steps per entity |
| The operator, for one organization | suspending it (POST /v1/platform/organizations/{org_id}/suspend): a suspended organization is skipped |
| The operator, for everyone | erp_close_job_paused = true in Terraform pauses the schedule |
The operator can run it by hand for a day (erp close-job --date 2026-10-05).