StabisoDocs

Autopilot and routines

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 failed with 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).

Stabiso, the Enterprise System of Record · generated from the code and its guides · release 20b7dd5b0c4d