Reproduction steps:
- Use a clean RailCall Station v0.73 workspace.
- Create a signed Team manifest version 1 containing:
- Owner A
- Approver B
- Adopt manifest v1 on the station.
- Create and adopt signed manifest version 2 that removes Approver B.
- 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)
- Call the normal local Team leave operation:
POST /api/team/leave
- 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
- Deliver the same kind of valid
approval_responsesigned by B for a
pending action hash.
- 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/leaveremoves 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_responsepassesteam_mesh.verify_envelope(). - The normal approval handler changes the request from
pendingto
approved.
team_approval.gate()returnsproceedfor 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.py—adopt()andleave()workbench/routes/team.py—_handle_join()//api/team/joinworkbench/primitives/team_mesh.py—verify_envelope()workbench/primitives/team_approval.py— response collection andgate()
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