← Community
bugfixed

After `railcall team leave`, a previously valid signed manifest can be replayed to restore a removed approver and authorize an action

DaveDave#316d ago · 66 views
fixed in: station-v0.75

Reproduction steps:

  1. Use a clean RailCall Station v0.73 workspace.
  2. Create a signed Team manifest version 1 containing:

- Owner A
- Approver B

  1. Adopt manifest v1 on the station.
  2. Create and adopt signed manifest version 2 that removes Approver B.
  3. Control case: while version 2 is still adopted, deliver an

approval_response envelope signed by B. The receiver rejects it with:

sender is not a member (per my adopted manifest)

  1. Call the normal local Team leave operation:

POST /api/team/leave

  1. Present the station with the previously valid, root-signed manifest v1

(for example, a stale snapshot returned by the Relay during re-join):

POST /api/team/join with the same team_id

  1. Deliver the same kind of valid approval_response signed by B for a

pending action hash.

  1. Inspect the approval request and call the normal team approval gate.

Expected:

  • Manifest version monotonicity must survive team/leave.
  • A station that has already seen version 2 must continue rejecting version 1,

even after leaving and re-joining the same team.

  • B was removed in version 2, so B's approval response must remain rejected.
  • The approval gate must not authorize the action.

Actual:

  • team/leave removes the only stored manifest and its version high-water

state.

  • The old, validly signed version 1 is then accepted by team_manifest.adopt().
  • B appears again as a current Team member and approver.
  • B's signed approval_response passes team_mesh.verify_envelope().
  • The normal approval handler changes the request from pending to

approved.

  • team_approval.gate() returns proceed for the action hash.

Redacted harness result:

adopt_v1                         -> True
adopt_v2                         -> True
v2_response                      -> False (sender is not a member)
leave                            -> True
adopt_stale_v1                   -> True
member_removed_is_back           -> True
approval_status_after_replay     -> approved
gate_after_replay                -> proceed

Root cause:

workbench/primitives/team_manifest.py documents a persisted per-team
high-water mark and implements the version check in adopt() by comparing the
incoming version only with tm.current(ws). The only current manifest is
stored at team/manifest.json.

team_manifest.leave() deletes team/manifest.json and does not preserve a
separate high-water record. After that deletion, adopt() has no memory that
version 2 was previously accepted and therefore accepts the older version 1.

Relevant source paths:

  • workbench/primitives/team_manifest.pyadopt() and leave()
  • workbench/routes/team.py_handle_join() / /api/team/join
  • workbench/primitives/team_mesh.pyverify_envelope()
  • workbench/primitives/team_approval.py — response collection and gate()

Impact:

A member removed by a signed manifest update can become authorized again after
the station leaves and re-joins from a stale signed manifest. In the
reproduction, the removed member's approval is accepted and changes the gate
verdict to proceed. This reopens authorization for actions that should
require an approver from the current roster.

No provider request, financial write, credential, or external destination was
used. The reproduction uses only signed ephemeral manifests, a harmless action
hash, and the real Station Team approval handlers.

Deterministic: Yes.

Counter-evidence checked:

  • Replay is rejected while the newer manifest remains stored.
  • The signature is valid and the same stale document is rejected only when the

high-water state is present.

  • The behavior is not a filesystem tampering scenario; leave() is the normal

supported Team operation and the stale document is a previously valid signed
snapshot.

  • The Teams architecture documentation explicitly requires a persisted

high-water mark and says old manifests containing removed members must be
rejected.

Suggested fix:

Persist a per-team manifest high-water mark separately from the current
manifest, and never delete it during team/leave. On join or sync, reject any
manifest whose version is less than or equal to the stored high-water mark.

Station version (railcall version):

station-v0.73

Module slug + version:

N/A — Station Teams manifest and approval path

AFFECTED VERSION
station-v0.73

5 pts

1 reply

Verified — reproduced exactly, deterministic. Root cause is as you diagnosed: adopt() enforced monotonicity only against current(ws) (the team/manifest.json file itself), and leave() deleted that file — so after leaving, the station had no memory that v2 had been adopted and re-accepted the older, still-validly-signed v1. Removed approver B reappeared, B's approval_response verified, and team_approval.gate() returned proceed.

Fix (your suggestion): a separate per-team high-water markteam/manifest_highwater.json ({team_id: version}) — that leave() never touches. adopt() now rejects any manifest whose version <= the stored high-water for that team_id (in addition to the existing current-manifest check) and bumps it on every successful adopt. A genuinely newer version still adopts; a different team is unaffected (keyed by team_id); the mark is authoritative across leave/join.

Harness now returns: adopt_stale_v1 -> rejected (version 1 <= persisted high-water mark 2), member_removed_is_back -> False, gate stays pending. Covered by 3 regression tests (leave+replay rejected / high-water survives leave but newer still adopts / different team unaffected); existing manifest suite still green.

Fixed in code on the batch branch; ships in station-v0.75. Thanks Dave — textbook rollback report, root cause spot on.

Sign in to reply.