@multimindlab/reversi-connectors ships fourteen connectors. Every tool has a manifest, and every
compensation named in a manifest is itself a tool in the same package that accepts exactly the arguments the
argument_map produces. Rollback therefore works end to end, with no glue code.
Tool names are <connector>.<tool>, such as fs.write_file or stripe.create_refund.
Run a connector behind the gateway
Print the manifests, then serve the connector over MCP and put the gateway in front of it.compensate reads one compensation call as JSON on stdin, runs the matching tool and exits 0 when the undo
succeeded. It enables each connector whose credential is in the environment; the filesystem connector is always
enabled, rooted at --root. The call’s idempotency_key is passed to Stripe as the Idempotency-Key header, so a
retried rollback never repeats a side effect.
<name> is one of fs, github, slack, stripe, gitlab, teams, linear, jira, gcalendar, postgres, aws, gcp, azure, kubernetes.
Credentials are read from the environment only; a flag such as
--token is not accepted. compensate enables every
connector whose main credential variable is set (GITHUB_TOKEN, SLACK_BOT_TOKEN, STRIPE_SECRET_KEY,
GITLAB_TOKEN, TEAMS_TOKEN, LINEAR_API_KEY, JIRA_API_TOKEN, GOOGLE_CALENDAR_TOKEN, POSTGRES_URL,
AWS_ACCESS_KEY_ID, GCP_ACCESS_TOKEN, AZURE_STORAGE_TOKEN, KUBE_API_SERVER).
Secrets are redacted from every error message.
In code:
EffectUncertainError, so the step is journaled uncertain and rollback reports it instead of guessing.
Filesystem (fs)
Every path is relative to --root. Absolute paths, .., and symlinks that lead outside the root are rejected. The
backup directory (default <root>/.reversi-backups) is reserved and hidden from fs.list_dir.
Running a compensation a second time succeeds and changes nothing.
fs.remove_dir only removes empty directories.
GitHub (github)
Calls https://api.github.com (override with baseUrl) with a bearer token and API version 2022-11-28.
Limits:
- Issues cannot be deleted through the API, so undoing
create_issuecloses it. The issue stays visible. github.delete_branchis declaredidempotentbecause it must be callable as a compensation. That also means an agent that deletes a branch itself is not undone by rollback. Leavegithub:branch:deleteout of the agent’s scope; compensations run outside the agent’s scope.- Recreating a deleted branch needs its SHA, which the connector does not keep.
Slack (slack)
Calls https://slack.com/api. Slack answers HTTP 200 with { "ok": false, "error": "..." } on failure; the connector
treats that as a failure.
message_not_found, already_reacted and no_reaction count as success where the goal is already met.
Limits:
- A bot can only delete messages it posted, and the workspace may forbid deletion. Then rollback reports the step as failed.
- People may have replied or seen the message before it is deleted.
- If the bot had already reacted with the same emoji,
add_reactionsucceeds without changing anything, and rollback still removes that reaction.
Stripe (stripe)
Calls https://api.stripe.com with form-encoded bodies. Each write carries an Idempotency-Key: the engine’s key when
running a compensation, otherwise a fresh random one (so two deliberate identical calls are not merged).
Limits:
- Refunds move money and cannot be recalled. Rollback lists them as unrecoverable and a human must approve each one.
- A payment intent that has already succeeded cannot be canceled; rollback fails that step. Create intents unconfirmed and confirm them outside the agent.
- A deleted Stripe customer cannot be restored, and its saved payment methods are gone. Rollback only deletes customers the run created.
GitLab (gitlab)
Calls https://gitlab.com/api/v4. For a self-hosted instance set GITLAB_BASE_URL (or baseUrl in code) to its root; /api/v4 is added. The token goes in the PRIVATE-TOKEN header. project is a numeric ID or a full path such as group/project.
Required token scopes:
read_api for get_issue; api for everything else. The user behind the token needs at least the Developer role in the project.
Limits:
- The issue is closed, not deleted (as with GitHub): it stays visible in the project.
gitlab.delete_branchis idempotent so it can run as a compensation; an agent that calls it itself is not undone. Keepgitlab:branch:deleteout of the agent’s scope. Protected branches cannot be deleted without the Maintainer role.
Microsoft Teams (teams)
Calls Microsoft Graph, https://graph.microsoft.com/v1.0, with a bearer token in TEAMS_TOKEN. Obtain the token yourself (the connector does not run an OAuth flow).
Microsoft Graph permissions, as we read the Graph reference (confirm against the current reference for your tenant, since Microsoft changes these):
Limits:
- Posting needs a signed-in user (delegated token). A service principal with only application permissions cannot use
teams.post_message. softDeletemarks the message deleted; members still see a “message deleted” placeholder in the channel, and the message remains in compliance exports. It is not a purge.- People may have read or replied to the message before it is deleted.
- The Graph endpoints and permissions above are implemented from Microsoft’s documentation and tested against a fake; they have not been run against a live tenant.
Linear (linear)
One GraphQL endpoint, https://api.linear.app/graphql. A personal API key is sent as the whole Authorization value (no Bearer prefix). To use an OAuth token instead, pass it prefixed with Bearer .
OAuth scopes:
read for get_issue; write (or issues:create and comments:create for the create calls) for the rest. A personal API key has the user’s full access.
Limits:
- An archived issue still exists; it is hidden from boards and can be restored in the Linear UI. Linear sends notifications when an issue is created, and archiving does not recall them.
- GraphQL errors come back with HTTP 200 or 400; both are treated as failures. “Entity not found” counts as success for the two compensations, since the goal (the thing is gone) is met.
Jira (jira)
Jira Cloud REST API v3 at JIRA_BASE_URL (such as https://yourco.atlassian.net), with basic authentication: your account email and an API token. Descriptions and comments are sent as Atlassian Document Format paragraphs.
jira.transition_issue is not reversible by itself. It reads the issue’s current status first and returns previous_status and previous_status_id; rollback calls jira.restore_status, which finds a workflow transition that leads to that status and runs it. A transition can be given as an ID, a transition name, or the name of the status it leads to.
Jira project permissions the account needs: Browse Projects, Create Issues, Add Comments, Transition Issues, Delete Issues (for the create_issue compensation), Delete Own Comments (for the add_comment compensation). With OAuth 3LO instead of basic auth the scopes are read:jira-work and write:jira-work.
Limits:
- Deleting an issue cannot be undone and the issue key number is not reused.
jira.delete_issueis idempotent only so it can run as a compensation; keepjira:issues:deleteout of the agent’s scope. Jira refuses to delete an issue that has subtasks. - If the workflow has no transition back to the previous status,
jira.restore_statusfails and the rollback reports the step as failed. The issue history shows both transitions, and workflow post-functions (assignee changes, resolutions, notifications) run each time.
Google Calendar (gcalendar)
Calls https://www.googleapis.com/calendar/v3 with a bearer token in GOOGLE_CALENDAR_TOKEN. calendar_id defaults to primary. Every write passes sendUpdates=none, so no invitation or change emails go out.
gcalendar.update_event reads the event first and returns previous, holding the old value (or null if the field was empty) of exactly the fields it changed. gcalendar.restore_event patches those values back; a null removes the field. Fields: summary, description, location, start, end.
OAuth scopes: https://www.googleapis.com/auth/calendar.events.readonly for list_events; https://www.googleapis.com/auth/calendar.events for the rest.
Limits:
- Attendees are not managed by this connector, and nothing is emailed. If you need invitations, do that outside the agent.
- A deleted event goes to the calendar’s trash for a while but is not restored by rollback of
create_event; it is deleted. - Someone editing the event between
update_eventand rollback has those edits to the same fields overwritten by the restore.
Postgres (postgres)
Connects with POSTGRES_URL through the pg package. Every tool takes parameters only:
- Table, schema and column names must match
^[A-Za-z_][A-Za-z0-9_]{0,62}$and are always double-quoted. - Every value is a bound parameter (
$1,$2, …). No value is ever written into SQL text. The tests assert the exact SQL and parameters.
How each works:
insert_rowrunsINSERT INTO "t" ("a","b") VALUES ($1,$2) RETURNING "id"(the key column isidunlesskey_columnsays otherwise) and returns the key.update_rowruns one transaction:BEGIN,SELECTof the old values of the columns being set withFOR UPDATE,UPDATE,COMMIT. It returns the old values;restore_rowsets them back.querytakes oneSELECTorWITHstatement with$nplaceholders andvalues. It runs betweenBEGIN READ ONLYandROLLBACK, so a write hidden in aWITHis refused by the server. Statements containing;are rejected up front because a multi-statement string sent without parameters would run outside the guard. At most 1000 rows come back.- Objects and arrays are bound as JSON text (for
jsonandjsonbcolumns). Binary columns are not supported. - A server error carries its SQLSTATE (
Postgres error 42501: ...) and the transaction is rolled back. A lost connection after a write was sent isEffectUncertainError.
SELECT for query; INSERT for insert_row; UPDATE for update_row and restore_row; DELETE for delete_row. Use a dedicated role.
Limits:
READ ONLYstops writes to tables, not every side effect:nextval(), advisory locks and functions from extensions still run. Grant the role only what it needs.- Rollback of
insert_rowdeletes the row, but sequences stay advanced (gaps inserialids), triggers andON DELETE CASCADErules run, and rows other transactions linked to it meanwhile are affected. restore_rowwrites the old values back, soupdated_at-style triggers fire again and any change by others to those columns since is overwritten.postgres.delete_rowdeletes data for good. It is idempotent so it can run as a compensation; keeppostgres:rows:deleteout of the agent’s scope.
Amazon S3 (aws)
Signs requests with AWS Signature Version 4, implemented here with node:crypto (no AWS SDK). Credentials: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, optional AWS_SESSION_TOKEN. Requests go to https://<bucket>.s3.<region>.amazonaws.com (path-style for bucket names containing dots, or against endpoint for MinIO and similar). Content is text by default, or base64 with encoding: "base64"; objects up to 5 MiB.
How the undo works: before
put_object overwrites or delete_object deletes an existing key, the connector copies it server-side to .reversi-backups/<uuid> in the same bucket and returns backup_key and existed. restore_object copies the backup back over the key, or deletes the key when there was no previous object (backup_key is null). Without s3:ListBucket S3 answers 403 instead of 404 for a missing key, so grant it.
Limits:
- Backup objects are kept (that is what makes the restore repeatable). Add a lifecycle rule that expires
.reversi-backups/. - Copies use
CopyObject, which is limited to 5 GiB per object; larger objects fail. - There is no precondition on S3 writes here, so a writer racing between the backup and the write can have its change restored over. The Cloud Storage and Blob Storage connectors guard this with generation and ETag preconditions.
- Bucket event notifications, replication and versioning react to every write, copy and delete the connector makes.
.reversi-backups/keys cannot be written or deleted through the tools and are hidden fromlist_objects.
Google Cloud Storage (gcp)
Calls the Cloud Storage JSON API at https://storage.googleapis.com with a bearer token in GCP_ACCESS_TOKEN. Same shape as S3, with preconditions: the connector reads the object’s generation, copies that generation to .reversi-backups/<uuid> with rewriteTo (looping until done, so large objects work), and then writes or deletes with ifGenerationMatch set to that generation (0 for a new object). If someone changed the object in between, the write is refused with 412 and nothing is overwritten.
Required: OAuth scope
https://www.googleapis.com/auth/devstorage.read_write (devstorage.read_only for reads only) and the IAM permissions storage.objects.get, storage.objects.list, storage.objects.create, storage.objects.delete. The role roles/storage.objectAdmin covers them.
Limits: as for S3, backup objects remain (add a lifecycle rule); object versioning and Pub/Sub notifications react to every write; restore_object overwrites whatever is at the key now.
Azure Blob Storage (azure)
Calls https://<AZURE_STORAGE_ACCOUNT>.blob.core.windows.net (service version 2023-11-03) with a Microsoft Entra bearer token in AZURE_STORAGE_TOKEN (audience https://storage.azure.com/). Same shape as S3, with ETag preconditions: the blob is copied synchronously to .reversi-backups/<uuid> only if its ETag is unchanged (x-ms-source-if-match), then written with If-Match (or If-None-Match: * for a new blob) or deleted with If-Match.
Required Azure roles: Storage Blob Data Reader for reads; Storage Blob Data Contributor for writes and deletes.
Limits: the synchronous copy (
Copy Blob From URL) is limited to 256 MiB; larger blobs fail. Backup blobs remain (add a lifecycle management rule). Soft delete, versioning and Event Grid events on the account react to each operation. Only block blobs are written.
Kubernetes (kubernetes)
Calls the Kubernetes REST API at KUBE_API_SERVER with a bearer token in KUBE_TOKEN. With KUBE_CA_CERT (a path to the cluster CA in PEM) requests go through node:https with that CA; without it the connector uses the runtime’s fetch. Namespaces must be DNS-1123 labels and names DNS-1123 subdomains, so they cannot contain a path or a query.
scale_deploymentreads the current replica count through the scale subresource, scales with a merge patch, and returnsprevious_replicas; rollback sets it back.create_configmapfails with 409 if the name exists, so rollback never deletes an object it did not create. Rollback deletes with a UID precondition, so a different ConfigMap that later took the same name is left alone (the 409 is treated as success).rollout_restartstamps the pod template annotationkubectl.kubernetes.io/restartedAt, likekubectl rollout restart. Pods are replaced, so it is declared irreversible and always needs approval.delete_podlikewise.
restore_replicas sets the saved count even if someone changed it since; pods that read a ConfigMap you created may have acted on it before it was deleted. The KUBE_CA_CERT transport is tested against a local HTTP server, not a TLS handshake with a real CA.
Certification
Every connector is run through a twelve-check certification suite innpm test. The suite drives each tool against a fake backend with fault injection, using a real Reversi engine for the rollback checks. A connector that fails any check fails the test run. Each check records pass, fail or not_applicable, and not_applicable must carry a reason.
Results, generated from the test run (the test suite fails if this table or the reasons below drift from the actual results):
Why a check is
n/a:
- fs, Retry: the local filesystem has no network transport that can fail transiently; errors are definite
- fs, Permission denial: access control is the operating system’s; the connector confines paths to its root instead (covered in filesystem.test.ts)
- fs, Timeout: local file operations have no network wait to time out
- fs, Redaction: the connector holds no credential
- All connectors, Dependency block: no tool manifest declares
requires
requires; the suite’s self-test proves it with a synthetic connector that does. No shipped connector declares one yet, so for them it has not been exercised.
Run reversi-connectors certify all for the checklist, and see packages/connectors/test/certify.test.ts, test/certification.ts and test/fixtures/<name>.ts for the suite. test/certification.selftest.test.ts shows the suite failing a deliberately broken connector (credential leak, lost uncertainty, an undo that does nothing, no timeout).
Manifest drift
connectorSchemaHashes(connector) returns a SHA-256 of each tool’s input schema (canonical JSON, so key order does not matter). Store it as a baseline; detectManifestDrift(manifests, inputSchemas, baseline) later returns the tool names that were added, removed or changed:
Honest limits of the certification
- Everything is tested against fakes, not live services. Each fake is written from the vendor’s API documentation. If a real service behaves differently from its fake, the suite cannot tell. The first run against a real account is the real test; start with a throwaway project, bucket or database.
- An HTTP 5xx on a write is reported as an HTTP error naming the status, not as
EffectUncertainError. The server answered, so the connector treats the call as failed. Only a lost connection or a timeout is uncertain. For a gateway timeout (502, 504) the write may in fact have been applied; check before retrying. - Compensations that leave residue: GitHub and GitLab issues are closed, not deleted; Stripe payment intents are canceled, not removed; Linear issues are archived; Teams messages become soft-deleted placeholders; S3, Cloud Storage and Blob Storage keep
.reversi-backups/copies; the filesystem keeps its backup directory; Postgres sequences stay advanced; a Jira rollback leaves two transitions in the issue history. Where a fake backend does not return to its exact prior state after rollback, the fixture overrides the comparison in code (restored, or a fingerprint that ignores backups), so the difference is stated rather than hidden. Residue the fakes do not model (Jira history, Postgres sequences, notifications) is documented here but not tested. - Irreversible tools are certified only for what they can be: Stripe refunds,
kubernetes.rollout_restartandkubernetes.delete_podare checked for forward behaviour, failure, retry, permission, timeout and redaction. They have no compensation, so rollback reports them unrecoverable. - Concurrent writers. The restore tools put back what the connector saw. Someone else changing the same thing between the call and the rollback is overwritten, except where a precondition guards it (Cloud Storage generations, Blob Storage ETags, Kubernetes UIDs, Postgres row locks during
update_row).

