# Can duplicate gateway instances split your audit trail?

A copied app bundle is not a harmless way to create a second local gateway. It is a test of whether the software can keep one authority over credentials, approvals, revocation, and evidence when macOS presents it with two plausible claimants.

The dangerous failure is not always a crash. A crash is loud. The quieter failure has two processes that both look healthy, both accept agent requests, and both leave enough evidence to reassure the person who launched them. Then a credential change, a revoke, or an incident review exposes the split: one process knew something the other did not.

Sallyport is designed as one signed, always-running macOS menu-bar app with an in-process vault core, so launching stable, beta, and copied bundles together should be treated as a deliberate concurrency and identity test, not normal operating practice. The test should establish a narrow answer: does the system refuse the duplicate, coordinate it safely, or permit it while preserving one authoritative vault and one verifiable history?

## Two bundles do not automatically mean two identities

The Finder name is the weakest identity signal in this experiment. `Sallyport.app`, `Sallyport Beta.app`, and `Sallyport Copy.app` can look like three independent applications to a human while still carrying the same bundle identifier and the same signing identity inside their bundles.

Apple documents the bundle identifier as the identifier used by macOS for app-level identification, and its code-signing material explains that a designated requirement gives the system a way to recognize code as the same application across updates. Those are useful concepts, but neither one settles the question you care about here. Identity recognition by macOS does not prove that two live processes own state safely.

Start by recording what you actually launched. Do this before opening the vault, attaching an agent, or approving anything.

```sh
APP_A="/Applications/Sallyport.app"
APP_B="$HOME/Desktop/Sallyport Beta.app"

for app in "$APP_A" "$APP_B"; do
  echo "=== $app ==="
  plutil -p "$app/Contents/Info.plist" | grep -E 'CFBundleIdentifier|CFBundleShortVersionString|CFBundleVersion'
  codesign -dvv "$app" 2>&1 | grep -E 'Identifier=|TeamIdentifier=|Authority='
  codesign -d -r- "$app" 2>&1 | grep 'designated =>'
done
```

The output shape matters more than the exact text:

```text
=== /Applications/Sallyport.app ===
"CFBundleIdentifier" => "..."
"CFBundleShortVersionString" => "..."
Identifier=...
TeamIdentifier=...
designated => identifier "..." and anchor ...
```

Keep this artifact with the test record. If the two intact bundles report the same identifier and designated requirement, call them two copies of one code identity. If they differ, call them separate code identities. Do not use labels such as “stable” and “beta” as substitutes for either fact.

There is a second distinction that teams often blur: a code identity is not a storage identity. Two processes with the same designated requirement may be expected to read the same protected material. Two processes with different requirements may receive separate system permissions. Neither outcome says whether they can safely share an encrypted vault or append to an audit chain. You must test storage ownership separately.

## A split audit trail is worse than an incomplete log

An incomplete journal tells you that evidence is missing. A split journal can tell two internally consistent stories, each missing the records held by the other. That makes incident review slower and can make a revoked session appear valid from one process's point of view.

For an action gateway, the journal needs to answer concrete questions after the fact:

- Which agent process asked for the action?
- Which gateway process authorized or denied it?
- Which credential reference was used without exposing its secret?
- Did a human approve the session or the individual call?
- Did a revoke happen before or after the action?

If process A and process B each write an independent sequence, both sequences may verify on their own. That is not enough. Verification of a cryptographic chain shows that records within the chain have not been altered without detection. It does not, by itself, prove that another legitimate writer did not start a separate chain elsewhere.

This is why the distinction between tamper evidence and completeness matters. A hash chain protects the relationship between records that are present in that chain. Completeness requires a clear ownership rule, a single append point, or durable evidence of every accepted branch and how it was reconciled. If you get this wrong, an operator can run verification successfully and still miss half the actions.

The topic is broader than logs. A split audit trail often follows split authority:

- One process believes the vault is locked while another has an unlocked session.
- One process revokes an agent run while another still accepts it.
- One process records a per-call approval while another sees no reason to ask.
- One process writes the final action result while the other writes only the request.

Do not judge the test by whether both copies can complete an HTTP call or SSH command. A successful call only proves that a path exists. The test passes when a later reviewer can reconstruct the full action history without guessing which copy held the missing state.

## Decide the ownership contract before starting the race

A duplicate-instance test without expected invariants produces anecdotes. Write the contract first, then try to break it.

There are three defensible contracts for local gateway copies.

1. **Exclusive ownership.** The first process owns the vault and journals. Later copies refuse to operate, bring the first app forward, or exit with a clear reason.
2. **One active process with handoff.** A later launch discovers the owner and asks it to perform the requested work. The new process does not independently unlock, approve, or append.
3. **Coordinated multi-process ownership.** More than one process can run, but all of them use a deliberate shared-state protocol that preserves atomic vault changes and a single auditable event order.

The first contract is usually easiest to reason about for a desktop application that holds high-consequence state. The third can be valid, but it carries a much larger proof burden. “They both point at the same folder” is not a coordination protocol.

Write expected outcomes in a table before testing. Keep each outcome observable.

| Condition | Expected behavior | Evidence to retain |
| --- | --- | --- |
| Stable owns an unlocked vault | Beta launch is refused, handed off, or coordinated | UI state, process list, gateway response |
| Stable owns a locked vault | No copy may execute an action until the vault gate opens | denied request record and local state |
| Stable has an approved agent session | A new agent process needs its own session decision | approval records with process identity |
| Per-call approval is set for a credential | Every use asks, regardless of which copy receives it | one approval record per attempted call |
| A run is revoked in one process | No process may continue that run | revoke event and denied follow-up call |
| Both copies request a benign action at once | History remains whole and verifies | ordered activity records and audit verification |

The wording matters. “The copies should not conflict” is not testable. “A copied bundle cannot return a successful action response after an existing owner has revoked the same agent run” is testable.

Also separate product behavior from test harness behavior. Your shell script can prevent two launches by accident, but that does not prove the app prevents them. Your reverse proxy can serialize requests, but that does not prove the local vault handles simultaneous requests. Do not let the harness solve the bug you intended to find.

## Launch intact copies before testing altered artifacts

The clean experiment begins with intact app bundles. Copy an application bundle without changing files inside it, put the copies in separate ordinary locations, and record each path. Altering an executable or `Info.plist` first will often invalidate code signing, which changes the experiment into a signature-validation test.

Apple's TN3127 explains why this boundary matters: macOS relies on designated requirements to decide whether code satisfies an established identity. If you modify a bundle and its signature no longer validates, any refusal may be correct but tells you nothing about duplicate ownership among valid builds.

Use a small launch worksheet. It should contain only facts you can observe:

```text
Test ID: duplicate-owner-01
Build A path: /Applications/Sallyport.app
Build B path: /Users/tester/Desktop/Sallyport Beta.app
Bundle IDs: [recorded value A] / [recorded value B]
Designated requirements: [recorded value A] / [recorded value B]
Launch order: A first, B second
Vault state before launch: locked
Agent process IDs: [record after start]
Expected contract: exclusive ownership
```

Then launch A, wait until it has reached its idle state, and launch B. Record the result before you do anything else. A refusal must be explicit enough that the operator knows which existing process owns the state. A silent exit creates support tickets and tempts people to retry until they produce a race.

Confirm actual process separation with the operating system rather than app windows. A second window can belong to the same process, while a background helper can create a second process even when there is one visible menu-bar item.

```sh
pgrep -alf 'Sallyport|sp mcp|sp-ssh'
ps -axo pid,ppid,start,command | grep -E 'Sallyport|sp mcp|sp-ssh' | grep -v grep
```

Treat the output as a timeline artifact. Save it immediately after each launch phase. Include parent process IDs where possible. They help explain whether the second action came from an independent app process, an agent-launched shim, or a child helper.

Do not use production credentials for this work. Point HTTP requests at a dedicated endpoint that accepts a harmless method or a controlled test service. For SSH, use a dedicated account with a command restriction or a host that has no production access. You are testing process ownership and audit behavior. You do not need a destructive endpoint to learn whether a second copy can act.

## The vault gate must have one visible owner

The vault gate is an absolute boundary only if every action path reaches the same gate. If one process can remain unlocked after another process locks, quits, or loses access, then “locked” describes a window rather than the actual system state.

Test this sequence with a benign HTTP action first:

1. Start copy A with the vault locked.
2. Connect one agent process through the local MCP shim and attempt the harmless action. Expect denial.
3. Unlock A through the normal local interaction, then repeat the action. Record the successful result and its activity entry.
4. Launch copy B while A remains available. Do not unlock B yet.
5. Ask the same agent process and a fresh agent process to attempt the same action through B's reachable route, if one exists.
6. Lock A or quit it, then repeat the requests from both agents.

A passing result depends on the ownership contract, but the unsafe result is easy to name: B performs the action because it retained or independently acquired authority that the human could not see when they locked A.

This is where a common recommendation fails: “Just share the unlocked state so beta does not keep asking.” People recommend it because repeated local authentication is annoying during development. It is wrong unless the shared state has a defined owner, a clear lifetime, and a revocation path that every process observes before acting. Saving a convenience prompt is not worth creating an invisible second unlock state.

The hardware-gated vault model adds a useful test boundary. While the vault is locked, an agent should receive a denial rather than a placeholder secret, a partially prepared request, or a queued action that later fires after unlock. An action gateway should execute an approved action itself and return its result. It should not hand credential material to the agent while waiting for the human to resolve local state.

Record both the action response and the journal evidence. A denial that disappears from history makes later debugging unnecessarily hard. A success that appears without a preceding unlock or approval record is worse.

## Session approval must bind to a process, not a label

Per-session authorization exists to make a human judge a particular agent run. It loses its meaning when the authorization spills from one process to another because both runs have a similar name, run from the same terminal, or connect through the same copied bundle.

The approval record should let an operator answer: which executable asked, who signed it, when did that process begin, and when did its authority end? The process's code-signing authority is especially useful because a terminal command alone does not identify the code behind it.

Run this test with two deliberately separate agent processes. Do not reuse one shell process and call it two agents.

```sh
# Terminal 1
sp mcp

# Terminal 2
sp mcp
```

The commands are intentionally plain. The important part is that they originate from separate agent processes in your test harness, then each sends one harmless call. Approve the first session and leave the second unanswered. If your setup exposes process details in the approval card, capture the displayed authority and compare it to the process you launched.

Now try the following transitions:

- Exit the approved agent process, then start a replacement process with the same command.
- Launch or activate a copied gateway while the first session remains approved.
- Revoke the first run from the session journal.
- Ask both processes to issue one more harmless request.

The expected rule is simple: an approval belongs to a run until that run exits, and a revoke ends that run's authority everywhere. A replacement process must not inherit approval merely because it looks familiar. A copied gateway must not interpret the same agent connection as permission to bypass a decision that its peer would require.

Do not confuse per-session approval with per-call keys. A session approval answers whether this agent process may use the gateway for its run. A per-call key answers whether every use of this specific credential needs a fresh human decision. One is about the caller's lifetime; the other is about the credential's sensitivity. If you merge them in your test notes, you will misread the result.

## Per-call approval exposes hidden duplicate paths

A credential marked for approval on every use makes a good duplicate-instance probe because it forces the software to account for each action separately. Use one dedicated test credential or SSH target, set its per-call requirement, and send simultaneous benign requests through the two candidate paths.

The test is not “did I get two prompts?” Two prompts might be correct, or they might show that both copies created independent authority. The question is whether each completed action has one corresponding approval and whether either copy can reuse another copy's approval.

Build a small event ledger while you run the test:

| Time | Gateway process | Agent PID | Request ID | Human decision | Result |
| --- | --- | --- | --- | --- | --- |
| 10:03:01 | A | 4128 | req-a1 | approved | success |
| 10:03:02 | B | 4194 | req-b1 | denied | denied |
| 10:03:04 | A | 4128 | req-a2 | approved | success |

Use request IDs generated by your test endpoint or harness. Do not rely on the order in which windows appeared. Desktop scheduling can reorder visible prompts, and an HTTP result can return after a later request.

A bad outcome often looks tidy at first: approve request A in copy A, then discover that copy B can finish request B without its own approval. That indicates approval state crossed a boundary without an operator decision. The reverse failure is also serious: approve B, but the result records under A's activity stream with no explanation of why A acted.

Repeat the race after locking the vault, after quitting one copy, and after revoking the first agent run. Those transitions catch stale in-memory state. The most revealing bugs tend to appear after a state change, when one process has refreshed its view and the other has not.

## Audit verification needs a before-and-after proof

A test report that says “the log looked fine” is not evidence. Capture a baseline, create a controlled set of actions, verify after the run, then compare expected records with observed records.

The audit design described for this gateway has an advantage: the same write-blind encrypted, hash-chained log projects into both the Sessions journal and Activity journal. The journals should therefore be two views of one history, not two independently maintained records that merely resemble each other.

Start with an empty or clearly bounded test period. Note the starting time, then generate a known sequence: denied while locked, approved session action, per-call approved action, per-call denial, session revoke, denied post-revoke action. Keep the sequence short enough that you can account for every event.

Afterward, run the offline verifier:

```sh
sp audit verify
```

Retain the complete command output, including a nonzero exit status if it fails. The verifier should not need vault access to check the ciphertext chain. That property lets an investigator verify integrity without first unlocking the credential store, which is exactly what you want when a duplicate-process test has gone badly.

Verification alone is only the first check. Compare three views:

1. Your external test ledger, including every request ID and expected result.
2. The Sessions journal, including approvals, run exits, and revokes.
3. The Activity journal, including each action attempt and result.

Every ledger event should map to the appropriate journal view. Every journal event in the bounded test period should map back to something you intentionally generated. Investigate extra records, even if the chain verifies. An unexpected successful action may be a retry, a queued request, or evidence that a second process remained active after you thought it had exited.

If two independent chains verify, report that as a failure of history completeness unless the documented multi-process design records a parent relationship and a deterministic merge. Do not solve this by concatenating exported logs after the test. A manual merge produces a report, not an audit trail.

## Recovery behavior decides whether a duplicate bug becomes an incident

You should expect interrupted ownership attempts. People force-quit beta builds. Laptops sleep. A test harness kills a process between an approval and an action result. The application must handle these cases without letting an abandoned owner block work forever or letting a replacement owner assume that no action happened.

Test at least four interruption points:

- Kill copy A after it receives a request but before it obtains approval.
- Kill copy A after approval but before the external action completes.
- Kill copy A after the action completes but before the result is visible to the agent.
- Launch copy B immediately after each interruption.

For each case, write down the safe recovery rule before you perform it. A reasonable rule might be that B refuses until it can establish that A has exited and recover durable state. Another might be that B resumes only from committed audit records and marks any uncertain action as unknown rather than declaring success or failure. The correct rule depends on the implementation, but guessing is never acceptable.

Do not hide uncertainty. External systems can complete an HTTP request or SSH command just as the local process dies. If the gateway cannot prove whether the operation completed, it should preserve that ambiguity in the evidence. Retrying an unknown write can create a second real-world action. Pretending the first one never happened creates a false audit story.

This is also where instant revoke needs scrutiny. Revoke one agent run, terminate the current owner immediately afterward, launch a copy, and attempt the same action from that agent. If the replacement process treats missing in-memory state as a fresh session, the revoke was not durable enough. A revoke that only works while one process remains alive is not a revoke you can rely on during failure.

## Keep stable and beta tests isolated unless sharing is deliberate

Stable and beta builds tempt teams into casual coexistence because both are useful. Stable carries real work. Beta needs realistic pressure. Putting them against the same vault and journals without a deliberate compatibility contract gives you the risks of production with the observability of an experiment.

Use one of two arrangements. The safer arrangement gives beta a dedicated test vault, test credentials, test agents, and a separate evidence set. This tests beta behavior while preventing an experimental process from becoming part of the production action record.

The harder arrangement makes stable and beta intentionally share state. Choose it only when cross-version continuity is itself the subject of the test. In that case, test version skew in both directions: stable owns state then beta launches, beta owns state then stable launches, one process upgrades while another remains open, and one process rolls back after the other has written new history. Record whether every transition is refused, handed off, or coordinated.

For Sallyport, the fixed decision ladder gives the test a useful acceptance frame: the vault gate remains absolute, a new agent process receives its own session authorization by default, and credentials marked for per-call approval still ask on each use. The implementation may choose exclusive ownership or a safe handoff, but it cannot quietly weaken those controls to make two bundles feel convenient.

Finish the test with a clean statement that another engineer can challenge: which process owned the vault, what happened when another copy appeared, whether any action crossed an approval or revoke boundary, and whether one offline verification covered every generated event. If the answer depends on which window you happened to watch, run the test again. The evidence is not good enough yet.
