
Table of contents
Get a personalized demo of Stuut and see how it can help with AR automation.
Stuut runs accounts receivable for finance teams, and increasingly it does the work rather than surfacing it. That makes the record of what happened the only place the work is visible, so we built it as one append-only table, written inside the same transaction as the change it describes. It backs the audit log a customer reads, it drives the workflow engine that decides what happens next, and it feeds Ask Stuut. The audit log makes the heaviest demands of the three, and a table built to meet them turned out to be exactly what the other two needed.
Why autonomous AR work needs an audit trail
Overnight, Stuut's agent matched an incoming payment against an overdue invoice and applied $12,400.00 to it. The finance team woke up to the work already done.
That autonomy is the product, and it raises the standard for the record behind it. A log of your own work is a convenience, a second pair of eyes on something you already remember doing. A log of the agent's work is how a finance team stays in command of a ledger they never touched. An auditor wants to know who applied that payment and on what basis, and a finance lead should be able to answer in one filter and export the proof.
We started from four things an audited action has to promise, and worked backwards to the schema.
- Completeness. Every audited action produces a record, in the same transaction as the change it describes. If the payment posted, the event exists.
- Immutability. Audited records are appended and never edited, so the export you take today matches the one you took last quarter.
- Attribution. Every audited action names who or what was behind it, decided at the moment it happened.
- Independent lifecycle. The log sets its own retention and schema, and nothing reading it gets to change either.
One append-only table came out of that, with three readers: the audit log, the workflow engine, and Ask Stuut.

Why the previous event table was not enough
We already have an events table. It watches the ORM, notices which objects changed at commit, writes a row describing each change, and hands those rows to workflow subscribers. Every write produces a row, and a query reads those rows back as a feed. We wanted to reuse it, and it fails all four promises. None of those failures is a bug. Each one is the table doing exactly what a dispatch queue should do.
How Stuut records an action
Let's go back to that overnight allocation. The agent has matched the payment to the invoice and is about to apply $12,400.00 to it.
The code doing the allocation calls one function, passing it the database session it already has open and a typed event describing what happened. The function stages a row and returns. No flush, no query, no network call. The row is now inside the same transaction as the allocation, so they commit together or not at all. Completeness becomes something the database guarantees.
Almost no call site has to explicitly state who is acting. The recorder pulls that from an ambient context carried through the request and job lifecycle, and records what kind of actor was behind it: the organization's own user, Stuut staff, the AI agent, or the system itself. It is frozen at that moment.
Attribution is the one field here that cannot be backfilled. A missing amount or timestamp can be reconstructed from somewhere else in the system; who was acting, once the moment has passed, is a guess.
The row that lands decomposes like this. The middle four parts are its participants: records the action touches, each attached under the role it played.
Because participants attach by role, the payment and the invoice arrive as two sides of one record and a reader never has to pair them back up. Other events carry a source, or a related record such as the customer. The action then shows up when someone looks at that customer, even though nothing about the customer changed. Assembling "Agent applied $12,400.00 from payment REF-10294 to invoice INV-4471" needs nothing beyond these parts.
Each participant stores a type, an identifier and the name as it read at the time, with no foreign keys. An audit record has to outlive the thing it describes, and a database constraint would either block the deletion or quietly blank the reference. A name that has since changed is the correct answer to "what was this called when it happened?"
Every event in the catalog is declared by hand, which costs a line of work per action. Automatic detection was the free option, and it could only ever report what data changed. The database sees a payment status move from pending to posted. It cannot see that a controller approved a proposed match above their threshold, and that is the sentence the auditor came for.
What customers see in the audit log
The agent's allocation reaches the customer as a sentence: Agent applied $12,400.00 from payment REF-10294 to invoice INV-4471. None of that wording is stored. A registry declares how each event reads and the sentence is assembled on the server per request, which settles two things at once.
Wording stays a read-time decision. Improving how an event reads applies to every record ever written, back to the first one, and storing sentences would have made each copy edit a migration. The screen and the CSV export run the same renderer, so an exported sentence matches what the customer read.
Masking holds. Phrasing an action in the browser would mean sending it everything the sentence needs, including the actor name we mask and the event types a customer should never see. Deciding on the server means a hidden row never leaves and a staff name is never serialized. The actor column in the product is that rule made visible: a colleague by name, Agent for the AI agent, System for background work, Stuut for our own staff.
Two more read-time rules matter to an auditor. Which events a customer sees is a registry decision, applied the same way. And a CSV export pins an upper bound in time and pages beneath it, which the append-only table turns into a frozen result: anything recorded while the export runs belongs to the next one.
The registry mostly encodes who is doing the verb. A person posts a payment. An invoice becomes overdue on its own, with nobody behind it, so the sentence leads with the invoice. Getting that grammatical distinction into the data model is most of why the feed reads like English.

How workflows and Ask Stuut use the same record
That is one reader served, and it is the only one that wants a sentence. The other two want the same rows for entirely different work: the workflow engine needs to know a payment was applied so something can happen next, and Ask Stuut needs to answer a question that spans thousands of rows at once.
The workflow engine brings a problem the audit log never had. Anything reacting to a change has to be told the change happened: the change goes into the database and the notification goes somewhere else, two systems with no shared transaction, and a window where one lands without the other.
The standard fix is a transactional outbox: write the notification into your own database inside the same transaction as the change, and let a separate process deliver committed rows afterwards.
Ordinarily that means standing up a dedicated table whose only job is to be filled and drained. We did not need one. The audit log already commits inside the business transaction, already covers every recorded action, already carries the order things happened in. All three came free with the audit promises, and they are exactly what an outbox needs. Making it work as one took a single extra column: a marker for whether a row has been handed to subscribers yet. Sharing the table does not mean sharing a lifecycle, though: retention and schema belong to the audit log, and a subscriber that wants a shorter horizon or an extra field does not get to reshape either.
A scheduled sweep collects rows that have not been marked, hands them to their subscribers, and marks them. It can crash between those last two steps, so delivery is at-least-once, and each delivery is identified by the row it came from and the subscriber it goes to. A repeat lands on an identifier the engine has already seen and does nothing.
The row behind that allocation is the outbox entry too. Within the minute it is swept up and handed to whichever subscribers care that a payment was applied, so work the agent started on its own still triggers everything that should follow it.
An outbox is normally drained, so a delivered message is gone. This one is the audit log, so it is never drained, and a subscriber can be replayed over history. That is how a handler catches up after a bug is fixed, and how a newly added subscriber sees events that predate it.
A log that keeps everything is also a dataset, and that is what the third reader wants. Ask Stuut answers questions about AR by routing them across warehouse-native semantic views, one per domain, each declaring its tables, dimensions and metrics in plain English. The audit log slots in as one more domain, so the machinery for it already existed.
This Ask Stuut integration ships shortly after the audit log itself. Its warehouse table will hold exactly the rows a customer may see, with the same masking rule applied when the data lands rather than when it is queried: staff appear as the fixed label, the join that would resolve a staff name is never made, and the payload column is never carried across at all. Thus, however the agent is asked, it will have no way to surface a staff name or a hidden event type, because those values were never loaded. Governance lives in the data, out of reach of the prompt.
The more interesting half of that work turned out to be teaching the agent what not to conclude:
- The log started recording on a specific date. An empty result for an earlier period means the activity was not being recorded then, and the agent has to say so.
- Only some events name a customer. A per-customer question that comes back empty has to be reported as covering customer-linked events only.
- Record names are snapshotted and often missing, so a search by name that finds nothing should be retried by identifier before anyone calls it an empty history.
Every one of those exists to stop a gap in coverage becoming a confident "this never happened." Someone asking whether a colleague changed a setting is asking a question where a wrong no does far more damage than an honest "I cannot tell you from this."
What is live today and what comes next
Today the audit log covers cash application and payments. An admin can filter by who acted, open any entry to see which fields moved and the reason behind them, and export a date range for an auditor. The same records drive the workflow engine, so the log and the automation are reading the same rows rather than keeping two accounts of the same day.
The record handles actions that change no data at all, including sign-ins, exports and approvals, but those arrive domain by domain, and the two above are what a customer can use now.
Collections events are next, which brings dunning and outreach under the same record and closes the gap between what Stuut does on a customer's behalf and what the log can account for.
Asking Ask Stuut about the log directly is close behind, with the data layer built and the agent tooling in review. A finance lead will be able to ask who changed the payment terms on an account last quarter and get an answer with the underlying entries attached, without building a filter first. If you want the other half of the picture, Building Ask Stuut covers the governed semantic layer this log now plugs into.
Staff impersonation follows. When one of our team acts on a customer's behalf, the log will say so plainly, as James, acting as Jane, so a finance team can tell our actions from their own.
Closing
Each of those points the same way. Stuut can take on more of the work while the finance team keeps a reliable record of every important action.


