Where it came from
Reconciling records between two work-management sheets, an agent needed to duplicate a row as a new record. The API had no copy operation inside a sheet, so it had to read the row and post a new one, and the post failed with a generic "value not supported for this column type" that did not name the column. My first debugging approach was to bisect by posting half-payloads. Every "successful" half left a malformed row in the production sheet, and I had to find and delete them afterwards.
The fixes became rules, and the rules became this library. Classify columns from their metadata before writing. If a generic error still happens, bisect with updates to one disposable row, always delete it, and audit for orphans afterwards.
Mechanisms
- Risk-tiered tools. Each tool is registered as read, reversible write, irreversible write or external notification, and policy follows from the tier.
- Mandatory dry run. A write plan cannot execute until a preview with no side effects has returned a before/after diff and found no failing call.
- Approval bound to the exact plan. The token is signed over the plan's hash, expires, and works once. A plan edited after approval is refused.
- Hash-chained audit log. Append-only, records undo data, detects edits, reordering and deletion, and is the only source undo will trust.
- Classify before writing. Formula columns skipped, disguised multi-select columns detected, contacts outside an approved list left blank and flagged, name-only fields sent to a human.
The demo
Real output, trimmed. The sheet and its data are fictional.
== 1. The naive copy fails, and the error does not say why == add_row(source cells) -> Value is not supported for this column type == 2. Classify every column before writing == Owner contact blank ! not in the approved contact list; left blank Reviewer text needs human ! a name without a contact record Areas multi-picklist (reported as TEXT) copy options plus matching values: treated as multi-select Summary formula skip computed by the sheet; direct writes are rejected == 4. Approval is bound to the exact plan == edited plan with the old token -> refused: ApprovalMismatch == 7. Tampering is detected, and undo refuses to trust a bad log == verify after edit: ok=False (entry 3 content does not match its hash) undo -> AuditIntegrityError == 9. Diagnose a rejected write with one disposable row == offending columns: ['Areas'] (5 probes) rows before/after diagnosis: 1/1
Failure mode to test
The README maps each failure mode to the mechanism that prevents it and the test that proves it. A few of them:
| Failure mode | Mechanism | Test |
|---|---|---|
| Approved one plan, ran another | Token signed over the plan hash | test_plan_edited_after_approval_is_refused |
| Orphan rows from trial and error | One disposable row, deleted in finally | test_cleans_up_when_the_bisection_raises_part_way |
| History edited after the fact | Hash chain over every entry | test_random_tampering_is_always_detected |
| Forged undo data used as a write | Undo verifies the chain first | test_undo_refuses_a_log_that_fails_verification |
Writing the tests found two real bugs, both fixed: classification crashed on an unhashable value in a contact column, and undoing an update split single-select values into lists.
Limits
- The spreadsheet service is an in-memory mock written for this repository. It models common failure shapes and is not a clone of any product. Nothing here talks to a real API.
- Single process, no authentication, no real rate limits.
- The dry run is a preview, not a lock. If the sheet changes after approval, a call can still fail mid-run; undo then restores what was applied.
- Nothing stops an agent from being handed
Executor.approve. Keeping approval on the human side is a convention of how you wire it up.