Append-only records
Some records must never change once written: journal entries, audit events, consent receipts. --append-only makes a resource that is created and read, and refused every way it could otherwise be changed or deleted.
Generate one
$grit generate resource JournalEntry \$ --fields "reference:string,memo:text" \$ --items "JournalLine:account:belongs_to:Account,debit:money,credit:money" \$ --append-only$grit migrate
grit migrate is part of the command, not an afterthought: it is what puts the trigger on the table.
What you get
| Piece | What it does |
|---|---|
| Routes | List, export, get, PDF and create. No PUT, PATCH, DELETE, bulk or CSV import, and the API reference does not document any of them. |
| GORM guard | Every update or delete through GORM is refused with a respond.Rule, so the caller gets 422 and the reason. That covers your handlers, the CSV importer, the offline sync endpoint and GORM Studio's row editor, which all share the connection. |
| Database trigger | Installed by grit migrate. Refuses UPDATE and DELETE from anything, GORM or not, and TRUNCATE on Postgres. |
| Admin | The table offers create and view, bulk export only, and the detail page shows no Edit or Delete button. |
| Line items | An --items child is append-only too. An entry that cannot change, holding lines that can, is not append-only. |
Why there are two layers
The GORM guard alone was tried first, on a real double-entry ledger. It stopped the API, the importer and Studio's row editor. Then one statement typed into the Studio SQL editor went straight through, because that editor sends what it is given to db.Exec, and a raw statement never passes a GORM callback:
UPDATE journal_lines SET debit_amount = 1; -- 200 OK, and the books no longer balanced
A trigger lives in the database, so nothing that talks to the database can go around it. This is what the same attacks do against a table generated with the flag, run against Postgres:
| Attempt | Result |
|---|---|
PUT or DELETE on the API | 404: there is no such route |
| Studio row editor | Refused by the GORM guard, with the reason |
| Studio SQL editor | Refused by the trigger: journal_lines is append-only: UPDATE refused |
psql directly | Refused by the trigger |
The guard is still worth having: it is what turns a refusal into a 422 with a sentence rather than a database error.
Corrections
A mistake is fixed with a new row that reverses the old one, which is how an accountant expects it and how an auditor can follow it. The original stays, and so does the fact that it was wrong.
// Reverse posts the mirror image of an entry: every debit becomes a credit.func (s *JournalEntryService) Reverse(original models.JournalEntry, reason string) (*models.JournalEntry, error) {reversal := models.JournalEntry{Reference: original.Reference + "-REV",Memo: "Reverses " + original.Reference + ": " + reason,}for _, line := range original.Items {reversal.Items = append(reversal.Items, models.JournalLine{AccountID: line.AccountID,Debit: line.Credit,Credit: line.Debit,})}return &reversal, s.DB.Create(&reversal).Error}
GORM Studio's write switches
Studio can also be told not to write at all, which protects every table rather than the registered ones:
GORM_STUDIO_READ_ONLY=false # true refuses every write from StudioGORM_STUDIO_DISABLE_SQL=false # true turns the raw SQL editor off
The production environment template sets GORM_STUDIO_DISABLE_SQL=true.
Existing projects
The guard needs two calls, one when the API connects and one when it migrates. New projects have both. On an older project, grit upgrade adds them, and so does the generator the first time you pass the flag. If it cannot find where they go, because the file has been reshaped, it stops and names the call to add rather than generating a resource that looks protected and is not.
--append-only cannot be combined with --tree: moving a node rewrites its path, which is an update. The handler still contains its update and delete methods, unrouted, so allowing corrections in place later is a route rather than a regenerate; the table refuses them regardless while the model registers itself. The triggers have been run against Postgres and SQLite. The MySQL trigger is written but has not yet been exercised against a live MySQL, where creating triggers can require the TRIGGER privilege or, with binary logging on, log_bin_trust_function_creators.