DEV Community

Cover image for Why does osFoundry make Claude propose GitNest changes instead of pushing them?
BBT
BBT

Posted on

Why does osFoundry make Claude propose GitNest changes instead of pushing them?

This is the tool a Claude chat uses when it wants to put files into a GitNest repository through osFoundry's MCP connector. It comes from the public tools/list of the live endpoint, fetched with curl on 2026-11-06 (no token is needed for the catalogue):

$ curl -s -X POST https://api.osfoundry.io/api/v1/external-mcp \
    -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
Enter fullscreen mode Exit fullscreen mode
{"name": "propose_gitnest_file_import",
 "description": "Prepare up to 20 UTF-8 files (200 KB total) for a native GitNest repository branch. The owner reviews every file and approves the commit in osFoundry. Repository GitNest must be enabled.",
 "inputSchema": {"required": ["repository_id", "branch", "message", "files", "idempotency_key"],
   "properties": {
     "files": {"minItems": 1, "maxItems": 20,
               "items": {"properties": {"path": {"maxLength": 2048}, "content": {"maxLength": 100000}}}},
     "idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128}}},
 "annotations": {"readOnlyHint": false, "destructiveHint": true, "openWorldHint": false},
 "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]}
Enter fullscreen mode Exit fullscreen mode

Read the verbs. The tool "prepares", the owner "approves the commit". The call returns before anything touches Git, and there is no other tool on the connector that does touch it. The idempotency_key is required, not optional, and has a minimum length, so a client can't send an empty or one-character key and call that idempotent.

The rest of this post looks at why that shape is right for an agent and what the server has to do to keep it honest. Then it rebuilds the smallest version over a real Git repository and measures what goes wrong when you leave pieces out.

What do the seven propose tools pin?

GitNest has 13 tools on the connector. Six only read: read_gitnest_repository, search_gitnest_code, list_gitnest_work, read_gitnest_work, list_gitnest_saved_results and list_gitnest_checks. The other seven start with propose_. Their required fields show what each one fixes in place at the moment the model calls it:

Tool idempotency_key expected_change_set_version expected_composition_oid openWorldHint
propose_gitnest_work required no no false
propose_gitnest_change_set required no no false
propose_gitnest_server_composition required required no false
propose_gitnest_file_import required no no false
propose_gitnest_check_rerun required required required false
propose_gitnest_native_publication required required required true
propose_gitnest_provider_publication required required required true

The pins grow with the consequence. A task pins only its key. Composing pins the change-set version the model read. Retrying a check or publishing a pull request pins the version and the exact composed commit. Both publication tools are marked openWorldHint: true, since they produce something outside the proposal store: a pull request, and for the provider tool, a branch pushed to GitHub or GitLab.

In the owner's review card those pins become text: "Change set: … · expected version 3", "Reviewed commit: 9c1e47a…". What the owner approves is that version and that commit, not whatever is current when the click arrives.

Why not hand the agent a push token?

Because a push is decided when the model emits it, and a proposal is decided when a person with current rights looks at it.

osFoundry's code draws that line explicitly. GitNest's write routes are guarded by denyAgentScope, and the in-app Maestro bridge to GitNest permits reads only. The Connected tools don't route around that with a borrowed browser token. Each proposal is stored, shown to the connection owner, and executed by osFoundry's own writer after approval, attributed to the connection. A Claude conversation never holds a credential that can write the repository, and never sees the owner's GitHub or GitLab token. The provider publication pushes only the change set's dedicated branch, with an absent-ref lease, and never mirrors unrelated refs.

The cost of that choice is time. The gap between "proposed" and "approved" is human time: minutes, sometimes a day. In that window the branch moves, someone recomposes the change set, a check finishes, the owner loses their repository role, the connection gets revoked. osFoundry's approval rechecks the connection, the project membership, the owner's current repository role, expiry, and for the change-set actions the exact version and commit, and for publication the risk policy and required checks. "A declined, expired, or stale proposal must not execute" is a line in its plan.

Two failure modes are left, and they're the subject of the rebuild:

  1. Two approvals against the same base. Both look fine on their own.
  2. A retry after a partial failure. Git has the commit, the database doesn't know yet, and the owner clicks again.

For the second one, the osFoundry plan describes the fix it shipped in the Git service: file imports pass the immutable proposal ID as an idempotency key, and the service "updates the branch and replay marker atomically with compare-and-swap, so a database failure after the Git commit can be retried without another commit."

The rebuild

Two files, standard library plus the git CLI (Apple Git 2.39.5), run on an Apple M1 Mac that was also rendering video for other jobs:

  • server.py, 225 lines: a JSON-RPC endpoint over a bare Git repository and a sqlite file.
  • client.py, 203 lines: a scripted "Claude" and a scripted owner.

No Claude binary or Claude account was involved. The client sends the same kind of tools/call requests a connected chat would. The rebuild's tools are read_repo, propose_file_import and read_proposal. Those names are the rebuild's own, and none of them writes Git. The owner decides on a separate route, /owner/decide, which answers an agent token with 403 agent_scope_denied. That's the denyAgentScope idea in one if.

One deliberate difference: the rebuild pins a base commit on file imports too (expected_base_oid, defaulting to the branch tip at proposal time). osFoundry's propose_gitnest_file_import schema has no such field. Treat that part as the rebuild's design choice, not a description of osFoundry.

Proposal intake is idempotent on (connection, idempotency_key), with a hash of the body so a reused key with a different body is an error and not a silent replay:

c.execute("INSERT OR IGNORE INTO proposals (id,conn,idem_key,body_hash,branch,base_oid,files,"
          "message,status,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,'pending',?,?)", (...))
row = c.execute("SELECT * FROM proposals WHERE conn=? AND idem_key=?", (conn, key)).fetchone()
if row["body_hash"] != h:
    raise Conflict("idempotency_key_reused_with_different_body")
Enter fullscreen mode Exit fullscreen mode

Approval builds the commit with plumbing only (read-tree into a temporary index, hash-object -w, update-index --cacheinfo, write-tree, commit-tree -p <base>). None of that moves a ref. Then one transaction moves the branch only if it still points at the reviewed base, and creates a marker ref named after the proposal:

tx = (f"start\nupdate refs/heads/{branch} {new} {base_oid}\n"
      f"create refs/proposals/{proposal_id} {new}\nprepare\ncommit\n")
git("update-ref", "--stdin", inp=tx)
Enter fullscreen mode Exit fullscreen mode

update with an old value is compare-and-swap. create fails if the ref exists. Git applies both or neither. Before building anything, approval looks for refs/proposals/<id>. If it's there, an earlier attempt already reached Git, so it records that commit and returns it as a replay.

What the scripted Claude saw

python3 client.py scenario, with proposal UUIDs shortened. The expiry line comes from a second server started with a 0.3 s TTL:

read_repo                          -> head 29a940e8  (1 commit)
propose A  README.md  key=readme-fix-0001 -> {"proposal_id": "96dcf05f-…", "status": "pending", "base_oid": "29a940e8"}
propose A  (retry, same key+body)  -> {"proposal_id": "96dcf05f-…", "status": "pending", "base_oid": "29a940e8", "replayed": true}
propose A' (same key, other body)  -> idempotency_key_reused_with_different_body
propose B  app.py     key=app-bump-0001   -> {"proposal_id": "c91824e3-…", "status": "pending", "base_oid": "29a940e8"}
decide A with the agent token      -> HTTP 403 {'error': 'agent_scope_denied'}
owner approves A                   -> HTTP 200 {"status": "executed", "commit": "f07b9fda"}  (2 commits)
owner approves B                   -> HTTP 200 {"status": "stale", "refused": true, "base_oid": "29a940e8", "branch_head": "f07b9fda"}  (2 commits)
propose B2 on new head f07b9fda    -> {"proposal_id": "bc3cedfc-…", "status": "pending", "base_oid": "f07b9fda"}
owner approves B2, crash before DB -> HTTP 500 {'error': 'injected: crash after git, before DB'}  (3 commits)
owner retries approval of B2       -> HTTP 200 {"status": "executed", "commit": "fb736eda", "replayed": true}  (3 commits)
owner clicks approve on A again    -> HTTP 200 {"status": "executed", "commit": "f07b9fda", "replayed": true}  (3 commits)
approve after decline              -> HTTP 200 {'status': 'declined', 'refused': True}  (3 commits)
approve after expiry (ttl 0.3 s)   -> HTTP 200 {'status': 'expired', 'refused': True}  (1 commit)

fb736ed Claude (conn-claude-research) | repo-owner | Bump app
f07b9fd Claude (conn-claude-research) | repo-owner | Pin install version
29a940e owner | owner | seed
Enter fullscreen mode Exit fullscreen mode

B touched app.py and A touched README.md, and B was still refused. That's the strict reading of "the owner reviewed this against 29a940e8". The fix is cheap: the client reads the new head and proposes again, which is what B2 is. The git log line shows the provenance the store writes. The author is the connection, the committer is the person who approved, and the message carries Proposal: <id> and Approved-by: trailers.

What breaks without compare-and-swap?

--mode naive keeps everything except the transaction. It checks the tip in Python, builds the commit, then runs git update-ref refs/heads/main <new> with no old value and no marker. Same server, same client, same repository layout.

Race test: two different proposals on the same base, approved at the same instant from two threads (threading.Barrier), 100 trials per run, two runs. After each trial the client asks git merge-base --is-ancestor whether every commit reported as executed is actually on main.

Crash test: approve with an injected failure after the Git step and before the database update, then approve again. 50 trials per run, two runs. The client counts commits on main before and after.

Mode Race: both reported "executed" Race: approved commits not on main Crash, then retry Extra commits after retry
CAS + marker, run 1 0 / 100 0 50 × executed (replayed) 0
CAS + marker, run 2 0 / 100 0 50 × executed (replayed) 0
naive, run 1 100 / 100 100 50 × stale 0
naive, run 2 100 / 100 100 50 × stale 0

The naive race failed in every trial, not occasionally. The window between "tip equals base" and "write the ref" is four git processes long, tens of milliseconds, and two threads released by a barrier both land inside it every time. Both approvals return executed with a commit ID. The owner sees two green results, and one of the two commits isn't in the branch's history. Nothing errors. With compare-and-swap, every trial ended with exactly one executed proposal and one stale refusal naming the new head.

The naive crash retry didn't double-commit. The base check caught it, since the branch had moved. But it caught it as stale. The proposal's own commit is on main, and the store now reports that proposal as refused. If you drop the base check to make that go away, the retry writes the commit a second time. The replay marker is what lets the retry say "this already happened, here is the commit".

What does it cost?

Wall-clock per request from the client over loopback HTTP (urllib, new connection each time), n = 200 per operation per run, two runs. Ranges are run 1 to run 2.

Operation CAS median CAS p95 naive median naive p95
propose (new key) 4.42–4.44 ms 7.1–12.3 ms 4.08–4.66 ms 6.4–10.0 ms
propose (same key, replay) 1.42–1.50 ms 3.3–4.5 ms 1.27–1.49 ms 2.4–4.1 ms
approve, executes 115–119 ms 199–257 ms 92–105 ms 130–193 ms
approve, refused as stale 28–31 ms 52–70 ms 14–17 ms 24–34 ms
approve again, already executed 1.46–1.60 ms 3.4–4.7 ms 1.38–1.54 ms 2.6–4.2 ms

Almost all of it is process spawns. An executing CAS approval runs eight git processes (marker lookup, tip lookup, read-tree, hash-object, update-index, write-tree, commit-tree, update-ref --stdin), the naive one six. A stale refusal is two lookups against one. At roughly 14 ms per git process on this loaded machine, the safe path costs about two extra spawns, around 15–25 ms, on a request that waits on a human click anyway. A server that links libgit2 instead of shelling out would cut all of these columns. The difference between them would stay the two lookups.

Replays are the cheapest path in both tables, a single sqlite read. A retrying client costs the server less than a new request.

If you wire this up yourself

  • Give the agent no write tool for the repository, only propose_*, and check on the decide route that the caller isn't an agent token. Don't rely on the agent not finding the route.
  • Pin what the reviewer saw, version and commit, in the proposal, and compare it at approval. Refuse with the current head in the response, so the client can re-read and propose again.
  • Make the Git write one ref transaction: branch update with the old value, plus a create-only marker named after the proposal. Look for the marker before building anything.
  • Require an idempotency key with a minimum length, store it with a hash of the body, and reject a reused key with a different body.
  • Write provenance into the commit itself. The author is the connection, the committer is the approver, and a trailer carries the proposal ID. git log will still have it after your database is gone.
  • Stop at a pull request. Merge and deploy belong to whoever owns the repository, on their own screen.

The GitNest side of this, clone, push and access tokens, is described on osFoundry's GitNest page. The connector speaks plain MCP, so the same proposal tools work from Claude Code or any other MCP host.

Top comments (0)