Work / Safe writes

Safe writes for tool-using agents.

Agents that can write to spreadsheets and trackers fail in boring, costly ways: malformed or orphaned records left by trial and error, silent overwrites, and actions nobody approved. This is a small safety layer for that, with a mock API that reproduces the usual failure modes.

Role
Sole author
Stack
Python 3.9, standard library only
Includes
Policy, audit log, executor, mock API, demo
Tests
138, with seeded property tests

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 modeMechanismTest
Approved one plan, ran anotherToken signed over the plan hashtest_plan_edited_after_approval_is_refused
Orphan rows from trial and errorOne disposable row, deleted in finallytest_cleans_up_when_the_bisection_raises_part_way
History edited after the factHash chain over every entrytest_random_tampering_is_always_detected
Forged undo data used as a writeUndo verifies the chain firsttest_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

Honest 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.